From 08189a71af8ce2775fc4704122809f56ae47197a Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 14:13:51 +0700 Subject: [PATCH] fix(dm): read history from the user's real inbox relays, not a stale list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four DM read hooks used config.relayMetadata.dmRelays, which is only set by the login-time sync. When it is stale/empty the app reads only NIP-65 relays — where gift wraps almost never are (real case: 94 addressed wraps on nostr.mom, 0-1 on the user's NIP-65 relays), so Messages showed 'No messages yet' with a full inbox. - useResolvedDmRelays(): discovers the user's own kind-10050 inbox relays at query time (per-relay, 6s each, over read relays + aggregators) and unions them in. Used by useConversation/useConversations/useGroupConversations/ useGroupConversation. - useConversations now returns { conversations, undecryptable, wraps } and the DM page surfaces 'N encrypted messages couldn't be opened', so a decrypt failure stops looking identical to an empty inbox. Updated DirectMessagesPage and ContactsPage. --- AGENTS.md | 16 ++++ src/hooks/useDirectMessages.ts | 127 ++++++++++++++++++++++--------- src/pages/ContactsPage.tsx | 4 +- src/pages/DirectMessagesPage.tsx | 14 +++- 4 files changed, 122 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df246f1..db93ef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,22 @@ session with **no other memory of this project**. So: schema** or it silently won't persist. Verified end-to-end: before the fix the gift wrap reached only write relays (recipient found 0); after, it lands on the configured inbox relays (recipient and self-fetch both find it). + - ⚠️ **DM history read from the wrong relays — "No messages yet" with a full + inbox (fixed 2026-09-28).** All four DM read hooks built their relay list + from `config.relayMetadata.dmRelays` (login-time sync only). If that was + stale/empty, only the NIP-65 read relays were queried — and gift wraps + almost never live there (real case: 94 addressed wraps on the user's inbox + relay `nostr.mom`, **0–1** on their NIP-65 relays). Fix: **`useResolvedDmRelays()`** + in `useDirectMessages.ts` discovers the user's own **kind-10050** inbox relays + at query time (per-relay, 6s each, over the read relays + `INBOX_DISCOVERY_RELAYS`) + and unions them in — so a stale config can never silently hide history. Used + by `useConversation`/`useConversations`/`useGroupConversations`/`useGroupConversation`. + **`useConversations` now returns `ConversationsResult` (`{ conversations, + undecryptable, wraps }`)** — consumers are `DirectMessagesPage` and + `ContactsPage` — and the DM page shows "N encrypted messages couldn't be + opened" so a decrypt failure is no longer indistinguishable from an empty + inbox. Diagnostic recipe: count `{#p:[pubkey], kinds:[1059]}` per relay + (per-relay, not one pool query) before assuming a code bug. - **DM send/read robustness (same date):** gift-wrap publishing and gift-wrap reading were both **sequential with one shared timeout**, so a single dead relay (e.g. `wotr.relatr.xyz`) or an auth-required one (`auth.nostr1.com`) diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 1a8951f..cff34ea 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -42,6 +42,74 @@ function amberSafeQueryOptions(interactive: boolean) { }; } +/** + * Aggregators that index kind-10050 (NIP-17 DM inbox) events. Tried alongside + * the user's own relays so inbox discovery never hangs on a single relay. + */ +const INBOX_DISCOVERY_RELAYS = [ + 'wss://purplepag.es', + 'wss://relay.ditto.pub', + 'wss://goskatespots.nostr1.com', + 'wss://nos.lol', +]; + +/** + * The relay set to READ DMs from: the user's NIP-65 read relays and their + * kind-10050 inbox relays (`config.relayMetadata.dmRelays`), PLUS the inbox + * relays discovered LIVE from their kind-10050 event. + * + * ⚠️ The live discovery is load-bearing. `dmRelays` is only populated by the + * login-time sync (`NostrSync`), so a stale or empty value silently hid ALL DM + * history: gift wraps live on inbox relays (e.g. `nostr.mom`) that are usually + * NOT in a user's NIP-65 list. Reading the user's own 10050 at query time makes + * the inbox correct even when the stored config is wrong. (Fixed 2026-09-28.) + */ +function useResolvedDmRelays(): { relays: string[]; key: string } { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { config } = useAppContext(); + + const base = dmReadRelays(config.relayMetadata); + const baseKey = [...base].sort().join(','); + + const { data: discovered } = useQuery({ + queryKey: ['dm-inbox-relays', user?.pubkey, baseKey], + enabled: !!user?.pubkey, + staleTime: 10 * 60 * 1000, + queryFn: async (c): Promise => { + const pubkey = user?.pubkey; + if (!pubkey) return []; + const found = new Set(); + const seeds = [...new Set([...base, ...INBOX_DISCOVERY_RELAYS])]; + await Promise.all( + seeds.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + const events = await relay.query( + [{ kinds: [10050], authors: [pubkey], limit: 1 }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(6000)]) }, + ); + for (const ev of events) { + for (const [name, url] of ev.tags) { + if (name === 'relay' && url) found.add(url); + } + } + } catch { + // One relay failing must not stop discovery — try the rest. + } + }), + ); + return [...found]; + }, + }); + + const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase())); + const relays = [...new Set([...base, ...(discovered ?? [])])].filter( + (u) => !blocked.has(u.toLowerCase()), + ); + return { relays, key: [...relays].sort().join(',') }; +} + // NIP-17 Message Types export interface DirectMessage { id: string; @@ -63,6 +131,19 @@ export interface Conversation { subject?: string; } +/** + * Result of the inbox query. The counts exist so the UI can tell "you have no + * messages" apart from "we fetched N messages but couldn't open them" — the + * latter used to be silently identical to an empty inbox. + */ +export interface ConversationsResult { + conversations: Conversation[]; + /** Fetched gift wraps that failed to decrypt/parse. */ + undecryptable: number; + /** Unique gift wraps considered. */ + wraps: number; +} + // Group chat identifier is hash of sorted participants export function getGroupId(participants: string[]): string { const sorted = [...participants].sort(); @@ -343,21 +424,14 @@ export function useConversation(otherPubkey: string) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); - const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - - const relayUrls = dmReadRelays(config.relayMetadata) - .slice() - .sort() - .join(','); + const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['direct-messages', otherPubkey, user?.pubkey, relayUrls], queryFn: async (c) => { if (!user?.signer?.nip44 || !otherPubkey) return []; - const readRelays = dmReadRelays(config.relayMetadata); - // Query every relay IN PARALLEL with its OWN timeout. Sequentially, one // dead / slow / auth-required relay burned the shared 15s budget and then // the already-aborted signal made every remaining relay fail instantly — @@ -458,20 +532,13 @@ export function useConversations() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); - const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - - const relayUrls = dmReadRelays(config.relayMetadata) - .slice() - .sort() - .join(','); + const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['conversations', user?.pubkey, relayUrls], - queryFn: async (c) => { - if (!user?.signer?.nip44) return []; - - const readRelays = dmReadRelays(config.relayMetadata); + queryFn: async (c): Promise => { + if (!user?.signer?.nip44) return { conversations: [], undecryptable: 0, wraps: 0 }; // Query for gift wraps addressed to us const batches = await Promise.all( @@ -501,6 +568,7 @@ export function useConversations() { // Decrypt and organize by conversation const conversationMap = new Map(); + let undecryptable = 0; for (const giftWrap of uniqueEvents) { try { @@ -551,6 +619,7 @@ export function useConversations() { conversationMap.set(conversationWith, existing); } catch (error) { + undecryptable += 1; console.debug('[DM] Failed to decrypt:', error); } } @@ -570,9 +639,10 @@ export function useConversations() { } // Sort conversations by last message time - return conversations.sort((a, b) => + const sorted = conversations.sort((a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0) ); + return { conversations: sorted, undecryptable, wraps: uniqueEvents.length }; }, enabled: nip44Available, ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), @@ -595,21 +665,14 @@ export function useGroupConversations() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); - const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - - const relayUrls = dmReadRelays(config.relayMetadata) - .slice() - .sort() - .join(','); + const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['group-conversations', user?.pubkey, relayUrls], queryFn: async (c): Promise => { if (!user?.signer?.nip44) return []; - const readRelays = dmReadRelays(config.relayMetadata); - const batches = await Promise.all( readRelays.map(async (relayUrl) => { try { @@ -709,23 +772,15 @@ export function useGroupConversation(participants: string[]) { const { nostr } = useNostr(); const { logins } = useNostrLogin(); const { user } = useCurrentUser(); - const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - const groupId = getGroupId(participants); - - const relayUrls = dmReadRelays(config.relayMetadata) - .slice() - .sort() - .join(','); + const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['group-messages', groupId, user?.pubkey, relayUrls], queryFn: async (c) => { if (!user?.signer?.nip44 || participants.length === 0) return []; - const readRelays = dmReadRelays(config.relayMetadata); - // Query for gift wraps addressed to us const batches = await Promise.all( readRelays.map(async (relayUrl) => { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 065c04f..4144e97 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -127,7 +127,7 @@ export default function ContactsPage() { const { data: following, isLoading: followingLoading } = useFollowing(); const { crewmates, isLoading: crewmatesLoading } = useCrewmates(); - const { data: conversations, isLoading: convosLoading } = useConversations(); + const { data: inbox, isLoading: convosLoading } = useConversations(); const { data: savedContacts } = usePrivateContacts(); const { addContact, removeContact, isAdding, isRemoving } = usePrivateContactActions(); @@ -190,7 +190,7 @@ export default function ContactsPage() { const crewmatesFiltered = crewmates.filter((c) => matchNpub(c.pubkey)); const followingFiltered = (following ?? []).filter(matchNpub); - const convoPubkeys = (conversations ?? []).map((c) => c.pubkey).filter(matchNpub); + const convoPubkeys = (inbox?.conversations ?? []).map((c) => c.pubkey).filter(matchNpub); const savedFiltered = (savedContacts ?? []).filter(matchNpub); const renderList = (pubkeys: string[], subtitleFor?: (pk: string) => React.ReactNode) => ( diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index bf153a3..20130fb 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -207,7 +207,9 @@ function ConversationSkeleton() { export default function DirectMessagesPage() { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); - const { data: conversations, isLoading, error, refetch } = useConversations(); + const { data: inbox, isLoading, error, refetch } = useConversations(); + const conversations = inbox?.conversations; + const undecryptable = inbox?.undecryptable ?? 0; const { data: groups } = useGroupConversations(); const { getLastRead } = useDMRead(); const [showNewMessage, setShowNewMessage] = useState(false); @@ -388,6 +390,16 @@ export default function DirectMessagesPage() { )} + + {/* Honest diagnostic: fetched-but-unreadable is NOT the same as an empty + inbox. Without this, a decrypt failure looks identical to "nothing + here" and is impossible to diagnose. */} + {undecryptable > 0 && ( +

+ {undecryptable} encrypted message{undecryptable === 1 ? '' : 's'} couldn't be + opened by your signer. +

+ )}