From fdc7b1499fb94f112b464155e672dbce5116b930 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 28 Jan 2026 20:29:11 +0100 Subject: [PATCH] fix: build proper q-tag with relay hint and author pubkey for replies (#224) * fix: build proper q-tag with relay hint and author pubkey for replies When sending replies in NIP-29 and NIP-C7 adapters, now build the full q-tag format per NIP-C7 spec: ["q", eventId, relayUrl, pubkey] Previously only the event ID was included, making it harder for clients to fetch the referenced event. Now: - NIP-29: includes group relay URL and author pubkey - NIP-C7: includes seen relay hint and author pubkey https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K * chore: remove unused NIP-C7 adapter The NIP-C7 adapter was already disabled/commented out everywhere. Removing the file to reduce dead code. https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K * chore: remove NIP-C7 references from docs and code - Remove nip-c7 from ChatProtocol type - Remove commented NIP-C7 adapter imports and switch cases - Update comments to reference NIP-29 instead of NIP-C7 - Update kind 9 renderer docs to reference NIP-29 - Clean up chat-parser docs and error messages https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 3 - src/components/DynamicWindowTitle.tsx | 3 - .../nostr/kinds/ChatMessageRenderer.tsx | 4 +- src/components/nostr/kinds/index.tsx | 2 +- src/lib/chat-parser.test.ts | 2 +- src/lib/chat-parser.ts | 9 +- src/lib/chat/adapters/nip-29-adapter.ts | 17 +- src/lib/chat/adapters/nip-c7-adapter.ts | 422 ------------------ src/types/chat.ts | 12 +- 9 files changed, 23 insertions(+), 451 deletions(-) delete mode 100644 src/lib/chat/adapters/nip-c7-adapter.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index c5737db..efd7d23 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -26,7 +26,6 @@ import type { LiveActivityMetadata, } from "@/types/chat"; import { CHAT_KINDS } from "@/types/chat"; -// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter"; import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; @@ -1243,8 +1242,6 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { switch (protocol) { case "nip-10": return new Nip10Adapter(); - // case "nip-c7": // Phase 1 - Simple chat (coming soon) - // return new NipC7Adapter(); case "nip-29": return new Nip29Adapter(); // case "nip-17": // Phase 2 - Encrypted DMs (coming soon) diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 0ab9d65..a3575ee 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -24,7 +24,6 @@ import { getEventDisplayTitle } from "@/lib/event-title"; import { UserName } from "./nostr/UserName"; import { getTagValues } from "@/lib/nostr-utils"; import { getSemanticAuthor } from "@/lib/semantic-author"; -// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat"; import { useState, useEffect } from "react"; @@ -742,8 +741,6 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { // Currently only NIP-29 is supported const getAdapter = () => { switch (protocol) { - // case "nip-c7": // Coming soon - // return new NipC7Adapter(); case "nip-29": return new Nip29Adapter(); default: diff --git a/src/components/nostr/kinds/ChatMessageRenderer.tsx b/src/components/nostr/kinds/ChatMessageRenderer.tsx index b39e358..bf7c70f 100644 --- a/src/components/nostr/kinds/ChatMessageRenderer.tsx +++ b/src/components/nostr/kinds/ChatMessageRenderer.tsx @@ -9,13 +9,13 @@ import { isValidHexEventId } from "@/lib/nostr-validation"; import { InlineReplySkeleton } from "@/components/ui/skeleton"; /** - * Renderer for Kind 9 - Chat Message (NIP-C7) + * Renderer for Kind 9 - Chat Message (NIP-29) * Displays chat messages with optional quoted parent message */ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) { const { addWindow } = useGrimoire(); - // Parse 'q' tag for quoted parent message (NIP-C7 reply format) + // Parse 'q' tag for quoted parent message const quotedEventIds = getTagValues(event, "q"); const quotedEventId = quotedEventIds[0]; // First q tag diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 8add925..4d38bc8 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -172,7 +172,7 @@ const kindRenderers: Record> = { 6: RepostRenderer, // Repost 7: Kind7Renderer, // Reaction 8: BadgeAwardRenderer, // Badge Award (NIP-58) - 9: Kind9Renderer, // Chat Message (NIP-C7) + 9: Kind9Renderer, // Chat Message (NIP-29) 11: Kind1Renderer, // Public Thread Reply (NIP-10) 16: RepostRenderer, // Generic Repost 17: Kind7Renderer, // Reaction (NIP-25) diff --git a/src/lib/chat-parser.test.ts b/src/lib/chat-parser.test.ts index d0f430c..80f010d 100644 --- a/src/lib/chat-parser.test.ts +++ b/src/lib/chat-parser.test.ts @@ -89,7 +89,7 @@ describe("parseChatCommand", () => { ); }); - it("should throw error for npub (NIP-C7 disabled)", () => { + it("should throw error for npub (DMs not yet supported)", () => { expect(() => parseChatCommand(["npub1xyz"])).toThrow( /Unable to determine chat protocol/, ); diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index a393740..3ddf3f9 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,5 +1,4 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat"; -// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter"; import { Nip10Adapter } from "./chat/adapters/nip-10-adapter"; import { Nip29Adapter } from "./chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; @@ -17,7 +16,6 @@ import { nip19 } from "nostr-tools"; * 3. NIP-28 (channels) - specific event format (kind 40) * 4. NIP-29 (groups) - specific group ID format * 5. NIP-53 (live chat) - specific addressable format (kind 30311) - * 6. NIP-C7 (simple chat) - fallback for generic pubkeys * * @param args - Command arguments (first arg is the identifier) * @returns Parsed result with protocol and identifier @@ -67,9 +65,8 @@ export function parseChatCommand(args: string[]): ChatCommandResult { new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note) // new Nip17Adapter(), // Phase 2 // new Nip28Adapter(), // Phase 3 - new Nip29Adapter(), // Phase 4 - Relay groups - new Nip53Adapter(), // Phase 5 - Live activity chat - // new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now) + new Nip29Adapter(), // NIP-29 - Relay groups + new Nip53Adapter(), // NIP-53 - Live activity chat ]; for (const adapter of adapters) { @@ -106,6 +103,6 @@ Currently supported formats: chat naddr1... (group list address) More formats coming soon: - - npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)`, + - npub/nprofile/hex pubkey (NIP-17 direct messages)`, ); } diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index c2c9a99..4cb4646 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -459,9 +459,18 @@ export class Nip29Adapter extends ChatProtocolAdapter { }, ); - // Add q-tag for replies (NIP-29 specific, not in blueprint yet) + // Add q-tag for replies (quote tag format) + // Format: ["q", eventId, relayUrl, pubkey] if (options?.replyTo) { - draft.tags.push(["q", options.replyTo]); + // Look up the event to get the author's pubkey for the q-tag + const replyEvent = eventStore.getEvent(options.replyTo); + if (replyEvent) { + // Full q-tag with relay hint and author pubkey + draft.tags.push(["q", options.replyTo, relayUrl, replyEvent.pubkey]); + } else { + // Fallback: at minimum include the relay hint since we know it + draft.tags.push(["q", options.replyTo, relayUrl]); + } } // Add NIP-92 imeta tags for blob attachments (not yet handled by applesauce) @@ -545,7 +554,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { getCapabilities(): ChatCapabilities { return { supportsEncryption: false, // kind 9 messages are public - supportsThreading: true, // q-tag replies (NIP-C7 style) + supportsThreading: true, // q-tag replies supportsModeration: true, // kind 9005/9006 for delete/ban supportsRoles: true, // admin, moderator, member supportsGroupManagement: true, // join/leave via kind 9021 @@ -1108,7 +1117,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { } // Regular chat message (kind 9) - // Look for reply q-tags (NIP-29 uses q-tags like NIP-C7) + // Look for reply q-tags // Use getQuotePointer to extract full EventPointer with relay hints const replyTo = getQuotePointer(event); diff --git a/src/lib/chat/adapters/nip-c7-adapter.ts b/src/lib/chat/adapters/nip-c7-adapter.ts deleted file mode 100644 index ff16fdc..0000000 --- a/src/lib/chat/adapters/nip-c7-adapter.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { Observable, firstValueFrom } from "rxjs"; -import { map, first } from "rxjs/operators"; -import { nip19 } from "nostr-tools"; -import type { Filter } from "nostr-tools"; -import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; -import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; -import type { - Conversation, - Message, - ProtocolIdentifier, - ChatCapabilities, - LoadMessagesOptions, -} from "@/types/chat"; -import type { NostrEvent } from "@/types/nostr"; -import eventStore from "@/services/event-store"; -import pool from "@/services/relay-pool"; -import { publishEvent } from "@/services/hub"; -import accountManager from "@/services/accounts"; -import { isNip05, resolveNip05 } from "@/lib/nip05"; -import { getDisplayName, getQuotePointer } from "@/lib/nostr-utils"; -import { isValidHexPubkey } from "@/lib/nostr-validation"; -import { getProfileContent } from "applesauce-core/helpers"; -import { EventFactory } from "applesauce-core/event-factory"; -import { ReactionBlueprint } from "applesauce-common/blueprints"; - -/** - * NIP-C7 Adapter - Simple Chat (Kind 9) - * - * Features: - * - Direct messaging between users - * - Quote-based threading (q-tag) - * - No encryption - * - Uses outbox relays - */ -export class NipC7Adapter extends ChatProtocolAdapter { - readonly protocol = "nip-c7" as const; - readonly type = "dm" as const; - - /** - * Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05 - */ - parseIdentifier(input: string): ProtocolIdentifier | null { - // Try bech32 decoding (npub/nprofile) - try { - const decoded = nip19.decode(input); - if (decoded.type === "npub") { - return { - type: "chat-partner", - value: decoded.data, - }; - } - if (decoded.type === "nprofile") { - return { - type: "chat-partner", - value: decoded.data.pubkey, - relays: decoded.data.relays, - }; - } - } catch { - // Not bech32, try other formats - } - - // Try hex pubkey - if (isValidHexPubkey(input)) { - return { - type: "chat-partner", - value: input, - }; - } - - // Try NIP-05 - if (isNip05(input)) { - return { - type: "chat-partner-nip05", - value: input, - }; - } - - return null; - } - - /** - * Resolve conversation from identifier - */ - async resolveConversation( - identifier: ProtocolIdentifier, - ): Promise { - let pubkey: string; - - // Resolve NIP-05 if needed - if (identifier.type === "chat-partner-nip05") { - const resolved = await resolveNip05(identifier.value); - if (!resolved) { - throw new Error(`Failed to resolve NIP-05: ${identifier.value}`); - } - pubkey = resolved; - } else if ( - identifier.type === "chat-partner" || - identifier.type === "dm-recipient" - ) { - pubkey = identifier.value; - } else { - throw new Error( - `NIP-C7 adapter cannot handle identifier type: ${identifier.type}`, - ); - } - - const activePubkey = accountManager.active$.value?.pubkey; - if (!activePubkey) { - throw new Error("No active account"); - } - - // Get display name for partner - const metadataEvent = await this.getMetadata(pubkey); - const metadata = metadataEvent - ? getProfileContent(metadataEvent) - : undefined; - const title = getDisplayName(pubkey, metadata); - - return { - id: `nip-c7:${pubkey}`, - type: "dm", - protocol: "nip-c7", - title, - participants: [ - { pubkey: activePubkey, role: "member" }, - { pubkey, role: "member" }, - ], - unreadCount: 0, - }; - } - - /** - * Load messages between active user and conversation partner - */ - loadMessages( - conversation: Conversation, - options?: LoadMessagesOptions, - ): Observable { - const activePubkey = accountManager.active$.value?.pubkey; - if (!activePubkey) { - throw new Error("No active account"); - } - - const partner = conversation.participants.find( - (p) => p.pubkey !== activePubkey, - ); - if (!partner) { - throw new Error("No conversation partner found"); - } - - // Subscribe to kind 9 messages between users - const filter: Filter = { - kinds: [9], - authors: [activePubkey, partner.pubkey], - "#p": [activePubkey, partner.pubkey], - limit: options?.limit || 50, - }; - - if (options?.before) { - filter.until = options.before; - } - if (options?.after) { - filter.since = options.after; - } - - // Start subscription to populate EventStore - pool - .subscription([], [filter], { - eventStore, // Automatically add to store - }) - .subscribe({ - next: (response) => { - if (typeof response === "string") { - // EOSE received - console.log("[NIP-C7] EOSE received for messages"); - } else { - // Event received - console.log( - `[NIP-C7] Received message: ${response.id.slice(0, 8)}...`, - ); - } - }, - }); - - // Return observable from EventStore which will update automatically - return eventStore.timeline(filter).pipe( - map((events) => { - console.log(`[NIP-C7] Timeline has ${events.length} messages`); - return events - .map((event) => this.eventToMessage(event, conversation.id)) - .sort((a, b) => a.timestamp - b.timestamp); - }), - ); - } - - /** - * Load more historical messages (pagination) - */ - async loadMoreMessages( - _conversation: Conversation, - _before: number, - ): Promise { - // For now, return empty - pagination to be implemented in Phase 6 - return []; - } - - /** - * Send a message - */ - async sendMessage( - conversation: Conversation, - content: string, - options?: SendMessageOptions, - ): Promise { - const activePubkey = accountManager.active$.value?.pubkey; - const activeSigner = accountManager.active$.value?.signer; - - if (!activePubkey || !activeSigner) { - throw new Error("No active account or signer"); - } - - const partner = conversation.participants.find( - (p) => p.pubkey !== activePubkey, - ); - if (!partner) { - throw new Error("No conversation partner found"); - } - - // Create event factory and sign event - const factory = new EventFactory(); - factory.setSigner(activeSigner); - - const tags: string[][] = [["p", partner.pubkey]]; - if (options?.replyTo) { - tags.push(["q", options.replyTo]); // NIP-C7 quote tag for threading - } - - // Add NIP-30 emoji tags - if (options?.emojiTags) { - for (const emoji of options.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); - } - } - - const draft = await factory.build({ kind: 9, content, tags }); - const event = await factory.sign(draft); - await publishEvent(event); - } - - /** - * Send a reaction (kind 7) to a message - */ - async sendReaction( - conversation: Conversation, - messageId: string, - emoji: string, - customEmoji?: { shortcode: string; url: string }, - ): Promise { - const activePubkey = accountManager.active$.value?.pubkey; - const activeSigner = accountManager.active$.value?.signer; - - if (!activePubkey || !activeSigner) { - throw new Error("No active account or signer"); - } - - const partner = conversation.participants.find( - (p) => p.pubkey !== activePubkey, - ); - if (!partner) { - throw new Error("No conversation partner found"); - } - - // Fetch the message being reacted to - const messageEvent = await firstValueFrom(eventStore.event(messageId), { - defaultValue: undefined, - }); - - if (!messageEvent) { - throw new Error("Message event not found"); - } - - // Create event factory - const factory = new EventFactory(); - factory.setSigner(activeSigner); - - // Use ReactionBlueprint - auto-handles e-tag, k-tag, p-tag, custom emoji - const emojiArg = customEmoji - ? { shortcode: customEmoji.shortcode, url: customEmoji.url } - : emoji; - - const draft = await factory.create( - ReactionBlueprint, - messageEvent, - emojiArg, - ); - - // Note: ReactionBlueprint already adds p-tag for message author - // For NIP-C7, we might want to ensure partner is tagged if different from author - // but the blueprint should handle this correctly - - // Sign the event - const event = await factory.sign(draft); - await publishEvent(event); - } - - /** - * Get protocol capabilities - */ - getCapabilities(): ChatCapabilities { - return { - supportsEncryption: false, - supportsThreading: true, // q-tag quotes - supportsModeration: false, - supportsRoles: false, - supportsGroupManagement: false, - canCreateConversations: true, - requiresRelay: false, - }; - } - - /** - * Load a replied-to message - * First checks EventStore, then fetches from relays if needed - */ - async loadReplyMessage( - _conversation: Conversation, - pointer: EventPointer | AddressPointer, - ): Promise { - // Extract event ID from pointer (EventPointer has 'id', AddressPointer doesn't) - const eventId = "id" in pointer ? pointer.id : null; - - if (!eventId) { - console.warn( - "[NIP-C7] AddressPointer not supported for loadReplyMessage", - ); - return null; - } - - // First check EventStore - might already be loaded - const cachedEvent = await eventStore - .event(eventId) - .pipe(first()) - .toPromise(); - if (cachedEvent) { - return cachedEvent; - } - - // Not in store, fetch from relay pool (use pointer relays if available) - const relays = pointer.relays || []; - console.log( - `[NIP-C7] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length > 0 ? relays.join(", ") : "global pool"}`, - ); - - const filter: Filter = { - ids: [eventId], - limit: 1, - }; - - const events: NostrEvent[] = []; - const obs = pool.subscription(relays, [filter], { eventStore }); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log( - `[NIP-C7] Reply message fetch timeout for ${eventId.slice(0, 8)}...`, - ); - resolve(); - }, 3000); - - const sub = obs.subscribe({ - next: (response) => { - if (typeof response === "string") { - // EOSE received - clearTimeout(timeout); - sub.unsubscribe(); - resolve(); - } else { - // Event received - events.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error(`[NIP-C7] Reply message fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); - }); - - return events[0] || null; - } - - /** - * Helper: Convert Nostr event to Message - */ - private eventToMessage(event: NostrEvent, conversationId: string): Message { - // Use getQuotePointer to extract full EventPointer with relay hints - const replyTo = getQuotePointer(event); - - return { - id: event.id, - conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - replyTo: replyTo || undefined, - protocol: "nip-c7", - event, - }; - } - - /** - * Helper: Get user metadata - */ - private async getMetadata(pubkey: string): Promise { - return firstValueFrom(eventStore.replaceable(0, pubkey), { - defaultValue: undefined, - }); - } -} diff --git a/src/types/chat.ts b/src/types/chat.ts index 39228f2..cdab266 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -15,13 +15,7 @@ export const CHAT_KINDS = [ /** * Chat protocol identifier */ -export type ChatProtocol = - | "nip-c7" - | "nip-17" - | "nip-28" - | "nip-29" - | "nip-53" - | "nip-10"; +export type ChatProtocol = "nip-17" | "nip-28" | "nip-29" | "nip-53" | "nip-10"; /** * Conversation type @@ -171,7 +165,7 @@ export interface LiveActivityIdentifier { } /** - * NIP-C7/NIP-17 direct message identifier (resolved pubkey) + * NIP-17 direct message identifier (resolved pubkey) */ export interface DMIdentifier { type: "dm-recipient" | "chat-partner"; @@ -182,7 +176,7 @@ export interface DMIdentifier { } /** - * NIP-C7 NIP-05 identifier (needs resolution) + * NIP-05 identifier for DMs (needs resolution) */ export interface NIP05Identifier { type: "chat-partner-nip05";