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/ZapWindow.tsx b/src/components/ZapWindow.tsx index 2580eff..d5da0cc 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -57,6 +57,11 @@ export interface ZapWindowProps { eventPointer?: EventPointer | 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[][]; } // Default preset amounts in sats @@ -83,6 +88,7 @@ export function ZapWindow({ recipientPubkey: initialRecipientPubkey, eventPointer, onClose, + customTags, }: ZapWindowProps) { // Load event if we have a pointer and no recipient pubkey (derive from event author) const event = use$(() => { @@ -356,6 +362,7 @@ export function ZapWindow({ eventPointer, 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..358d0b9 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,15 @@ export function ChatMessageContextMenu({ } }; + const openZapWindow = () => { + if (!zapConfig || !zapConfig.supported) return; + + addWindow("zap", { + recipientPubkey: zapConfig.recipientPubkey, + customTags: zapConfig.customTags, + }); + }; + return ( <> @@ -170,6 +189,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..d2ac8bc 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -17,6 +17,23 @@ 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; + /** 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 +197,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..d0067a6 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,63 @@ export class Nip53Adapter extends ChatProtocolAdapter { }; } + /** + * Get zap configuration for a message in a live activity + * + * NIP-53 zap tagging rules: + * - Always include: p-tag (message author), a-tag (live activity) + * - If zapping the host AND stream has a goal: also e-tag the goal + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + const activityAddress = conversation.metadata?.activityAddress; + const liveActivity = conversation.metadata?.liveActivity as + | { + relays?: string[]; + hostPubkey?: string; + goal?: string; + } + | undefined; + + if (!activityAddress) { + return { + supported: false, + unsupportedReason: "Missing activity address", + recipientPubkey: "", + }; + } + + const { pubkey: activityPubkey, identifier } = activityAddress; + const aTagValue = `30311:${activityPubkey}:${identifier}`; + const hostPubkey = liveActivity?.hostPubkey; + const goal = liveActivity?.goal; + + // Get relays + const relays = + liveActivity?.relays && liveActivity.relays.length > 0 + ? liveActivity.relays + : conversation.metadata?.relayUrl + ? [conversation.metadata.relayUrl] + : []; + + // Build custom tags + const customTags: string[][] = [ + // Always a-tag the live activity + ["a", aTagValue, relays[0] || ""], + ]; + + // If zapping the host AND stream has a goal, e-tag the goal + if (message.author === hostPubkey && goal) { + customTags.push(["e", goal, relays[0] || ""]); + } + + return { + supported: true, + recipientPubkey: message.author, + customTags, + relays, + }; + } + /** * Load a replied-to message * First checks EventStore, then fetches from relays if needed diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts index 34d1ea6..80382ca 100644 --- a/src/lib/create-zap-request.ts +++ b/src/lib/create-zap-request.ts @@ -29,6 +29,12 @@ export interface ZapRequestParams { lnurl?: string; /** NIP-30 custom emoji tags */ emojiTags?: EmojiTag[]; + /** + * Custom tags to include in the zap request (beyond standard p/amount/relays) + * Used for protocol-specific tagging like NIP-53 live activity references + * These are added after eventPointer tags to allow overriding + */ + customTags?: string[][]; } /** @@ -94,6 +100,13 @@ export async function createZapRequest( } } + // Add custom tags (protocol-specific like NIP-53 live activity references) + if (params.customTags) { + for (const tag of params.customTags) { + tags.push(tag); + } + } + // Add NIP-30 emoji tags if (params.emojiTags) { for (const emoji of params.emojiTags) { 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/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