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
This commit is contained in:
Claude
2026-01-19 09:44:11 +00:00
parent ab64fc75f4
commit 5415f4f64f
9 changed files with 151 additions and 3 deletions

View File

@@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
conversation={conversation}
adapter={adapter}
message={message}
>
{messageContent}
</ChatMessageContextMenu>

View File

@@ -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);

View File

@@ -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 (
<>
<ContextMenu>
@@ -170,6 +189,12 @@ export function ChatMessageContextMenu({
<Smile className="size-4 mr-2" />
React
</ContextMenuItem>
{zapConfig?.supported && (
<ContextMenuItem onClick={openZapWindow}>
<Zap className="size-4 mr-2" />
Zap
</ContextMenuItem>
)}
<ContextMenuSeparator />
</>
)}

View File

@@ -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<void>;
/**
* 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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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,
};
}

View File

@@ -49,6 +49,7 @@ export interface LiveActivityMetadata {
totalParticipants?: number;
hashtags: string[];
relays: string[];
goal?: string; // Event ID of a kind 9041 zap goal
}
/**

View File

@@ -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