diff --git a/package-lock.json b/package-lock.json index 36fc8ce..55ebf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6069,7 +6069,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6090,7 +6089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6111,7 +6109,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6132,7 +6129,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6153,7 +6149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6174,7 +6169,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6195,7 +6189,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6216,7 +6209,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6237,7 +6229,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6258,7 +6249,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6279,7 +6269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index a88872d..c706c25 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -7,6 +7,7 @@ import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppC import { AuthorLine } from '@/components/nostr/AuthorLine'; import { NoteContent } from '@/components/nostr/NoteContent'; import { NoteCard } from '@/components/nostr/NoteCard'; +import { ZapButton } from '@/components/nostr/ZapButton'; import { ReactionButton } from '@/components/nostr/ReactionButton'; import { Composer } from '@/apps/feed/Composer'; import { DraftNote } from './Draft'; @@ -124,6 +125,7 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {

{absoluteTime(event.created_at)}

+
diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index e5bcdd4..6192bbb 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Check, Plus, Trash2 } from 'lucide-react'; +import { Check, Plus, Trash2, Zap } from 'lucide-react'; import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +10,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { useAppContext } from '@/hooks/useAppContext'; import { useTheme } from '@/hooks/useTheme'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNwcConnection } from '@/hooks/useNwc'; import { useWindowManager } from '@/os/useWindowManager'; import { desktopApps } from '@/os/registry'; import { useIconLayout } from '@/os/useIconLayout'; @@ -44,6 +45,8 @@ export default function SettingsApp({ setTitle }: AppProps) { + + @@ -304,6 +307,81 @@ function MediaSection() { ); } +function WalletSection() { + const { user } = useCurrentUser(); + const { connection, connect, disconnect } = useNwcConnection(); + const { toast } = useToast(); + const [draft, setDraft] = useState(''); + + const handleConnect = () => { + try { + connect(draft); + setDraft(''); + toast({ title: 'Wallet connected' }); + } catch (error) { + toast({ + title: 'Could not connect that wallet', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + } + }; + + const handleDisconnect = () => { + disconnect(); + toast({ title: 'Wallet disconnected' }); + }; + + return ( +
+ {!user ? ( +

+ Sign in to connect a wallet. +

+ ) : connection ? ( +
+
+ +
+

Connected

+

+ {connection.pubkey.slice(0, 12)}… via {connection.relay.replace(/^wss:\/\//, '')} +

+
+
+ +
+ ) : ( +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => event.key === 'Enter' && handleConnect()} + placeholder="nostr+walletconnect://…" + className="font-mono text-xs" + aria-label="Nostr Wallet Connect link" + /> + +
+

+ Paste a connection link from your wallet (e.g. Alby, Mutiny). It is stored only in this browser and used + solely to sign and send payment requests to your wallet's relay — never published or shared elsewhere. + Without a connected wallet, zapping still works: you'll pay each invoice manually. +

+
+ )} +
+ ); +} + function SessionSection() { const { resetSession } = useWindowManager(); const { toast } = useToast(); diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index 59e746b..7fb138d 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react'; import { MessageSquare, Repeat2 } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; +import { ZapButton } from './ZapButton'; import { ReactionButton } from './ReactionButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; @@ -26,6 +28,10 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { const { openApp } = useWindowManager(); const { toast } = useToast(); const hints = useRelayHints(); + // Tracks the same interaction that reveals the action row via CSS + // (`group-hover`/`focus-within`), so `ZapButton` can defer its relay query + // until this note is actually looked at instead of firing on every mount. + const [revealed, setRevealed] = useState(false); const copyLink = async () => { try { @@ -45,6 +51,8 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { 'group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40', className, )} + onMouseEnter={() => setRevealed(true)} + onFocus={() => setRevealed(true)} > @@ -71,6 +79,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { Copy link + diff --git a/src/components/nostr/ZapButton.tsx b/src/components/nostr/ZapButton.tsx new file mode 100644 index 0000000..0579289 --- /dev/null +++ b/src/components/nostr/ZapButton.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import AuthDialog from '@/components/auth/AuthDialog'; +import { ZapDialog } from './ZapDialog'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useZapReceipts, summarizeZapReceipts, formatSats } from '@/hooks/useZaps'; +import { cn } from '@/lib/utils'; + +/** + * Shows a note's zap total and opens the zap flow (NIP-57), from the feed or + * a thread. + * + * `revealed` gates the zap-receipts query itself, not just the total's + * visibility: a feed page mounts one of these per note, and firing every + * one's relay query unconditionally on mount turns a page of notes into a + * page of concurrent queries before anyone's looked at any of them. Callers + * in a list (`NoteCard`) pass `revealed` once the row is actually hovered or + * focused — the same interaction that already reveals the row via CSS — so + * off-screen or never-looked-at notes never fetch. A caller showing the + * button on its own (the thread root) can just leave it `true`. + */ +export function ZapButton({ target, className, revealed = true }: { target: NostrEvent; className?: string; revealed?: boolean }) { + const { user } = useCurrentUser(); + const [authOpen, setAuthOpen] = useState(false); + const [zapOpen, setZapOpen] = useState(false); + const receipts = useZapReceipts(target.id, { enabled: revealed || zapOpen }); + const recipient = useAuthor(target.pubkey); + + const { totalSats } = summarizeZapReceipts(receipts.data); + + const handleClick = () => { + if (!user) { + setAuthOpen(true); + return; + } + setZapOpen(true); + }; + + return ( + <> + + + setAuthOpen(false)} /> + + {zapOpen && ( + setZapOpen(false)} + target={target} + recipientMetadata={recipient.data?.event} + recipientMetadataLoading={recipient.isLoading} + /> + )} + + ); +} diff --git a/src/components/nostr/ZapDialog.tsx b/src/components/nostr/ZapDialog.tsx new file mode 100644 index 0000000..f6a5e85 --- /dev/null +++ b/src/components/nostr/ZapDialog.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { CheckCircle2, Copy, Loader2, XCircle, Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useToast } from '@/hooks/useToast'; +import { useCreateZapInvoice, useZapReceipts, hasValidReceiptForInvoice, formatSats } from '@/hooks/useZaps'; +import { useNwcConnection, usePayWithNwc } from '@/hooks/useNwc'; +import { cn } from '@/lib/utils'; + +const PRESET_AMOUNTS = [21, 100, 500, 1_000, 5_000, 21_000]; + +type Stage = 'amount' | 'requesting' | 'paying' | 'manual' | 'paid' | 'failed'; + +interface ZapDialogProps { + open: boolean; + onClose: () => void; + /** The note or reply being zapped. */ + target: NostrEvent; + /** The recipient's kind-0 event, so a zap endpoint can be resolved. */ + recipientMetadata: NostrEvent | undefined; + recipientMetadataLoading: boolean; +} + +export function ZapDialog({ open, onClose, target, recipientMetadata, recipientMetadataLoading }: ZapDialogProps) { + const { toast } = useToast(); + const { connection } = useNwcConnection(); + const createInvoice = useCreateZapInvoice(); + const payWithNwc = usePayWithNwc(); + + const [stage, setStage] = useState('amount'); + const [amount, setAmount] = useState(21); + const [customAmount, setCustomAmount] = useState(''); + const [comment, setComment] = useState(''); + const [invoice, setInvoice] = useState(null); + const [amountSats, setAmountSats] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + + // Re-fetched on an interval only while this dialog is showing an unpaid + // manual invoice. Whether that now means "paid" is derived at render time + // below instead of copied into state, so there is nothing to keep in sync + // by hand. Matched against the specific invoice, not just a receipt-count + // increase — someone else zapping the same note while this dialog waits + // must not be mistaken for this payment completing. + const receipts = useZapReceipts(target.id, { refetchInterval: stage === 'manual' ? 4_000 : false }); + const manualPaymentConfirmed = stage === 'manual' && invoice !== null && hasValidReceiptForInvoice(receipts.data, invoice); + const effectiveStage: Stage = manualPaymentConfirmed ? 'paid' : stage; + + const resolvedAmount = customAmount.trim() ? Number(customAmount) : amount; + const canSubmit = Boolean(recipientMetadata) && Number.isFinite(resolvedAmount) && (resolvedAmount ?? 0) > 0; + + const handleSubmit = async () => { + if (!recipientMetadata || !resolvedAmount || resolvedAmount <= 0) return; + setStage('requesting'); + setErrorMessage(''); + + try { + const result = await createInvoice.mutateAsync({ + target, + recipientMetadata, + amountSats: resolvedAmount, + comment: comment.trim() || undefined, + }); + setInvoice(result.invoice); + setAmountSats(result.amountSats); + + if (connection) { + setStage('paying'); + try { + await payWithNwc.mutateAsync({ connection, invoice: result.invoice }); + setStage('paid'); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Your wallet did not complete the payment.'); + setStage('manual'); + } + } else { + setStage('manual'); + } + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Could not request an invoice.'); + setStage('failed'); + } + }; + + const copyInvoice = async () => { + if (!invoice) return; + try { + await navigator.clipboard.writeText(invoice); + toast({ title: 'Invoice copied' }); + } catch { + toast({ title: 'Could not copy the invoice', variant: 'destructive' }); + } + }; + + return ( + !next && onClose()}> + + + + + Zap this note + + Send a Lightning zap for this note. + + + {effectiveStage === 'amount' && ( +
+
+ {PRESET_AMOUNTS.map((preset) => ( + + ))} +
+ +
+ + setCustomAmount(event.target.value)} + placeholder="1000" + /> +
+ +
+ +