SkateSpots/src/hooks/useDirectMessages.ts

1042 lines
38 KiB
TypeScript

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<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;
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<string, DecryptedDm>();
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
/**
* 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<string, string>();
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<string>();
const dmCacheHydrating = new Map<string, Promise<void>>();
/** Load an account's persisted decrypt cache into memory (once per session). */
async function ensureDmCacheHydrated(viewerPk: string): Promise<void> {
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<string>;
};
}
async function decryptGiftWrap(
viewerPk: string,
signer: DmSigner,
wrap: NostrEvent,
signal?: AbortSignal,
): Promise<DecryptedDm | null> {
// 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<DecryptedDm | null> => {
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<void> => {
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<NostrEvent> => {
// 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<string>(config.relayMetadata.dmRelays ?? []);
const inboxes = new Map<string, string[]>();
// 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<void>((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<DirectMessage[]>(
{ 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<ConversationsResult> => {
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<string, DirectMessage[]>();
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<Conversation[]> => {
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<string, 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;
// 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)),
});
}