diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index a7f2155..3d5d7a1 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 { Composer } from '@/apps/feed/Composer'; import { DraftNote } from './Draft'; import { Button } from '@/components/ui/button'; @@ -121,7 +122,10 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
-

{absoluteTime(event.created_at)}

+
+

{absoluteTime(event.created_at)}

+ +
{user && ( 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 d0ac279..71d1860 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; +import { ZapButton } from './ZapButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; import { useToast } from '@/hooks/useToast'; @@ -70,6 +71,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..b5ae641 --- /dev/null +++ b/src/components/nostr/ZapButton.tsx @@ -0,0 +1,57 @@ +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. */ +export function ZapButton({ target, className }: { target: NostrEvent; className?: string }) { + const { user } = useCurrentUser(); + const [authOpen, setAuthOpen] = useState(false); + const [zapOpen, setZapOpen] = useState(false); + const receipts = useZapReceipts(target.id); + 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..c259d6a --- /dev/null +++ b/src/components/nostr/ZapDialog.tsx @@ -0,0 +1,233 @@ +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, summarizeZapReceipts, 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(''); + const [receiptBaseline, setReceiptBaseline] = useState(0); + + // 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. + const receipts = useZapReceipts(target.id, { refetchInterval: stage === 'manual' ? 4_000 : false }); + const manualPaymentConfirmed = stage === 'manual' && summarizeZapReceipts(receipts.data).count > receiptBaseline; + 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 { count } = summarizeZapReceipts(receipts.data); + setReceiptBaseline(count); + + 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" + /> +
+ +
+ +