From 29ae487e2adb3f4d30d9acb788142c3e99bfc2ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:47:13 +0000 Subject: [PATCH] fix: Improve NIP-17 chat UX and fix e-tag reply resolution - Hide "load older messages" button for NIP-17 (loads all at once) - Show loading indicator while waiting for message decryption - Remove upload button for NIP-17 (encrypted uploads not supported) - Fix inbox click to pass proper ProtocolIdentifier with hex pubkeys - Fetch inbox relays for all participants (not just current user) - Use participant's outbox relays + aggregators for inbox relay lookup - Fix NIP-10 e-tag reply resolution with proper marker handling (prioritizes "reply" marker, falls back to last unmarked e-tag) --- src/components/ChatViewer.tsx | 52 ++++--- src/components/InboxViewer.tsx | 15 +- src/lib/chat/adapters/nip-17-adapter.ts | 178 ++++++++++++++++++++++-- 3 files changed, 206 insertions(+), 39 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index eb31516..bb85aaa 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -966,7 +966,10 @@ export function ChatViewer({ alignToBottom components={{ Header: () => - hasMore && conversationResult.status === "success" ? ( + // NIP-17 loads all messages at once, no pagination + hasMore && + conversationResult.status === "success" && + conversation.protocol !== "nip-17" ? (
- - -

Attach media

