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)
This commit is contained in:
Claude
2026-01-16 10:47:13 +00:00
parent 9332dcc35a
commit 29ae487e2a
3 changed files with 206 additions and 39 deletions

View File

@@ -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" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}
@@ -1014,6 +1017,12 @@ export function ChatViewer({
}}
style={{ height: "100%" }}
/>
) : messages === undefined && conversation.protocol === "nip-17" ? (
// NIP-17: show loading while waiting for decryption
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-sm">Loading messages...</span>
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start the conversation!
@@ -1031,25 +1040,28 @@ export function ChatViewer({
/>
)}
<div className="flex gap-1.5 items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
onClick={openUpload}
disabled={isSending}
>
<Paperclip className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach media</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hide upload for NIP-17 (encrypted uploads not yet supported) */}
{conversation.protocol !== "nip-17" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
onClick={openUpload}
disabled={isSending}
>
<Paperclip className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach media</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<MentionEditor
ref={editorRef}
placeholder="Type a message..."

View File

@@ -294,15 +294,20 @@ function InboxViewer() {
conversation={conv}
currentUserPubkey={account.pubkey}
onClick={() => {
// 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",
});
}}

View File

@@ -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<string[]> {
// 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<string, string[]> = {};
// 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", <event-id>, <relay-hint>, <marker>]
const replyTo = this.findReplyTarget(rumor.tags);
// Create a synthetic event from the rumor for display
// This allows RichText to parse content correctly