diff --git a/package.json b/package.json index 84052e6..d502836 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.1.6", + "version": "0.1.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a37c565..b729d3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5388,7 +5388,7 @@ dependencies = [ [[package]] name = "wrystr" -version = "0.1.4" +version = "0.1.5" dependencies = [ "keyring", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f661eb9..fb55279 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.1.6" +version = "0.1.7" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c876faa..2c9846d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Wrystr", - "version": "0.1.6", + "version": "0.1.7", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 3e97d82..dcfbd01 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -1,11 +1,13 @@ import { useState, useRef } from "react"; import { publishNote } from "../../lib/nostr"; +import { uploadImage } from "../../lib/upload"; import { useUserStore } from "../../stores/user"; import { shortenPubkey } from "../../lib/utils"; export function ComposeBox({ onPublished }: { onPublished?: () => void }) { const [text, setText] = useState(""); const [publishing, setPublishing] = useState(false); + const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const textareaRef = useRef(null); @@ -15,7 +17,35 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) { const charCount = text.length; const overLimit = charCount > 280; - const canPost = text.trim().length > 0 && !overLimit && !publishing; + const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading; + + const handlePaste = async (e: React.ClipboardEvent) => { + const file = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); + if (!file) return; + e.preventDefault(); + setUploading(true); + setError(null); + try { + const url = await uploadImage(file); + const ta = textareaRef.current; + if (ta) { + const start = ta.selectionStart ?? text.length; + const end = ta.selectionEnd ?? text.length; + const next = text.slice(0, start) + url + text.slice(end); + setText(next); + setTimeout(() => { + ta.selectionStart = ta.selectionEnd = start + url.length; + ta.focus(); + }, 0); + } else { + setText((t) => t + url); + } + } catch (err) { + setError(`Image upload failed: ${err}`); + } finally { + setUploading(false); + } + }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { @@ -66,6 +96,7 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) { value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} + onPaste={handlePaste} placeholder="What's on your mind?" rows={3} className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none" @@ -77,7 +108,7 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
- {charCount > 0 && `${charCount}/280`} + {uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
Ctrl+Enter to post diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index fca30b1..271ac71 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -201,14 +201,16 @@ export function NoteCard({ event }: NoteCardProps) { > quote - + {(profile?.lud16 || profile?.lud06) && ( + + )}
)} diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx index 52d37d2..2ddc6de 100644 --- a/src/components/profile/ProfileView.tsx +++ b/src/components/profile/ProfileView.tsx @@ -283,12 +283,14 @@ export function ProfileView() { )} {!isOwn && loggedIn && (
- + {(lud16 || profile?.lud06) && ( + + )}
+
+ Sponsors + + {GITHUB_SPONSORS_URL} ↗ + +
diff --git a/src/components/shared/LoginModal.tsx b/src/components/shared/LoginModal.tsx index 2761519..9f9da53 100644 --- a/src/components/shared/LoginModal.tsx +++ b/src/components/shared/LoginModal.tsx @@ -1,12 +1,84 @@ import { useState } from "react"; +import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { useUserStore } from "../../stores/user"; interface LoginModalProps { onClose: () => void; } +function NewAccountTab({ onClose }: { onClose: () => void }) { + const { loginWithNsec, loginError } = useUserStore(); + const [signer] = useState(() => NDKPrivateKeySigner.generate()); + const [confirmed, setConfirmed] = useState(false); + const [copied, setCopied] = useState(false); + const [logging, setLogging] = useState(false); + + const nsec = signer.nsec; + + const handleCopy = () => { + navigator.clipboard.writeText(nsec); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleConfirm = async () => { + if (!confirmed) return; + setLogging(true); + await loginWithNsec(nsec); + if (!useUserStore.getState().loginError) { + onClose(); + } + setLogging(false); + }; + + return ( +
+

+ A new private key has been generated for you. Save it somewhere safe — it cannot be recovered. +

+ +
+ {nsec} +
+ + + + + + {loginError && ( +

{loginError}

+ )} + + +
+ ); +} + export function LoginModal({ onClose }: LoginModalProps) { - const [tab, setTab] = useState<"nsec" | "pubkey">("nsec"); + const [tab, setTab] = useState<"nsec" | "pubkey" | "new">("nsec"); const [input, setInput] = useState(""); const { loginWithNsec, loginWithPubkey, loginError } = useUserStore(); @@ -15,7 +87,7 @@ export function LoginModal({ onClose }: LoginModalProps) { if (tab === "nsec") { await loginWithNsec(input.trim()); - } else { + } else if (tab === "pubkey") { await loginWithPubkey(input.trim()); } @@ -60,7 +132,7 @@ export function LoginModal({ onClose }: LoginModalProps) { : "text-text-muted hover:text-text" }`} > - Private key (nsec) + Private key + - {/* Input */} + {/* Content */}
- - setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={tab === "nsec" ? "nsec1…" : "npub1…"} - autoFocus - className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50" - /> + {tab === "new" ? ( + + ) : ( + <> + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={tab === "nsec" ? "nsec1…" : "npub1…"} + autoFocus + className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50" + /> - {tab === "nsec" && ( -

- Your key stays local. Never sent to any server. -

+ {tab === "nsec" && ( +

+ Your key stays local. Never sent to any server. +

+ )} + + {tab === "pubkey" && ( +

+ Read-only mode — you can browse but not post or zap. +

+ )} + + {loginError && ( +

{loginError}

+ )} + + + )} - - {tab === "pubkey" && ( -

- Read-only mode — you can browse but not post or zap. -

- )} - - {loginError && ( -

{loginError}

- )} - -
diff --git a/src/components/shared/NWCWizard.tsx b/src/components/shared/NWCWizard.tsx index de3c10d..2c1831f 100644 --- a/src/components/shared/NWCWizard.tsx +++ b/src/components/shared/NWCWizard.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useLightningStore } from "../../stores/lightning"; +import { useUserStore } from "../../stores/user"; import { isValidNwcUri, parseNwcUri } from "../../lib/lightning/nwc"; // ── Wallet catalogue ───────────────────────────────────────────────────────── @@ -166,6 +167,7 @@ function PasteStep({ onConnected: () => void; }) { const { setNwcUri } = useLightningStore(); + const { pubkey } = useUserStore(); const [input, setInput] = useState(""); const [error, setError] = useState(null); @@ -191,7 +193,7 @@ function PasteStep({ return; } try { - setNwcUri(uri); + setNwcUri(uri, pubkey ?? undefined); onConnected(); } catch (err) { setError(String(err)); @@ -254,11 +256,12 @@ function PasteStep({ export function NWCWizard() { const { nwcUri, clearNwcUri } = useLightningStore(); + const { pubkey } = useUserStore(); const [step, setStep] = useState<"choose" | "paste">("choose"); const [selectedWallet, setSelectedWallet] = useState(GENERIC); if (nwcUri) { - return ; + return clearNwcUri(pubkey ?? undefined)} />; } if (step === "paste") { diff --git a/src/components/sidebar/AccountSwitcher.tsx b/src/components/sidebar/AccountSwitcher.tsx index bae9c34..ba42e7b 100644 --- a/src/components/sidebar/AccountSwitcher.tsx +++ b/src/components/sidebar/AccountSwitcher.tsx @@ -4,15 +4,14 @@ import { useUIStore } from "../../stores/ui"; import { LoginModal } from "../shared/LoginModal"; import { shortenPubkey } from "../../lib/utils"; -function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number }) { +function Avatar({ account, size = "w-6 h-6", textSize = "text-[10px]" }: { account: SavedAccount; size?: string; textSize?: string }) { const initial = (account.name || account.npub || "?").charAt(0).toUpperCase(); - const cls = `w-${size} h-${size} rounded-sm object-cover shrink-0`; if (account.picture) { return ( { (e.target as HTMLImageElement).style.display = "none"; }} @@ -20,7 +19,7 @@ function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number }) ); } return ( -
+
{initial}
); @@ -88,13 +87,13 @@ export function AccountSwitcher() { return ( <>
- {/* Expanded account list */} + {/* Dropdown — other accounts + actions */} {open && (
{others.map((a) => (
handleSwitch(a.pubkey)} > @@ -111,35 +110,15 @@ export function AccountSwitcher() { -
- )} - {/* Current account row */} -
-
-
openProfile(pubkey)} - > - - {displayName(current)} -
- -
+
- {open && ( -
+
- )} +
+ )} + + {/* Active account row */} +
+
+
openProfile(pubkey)} + > + + {displayName(current)} +
+ +
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index ee381e9..1b65fe8 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -16,7 +16,7 @@ const NAV_ITEMS = [ export function Sidebar() { const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore(); - const { connected, notes } = useFeedStore(); + const { connected } = useFeedStore(); const { loggedIn } = useUserStore(); const c = sidebarCollapsed; @@ -100,13 +100,10 @@ export function Sidebar() { className={`w-2 h-2 rounded-full inline-block ${connected ? "bg-success" : "bg-danger"}`} /> ) : ( - /* Expanded: dot + label + note count */ -
-
- - {connected ? "online" : "offline"} -
-
{notes.length} notes
+ /* Expanded: dot + label */ +
+ + {connected ? "online" : "offline"}
)}
diff --git a/src/components/thread/ThreadView.tsx b/src/components/thread/ThreadView.tsx index 6b2bc63..137ec6c 100644 --- a/src/components/thread/ThreadView.tsx +++ b/src/components/thread/ThreadView.tsx @@ -3,18 +3,46 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useUIStore } from "../../stores/ui"; import { useUserStore } from "../../stores/user"; import { useProfile } from "../../hooks/useProfile"; -import { fetchReplies, publishReply } from "../../lib/nostr"; +import { useReactionCount } from "../../hooks/useReactionCount"; +import { useZapCount } from "../../hooks/useZapCount"; +import { fetchReplies, publishReaction, publishReply, getNDK } from "../../lib/nostr"; import { shortenPubkey, timeAgo } from "../../lib/utils"; import { NoteContent } from "../feed/NoteContent"; import { NoteCard } from "../feed/NoteCard"; +import { ZapModal } from "../zap/ZapModal"; function RootNote({ event }: { event: NDKEvent }) { const { openProfile } = useUIStore(); + const { loggedIn } = useUserStore(); const profile = useProfile(event.pubkey); const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const avatar = profile?.picture; const nip05 = profile?.nip05; const time = event.created_at ? timeAgo(event.created_at) : ""; + const [reactionCount, adjustReactionCount] = useReactionCount(event.id); + const zapData = useZapCount(event.id); + const [liked, setLiked] = useState(() => { + try { return new Set(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")).has(event.id); } + catch { return false; } + }); + const [liking, setLiking] = useState(false); + const [showZap, setShowZap] = useState(false); + const hasLightning = !!(profile?.lud16 || profile?.lud06); + + const handleLike = async () => { + if (!loggedIn || liked || liking) return; + setLiking(true); + try { + await publishReaction(event.id, event.pubkey); + const likedSet = new Set(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")); + likedSet.add(event.id); + localStorage.setItem("wrystr_liked", JSON.stringify(Array.from(likedSet))); + setLiked(true); + adjustReactionCount(1); + } finally { + setLiking(false); + } + }; return (
@@ -38,6 +66,39 @@ function RootNote({ event }: { event: NDKEvent }) {
{time}
+ + {/* Action row */} + {loggedIn && !!getNDK().signer && ( +
+ + {hasLightning && ( + + )} +
+ )} + + {showZap && ( + setShowZap(false)} + /> + )}
); } diff --git a/src/stores/lightning.ts b/src/stores/lightning.ts index 6c5f813..79dea6f 100644 --- a/src/stores/lightning.ts +++ b/src/stores/lightning.ts @@ -4,6 +4,7 @@ import { getNDK } from "../lib/nostr"; import { payInvoiceViaNWC, isValidNwcUri } from "../lib/lightning/nwc"; const NWC_STORAGE_KEY = "wrystr_nwc_uri"; +const nwcKeyForAccount = (pubkey: string) => `wrystr_nwc_${pubkey}`; interface ZapTarget { type: "note"; @@ -22,25 +23,35 @@ export type ZapTargetSpec = ZapTarget | ZapProfileTarget; interface LightningState { nwcUri: string | null; - setNwcUri: (uri: string) => void; - clearNwcUri: () => void; + setNwcUri: (uri: string, pubkey?: string) => void; + clearNwcUri: (pubkey?: string) => void; + loadNwcForAccount: (pubkey: string) => void; zap: (target: ZapTargetSpec, amountSats: number, comment?: string) => Promise; } export const useLightningStore = create(() => ({ nwcUri: localStorage.getItem(NWC_STORAGE_KEY), - setNwcUri: (uri: string) => { + setNwcUri: (uri: string, pubkey?: string) => { if (!isValidNwcUri(uri)) throw new Error("Invalid NWC URI"); localStorage.setItem(NWC_STORAGE_KEY, uri); + if (pubkey) localStorage.setItem(nwcKeyForAccount(pubkey), uri); useLightningStore.setState({ nwcUri: uri }); }, - clearNwcUri: () => { + clearNwcUri: (pubkey?: string) => { localStorage.removeItem(NWC_STORAGE_KEY); + if (pubkey) localStorage.removeItem(nwcKeyForAccount(pubkey)); useLightningStore.setState({ nwcUri: null }); }, + loadNwcForAccount: (pubkey: string) => { + const uri = localStorage.getItem(nwcKeyForAccount(pubkey)); + localStorage.setItem(NWC_STORAGE_KEY, uri ?? ""); + if (!uri) localStorage.removeItem(NWC_STORAGE_KEY); + useLightningStore.setState({ nwcUri: uri }); + }, + zap: async (targetSpec: ZapTargetSpec, amountSats: number, comment?: string) => { const { nwcUri } = useLightningStore.getState(); if (!nwcUri) throw new Error("No wallet connected. Add an NWC connection in Settings."); diff --git a/src/stores/user.ts b/src/stores/user.ts index 0512c1e..dc21396 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -4,6 +4,7 @@ import { getNDK, publishContactList } from "../lib/nostr"; import { nip19 } from "@nostr-dev-kit/ndk"; import { invoke } from "@tauri-apps/api/core"; import { useMuteStore } from "./mute"; +import { useLightningStore } from "./lightning"; export interface SavedAccount { pubkey: string; @@ -98,6 +99,9 @@ export const useUserStore = create((set, get) => ({ // Store nsec in OS keychain (best-effort — gracefully ignored if unavailable) invoke("store_nsec", { pubkey, nsec: nsecInput }).catch(() => {}); + // Load per-account NWC wallet + useLightningStore.getState().loadNwcForAccount(pubkey); + // Fetch profile, follows, and mute list get().fetchOwnProfile(); get().fetchFollows(); @@ -134,6 +138,9 @@ export const useUserStore = create((set, get) => ({ localStorage.setItem("wrystr_pubkey", pubkey); localStorage.setItem("wrystr_login_type", "pubkey"); + // Load per-account NWC wallet + useLightningStore.getState().loadNwcForAccount(pubkey); + get().fetchOwnProfile(); get().fetchFollows(); useMuteStore.getState().fetchMuteList(pubkey); @@ -175,6 +182,8 @@ export const useUserStore = create((set, get) => ({ }, switchAccount: async (pubkey: string) => { + // Clear signer immediately — no window where old account could sign + getNDK().signer = undefined; // Try nsec from keychain first; fall back to read-only try { const nsec = await invoke("load_nsec", { pubkey });