diff --git a/CLAUDE.md b/CLAUDE.md index c5d0910..b68749b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,94 @@ const text = getHighlightText(event); - ❌ Direct calls to applesauce helpers (they cache internally) - ❌ Grimoire helpers that wrap `getTagValue` (caching propagates) +## Major Hooks + +Grimoire provides custom React hooks for common Nostr operations. All hooks handle cleanup automatically. + +### Account & Authentication + +**`useAccount()`** (`src/hooks/useAccount.ts`): +- Access active account with signing capability detection +- Returns: `{ account, pubkey, canSign, signer, isLoggedIn }` +- **Critical**: Always check `canSign` before signing operations +- Read-only accounts have `canSign: false` and no `signer` + +```typescript +const { canSign, signer, pubkey } = useAccount(); +if (canSign) { + // Can sign and publish events + await signer.signEvent(event); +} else { + // Show "log in to post" message +} +``` + +### Nostr Data Fetching + +**`useProfile(pubkey, relayHints?)`** (`src/hooks/useProfile.ts`): +- Fetch and cache user profile metadata (kind 0) +- Loads from IndexedDB first (fast), then network +- Uses AbortController to prevent race conditions +- Returns: `ProfileContent | undefined` + +**`useNostrEvent(pointer, context?)`** (`src/hooks/useNostrEvent.ts`): +- Unified hook for fetching events by ID, EventPointer, or AddressPointer +- Supports relay hints via context (pubkey string or full event) +- Auto-loads missing events using smart relay selection +- Returns: `NostrEvent | undefined` + +**`useTimeline(id, filters, relays, options?)`** (`src/hooks/useTimeline.ts`): +- Subscribe to timeline of events matching filters +- Uses applesauce loaders for efficient caching +- Returns: `{ events, loading, error }` +- The `id` parameter is for caching (use stable string) + +### Relay Management + +**`useRelayState()`** (`src/hooks/useRelayState.ts`): +- Access global relay state and auth management +- Returns relay connection states, pending auth challenges, preferences +- Methods: `authenticateRelay()`, `rejectAuth()`, `setAuthPreference()` +- Automatically subscribes to relay state updates + +**`useRelayInfo(relayUrl)`** (`src/hooks/useRelayInfo.ts`): +- Fetch NIP-11 relay information document +- Cached in IndexedDB with 24-hour TTL +- Returns: `RelayInfo | undefined` + +**`useOutboxRelays(pubkey)`** (`src/hooks/useOutboxRelays.ts`): +- Get user's outbox relays from kind 10002 relay list +- Cached via RelayListCache for performance +- Returns: `string[] | undefined` + +### Advanced Hooks + +**`useReqTimelineEnhanced(filter, relays, options)`** (`src/hooks/useReqTimelineEnhanced.ts`): +- Enhanced timeline with accurate state tracking +- Tracks per-relay EOSE and connection state +- Returns: `{ events, state, relayStates, stats }` +- Use for REQ viewer and advanced timeline UIs + +**`useNip05(nip05Address)`** (`src/hooks/useNip05.ts`): +- Resolve NIP-05 identifier to pubkey +- Cached with 1-hour TTL +- Returns: `{ pubkey, relays, loading, error }` + +**`useNip19Decode(nip19String)`** (`src/hooks/useNip19Decode.ts`): +- Decode nprofile, nevent, naddr, note, npub strings +- Returns: `{ type, data, error }` + +### Utility Hooks + +**`useStableValue(value)`** / **`useStableArray(array)`** (`src/hooks/useStable.ts`): +- Prevent unnecessary re-renders from deep equality +- Use for filters, options, relay arrays +- Returns stable reference when deep-equal + +**`useCopy()`** (`src/hooks/useCopy.ts`): +- Copy text to clipboard with toast feedback +- Returns: `{ copy, copied }` function and state + ## Key Conventions - **Path Alias**: `@/` = `./src/` diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index f76f83c..2808c46 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({ onReply={canReply && onReply ? () => onReply(message.id) : undefined} conversation={conversation} adapter={adapter} + message={message} > {messageContent} diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 834bed7..0ab9d65 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -23,9 +23,7 @@ import { import { getEventDisplayTitle } from "@/lib/event-title"; import { UserName } from "./nostr/UserName"; import { getTagValues } from "@/lib/nostr-utils"; -import { getLiveHost } from "@/lib/live-activity"; -import type { NostrEvent } from "@/types/nostr"; -import { getZapSender } from "applesauce-common/helpers/zap"; +import { getSemanticAuthor } from "@/lib/semantic-author"; // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat"; @@ -37,31 +35,6 @@ export interface WindowTitleData { tooltip?: string; } -/** - * Get the semantic author of an event based on kind-specific logic - * Returns the pubkey that should be displayed as the "author" for UI purposes - * - * Examples: - * - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey - * - Live activities (30311): Returns the host (first p tag with "Host" role) - * - Regular events: Returns event.pubkey - */ -function getSemanticAuthor(event: NostrEvent): string { - switch (event.kind) { - case 9735: { - // Zap: show the zapper, not the lightning service pubkey - const zapSender = getZapSender(event); - return zapSender || event.pubkey; - } - case 30311: { - // Live activity: show the host - return getLiveHost(event); - } - default: - return event.pubkey; - } -} - /** * Format profile names with prefix, handling $me and $contacts aliases * @param prefix - Prefix to use (e.g., 'by ', '@ ') @@ -474,8 +447,21 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { const countHashtags = appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : []; - // Zap titles - const zapRecipientPubkey = appId === "zap" ? props.recipientPubkey : null; + // Zap titles - load event to derive recipient if needed + const zapEventPointer: EventPointer | AddressPointer | undefined = + appId === "zap" ? props.eventPointer : undefined; + const zapEvent = useNostrEvent(zapEventPointer); + + // Derive recipient: use explicit pubkey or semantic author from event + const zapRecipientPubkey = useMemo(() => { + if (appId !== "zap") return null; + // If explicit recipient provided, use it + if (props.recipientPubkey) return props.recipientPubkey; + // Otherwise derive from event's semantic author + if (zapEvent) return getSemanticAuthor(zapEvent); + return null; + }, [appId, props.recipientPubkey, zapEvent]); + const zapRecipientProfile = useProfile(zapRecipientPubkey || ""); const zapTitle = useMemo(() => { if (appId !== "zap" || !zapRecipientPubkey) return null; diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index 23e7e8e..55e5db7 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -22,6 +22,7 @@ import { ChevronRight, Eye, EyeOff, + ExternalLink, } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useWallet } from "@/hooks/useWallet"; @@ -52,7 +53,7 @@ import { } from "@/components/ui/tooltip"; import ConnectWalletDialog from "./ConnectWalletDialog"; import { RelayLink } from "@/components/nostr/RelayLink"; -import { parseZapRequest } from "@/lib/wallet-utils"; +import { parseZapRequest, getInvoiceDescription } from "@/lib/wallet-utils"; import { Zap } from "lucide-react"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { KindRenderer } from "./nostr/kinds"; @@ -96,6 +97,62 @@ interface InvoiceDetails { const BATCH_SIZE = 20; const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds +/** + * Helper: Detect if a transaction is a Bitcoin on-chain transaction + * Bitcoin transactions have invoice field containing a Bitcoin address instead of a Lightning invoice + * Bitcoin address formats: + * - Legacy (P2PKH): starts with 1 + * - P2SH: starts with 3 + * - Bech32 (native segwit): starts with bc1 + * - Bech32m (taproot): starts with bc1p + */ +function isBitcoinTransaction(transaction: Transaction): boolean { + if (!transaction.invoice) return false; + + const invoice = transaction.invoice.trim(); + + // Lightning invoices start with "ln" (lnbc, lntb, lnbcrt, etc.) + if (invoice.toLowerCase().startsWith("ln")) { + return false; + } + + // Check if it looks like a Bitcoin address + // Legacy: 1... (26-35 chars) + // P2SH: 3... (26-35 chars) + // Bech32: bc1... (42-62 chars for bc1q, 62 chars for bc1p) + // Testnet: tb1..., 2..., m/n... + const isBitcoinAddress = + /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(invoice) || // Legacy or P2SH + /^bc1[a-z0-9]{39,59}$/i.test(invoice) || // Mainnet bech32/bech32m + /^tb1[a-z0-9]{39,59}$/i.test(invoice) || // Testnet bech32 + /^[2mn][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(invoice); // Testnet legacy + + return isBitcoinAddress; +} + +/** + * Helper: Extract txid from preimage field + * Bitcoin preimage format: "txid" or "txid:outputIndex" + * We only need the txid part for mempool.space + */ +function extractTxid(preimage: string): string { + // Remove output index if present (e.g., "txid:0" -> "txid") + return preimage.split(":")[0]; +} + +/** + * Helper: Get mempool.space URL for a Bitcoin transaction + */ +function getMempoolUrl(txid: string, network?: string): string { + const baseUrl = + network === "testnet" + ? "https://mempool.space/testnet" + : network === "signet" + ? "https://mempool.space/signet" + : "https://mempool.space"; + return `${baseUrl}/tx/${txid}`; +} + /** * Helper: Format timestamp as a readable day marker */ @@ -312,14 +369,14 @@ function ZapTransactionDetail({ transaction }: { transaction: Transaction }) { function TransactionLabel({ transaction }: { transaction: Transaction }) { const zapInfo = parseZapRequest(transaction); - // Not a zap - use original description or default label + // Not a zap - use original description, invoice description, or default label if (!zapInfo) { - return ( - - {transaction.description || - (transaction.type === "incoming" ? "Received" : "Payment")} - - ); + const description = + transaction.description || + getInvoiceDescription(transaction) || + (transaction.type === "incoming" ? "Received" : "Payment"); + + return {description}; } // It's a zap! Show username + message on one line @@ -1104,93 +1161,99 @@ export default function WalletViewer() { )} {/* Transaction History */} -
- {walletInfo?.methods.includes("list_transactions") ? ( - loading ? ( -
- -
- ) : txLoadFailed ? ( -
-

- Failed to load transaction history -

- -
- ) : transactionsWithMarkers.length === 0 ? ( -
-

- No transactions found -

-
- ) : ( - { - if (item.type === "day-marker") { +
+
+ {walletInfo?.methods.includes("list_transactions") ? ( + loading ? ( +
+ +
+ ) : txLoadFailed ? ( +
+

+ Failed to load transaction history +

+ +
+ ) : transactionsWithMarkers.length === 0 ? ( +
+

+ No transactions found +

+
+ ) : ( + { + if (item.type === "day-marker") { + return ( +
+ +
+ ); + } + + const tx = item.data; + return (
handleTransactionClick(tx)} > - +
+ {tx.type === "incoming" ? ( + + ) : ( + + )} + +
+
+

+ {state.walletBalancesBlurred + ? "✦✦✦✦" + : formatSats(tx.amount)} +

+
); - } - - const tx = item.data; - - return ( -
handleTransactionClick(tx)} - > -
- {tx.type === "incoming" ? ( - - ) : ( - - )} - -
-
-

- {state.walletBalancesBlurred - ? "✦✦✦✦" - : formatSats(tx.amount)} -

-
-
- ); - }} - components={{ - Footer: () => - loadingMore ? ( -
- -
- ) : !hasMore && transactions.length > 0 ? ( -
- No more transactions -
- ) : null, - }} - /> - ) - ) : ( -
-

- Transaction history not available -

-
- )} + }} + components={{ + Footer: () => + loadingMore ? ( +
+ +
+ ) : !hasMore && transactions.length > 0 ? ( +
+ No more transactions +
+ ) : null, + }} + /> + ) + ) : ( +
+

+ Transaction history not available +

+
+ )} +
{/* Disconnect Confirmation Dialog */} @@ -1231,7 +1294,7 @@ export default function WalletViewer() { } }} > - + Transaction Details @@ -1260,17 +1323,24 @@ export default function WalletViewer() {
- {selectedTransaction.description && - !parseZapRequest(selectedTransaction) && ( -
- -

- {selectedTransaction.description} -

-
- )} + {(() => { + const description = + selectedTransaction.description || + getInvoiceDescription(selectedTransaction); + const isZap = parseZapRequest(selectedTransaction); + + return ( + description && + !isZap && ( +
+ +

{description}

+
+ ) + ); + })()}
)} - {selectedTransaction.payment_hash && ( -
- -

- {selectedTransaction.payment_hash} -

-
- )} + {(() => { + const isBitcoin = isBitcoinTransaction(selectedTransaction); - {selectedTransaction.preimage && ( -
- -

- {selectedTransaction.preimage} -

-
- )} + if (isBitcoin) { + // Bitcoin on-chain transaction - show Transaction ID with mempool.space link + // For Bitcoin txs, preimage contains the txid (possibly with :outputIndex) + if (!selectedTransaction.preimage) { + return null; + } + + const txid = extractTxid(selectedTransaction.preimage); + + return ( +
+ +
+

+ {txid} +

+ + + +
+
+ ); + } + + // Lightning transaction - show payment hash and preimage + return ( + <> + {selectedTransaction.payment_hash && ( +
+ +

+ {selectedTransaction.payment_hash} +

+
+ )} + + {selectedTransaction.preimage && ( +
+ +

+ {selectedTransaction.preimage} +

+
+ )} + + ); + })()}
{/* Zap Details (if this is a zap payment) */} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 22378c6..0ad2c71 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -234,6 +234,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { ); diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index 2580eff..b4eb3c4 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -21,10 +21,14 @@ import { Loader2, CheckCircle2, LogIn, + EyeOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { PrivateKeySigner } from "applesauce-signers"; +import { generateSecretKey } from "nostr-tools"; import QRCode from "qrcode"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -49,14 +53,24 @@ import { } from "@/lib/create-zap-request"; import { fetchInvoiceFromCallback } from "@/lib/lnurl"; import { useLnurlCache } from "@/hooks/useLnurlCache"; +import { getSemanticAuthor } from "@/lib/semantic-author"; export interface ZapWindowProps { /** Recipient pubkey (who receives the zap) */ recipientPubkey: string; - /** Optional event being zapped (adds context) */ - eventPointer?: EventPointer | AddressPointer; + /** Optional event being zapped (adds e-tag for context) */ + eventPointer?: EventPointer; + /** Optional addressable event context (adds a-tag, e.g., live activity) */ + addressPointer?: AddressPointer; /** Callback to close the window */ onClose?: () => void; + /** + * Custom tags to include in the zap request + * Used for protocol-specific tagging like NIP-53 live activity references + */ + customTags?: string[][]; + /** Relays where the zap receipt should be published */ + relays?: string[]; } // Default preset amounts in sats @@ -82,24 +96,21 @@ function formatAmount(amount: number): string { export function ZapWindow({ recipientPubkey: initialRecipientPubkey, eventPointer, + addressPointer, onClose, + customTags, + relays: propsRelays, }: ZapWindowProps) { - // Load event if we have a pointer and no recipient pubkey (derive from event author) + // Load event if we have an eventPointer and no recipient pubkey (derive from event author) const event = use$(() => { if (!eventPointer) return undefined; - if ("id" in eventPointer) { - return eventStore.event(eventPointer.id); - } - // AddressPointer - return eventStore.replaceable( - eventPointer.kind, - eventPointer.pubkey, - eventPointer.identifier, - ); + return eventStore.event(eventPointer.id); }, [eventPointer]); - // Resolve recipient: use provided pubkey or derive from event author - const recipientPubkey = initialRecipientPubkey || event?.pubkey || ""; + // Resolve recipient: use provided pubkey or derive from semantic author + // For zaps, this returns the zapper; for streams, returns the host; etc. + const recipientPubkey = + initialRecipientPubkey || (event ? getSemanticAuthor(event) : ""); const recipientProfile = useProfile(recipientPubkey); @@ -131,6 +142,7 @@ export function ZapWindow({ const [showQrDialog, setShowQrDialog] = useState(false); const [showLogin, setShowLogin] = useState(false); const [paymentTimedOut, setPaymentTimedOut] = useState(false); + const [zapAnonymously, setZapAnonymously] = useState(false); // Editor ref and search functions const editorRef = useRef(null); @@ -349,13 +361,24 @@ export function ZapWindow({ } // Step 3: Create and sign zap request event (kind 9734) + // If zapping anonymously, create a throwaway signer + let anonymousSigner; + if (zapAnonymously) { + const throwawayKey = generateSecretKey(); + anonymousSigner = new PrivateKeySigner(throwawayKey); + } + const zapRequest = await createZapRequest({ recipientPubkey, amountMillisats, comment, eventPointer, + addressPointer, + relays: propsRelays, lnurl: lud16 || undefined, emojiTags, + customTags, + signer: anonymousSigner, }); const serializedZapRequest = serializeZapRequest(zapRequest); @@ -647,6 +670,26 @@ export function ZapWindow({ className="rounded-md border border-input bg-background px-3 py-1 text-base md:text-sm min-h-9" /> )} + + {/* Anonymous zap checkbox */} + {hasLightningAddress && ( +
+ + setZapAnonymously(checked === true) + } + /> + +
+ )} {/* No Lightning Address Warning */} @@ -657,7 +700,7 @@ export function ZapWindow({ )} {/* Payment Button */} - {!canSign ? ( + {!canSign && !zapAnonymously ? (