From ed86698c07a8a03cb2cf302d29bda6af54270c38 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 09:36:42 +0100 Subject: [PATCH 01/13] fix: pass full zap request event to RichText for proper parsing (#146) Previously, only the content string was passed to RichText when rendering zap comments. This prevented proper parsing of mentions, event references, hashtags, URLs, custom emojis, and other rich content features. Now passing the full zapRequest event object to RichText, enabling all content transformers to work correctly on zap comments. Changes: - Remove zapComment variable that extracted just the content string - Pass zapRequest event directly to RichText component - Update condition to check zapRequest && zapRequest.content Fixes rendering in both feed and detail views (detail falls back to feed renderer for kind 9735). Co-authored-by: Claude --- src/components/nostr/kinds/ZapReceiptRenderer.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/nostr/kinds/ZapReceiptRenderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx index 395b655..5e6bbbb 100644 --- a/src/components/nostr/kinds/ZapReceiptRenderer.tsx +++ b/src/components/nostr/kinds/ZapReceiptRenderer.tsx @@ -36,12 +36,6 @@ export function Kind9735Renderer({ event }: BaseEventProps) { const zappedEvent = useNostrEvent(eventPointer || undefined); const zappedAddress = useNostrEvent(addressPointer || undefined); - // Get zap comment from request - const zapComment = useMemo(() => { - if (!zapRequest) return null; - return zapRequest.content || null; - }, [zapRequest]); - // Format amount (convert from msats to sats) const amountInSats = useMemo(() => { if (!zapAmount) return 0; @@ -76,9 +70,9 @@ export function Kind9735Renderer({ event }: BaseEventProps) { {/* Zap comment */} - {zapComment && ( + {zapRequest && zapRequest.content && (
- +
)} From b000ef8dd337e8fcf8f2d79bbee0a2c9048c511f Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 09:58:19 +0100 Subject: [PATCH 02/13] feat: display zap recipient username in all contexts (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: display zap recipient username in all contexts - Add recipient username display for profile zaps - Show recipient after amount in compact preview - Show recipient with arrow (→) in full zap receipt renderer - Fixes missing context when zaps don't target specific events * refactor: remove arrow from zap recipient display --------- Co-authored-by: Claude --- .../nostr/compact/ZapCompactPreview.tsx | 22 +++++++++---------- .../nostr/kinds/ZapReceiptRenderer.tsx | 4 ++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/nostr/compact/ZapCompactPreview.tsx b/src/components/nostr/compact/ZapCompactPreview.tsx index 81f6628..be4f398 100644 --- a/src/components/nostr/compact/ZapCompactPreview.tsx +++ b/src/components/nostr/compact/ZapCompactPreview.tsx @@ -6,6 +6,7 @@ import { getZapEventPointer, getZapAddressPointer, getZapRequest, + getZapRecipient, } from "applesauce-common/helpers/zap"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { UserName } from "../UserName"; @@ -13,11 +14,12 @@ import { RichText } from "../RichText"; /** * Compact preview for Kind 9735 (Zap Receipt) - * Layout: [amount] [zap message] [target pubkey] [preview] + * Layout: [amount] [recipient] [zap message] [preview] */ export function ZapCompactPreview({ event }: { event: NostrEvent }) { const zapAmount = useMemo(() => getZapAmount(event), [event]); const zapRequest = useMemo(() => getZapRequest(event), [event]); + const zapRecipient = useMemo(() => getZapRecipient(event), [event]); // Get zap comment from request const zapMessage = useMemo(() => { @@ -46,6 +48,7 @@ export function ZapCompactPreview({ event }: { event: NostrEvent }) { {amountInSats.toLocaleString("en", { notation: "compact" })} + {zapRecipient && } {zapMessage && ( )} {zappedEvent && ( - <> - - - - - + + + )} ); diff --git a/src/components/nostr/kinds/ZapReceiptRenderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx index 5e6bbbb..de22a99 100644 --- a/src/components/nostr/kinds/ZapReceiptRenderer.tsx +++ b/src/components/nostr/kinds/ZapReceiptRenderer.tsx @@ -8,12 +8,14 @@ import { getZapEventPointer, getZapAddressPointer, getZapSender, + getZapRecipient, isValidZap, } from "applesauce-common/helpers/zap"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { KindRenderer } from "./index"; import { RichText } from "../RichText"; import { EventCardSkeleton } from "@/components/ui/skeleton"; +import { UserName } from "../UserName"; /** * Renderer for Kind 9735 - Zap Receipts @@ -25,6 +27,7 @@ export function Kind9735Renderer({ event }: BaseEventProps) { // Get zap details using applesauce helpers const zapSender = useMemo(() => getZapSender(event), [event]); + const zapRecipient = useMemo(() => getZapRecipient(event), [event]); const zapAmount = useMemo(() => getZapAmount(event), [event]); const zapRequest = useMemo(() => getZapRequest(event), [event]); @@ -67,6 +70,7 @@ export function Kind9735Renderer({ event }: BaseEventProps) { })} sats + {zapRecipient && } {/* Zap comment */} From 55d9c88f50fbd57c438641a2d42ef7b1b5383998 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 10:17:09 +0100 Subject: [PATCH 03/13] fix: pass zap request event to RichText for emoji rendering in compact preview (#148) The compact zap preview was passing only the content string to RichText, which meant NIP-30 custom emoji tags were not available for rendering. Now passes the full zap request event so emoji tags are properly parsed. Co-authored-by: Claude --- src/components/nostr/compact/ZapCompactPreview.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/nostr/compact/ZapCompactPreview.tsx b/src/components/nostr/compact/ZapCompactPreview.tsx index be4f398..991c521 100644 --- a/src/components/nostr/compact/ZapCompactPreview.tsx +++ b/src/components/nostr/compact/ZapCompactPreview.tsx @@ -21,12 +21,6 @@ export function ZapCompactPreview({ event }: { event: NostrEvent }) { const zapRequest = useMemo(() => getZapRequest(event), [event]); const zapRecipient = useMemo(() => getZapRecipient(event), [event]); - // Get zap comment from request - const zapMessage = useMemo(() => { - if (!zapRequest) return null; - return zapRequest.content || null; - }, [zapRequest]); - // Get zapped content pointers const eventPointer = useMemo(() => getZapEventPointer(event), [event]); const addressPointer = useMemo(() => getZapAddressPointer(event), [event]); @@ -49,10 +43,10 @@ export function ZapCompactPreview({ event }: { event: NostrEvent }) { {amountInSats.toLocaleString("en", { notation: "compact" })} {zapRecipient && } - {zapMessage && ( + {zapRequest?.content && ( From ab64fc75f4526382acdbb81d7eea48447e0e8bb7 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 10:19:16 +0100 Subject: [PATCH 04/13] Restrict relay auth to account owner (#149) * fix: only prompt relay auth for accounts that can sign - Add canAccountSign() helper to check if account is read-only - Block auth prompts for read-only accounts in shouldPromptAuth() - Throw error when authenticateRelay() called with read-only account - Document all major app hooks in CLAUDE.md for future reference Read-only accounts cannot sign events, so they should never be prompted for relay authentication or attempt to authenticate. This prevents confusing UX where users are asked to sign but cannot. * refactor: extract canAccountSign helper to useAccount - Move canAccountSign function from relay-state-manager to useAccount.ts - Import and reuse the shared helper in relay-state-manager - Update useAccount hook to use the extracted helper internally - Follows DRY principle by centralizing account sign capability logic This keeps the account sign capability detection logic in one place, making it easier to maintain and ensuring consistency across the app. --------- Co-authored-by: Claude --- CLAUDE.md | 88 +++++++++++++++++++++++++++++ src/hooks/useAccount.ts | 25 ++++---- src/services/relay-state-manager.ts | 11 +++- 3 files changed, 111 insertions(+), 13 deletions(-) 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/hooks/useAccount.ts b/src/hooks/useAccount.ts index bd30c51..4e25797 100644 --- a/src/hooks/useAccount.ts +++ b/src/hooks/useAccount.ts @@ -2,6 +2,19 @@ import { useMemo } from "react"; import { use$ } from "applesauce-react/hooks"; import accounts from "@/services/accounts"; +/** + * Check if an account can sign events + * Read-only accounts cannot sign and should not be prompted for auth + * + * @param account - The account to check (can be undefined) + * @returns true if the account can sign, false otherwise + */ +export function canAccountSign(account: typeof accounts.active): boolean { + if (!account) return false; + const accountType = account.constructor.name; + return accountType !== "ReadonlyAccount"; +} + /** * Hook to access the active account with signing capability detection * @@ -45,18 +58,8 @@ export function useAccount() { // Check if the account has a functional signer // Read-only accounts have a signer that throws errors on sign operations - // We detect this by checking for the ReadonlySigner type or checking signer methods const signer = account.signer; - let canSign = false; - - if (signer) { - // ReadonlyAccount from applesauce-accounts has a ReadonlySigner - // that throws on signEvent, nip04, nip44 operations - // We can detect it by checking if it's an instance with the expected methods - // but we'll use a safer approach: check the account type name - const accountType = account.constructor.name; - canSign = accountType !== "ReadonlyAccount"; - } + const canSign = canAccountSign(account); return { account, diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts index 8d1631f..9cb247d 100644 --- a/src/services/relay-state-manager.ts +++ b/src/services/relay-state-manager.ts @@ -9,6 +9,7 @@ import type { import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine"; import { createLogger } from "@/lib/logger"; import { normalizeRelayURL } from "@/lib/relay-url"; +import { canAccountSign } from "@/hooks/useAccount"; import pool from "./relay-pool"; import accountManager from "./accounts"; import db from "./db"; @@ -381,6 +382,11 @@ class RelayStateManager { throw new Error("No active account to authenticate with"); } + // Check if account can sign (read-only accounts cannot authenticate) + if (!canAccountSign(account)) { + throw new Error("Active account cannot sign events (read-only account)"); + } + // Update status to authenticating state.authStatus = "authenticating"; state.stats.authAttemptsCount++; @@ -491,8 +497,9 @@ class RelayStateManager { try { const normalizedUrl = normalizeRelayURL(relayUrl); - // Don't prompt if there's no active account - if (!accountManager.active) return false; + // Don't prompt if there's no active account or account can't sign + const account = accountManager.active; + if (!account || !canAccountSign(account)) return false; // Check permanent preferences const pref = this.authPreferences.get(normalizedUrl); From 599e8b6c6033b6bac8ffd80c09092b95581feaaa Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 11:41:55 +0100 Subject: [PATCH 05/13] Fix zap dialog to target correct event author (#152) * fix: use semantic author for zap targeting When zapping certain event kinds (zaps, streams), use the semantic author instead of event.pubkey: - Zaps (9735): Target the zapper, not the lightning service - Streams (30311): Target the host, not the event publisher Changes: - Extract getSemanticAuthor() to shared utility (src/lib/semantic-author.ts) - Update BaseEventRenderer to use semantic author when opening zap dialog - Update ZapWindow to resolve recipient using semantic author - Refactor DynamicWindowTitle to use shared utility This ensures that when you zap an event, you're zapping the right person (the one who semantically "owns" or created the event), not just whoever signed it. * fix: load event in DynamicWindowTitle to derive zap recipient When opening a zap dialog via 'zap naddr1...' or 'zap nevent1...', the window title was showing "ZAP" instead of "Zap {host name}" because DynamicWindowTitle only had access to the empty recipientPubkey from the initial props. Now DynamicWindowTitle: - Loads the event from eventPointer if present - Derives the recipient using getSemanticAuthor() if recipientPubkey is empty - Falls back to explicit recipientPubkey if provided This ensures the window title shows the correct recipient name immediately, matching the behavior in the ZapWindow component itself. --------- Co-authored-by: Claude --- src/components/DynamicWindowTitle.tsx | 46 +++++++------------ src/components/ZapWindow.tsx | 7 ++- .../nostr/kinds/BaseEventRenderer.tsx | 6 ++- src/lib/semantic-author.ts | 41 +++++++++++++++++ 4 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 src/lib/semantic-author.ts 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/ZapWindow.tsx b/src/components/ZapWindow.tsx index 2580eff..aea9b09 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -49,6 +49,7 @@ 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) */ @@ -98,8 +99,10 @@ export function ZapWindow({ ); }, [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); diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 9b4986a..1c43ca3 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -21,6 +21,7 @@ import { getSeenRelays } from "applesauce-core/helpers/relays"; import { EventFooter } from "@/components/EventFooter"; import { cn } from "@/lib/utils"; import { isAddressableKind } from "@/lib/nostr-kinds"; +import { getSemanticAuthor } from "@/lib/semantic-author"; /** * Universal event properties and utilities shared across all kind renderers @@ -173,9 +174,12 @@ export function EventMenu({ event }: { event: NostrEvent }) { }; } + // Get semantic author (e.g., zapper for zaps, host for streams) + const recipientPubkey = getSemanticAuthor(event); + // Open zap window with event context addWindow("zap", { - recipientPubkey: event.pubkey, + recipientPubkey, eventPointer, }); }; diff --git a/src/lib/semantic-author.ts b/src/lib/semantic-author.ts new file mode 100644 index 0000000..b28a2b3 --- /dev/null +++ b/src/lib/semantic-author.ts @@ -0,0 +1,41 @@ +/** + * Semantic Author Utilities + * + * Determines the "semantic author" of an event based on kind-specific logic. + * For most events, this is event.pubkey, but for certain event types the + * semantic author may be different (e.g., zapper for zaps, host for streams). + */ + +import type { NostrEvent } from "@/types/nostr"; +import { getZapSender } from "applesauce-common/helpers/zap"; +import { getLiveHost } from "@/lib/live-activity"; + +/** + * 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 + * + * This function should be used when determining: + * - Who to display as the author in UI + * - Who to zap when zapping an event + * - Who the "owner" of the event is semantically + */ +export 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; + } +} From 3f811ed0721ab75fb2ad8cbc6340eb35486bbd90 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 12:16:51 +0100 Subject: [PATCH 06/13] feat: zap action for chat (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add configurable zap tagging for chat messages Implements a protocol adapter interface for configuring how zap requests should be tagged for chat messages. This enables proper NIP-53 live activity zapping with appropriate a-tag and goal e-tag support. Changes: - Add ZapConfig interface to base-adapter for protocol-specific zap configuration - Add getZapConfig() method to ChatProtocolAdapter (default: unsupported) - Implement getZapConfig() in NIP-53 adapter with proper tagging: - Always a-tag the live activity (kind 30311) - If zapping host with goal, also e-tag the goal event - Add goal tag parsing to live-activity.ts and types - Update createZapRequest to accept custom tags parameter - Add Zap action to ChatMessageContextMenu (shown when supported) - Update ZapWindow to pass custom tags through to zap request - NIP-29 groups inherit default (unsupported) behavior * feat: add custom tags and relays to zap command Extends the zap command to support custom tags and relay specification, enabling full translation from chat zap config to zap command. Changes: - Add -T/--tag flag to specify custom tags (type, value, optional relay hint) - Add -r/--relay flag to specify where zap receipt should be published - Update ZapWindow to accept and pass through relays prop - Update ChatMessageContextMenu to pass relays from zapConfig - Update man page with new options and examples - Add comprehensive tests for zap parser flag handling Example usage: zap npub... -T a 30311:pk:id wss://relay.example.com zap npub... -r wss://relay1.com -r wss://relay2.com * fix: include event pointer when zapping chat messages Pass the message event as eventPointer when opening ZapWindow from chat context menu. This enables: - Event preview in the zap window - Proper window title showing "Zap [username]" * feat: add zap command reconstruction for Edit feature Add zap case to command-reconstructor.ts so that clicking "Edit" on a zap window title shows a complete command with: - Recipient as npub - Event pointer as nevent/naddr - Custom tags with -T flags - Relays with -r flags This enables users to see and modify the full zap configuration. * fix: separate eventPointer and addressPointer for proper zap tagging - Refactor createZapRequest to use separate eventPointer (for e-tag) and addressPointer (for a-tag) instead of a union type - Remove duplicate p-tag issue (only tag recipient, not event author) - Remove duplicate e-tag issue (only one e-tag with relay hint if available) - Update ZapConfig interface to include addressPointer field - Update NIP-53 adapter to return addressPointer for live activity context - Update ChatMessageContextMenu to pass addressPointer from zapConfig - Update command-reconstructor to properly serialize addressPointer as -T a - Update ZapWindow to pass addressPointer to createZapRequest This ensures proper NIP-53 zap tagging: message author gets p-tag, live activity gets a-tag, and message event gets e-tag (all separate). * refactor: move eventPointer to ZapConfig for NIP-53 adapter - Add eventPointer field to ZapConfig interface for message e-tag - NIP-53 adapter now returns eventPointer from getZapConfig - ChatMessageContextMenu uses eventPointer from zapConfig directly - Remove goal logic from NIP-53 zap config (simplify for now) This gives the adapter full control over zap configuration, including which event to reference in the e-tag. * fix: update zap-parser to return separate eventPointer and addressPointer The ParsedZapCommand interface now properly separates: - eventPointer: for regular events (nevent, note, hex ID) → e-tag - addressPointer: for addressable events (naddr) → a-tag This aligns with ZapWindowProps which expects separate fields, fixing the issue where addressPointer from naddr was being passed as eventPointer and ignored. * feat: improve relay selection for zap requests with e+a tags When both eventPointer and addressPointer are provided: - Collect outbox relays from both semantic authors - Include relay hints from both pointers - Deduplicate and use combined relay set Priority order: 1. Explicit params.relays (respects CLI -r flags) 2. Semantic author outbox relays + pointer relay hints 3. Sender read relays (fallback) 4. Aggregator relays (final fallback) * fix: pass all zap props from WindowRenderer to ZapWindow WindowRenderer was only passing recipientPubkey and eventPointer, dropping addressPointer, customTags, and relays. This caused CLI flags like -T (custom tags) and -r (relays) to be ignored. Now all parsed zap command props flow through to ZapWindow and subsequently to createZapRequest. * refactor: let createZapRequest collect relays from both authors Remove top-level relays from NIP-53 zapConfig so createZapRequest can automatically collect outbox relays from both: - eventPointer.author (message author / zap recipient) - addressPointer.pubkey (stream host) The relay hints in the pointers are still included via the existing logic in createZapRequest. * fix: deduplicate explicit relays in createZapRequest Ensure params.relays is deduplicated before use, not just the automatically collected relays. This handles cases where CLI -r flags might specify duplicate relay URLs. --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 1 + src/components/WindowRenderer.tsx | 3 + src/components/ZapWindow.tsx | 31 ++- .../chat/ChatMessageContextMenu.tsx | 32 ++- src/lib/chat/adapters/base-adapter.ts | 50 +++++ src/lib/chat/adapters/nip-53-adapter.ts | 67 +++++- src/lib/command-reconstructor.ts | 68 ++++++ src/lib/create-zap-request.ts | 102 ++++++--- src/lib/live-activity.ts | 1 + src/lib/zap-parser.test.ts | 207 ++++++++++++++++++ src/lib/zap-parser.ts | 133 +++++++++-- src/types/chat.ts | 1 + src/types/live-activity.ts | 1 + src/types/man.ts | 17 +- 14 files changed, 655 insertions(+), 59 deletions(-) create mode 100644 src/lib/zap-parser.test.ts 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/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 aea9b09..d3a94a9 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -54,10 +54,19 @@ 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 @@ -83,20 +92,15 @@ 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 semantic author @@ -357,8 +361,11 @@ export function ZapWindow({ amountMillisats, comment, eventPointer, + addressPointer, + relays: propsRelays, lnurl: lud16 || undefined, emojiTags, + customTags, }); const serializedZapRequest = serializeZapRequest(zapRequest); diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index 5042d8e..6c23fca 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { NostrEvent } from "@/types/nostr"; -import type { Conversation } from "@/types/chat"; +import type { Conversation, Message } from "@/types/chat"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import { ContextMenu, @@ -18,6 +18,7 @@ import { Reply, MessageSquare, Smile, + Zap, } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; @@ -37,6 +38,8 @@ interface ChatMessageContextMenuProps { onReply?: () => void; conversation?: Conversation; adapter?: ChatProtocolAdapter; + /** Message object for protocol-specific actions like zapping */ + message?: Message; } /** @@ -54,6 +57,7 @@ export function ChatMessageContextMenu({ onReply, conversation, adapter, + message, }: ChatMessageContextMenuProps) { const { addWindow } = useGrimoire(); const { copy, copied } = useCopy(); @@ -63,6 +67,12 @@ export function ChatMessageContextMenu({ // Extract context emojis from the conversation const contextEmojis = getEmojiTags(event); + // Get zap configuration from adapter + const zapConfig = useMemo(() => { + if (!adapter || !message || !conversation) return null; + return adapter.getZapConfig(message, conversation); + }, [adapter, message, conversation]); + const openEventDetail = () => { let pointer; // For replaceable/parameterized replaceable events, use AddressPointer @@ -138,6 +148,18 @@ export function ChatMessageContextMenu({ } }; + const openZapWindow = () => { + if (!zapConfig || !zapConfig.supported) return; + + addWindow("zap", { + recipientPubkey: zapConfig.recipientPubkey, + eventPointer: zapConfig.eventPointer, + addressPointer: zapConfig.addressPointer, + customTags: zapConfig.customTags, + relays: zapConfig.relays, + }); + }; + return ( <> @@ -170,6 +192,12 @@ export function ChatMessageContextMenu({ React + {zapConfig?.supported && ( + + + Zap + + )} )} diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index d0aca1c..4747f32 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -17,6 +17,36 @@ import type { GetActionsOptions, } from "@/types/chat-actions"; +/** + * Zap configuration for chat messages + * Defines how zap requests should be constructed for protocol-specific tagging + */ +export interface ZapConfig { + /** Whether zapping is supported for this message/conversation */ + supported: boolean; + /** Reason why zapping is not supported (if supported=false) */ + unsupportedReason?: string; + /** Recipient pubkey (who receives the sats) */ + recipientPubkey: string; + /** Event being zapped for e-tag (e.g., chat message) */ + eventPointer?: { + id: string; + author?: string; + relays?: string[]; + }; + /** Addressable event context for a-tag (e.g., live activity) */ + addressPointer?: { + kind: number; + pubkey: string; + identifier: string; + relays?: string[]; + }; + /** Custom tags to include in the zap request (beyond standard p/amount/relays) */ + customTags?: string[][]; + /** Relays where the zap receipt should be published */ + relays?: string[]; +} + /** * Blob attachment metadata for imeta tags (NIP-92) */ @@ -180,6 +210,26 @@ export abstract class ChatProtocolAdapter { */ leaveConversation?(conversation: Conversation): Promise; + /** + * Get zap configuration for a message + * Returns configuration for how zap requests should be constructed, + * including protocol-specific tagging (e.g., a-tag for live activities) + * + * Default implementation returns unsupported. + * Override in adapters that support zapping. + * + * @param message - The message being zapped + * @param conversation - The conversation context + * @returns ZapConfig with supported=true and tagging info, or supported=false with reason + */ + getZapConfig(_message: Message, _conversation: Conversation): ZapConfig { + return { + supported: false, + unsupportedReason: "Zaps are not supported for this protocol", + recipientPubkey: "", + }; + } + /** * Get available actions for this protocol * Actions are protocol-specific slash commands like /join, /leave, etc. diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index a3d6e8e..b6ad479 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -2,7 +2,11 @@ import { Observable, firstValueFrom } from "rxjs"; import { map, first, toArray } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; -import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; +import { + ChatProtocolAdapter, + type SendMessageOptions, + type ZapConfig, +} from "./base-adapter"; import type { Conversation, Message, @@ -214,6 +218,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { totalParticipants: activity.totalParticipants, hashtags: activity.hashtags, relays: chatRelays, + goal: activity.goal, }, }, unreadCount: 0, @@ -549,6 +554,66 @@ export class Nip53Adapter extends ChatProtocolAdapter { }; } + /** + * Get zap configuration for a message in a live activity + * + * NIP-53 zap tagging rules: + * - p-tag: message author (recipient) + * - e-tag: message event being zapped + * - a-tag: live activity context + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + const activityAddress = conversation.metadata?.activityAddress; + const liveActivity = conversation.metadata?.liveActivity as + | { + relays?: string[]; + } + | undefined; + + if (!activityAddress) { + return { + supported: false, + unsupportedReason: "Missing activity address", + recipientPubkey: "", + }; + } + + const { pubkey: activityPubkey, identifier } = activityAddress; + + // Get relays + const relays = + liveActivity?.relays && liveActivity.relays.length > 0 + ? liveActivity.relays + : conversation.metadata?.relayUrl + ? [conversation.metadata.relayUrl] + : []; + + // Build eventPointer for the message being zapped (e-tag) + const eventPointer = { + id: message.id, + author: message.author, + relays, + }; + + // Build addressPointer for the live activity (a-tag) + const addressPointer = { + kind: 30311, + pubkey: activityPubkey, + identifier, + relays, + }; + + // Don't pass top-level relays - let createZapRequest collect outbox relays + // from both eventPointer.author (recipient) and addressPointer.pubkey (stream host) + // The relay hints in the pointers will also be included + return { + supported: true, + recipientPubkey: message.author, + eventPointer, + addressPointer, + }; + } + /** * Load a replied-to message * First checks EventStore, then fetches from relays if needed diff --git a/src/lib/command-reconstructor.ts b/src/lib/command-reconstructor.ts index 627c8d3..890de6f 100644 --- a/src/lib/command-reconstructor.ts +++ b/src/lib/command-reconstructor.ts @@ -96,6 +96,74 @@ export function reconstructCommand(window: WindowInstance): string { case "debug": return "debug"; + case "zap": { + // Reconstruct zap command from props + const parts: string[] = ["zap"]; + + // Add recipient pubkey (encode as npub for readability) + if (props.recipientPubkey) { + try { + const npub = nip19.npubEncode(props.recipientPubkey); + parts.push(npub); + } catch { + parts.push(props.recipientPubkey); + } + } + + // Add event pointer if present (e-tag context) + if (props.eventPointer) { + const pointer = props.eventPointer; + try { + const nevent = nip19.neventEncode({ + id: pointer.id, + relays: pointer.relays, + author: pointer.author, + kind: pointer.kind, + }); + parts.push(nevent); + } catch { + // Fallback to raw ID + parts.push(pointer.id); + } + } + + // Add address pointer if present (a-tag context, e.g., live activity) + if (props.addressPointer) { + const pointer = props.addressPointer; + // Use -T a to add the a-tag as coordinate + parts.push( + "-T", + "a", + `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`, + ); + if (pointer.relays?.[0]) { + parts.push(pointer.relays[0]); + } + } + + // Add custom tags + if (props.customTags && props.customTags.length > 0) { + for (const tag of props.customTags) { + if (tag.length >= 2) { + parts.push("-T", tag[0], tag[1]); + // Add relay hint if present + if (tag[2]) { + parts.push(tag[2]); + } + } + } + } + + // Add relays + if (props.relays && props.relays.length > 0) { + for (const relay of props.relays) { + parts.push("-r", relay); + } + } + + return parts.join(" "); + } + case "chat": { // Reconstruct chat command from protocol and identifier const { protocol, identifier } = props; diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts index 34d1ea6..5ff3e48 100644 --- a/src/lib/create-zap-request.ts +++ b/src/lib/create-zap-request.ts @@ -21,14 +21,21 @@ export interface ZapRequestParams { amountMillisats: number; /** Optional comment/message */ comment?: string; - /** Optional event being zapped */ - eventPointer?: EventPointer | AddressPointer; + /** Optional event being zapped (adds e-tag) */ + eventPointer?: EventPointer; + /** Optional addressable event context (adds a-tag, e.g., live activity) */ + addressPointer?: AddressPointer; /** Relays where zap receipt should be published */ relays?: string[]; /** LNURL for the recipient */ lnurl?: string; /** NIP-30 custom emoji tags */ emojiTags?: EmojiTag[]; + /** + * Custom tags to include in the zap request (beyond standard p/amount/relays) + * Used for additional protocol-specific tagging + */ + customTags?: string[][]; } /** @@ -50,12 +57,53 @@ export async function createZapRequest( } // Get relays for zap receipt publication - let relays = params.relays; + // Priority: explicit params.relays > semantic author relays > sender read relays > aggregators + let relays: string[] | undefined = params.relays + ? [...new Set(params.relays)] // Deduplicate explicit relays + : undefined; + if (!relays || relays.length === 0) { - // Use sender's read relays (where they want to receive zap receipts) - const senderReadRelays = - (await relayListCache.getInboxRelays(account.pubkey)) || []; - relays = senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS; + const collectedRelays: string[] = []; + + // Collect outbox relays from semantic authors (event author and/or addressable event pubkey) + const authorsToQuery: string[] = []; + if (params.eventPointer?.author) { + authorsToQuery.push(params.eventPointer.author); + } + if (params.addressPointer?.pubkey) { + authorsToQuery.push(params.addressPointer.pubkey); + } + + // Deduplicate authors + const uniqueAuthors = [...new Set(authorsToQuery)]; + + // Fetch outbox relays for each author + for (const authorPubkey of uniqueAuthors) { + const authorOutboxes = + (await relayListCache.getOutboxRelays(authorPubkey)) || []; + collectedRelays.push(...authorOutboxes); + } + + // Include relay hints from pointers + if (params.eventPointer?.relays) { + collectedRelays.push(...params.eventPointer.relays); + } + if (params.addressPointer?.relays) { + collectedRelays.push(...params.addressPointer.relays); + } + + // Deduplicate collected relays + const uniqueRelays = [...new Set(collectedRelays)]; + + if (uniqueRelays.length > 0) { + relays = uniqueRelays; + } else { + // Fallback to sender's read relays (where they want to receive zap receipts) + const senderReadRelays = + (await relayListCache.getInboxRelays(account.pubkey)) || []; + relays = + senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS; + } } // Build tags @@ -70,27 +118,31 @@ export async function createZapRequest( tags.push(["lnurl", params.lnurl]); } - // Add event reference if zapping an event + // Add event reference if zapping an event (e-tag) if (params.eventPointer) { - if ("id" in params.eventPointer) { - // Regular event (e tag) - tags.push(["e", params.eventPointer.id]); - // Include author if available - if (params.eventPointer.author) { - tags.push(["p", params.eventPointer.author]); - } - // Include relay hints - if (params.eventPointer.relays && params.eventPointer.relays.length > 0) { - tags.push(["e", params.eventPointer.id, params.eventPointer.relays[0]]); - } + const relayHint = params.eventPointer.relays?.[0] || ""; + if (relayHint) { + tags.push(["e", params.eventPointer.id, relayHint]); + } else { + tags.push(["e", params.eventPointer.id]); + } + } + + // Add addressable event reference (a-tag) - for NIP-53 live activities, etc. + if (params.addressPointer) { + const coordinate = `${params.addressPointer.kind}:${params.addressPointer.pubkey}:${params.addressPointer.identifier}`; + const relayHint = params.addressPointer.relays?.[0] || ""; + if (relayHint) { + tags.push(["a", coordinate, relayHint]); } else { - // Addressable event (a tag) - const coordinate = `${params.eventPointer.kind}:${params.eventPointer.pubkey}:${params.eventPointer.identifier}`; tags.push(["a", coordinate]); - // Include relay hint if available - if (params.eventPointer.relays && params.eventPointer.relays.length > 0) { - tags.push(["a", coordinate, params.eventPointer.relays[0]]); - } + } + } + + // Add custom tags (protocol-specific like NIP-53 live activity references) + if (params.customTags) { + for (const tag of params.customTags) { + tags.push(tag); } } diff --git a/src/lib/live-activity.ts b/src/lib/live-activity.ts index 18d4cc2..28c1cd8 100644 --- a/src/lib/live-activity.ts +++ b/src/lib/live-activity.ts @@ -48,6 +48,7 @@ export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity { participants, hashtags: getTagValues(event, "t"), relays: getTagValues(event, "relays"), + goal: getTagValue(event, "goal"), lastUpdate: event.created_at || Date.now() / 1000, }; } diff --git a/src/lib/zap-parser.test.ts b/src/lib/zap-parser.test.ts new file mode 100644 index 0000000..b8b2ff5 --- /dev/null +++ b/src/lib/zap-parser.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; +import { parseZapCommand } from "./zap-parser"; + +describe("parseZapCommand", () => { + describe("positional arguments", () => { + it("should parse npub as recipient", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + // npub decodes to this hex pubkey + expect(result.recipientPubkey).toBe( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ); + }); + + it("should parse $me alias with active account", async () => { + const activePubkey = "abc123def456"; + const result = await parseZapCommand(["$me"], activePubkey); + expect(result.recipientPubkey).toBe(activePubkey); + }); + + it("should throw when $me used without active account", async () => { + await expect(parseZapCommand(["$me"])).rejects.toThrow( + "No active account", + ); + }); + + it("should throw for empty arguments", async () => { + await expect(parseZapCommand([])).rejects.toThrow( + "Recipient or event required", + ); + }); + }); + + describe("custom tags (-T, --tag)", () => { + it("should parse single custom tag with -T", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + ]); + expect(result.customTags).toEqual([["a", "30311:pubkey:identifier"]]); + }); + + it("should parse custom tag with --tag", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--tag", + "e", + "abc123", + ]); + expect(result.customTags).toEqual([["e", "abc123"]]); + }); + + it("should parse custom tag with relay hint", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "wss://relay.example.com", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier", "wss://relay.example.com/"], + ]); + }); + + it("should parse multiple custom tags", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "-T", + "e", + "goal123", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier"], + ["e", "goal123"], + ]); + }); + + it("should throw for incomplete tag", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + ]), + ).rejects.toThrow("Tag requires at least 2 arguments"); + }); + + it("should not include customTags when none provided", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.customTags).toBeUndefined(); + }); + }); + + describe("relays (-r, --relay)", () => { + it("should parse single relay with -r", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "wss://relay1.example.com", + ]); + expect(result.relays).toEqual(["wss://relay1.example.com/"]); + }); + + it("should parse relay with --relay", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--relay", + "wss://relay.example.com", + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should parse multiple relays", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "wss://relay1.example.com", + "-r", + "wss://relay2.example.com", + ]); + expect(result.relays).toEqual([ + "wss://relay1.example.com/", + "wss://relay2.example.com/", + ]); + }); + + it("should throw for missing relay URL", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + ]), + ).rejects.toThrow("Relay option requires a URL"); + }); + + it("should normalize relay URLs", async () => { + // normalizeRelayURL is liberal - it normalizes most inputs + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-r", + "relay.example.com", + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should not include relays when none provided", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.relays).toBeUndefined(); + }); + }); + + describe("combined flags", () => { + it("should parse tags and relays together", async () => { + const result = await parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "-T", + "a", + "30311:pubkey:identifier", + "-r", + "wss://relay.example.com", + "-T", + "e", + "goalid", + "wss://relay.example.com", + ]); + expect(result.customTags).toEqual([ + ["a", "30311:pubkey:identifier"], + ["e", "goalid", "wss://relay.example.com/"], + ]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + + it("should handle flags before positional args", async () => { + const result = await parseZapCommand([ + "-r", + "wss://relay.example.com", + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + ]); + expect(result.recipientPubkey).toBe( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); + }); + + describe("unknown options", () => { + it("should throw for unknown flags", async () => { + await expect( + parseZapCommand([ + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "--unknown", + ]), + ).rejects.toThrow("Unknown option: --unknown"); + }); + }); +}); diff --git a/src/lib/zap-parser.ts b/src/lib/zap-parser.ts index 060ada7..515a02d 100644 --- a/src/lib/zap-parser.ts +++ b/src/lib/zap-parser.ts @@ -11,8 +11,17 @@ import type { EventPointer, AddressPointer } from "./open-parser"; export interface ParsedZapCommand { /** Recipient pubkey (who receives the zap) */ recipientPubkey: string; - /** Optional event being zapped (adds context to the zap) */ - eventPointer?: EventPointer | AddressPointer; + /** Optional event being zapped - regular events (e-tag) */ + eventPointer?: EventPointer; + /** Optional addressable event being zapped - replaceable events (a-tag) */ + addressPointer?: AddressPointer; + /** + * 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[]; } /** @@ -23,6 +32,10 @@ export interface ParsedZapCommand { * - `zap ` - Zap an event (recipient derived from event author) * - `zap ` - Zap a specific person for a specific event * + * Options: + * - `-T, --tag [relay]` - Add custom tag (can be repeated) + * - `-r, --relay ` - Add relay for zap receipt publication (can be repeated) + * * Profile formats: npub, nprofile, hex pubkey, user@domain.com, $me * Event formats: note, nevent, naddr, hex event ID */ @@ -36,31 +49,117 @@ export async function parseZapCommand( ); } - const firstArg = args[0]; - const secondArg = args[1]; + // Parse flags and positional args + const positionalArgs: string[] = []; + const customTags: string[][] = []; + const relays: string[] = []; - // Case 1: Two arguments - zap - if (secondArg) { - const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); - const eventPointer = parseEventPointer(secondArg); - return { recipientPubkey, eventPointer }; + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === "-T" || arg === "--tag") { + // Parse tag: -T [relay-hint] + // Minimum 2 values after -T (type and value), optional relay hint + const tagType = args[i + 1]; + const tagValue = args[i + 2]; + + if (!tagType || !tagValue) { + throw new Error( + "Tag requires at least 2 arguments: -T [relay-hint]", + ); + } + + // Build tag array + const tag = [tagType, tagValue]; + + // Check if next arg is a relay hint (starts with ws:// or wss://) + const potentialRelay = args[i + 3]; + if ( + potentialRelay && + (potentialRelay.startsWith("ws://") || + potentialRelay.startsWith("wss://")) + ) { + try { + tag.push(normalizeRelayURL(potentialRelay)); + i += 4; + } catch { + // Not a valid relay, don't include + i += 3; + } + } else { + i += 3; + } + + customTags.push(tag); + } else if (arg === "-r" || arg === "--relay") { + // Parse relay: -r + const relayUrl = args[i + 1]; + if (!relayUrl) { + throw new Error("Relay option requires a URL: -r "); + } + + try { + relays.push(normalizeRelayURL(relayUrl)); + } catch { + throw new Error(`Invalid relay URL: ${relayUrl}`); + } + i += 2; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } else { + positionalArgs.push(arg); + i += 1; + } } - // Case 2: One argument - try event first, then profile + if (positionalArgs.length === 0) { + throw new Error( + "Recipient or event required. Usage: zap or zap or zap ", + ); + } + + const firstArg = positionalArgs[0]; + const secondArg = positionalArgs[1]; + + // Build result with optional custom tags and relays + const buildResult = ( + recipientPubkey: string, + pointer?: EventPointer | AddressPointer, + ): ParsedZapCommand => { + const result: ParsedZapCommand = { recipientPubkey }; + // Separate EventPointer from AddressPointer based on presence of 'id' vs 'kind' + if (pointer) { + if ("id" in pointer) { + result.eventPointer = pointer; + } else if ("kind" in pointer) { + result.addressPointer = pointer; + } + } + if (customTags.length > 0) result.customTags = customTags; + if (relays.length > 0) result.relays = relays; + return result; + }; + + // Case 1: Two positional arguments - zap + if (secondArg) { + const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); + const pointer = parseEventPointer(secondArg); + return buildResult(recipientPubkey, pointer); + } + + // Case 2: One positional argument - try event first, then profile // Events have more specific patterns (nevent, naddr, note) - const eventPointer = tryParseEventPointer(firstArg); - if (eventPointer) { + const pointer = tryParseEventPointer(firstArg); + if (pointer) { // For events, we'll need to fetch the event to get the author // For now, we'll return a placeholder and let the component fetch it - return { - recipientPubkey: "", // Will be filled in by component from event author - eventPointer, - }; + return buildResult("", pointer); } // Must be a profile const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); - return { recipientPubkey }; + return buildResult(recipientPubkey); } /** diff --git a/src/types/chat.ts b/src/types/chat.ts index f81b518..6030e08 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -49,6 +49,7 @@ export interface LiveActivityMetadata { totalParticipants?: number; hashtags: string[]; relays: string[]; + goal?: string; // Event ID of a kind 9041 zap goal } /** diff --git a/src/types/live-activity.ts b/src/types/live-activity.ts index ca947f1..c5e69c3 100644 --- a/src/types/live-activity.ts +++ b/src/types/live-activity.ts @@ -44,6 +44,7 @@ export interface ParsedLiveActivity { // Additional hashtags: string[]; // 't' tags relays: string[]; // 'relays' tag values + goal?: string; // Event ID of a kind 9041 zap goal // Computed lastUpdate: number; // event.created_at diff --git a/src/types/man.ts b/src/types/man.ts index 230a292..9031732 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -618,9 +618,10 @@ export const manPages: Record = { zap: { name: "zap", section: "1", - synopsis: "zap [event]", + synopsis: + "zap [event] [-T [relay]] [-r ]", description: - "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", + "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Custom tags can be added for protocol-specific tagging (e.g., NIP-53 live activities). Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", options: [ { flag: "", @@ -631,6 +632,16 @@ export const manPages: Record = { flag: "", description: "Event to zap: note, nevent, naddr, hex ID (optional)", }, + { + flag: "-T, --tag [relay]", + description: + "Add custom tag to zap request (can be repeated). Used for protocol-specific tagging like NIP-53 a-tags", + }, + { + flag: "-r, --relay ", + description: + "Relay where zap receipt should be published (can be repeated)", + }, ], examples: [ "zap fiatjaf.com Zap a user by NIP-05", @@ -638,6 +649,8 @@ export const manPages: Record = { "zap nevent1... Zap an event (recipient = event author)", "zap npub1... nevent1... Zap a specific user for a specific event", "zap alice@domain.com naddr1... Zap with event context", + "zap npub1... -T a 30311:pk:id wss://relay.example.com Zap with live activity a-tag", + "zap npub1... -r wss://relay1.com -r wss://relay2.com Zap with custom relays", ], seeAlso: ["profile", "open", "wallet"], appId: "zap", From 97dd30f587ba0759a21f209900bcc170c2e31fca Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 12:33:46 +0100 Subject: [PATCH 07/13] Add anonymous zap option with throwaway signer (#154) * feat: add anonymous zap option Add "Zap anonymously" checkbox that allows users to send zaps without revealing their identity. When enabled, creates a throwaway keypair to sign the zap request instead of using the active account's signer. This also enables users without a signer account to send zaps by checking the anonymous option. * feat: prioritize recipient's inbox relays for zap receipts Add selectZapRelays utility that properly selects relays for zap receipt publication with the following priority: 1. Recipient's inbox relays (so they see the zap) 2. Sender's inbox relays (so sender can verify) 3. Fallback aggregator relays This ensures zap receipts are published where recipients will actually see them, rather than just the sender's relays. Includes comprehensive tests for relay selection logic. --------- Co-authored-by: Claude --- src/components/ZapWindow.tsx | 41 ++++- src/lib/create-zap-request.ts | 89 ++++------ src/lib/zap-relay-selection.test.ts | 266 ++++++++++++++++++++++++++++ src/lib/zap-relay-selection.ts | 138 +++++++++++++++ 4 files changed, 476 insertions(+), 58 deletions(-) create mode 100644 src/lib/zap-relay-selection.test.ts create mode 100644 src/lib/zap-relay-selection.ts diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index d3a94a9..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"; @@ -138,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); @@ -356,6 +361,13 @@ 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, @@ -366,6 +378,7 @@ export function ZapWindow({ lnurl: lud16 || undefined, emojiTags, customTags, + signer: anonymousSigner, }); const serializedZapRequest = serializeZapRequest(zapRequest); @@ -657,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 */} @@ -667,7 +700,7 @@ export function ZapWindow({ )} {/* Payment Button */} - {!canSign ? ( + {!canSign && !zapAnonymously ? ( - - ) : 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 */}