fix(dm): read history from the user's real inbox relays, not a stale list

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.
This commit is contained in:
SkateSpots 2026-09-28 14:13:51 +07:00
parent 40799e1453
commit 08189a71af
4 changed files with 122 additions and 39 deletions

View file

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

View file

@ -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<string[]> => {
const pubkey = user?.pubkey;
if (!pubkey) return [];
const found = new Set<string>();
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<ConversationsResult> => {
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<string, DirectMessage[]>();
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<Conversation[]> => {
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) => {

View file

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

View file

@ -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() {
</CardContent>
</Card>
)}
{/* 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 && (
<p className="pt-1 text-center text-xs text-gray-500">
{undecryptable} encrypted message{undecryptable === 1 ? '' : 's'} couldn&apos;t be
opened by your signer.
</p>
)}
</div>
</div>