-
- - + {/* Hide upload for NIP-17 (encrypted uploads not yet supported) */} + {conversation.protocol !== "nip-17" && ( + + + + + + +

Attach media

+
+
+
+ )} { - // Build chat identifier from participants - // For self-chat, use $me; for others, use comma-separated npubs + // Build chat identifier from participants as ProtocolIdentifier + // For self-chat, use own pubkey; for others, use comma-separated hex pubkeys const others = conv.participants.filter( (p) => p !== account.pubkey, ); - const identifier = - others.length === 0 ? "$me" : others.join(","); + // Always use hex pubkeys, not $me, to ensure consistent conversation IDs + const value = + others.length === 0 ? account.pubkey : others.join(","); addWindow("chat", { - identifier, + identifier: { + type: "dm-recipient" as const, + value, + relays: [], + }, protocol: "nip-17", }); }} diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 5589722..9b49bdb 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -1,5 +1,5 @@ -import { Observable, of } from "rxjs"; -import { map } from "rxjs/operators"; +import { Observable, of, firstValueFrom } from "rxjs"; +import { map, filter, take, timeout } from "rxjs/operators"; import { nip19 } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; import type { @@ -15,10 +15,16 @@ import giftWrapService, { type Rumor } from "@/services/gift-wrap"; import accountManager from "@/services/accounts"; import { resolveNip05 } from "@/lib/nip05"; import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; +import relayListCache from "@/services/relay-list-cache"; /** Kind 14: Private direct message (NIP-17) */ const PRIVATE_DM_KIND = 14; +/** Kind 10050: DM relay list (NIP-17) */ +const DM_RELAY_LIST_KIND = 10050; + /** * Compute a stable conversation ID from sorted participant pubkeys */ @@ -27,6 +33,87 @@ function computeConversationId(participants: string[]): string { return `nip17:${sorted.join(",")}`; } +/** + * Fetch inbox relays (kind 10050) for a pubkey + * Strategy: + * 1. Check local eventStore first + * 2. Get participant's outbox relays from relay list cache + * 3. Fetch from their outbox relays + aggregator relays + */ +async function fetchInboxRelays(pubkey: string): Promise { + // First check if we already have the event in the store + try { + const existing = await firstValueFrom( + eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey).pipe( + filter((e): e is NostrEvent => e !== undefined), + take(1), + timeout(100), // Very short timeout since this is just checking local store + ), + ); + + if (existing) { + return existing.tags + .filter((tag) => tag[0] === "relay") + .map((tag) => tag[1]) + .filter(Boolean); + } + } catch { + // Not in store, try fetching from relays + } + + // Get participant's outbox relays to query (they should publish their inbox list there) + let outboxRelays: string[] = []; + try { + const cached = await relayListCache.get(pubkey); + if (cached) { + outboxRelays = cached.write.slice(0, 3); // Limit to 3 outbox relays + } + } catch { + // Cache miss, will just use aggregators + } + + // Combine outbox relays with aggregator relays (deduped) + const relaysToQuery = [ + ...outboxRelays, + ...AGGREGATOR_RELAYS.slice(0, 2), + ].filter((url, i, arr) => arr.indexOf(url) === i); + + // Fetch from relays using pool.request + try { + const { toArray } = await import("rxjs/operators"); + const events = await firstValueFrom( + pool + .request( + relaysToQuery, + [{ kinds: [DM_RELAY_LIST_KIND], authors: [pubkey], limit: 1 }], + { eventStore }, + ) + .pipe( + toArray(), + timeout(3000), // 3 second timeout + ), + ); + + if (events.length > 0) { + // Get the most recent event + const latest = events.reduce((a, b) => + a.created_at > b.created_at ? a : b, + ); + return latest.tags + .filter((tag) => tag[0] === "relay") + .map((tag) => tag[1]) + .filter(Boolean); + } + } catch (err) { + console.warn( + `[NIP-17] Failed to fetch inbox relays for ${pubkey.slice(0, 8)}:`, + err, + ); + } + + return []; +} + /** * Parse participants from a comma-separated list or single identifier * Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05, $me @@ -246,15 +333,34 @@ export class Nip17Adapter extends ChatProtocolAdapter { role: pubkey === activePubkey ? "member" : undefined, })); - // Get inbox relays for the current user - const userInboxRelays = giftWrapService.inboxRelays$.value; - - // Build per-participant inbox relay map (start with current user) + // Fetch inbox relays for all participants in parallel const participantInboxRelays: Record = {}; + + // Get current user's relays from service (already loaded) + const userInboxRelays = giftWrapService.inboxRelays$.value; if (userInboxRelays.length > 0) { participantInboxRelays[activePubkey] = userInboxRelays; } + // Fetch inbox relays for other participants in parallel + const otherParticipants = uniqueParticipants.filter( + (p) => p !== activePubkey, + ); + if (otherParticipants.length > 0) { + const relayResults = await Promise.all( + otherParticipants.map(async (pubkey) => ({ + pubkey, + relays: await fetchInboxRelays(pubkey), + })), + ); + + for (const { pubkey, relays } of relayResults) { + if (relays.length > 0) { + participantInboxRelays[pubkey] = relays; + } + } + } + return { id: conversationId, type: "dm", @@ -310,6 +416,54 @@ export class Nip17Adapter extends ChatProtocolAdapter { ); } + /** + * Find the reply target from e-tags using NIP-10 conventions + * + * NIP-10 marker priority: + * 1. Tag with "reply" marker - this is the direct parent + * 2. If only "root" marker exists and no other e-tags - use root as reply target + * 3. Deprecated: last e-tag without markers + */ + private findReplyTarget(tags: string[][]): string | undefined { + const eTags = tags.filter((tag) => tag[0] === "e" && tag[1]); + + if (eTags.length === 0) return undefined; + + // Check for explicit "reply" marker + const replyTag = eTags.find((tag) => tag[3] === "reply"); + if (replyTag) { + return replyTag[1]; + } + + // Check for "root" marker (if it's the only e-tag or no other is marked as reply) + const rootTag = eTags.find((tag) => tag[3] === "root"); + + // Check for unmarked e-tags (deprecated positional style) + const unmarkedTags = eTags.filter( + (tag) => + !tag[3] || + (tag[3] !== "root" && tag[3] !== "reply" && tag[3] !== "mention"), + ); + + // If there are unmarked tags, use the last one as reply (deprecated style) + if (unmarkedTags.length > 0) { + return unmarkedTags[unmarkedTags.length - 1][1]; + } + + // If only root exists, it's both root and reply target + if (rootTag) { + return rootTag[1]; + } + + // Fallback: last e-tag that isn't a mention + const nonMentionTags = eTags.filter((tag) => tag[3] !== "mention"); + if (nonMentionTags.length > 0) { + return nonMentionTags[nonMentionTags.length - 1][1]; + } + + return undefined; + } + /** * Get all participants from a rumor (author + all p-tag recipients) */ @@ -336,14 +490,10 @@ export class Nip17Adapter extends ChatProtocolAdapter { _giftWrap: NostrEvent, rumor: Rumor, ): Message { - // Find reply-to from e tags - let replyTo: string | undefined; - for (const tag of rumor.tags) { - if (tag[0] === "e" && tag[1]) { - // NIP-10: last e tag is usually the reply target - replyTo = tag[1]; - } - } + // Find reply-to from e tags using NIP-10 marker convention + // Markers: "reply" = direct parent, "root" = thread root, "mention" = just a mention + // Format: ["e", , , ] + const replyTo = this.findReplyTarget(rumor.tags); // Create a synthetic event from the rumor for display // This allows RichText to parse content correctly