1042 lines
38 KiB
TypeScript
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)),
|
|
});
|
|
}
|