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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-28 20:29:11 +01:00
committed by GitHub
parent f987ec7705
commit fdc7b1499f
9 changed files with 23 additions and 451 deletions

View File

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

View File

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

View File

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

View File

@@ -172,7 +172,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
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)

View File

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

View File

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

View File

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

View File

@@ -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<Conversation> {
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<Message[]> {
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<Message[]> {
// For now, return empty - pagination to be implemented in Phase 6
return [];
}
/**
* Send a message
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
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<void> {
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<NostrEvent | null> {
// 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<void>((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<NostrEvent | undefined> {
return firstValueFrom(eventStore.replaceable(0, pubkey), {
defaultValue: undefined,
});
}
}

View File

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