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