mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
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:
@@ -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..."
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user