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:
parent
40799e1453
commit
08189a71af
4 changed files with 122 additions and 39 deletions
16
AGENTS.md
16
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`)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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't be
|
||||
opened by your signer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue