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",