import { useCallback } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { nip44, nip19, generateSecretKey, finalizeEvent, getEventHash, verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { useRelays } from '@/lib/relays'; import { SKATE_RELAY } from '@/lib/types'; import { markOwnDmWraps } from '@/lib/dmOwnWraps'; import type { RelayMetadata } from '@/contexts/AppContext'; import { loadDmDecryptCache, saveDmDecryptKey, pruneDmDecryptCache, type CachedDm, } from '@/lib/dmDecryptCache'; /** * Read relays for DMs: the user's NIP-65 read relays plus their own NIP-17 * DM inbox relays (kind 10050), deduped. */ function dmReadRelays(metadata: RelayMetadata): string[] { const blocked = new Set((metadata.blockedRelays ?? []).map((u) => u.toLowerCase())); const read = metadata.relays.filter((r) => r.read).map((r) => r.url); return [...new Set([...(metadata.dmRelays ?? []), ...read])].filter( (u) => !blocked.has(u.toLowerCase()), ); } /** * React Query options that keep an interactive signer (Amber) from being stormed * with decrypt popups. Each DM query decrypts up to hundreds of gift wraps, and * every decrypt is an Amber intent — so with Amber we must NOT poll and must NOT * re-run on window focus (Amber handing focus back would re-trigger the query * and pop Amber again). With a silent signer (extension/nsec/bunker) we keep the * live polling behaviour. Callers spread this over their base options. */ function amberSafeQueryOptions(interactive: boolean) { return { refetchOnWindowFocus: false as const, // A relay reconnect would otherwise re-run the decrypt storm. refetchOnReconnect: !interactive, // No background polling for Amber (each poll = a decrypt storm). Silent // signers keep a 60s poll for near-live updates. refetchInterval: interactive ? (false as const) : 60000, // For Amber the whole point is: decrypt ONCE, then never again while the // session lives. A finite staleTime (it was 5 min) still re-decrypted on a // later navigation; `gcTime: Infinity` + `refetchOnMount: false` keep the // result cached for the session (mirrors useStash/useSyncedAppData). A // manual refresh still works — `refetch()` ignores staleTime. staleTime: interactive ? Infinity : 30000, ...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}), }; } /** * 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://relay.skate-spots.org', '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.) */ export 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; pubkey: string; // Sender's pubkey content: string; createdAt: number; replyTo?: string; // Parent message ID conversationWith: string; // The other party's pubkey (for 1:1) or group ID (for groups) participants?: string[]; // All participants in group chat subject?: string; // Optional conversation subject/title } export interface Conversation { pubkey: string; // The other party (1:1) or empty for groups participants?: string[]; // All participants (for groups) lastMessage?: DirectMessage; unreadCount: number; isGroup?: boolean; 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(); return sorted.join(':'); } // Constants const GIFT_WRAP_KIND = 1059; /** * Plaintext marker on the RECIPIENT copy of a gift wrap. The push service watches * for it (see `push-service/`); it leaks only "this wrap wants a push", nothing * about the content. Never added to the self copy. */ const PUSH_MARKER_TAG = 'skatespots'; const SEAL_KIND = 13; const DM_KIND = 14; const TWO_DAYS = 2 * 24 * 60 * 60; // Helper to generate random timestamp within past 2 days const randomPastTimestamp = () => Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS); /** * Create an unsigned rumor event (NIP-17 kind 14) * Supports both 1:1 and group conversations */ function createRumor( senderPubkey: string, recipientPubkeys: string | string[], content: string, options?: { replyTo?: string; subject?: string; } ): NostrEvent { const recipients = Array.isArray(recipientPubkeys) ? recipientPubkeys : [recipientPubkeys]; const tags: string[][] = recipients.map(pubkey => ['p', pubkey]); if (options?.replyTo) { tags.push(['e', options.replyTo]); } if (options?.subject) { tags.push(['subject', options.subject]); } const rumor = { pubkey: senderPubkey, created_at: Math.floor(Date.now() / 1000), kind: DM_KIND, tags, content, }; // Calculate id but don't sign const id = getEventHash(rumor as NostrEvent); return { ...rumor, id, sig: '', // Unsigned } as NostrEvent; } /** A decrypted NIP-17 message (the fields every reader needs). */ type DecryptedDm = CachedDm; /** * Session cache of decrypted gift wraps, keyed by `viewerPk:wrapId`. * * Every reader (`useConversations`, `useConversation`, `useGroupConversations`, * `useGroupConversation`) decrypts the SAME wraps — two signer operations each. * Without a shared cache, opening Messages decrypts the whole inbox, then opening * a single crew thread decrypts it ALL AGAIN (different query key), and each * further thread again — a pop-up storm for an interactive signer (Amber) and * wasted work for everyone. Decryption is deterministic and per-recipient, so a * successful result is safe to reuse (in-flight calls are coalesced too). * Only SUCCESSES are cached: a transient failure (e.g. an Amber timeout) is * retried rather than remembered. Bounded to avoid unbounded growth. */ const DM_DECRYPT_CACHE_MAX = 4000; const dmDecryptCache = new Map(); const dmDecryptInFlight = new Map>(); /** * Seal plaintext cache. A gift wrap needs TWO decrypts (seal, then rumor). If * the user navigates while Amber is open after the first one, the query aborts * before we can build the full message. Keeping the seal means the next visit * only asks Amber for the rumor instead of prompting twice for the same wrap. */ const dmSealCache = new Map(); function rememberSeal(key: string, sealJson: string): void { if (dmSealCache.size >= DM_DECRYPT_CACHE_MAX) { const oldest = dmSealCache.keys().next().value; if (oldest !== undefined) dmSealCache.delete(oldest); } dmSealCache.set(key, sealJson); } // Which accounts' persisted caches have been loaded into memory this session, // and any hydration in flight (so concurrent readers await the same load). const dmCacheHydrated = new Set(); const dmCacheHydrating = new Map>(); /** Load an account's persisted decrypt cache into memory (once per session). */ async function ensureDmCacheHydrated(viewerPk: string): Promise { if (dmCacheHydrated.has(viewerPk)) return; const flying = dmCacheHydrating.get(viewerPk); if (flying) return flying; const task = (async () => { try { const rows = await loadDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX); for (const [k, dm] of rows) { if (!dmDecryptCache.has(k)) dmDecryptCache.set(k, dm); } dmCacheHydrated.add(viewerPk); // Keep the on-disk set bounded (oldest dropped first). void pruneDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX).catch(() => {}); } catch { // A cache read failure just means we decrypt again — never fatal. } finally { dmCacheHydrating.delete(viewerPk); } })(); dmCacheHydrating.set(viewerPk, task); return task; } /** The signer surface DM reading needs (NIP-44 decrypt). */ interface DmSigner { nip44?: { decrypt: (pubkey: string, ciphertext: string) => Promise; }; } async function decryptGiftWrap( viewerPk: string, signer: DmSigner, wrap: NostrEvent, signal?: AbortSignal, ): Promise { // A cancelled query must stop launching NEW Amber intents. Without this, a // user who starts decrypting the inbox and then navigates away would keep // getting Amber popups for every remaining gift wrap in the background. if (signal?.aborted) return null; const key = `${viewerPk}:${wrap.id}`; // Hydrate the persisted cache once per account before the first lookup, so a // relaunch reuses decryptions that already happened on this device. await ensureDmCacheHydrated(viewerPk); if (signal?.aborted) return null; const cached = dmDecryptCache.get(key); if (cached) return cached; const flying = dmDecryptInFlight.get(key); if (flying) return flying; const task = (async (): Promise => { try { let sealJson = dmSealCache.get(key); if (!sealJson) { sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content); rememberSeal(key, sealJson); } // Navigation may have cancelled the query while Amber was open. Do not // ask for the second decrypt (which would pop Amber again) — the next // explicit visit reuses the cached seal and only asks for the rumor. if (signal?.aborted) return null; const seal = JSON.parse(sealJson) as NostrEvent; if (seal.kind !== SEAL_KIND) return null; // SECURITY: verify the seal's signature (sender impersonation). if (!verifyEvent(seal)) return null; const rumorJson = await signer.nip44!.decrypt(seal.pubkey, seal.content); const rumor = JSON.parse(rumorJson) as NostrEvent; if (rumor.pubkey !== seal.pubkey) return null; if (rumor.kind !== DM_KIND) return null; const pTags = rumor.tags.filter((t) => t[0] === 'p').map((t) => t[1]); const dm: DecryptedDm = { id: rumor.id, pubkey: rumor.pubkey, content: rumor.content, createdAt: rumor.created_at, recipients: pTags, participants: [...new Set([rumor.pubkey, ...pTags])], subject: rumor.tags.find((t) => t[0] === 'subject')?.[1], replyTo: rumor.tags.find((t) => t[0] === 'e')?.[1], }; if (dmDecryptCache.size >= DM_DECRYPT_CACHE_MAX) { // Drop the oldest insertion (Map preserves order). const oldest = dmDecryptCache.keys().next().value; if (oldest !== undefined) dmDecryptCache.delete(oldest); } dmDecryptCache.set(key, dm); dmSealCache.delete(key); // Persist so this wrap is never decrypted again (fire-and-forget). void saveDmDecryptKey(key, viewerPk, dm).catch(() => {}); return dm; } catch { // Not cached — a transient failure may succeed on the next attempt. return null; } finally { dmDecryptInFlight.delete(key); } })(); dmDecryptInFlight.set(key, task); return task; } /** * Hook to send NIP-17 direct messages (1:1 or group) */ export function useSendDirectMessage() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { config } = useAppContext(); const { write } = useRelays(); const queryClient = useQueryClient(); const sendMessage = useCallback(async ( recipientPubkeys: string | string[], content: string, options?: { replyTo?: string; subject?: string; } ): Promise => { if (!user?.signer?.nip44) { throw new Error('NIP-44 encryption not available. Please use a compatible signer.'); } const recipients = Array.isArray(recipientPubkeys) ? recipientPubkeys : [recipientPubkeys]; const isGroup = recipients.length > 1; // 1. Create the unsigned rumor (kind 14) const rumor = createRumor(user.pubkey, recipients, content, options); // 2. For each recipient, create a seal encrypted to them // In NIP-17, we encrypt the rumor to each recipient separately const createSealAndWrap = async (targetPubkey: string): Promise => { // Encrypt rumor to this specific recipient using NIP-44 const rumorJson = JSON.stringify(rumor); const encryptedRumor = await user.signer.nip44!.encrypt(targetPubkey, rumorJson); const sealEvent = await user.signer.signEvent({ kind: SEAL_KIND, created_at: randomPastTimestamp(), tags: [], content: encryptedRumor, }); // Generate ephemeral secret key for the gift wrap (finalizeEvent derives // the matching pubkey internally). const ephemeralSecretKey = generateSecretKey(); // Encrypt seal to target using ephemeral key. // getConversationKey expects the raw secret key bytes (Uint8Array). const sealJson = JSON.stringify(sealEvent); const conversationKey = nip44.v2.utils.getConversationKey( ephemeralSecretKey, targetPubkey ); const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey); // Mark the RECIPIENT copy (never the self copy) as push-worthy, so the // push service can wake their device while the app is closed. const wrapTags: string[][] = [['p', targetPubkey]]; if (targetPubkey !== user.pubkey) wrapTags.push([PUSH_MARKER_TAG, 'push']); // Create gift wrap event and sign with ephemeral key. // finalizeEvent takes an EventTemplate (no pubkey — it derives it from // the secret key) and returns a fully-signed event. return finalizeEvent({ created_at: randomPastTimestamp(), kind: GIFT_WRAP_KIND, tags: wrapTags, content: encryptedSeal, }, ephemeralSecretKey) as NostrEvent; }; // 3. Create gift wraps for all recipients AND sender (so sender sees own messages) const allTargets = [...new Set([...recipients, user.pubkey])]; // Each wrap costs TWO interactive signer ops (encrypt the rumor, sign the // seal). An interactive signer (Amber) opens a foreground activity per op — // firing them in parallel launches several intents at once, only one gets // answered, and the rest time out ("Amber didn't respond"). So for Amber we // MUST do them one at a time; silent signers keep the fast parallel path. const interactive = isInteractiveSigner(user, logins); const giftWraps: NostrEvent[] = []; if (interactive) { for (const target of allTargets) giftWraps.push(await createSealAndWrap(target)); } else { giftWraps.push(...(await Promise.all(allTargets.map(createSealAndWrap)))); } // Remember our own gift wraps so the decrypt-free DM badge does not count // the sender's self-copy as an incoming message. markOwnDmWraps(giftWraps.map((wrap) => wrap.id)); // 4. Work out where to deliver. NIP-17: publish the wrap to each // RECIPIENT's inbox relays (their kind 10050), plus our own inbox so we // can read our sent copy. const dmTargets = [...new Set([...recipients, user.pubkey])]; const deliverRelays = new Set(config.relayMetadata.dmRelays ?? []); const inboxes = new Map(); // Discover both lists IN PARALLEL: the recipients' inbox relays (10050) and // their relay lists (10002, used to derive write relays when they have no // inbox). Sequentially these two lookups doubled the send's worst case. const [dmEvents, relayListEvents] = await Promise.all([ nostr .query([{ kinds: [10050], authors: dmTargets, limit: dmTargets.length * 2 }], { signal: AbortSignal.timeout(4000), }) .catch((error) => { console.warn('[DM] Failed to fetch DM relay lists:', error); return [] as NostrEvent[]; }), nostr .query([{ kinds: [10002], authors: recipients, limit: recipients.length }], { signal: AbortSignal.timeout(4000), }) .catch(() => [] as NostrEvent[]), ]); for (const ev of dmEvents) { const urls = ev.tags .filter(([name]) => name === 'relay') .map(([, url]) => url) .filter(Boolean); if (urls.length) inboxes.set(ev.pubkey, urls); for (const url of urls) deliverRelays.add(url); } // A recipient with NO discoverable inbox list falls back to their NIP-65 // WRITE relays (their "inbox" in the base spec). Previously we skipped this // entirely — and because our own DM relays are always non-empty, the NIP-65 // write relays were never used at all, so a recipient without a 10050 was // simply unreachable. for (const ev of relayListEvents) { if (inboxes.get(ev.pubkey)?.length) continue; for (const [name, url, marker] of ev.tags) { if (name === 'r' && url && (!marker || marker === 'write')) deliverRelays.add(url); } } const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase())); let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); if (publishRelays.length === 0) publishRelays = write; // The push service watches the community relay for push-marked wraps, so the // recipient copy must land there for a closed-app notification to fire. if ( !blocked.has(SKATE_RELAY.toLowerCase()) && !publishRelays.some((u) => u.toLowerCase() === SKATE_RELAY.toLowerCase()) ) { publishRelays = [...publishRelays, SKATE_RELAY]; } // 5. Publish in PARALLEL, each relay with its own timeout. People's lists // commonly contain a dead relay or one that demands NIP-42 auth (e.g. // auth.nostr1.com); publishing sequentially with no timeout meant ONE bad // relay could stall the whole send. A failure is now per-relay, and we // only fail the send if NO relay accepted the message. const attempts = publishRelays.map(async (relayUrl) => { const relay = nostr.relay(relayUrl); await Promise.all( giftWraps.map((wrap) => relay.event(wrap, { signal: AbortSignal.timeout(6000) })), ); }); if (attempts.length > 0) { // Resolve as soon as ONE relay accepts, so a dead or auth-required relay // never delays the send; the remaining attempts finish in the background. await new Promise((resolve, reject) => { let remaining = attempts.length; let accepted = 0; attempts.forEach((attempt) => { attempt .then(() => { accepted += 1; resolve(); }) .catch((error) => { console.warn('[DM] A relay rejected the message:', error); }) .finally(() => { remaining -= 1; if (remaining === 0 && accepted === 0) { reject( new Error( 'Could not send the message — no relay accepted it. Check your connection and relays.', ), ); } }); }); }); } // Show the sender's own message immediately. The crew chat reads via // `['group-messages', groupId, …]` (`useGroupConversation`) — which the list // invalidations below do NOT cover — so a sent group message used to be // invisible until a poll/remount, and for an interactive signer (no polling) // never. Seed the cache with the plaintext we already hold (no refetch, so // no extra signer pop-ups). A later natural refetch replaces it. if (isGroup) { const groupId = getGroupId(allTargets); const optimistic: DirectMessage = { id: rumor.id, pubkey: user.pubkey, content, createdAt: rumor.created_at, conversationWith: groupId, participants: allTargets, subject: options?.subject, replyTo: options?.replyTo, }; queryClient.setQueriesData( { queryKey: ['group-messages', groupId, user.pubkey] }, (old) => { const list = old ?? []; if (list.some((m) => m.id === optimistic.id)) return list; return [...list, optimistic].sort((a, b) => a.createdAt - b.createdAt); }, ); } // Invalidate queries to refresh message list queryClient.invalidateQueries({ queryKey: ['direct-messages'] }); queryClient.invalidateQueries({ queryKey: ['conversations'] }); if (isGroup) { queryClient.invalidateQueries({ queryKey: ['group-conversations'] }); } }, [user, logins, nostr, config, write, queryClient]); return useMutation({ mutationFn: async ({ recipients, content, replyTo, subject }: { recipients: string | string[]; content: string; replyTo?: string; subject?: string; }) => { await sendMessage(recipients, content, { replyTo, subject }); }, }); } /** * Hook to send group DMs for P2P trading * Convenience wrapper with trading-specific defaults */ export function useSendTradeDM() { const sendDM = useSendDirectMessage(); return useMutation({ mutationFn: async ({ participants, content, itemTitle, tradeType }: { participants: string[]; content: string; itemTitle?: string; tradeType?: 'buy' | 'sell' | 'trade'; }) => { const subject = itemTitle ? `${tradeType === 'buy' ? 'WTB' : tradeType === 'sell' ? 'For Sale' : 'Trade'}: ${itemTitle}` : undefined; await sendDM.mutateAsync({ recipients: participants, content, subject }); }, }); } /** * Hook to fetch and decrypt direct messages for a conversation */ export function useConversation(otherPubkey: string) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const nip44Available = checkNip44(user, logins); const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['direct-messages', otherPubkey, user?.pubkey, relayUrls], queryFn: async (c) => { if (!user?.signer?.nip44 || !otherPubkey) return []; // 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 — // so a single bad relay in the list could hide ALL your messages. const batches = await Promise.all( readRelays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); return await relay.query( [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 100, }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); } catch (error) { console.warn(`[DM] Failed to query ${relayUrl}:`, error); return []; } }), ); const allEvents: NostrEvent[] = batches.flat(); // Deduplicate by ID const uniqueEvents = Array.from( new Map(allEvents.map(e => [e.id, e])).values() ); // Decrypt and filter messages const messages: DirectMessage[] = []; for (const giftWrap of uniqueEvents) { if (c.signal.aborted) break; try { // Decrypt via the shared session cache (coalesced; one decrypt per wrap // per session even across different views). const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 only. A message with more than one recipient is a GROUP message // (handled by useGroupConversation). Without this guard a group chat // leaks into the 1:1 thread with each of its participants. if (dm.recipients.length > 1) continue; const isFromOther = dm.pubkey === otherPubkey; const isToOther = dm.recipients.includes(otherPubkey); const isFromMe = dm.pubkey === user.pubkey; const isToMe = dm.recipients.includes(user.pubkey); // Message is part of this conversation if: // - It's from the other person to me, OR // - It's from me to the other person if ((isFromOther && isToMe) || (isFromMe && isToOther)) { messages.push({ id: dm.id, pubkey: dm.pubkey, content: dm.content, createdAt: dm.createdAt, replyTo: dm.replyTo, conversationWith: otherPubkey, }); } } catch (error) { // Failed to decrypt - not for us or corrupted console.debug('[DM] Failed to decrypt message:', error); } } // Sort by creation time (oldest first for chat display) return messages.sort((a, b) => a.createdAt - b.createdAt); }, enabled: nip44Available && !!otherPubkey, ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } /** * Hook to fetch all conversations */ export function useConversations() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const nip44Available = checkNip44(user, logins); const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['conversations', user?.pubkey, relayUrls], 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( readRelays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); return await relay.query( [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200, }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); } catch (error) { console.warn(`[DM] Failed to query ${relayUrl}:`, error); return []; } }), ); const allEvents: NostrEvent[] = batches.flat(); // Deduplicate const uniqueEvents = Array.from( new Map(allEvents.map(e => [e.id, e])).values() ); // Decrypt and organize by conversation const conversationMap = new Map(); let undecryptable = 0; for (const giftWrap of uniqueEvents) { if (c.signal.aborted) break; try { // Decrypt via the shared session cache. const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 list only. A message with more than one recipient is a GROUP // message, which this inbox does not surface. Without this guard each // group would masquerade as a phantom 1:1 with whichever participant // happened to speak, and would appear twice in the UI. if (dm.recipients.length > 1) continue; let conversationWith: string; if (dm.pubkey === user.pubkey) { // Message from me - conversation is with the recipient conversationWith = dm.recipients.find((p) => p !== user.pubkey) || dm.recipients[0]; } else { // Message to me - conversation is with the sender conversationWith = dm.pubkey; } if (!conversationWith) continue; const message: DirectMessage = { id: dm.id, pubkey: dm.pubkey, content: dm.content, createdAt: dm.createdAt, conversationWith, }; const existing = conversationMap.get(conversationWith) || []; existing.push(message); conversationMap.set(conversationWith, existing); } catch (error) { undecryptable += 1; console.debug('[DM] Failed to decrypt:', error); } } // Build conversation list const conversations: Conversation[] = []; for (const [pubkey, messages] of conversationMap) { // Sort messages to find the latest const sorted = messages.sort((a, b) => b.createdAt - a.createdAt); conversations.push({ pubkey, lastMessage: sorted[0], unreadCount: 0, // Could implement read receipts later }); } // Sort conversations by last message time 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)), }); } /** * List **group** (multi-recipient) conversations — the NIP-17 group primitive. * * A NIP-17 group message is the same kind-14 rumor inside kind-13/1059 gift * wraps; the only difference is that it carries one `p` tag per recipient. We * bucket those by the same sorted participant key `useGroupConversation` uses. * * Scope note: this is fan-out share-the-rumor group messaging. It has no * forward secrecy or membership ratcheting/roster — Marmot (MLS) and Cordn are * the protocols to adopt if those guarantees are required later. * See GROUP_MESSAGING.md for the full decision and upgrade paths. */ export function useGroupConversations() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const nip44Available = checkNip44(user, logins); const { relays: readRelays, key: relayUrls } = useResolvedDmRelays(); return useQuery({ queryKey: ['group-conversations', user?.pubkey, relayUrls], queryFn: async (c): Promise => { if (!user?.signer?.nip44) return []; const batches = await Promise.all( readRelays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); return await relay.query( // Groups list: a wider window than the 1:1 list — group wraps sit // among all the viewer's wraps, and the relay can't filter by group // (a gift wrap hides its participants), so we filter client-side. // The shared decrypt cache makes the extra reads affordable. [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 500 }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); } catch (error) { console.warn(`[DM] Failed to query ${relayUrl}:`, error); return []; } }), ); const allEvents: NostrEvent[] = batches.flat(); const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values()); const byGroup = new Map(); for (const giftWrap of uniqueEvents) { if (c.signal.aborted) break; try { const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 messages are surfaced by useConversations, not here. if (dm.recipients.length <= 1) continue; const groupId = getGroupId(dm.participants); const message: DirectMessage = { id: dm.id, pubkey: dm.pubkey, content: dm.content, createdAt: dm.createdAt, conversationWith: groupId, participants: dm.participants, subject: dm.subject, }; const existing = byGroup.get(groupId) || []; existing.push(message); byGroup.set(groupId, existing); } catch (error) { console.debug('[DM] Failed to decrypt group message:', error); } } const conversations: Conversation[] = []; for (const [groupId, messages] of byGroup) { const sorted = messages.sort((a, b) => b.createdAt - a.createdAt); const latest = sorted[0]; const participants = latest.participants ?? []; conversations.push({ pubkey: groupId, // Everyone except me — what the UI shows and the group URL encodes. participants: participants.filter(p => p !== user.pubkey), lastMessage: latest, unreadCount: 0, isGroup: true, subject: latest.subject, }); } return conversations.sort( (a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0), ); }, enabled: nip44Available, ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } /** * Helper to format pubkey for display */ export function formatPubkeyShort(pubkey: string): string { try { const npub = nip19.npubEncode(pubkey); return `${npub.slice(0, 8)}...${npub.slice(-4)}`; } catch { return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; } } /** * Hook to fetch messages in a specific group conversation */ export function useGroupConversation(participants: string[]) { const { nostr } = useNostr(); const { logins } = useNostrLogin(); const { user } = useCurrentUser(); const nip44Available = checkNip44(user, logins); const groupId = getGroupId(participants); 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 []; // Query for gift wraps addressed to us. Wider window than the 1:1 list so // older crew messages aren't pushed out by unrelated DMs (client-side group // filter; the shared decrypt cache keeps this affordable). const batches = await Promise.all( readRelays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); return await relay.query( [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 500, }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); } catch (error) { console.warn(`[DM] Failed to query ${relayUrl}:`, error); return []; } }), ); const allEvents: NostrEvent[] = batches.flat(); // Deduplicate const uniqueEvents = Array.from( new Map(allEvents.map(e => [e.id, e])).values() ); // Decrypt and filter to this group const messages: DirectMessage[] = []; for (const giftWrap of uniqueEvents) { if (c.signal.aborted) break; try { const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // Only include if this message is part of the target group. if (getGroupId(dm.participants) !== groupId) continue; messages.push({ id: dm.id, pubkey: dm.pubkey, content: dm.content, createdAt: dm.createdAt, conversationWith: groupId, participants: dm.participants, subject: dm.subject, replyTo: dm.replyTo, }); } catch (error) { console.debug('[DM] Failed to decrypt:', error); } } // Dedup (a re-sent rumor can arrive in more than one wrap), then sort by // creation time (oldest first for chat display). const byId = new Map(messages.map((m) => [m.id, m])); return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt); }, enabled: nip44Available && participants.length > 0, ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); }