mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
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:
@@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({
|
||||
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
|
||||
conversation={conversation}
|
||||
adapter={adapter}
|
||||
message={message}
|
||||
>
|
||||
{messageContent}
|
||||
</ChatMessageContextMenu>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface LiveActivityMetadata {
|
||||
totalParticipants?: number;
|
||||
hashtags: string[];
|
||||
relays: string[];
|
||||
goal?: string; // Event ID of a kind 9041 zap goal
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user