diff --git a/AGENTS.md b/AGENTS.md index d0ae2ab..01092cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,17 +56,20 @@ session with **no other memory of this project**. So: (tiny amounts) before trusting it with real money. - **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`). -- **Relays**: NIP-65 read/write (kind 10002) plus two **app-local** categories — - `localRelays` (never published; added to reads+writes) and `blockedRelays` - (excluded everywhere). `resolveRelays()` in `src/lib/relays.ts` applies them; - `NostrProvider` uses it for its req/event routers, and the Relays settings UI - (`RelayListManager`) has Local/Blocked editors. Deliberately **not** shipped: - KeyPackage relays (`10051`, no MLS consumer yet) and Amethyst's proxy/broadcast/ - private-home (we don't have that relay-discovery model) — don't add UI for - features we don't implement. +- **Relays**: NIP-65 read/write (kind 10002), NIP-17 **DM inbox relays** + (kind 10050; set in the Relays UI, fetched on login by `NostrSync`, delivered + to on send and read from in `useDirectMessages`), plus two **app-local** + categories — `localRelays` (never published; added to reads+writes) and + `blockedRelays` (excluded everywhere). `resolveRelays()` in `src/lib/relays.ts` + applies them; `NostrProvider` uses it for its req/event routers. + Deliberately **not** shipped: KeyPackage relays (`10051`, no MLS consumer yet), + "Trusted" (no distinct behaviour — would be empty UI), and Amethyst's proxy/ + broadcast/private-home (we don't have that relay-discovery model) — don't add + UI for features we don't implement. - ⚠️ A few hooks build relay lists directly from `config.relayMetadata.relays` - (DMs, listing reports, seller info, skate relays, zaps) and therefore do NOT - yet honour the blocklist — migrate them to `useRelays()` when touched. + (listing reports, seller info, skate relays, zaps) and therefore do NOT yet + honour Local/Blocked — migrate them to `useRelays()` when touched. DMs are + migrated, but their DM relays aren't block-filtered either. - **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index ee00418..73258b2 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -140,7 +140,11 @@ export function NostrSync() { if (fetchedRelays.length > 0) { updateConfig(c => ({ ...c, - relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at }, + relayMetadata: { + ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), + relays: fetchedRelays, + updatedAt: event.created_at, + }, })); return true; } @@ -152,7 +156,11 @@ export function NostrSync() { if (config.relayMetadata.updatedAt === 0) { updateConfig(c => ({ ...c, - relayMetadata: { relays: NEW_USER_RELAYS, updatedAt: 0 }, + relayMetadata: { + ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), + relays: NEW_USER_RELAYS, + updatedAt: 0, + }, })); } return true; @@ -163,6 +171,32 @@ export function NostrSync() { return false; }; + // ── helper: fetch kind 10050 (NIP-17 DM relays) ──────────────────────── + const fetchDmRelays = async (source: typeof nostr | ReturnType) => { + try { + const events = await source.query( + [{ kinds: [10050], authors: [pubkey], limit: 1 }], + { signal: AbortSignal.timeout(6000) }, + ); + if (events.length === 0) return false; + const urls = events[0].tags + .filter(([name]) => name === 'relay') + .map(([, url]) => url) + .filter((u): u is string => !!u); + if (urls.length === 0) return false; + updateConfig((c) => ({ + ...c, + relayMetadata: { + ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), + dmRelays: urls, + }, + })); + return true; + } catch (e) { + console.warn('[NostrSync] fetchDmRelays failed:', e); + return false; + } + }; // ── 1. Citrine (local relay — fastest, no internet needed) ─────────────── const citrineAvailable = await isCitrineAvailable(); if (citrineAvailable) { @@ -193,6 +227,9 @@ export function NostrSync() { if (!profileSynced) profileSynced = p; } + // DM relays (kind 10050) are optional — fetch without gating sync on them. + void fetchDmRelays(nostr); + const success = profileSynced && relaysSynced; if (success) { diff --git a/src/components/RelayListManager.tsx b/src/components/RelayListManager.tsx index 40a2bac..38a1eda 100644 --- a/src/components/RelayListManager.tsx +++ b/src/components/RelayListManager.tsx @@ -65,9 +65,11 @@ export function RelayListManager() { const [newRelayUrl, setNewRelayUrl] = useState(''); const [newLocalUrl, setNewLocalUrl] = useState(''); const [newBlockedUrl, setNewBlockedUrl] = useState(''); + const [newDmUrl, setNewDmUrl] = useState(''); const localRelays = config.relayMetadata.localRelays ?? []; const blockedRelays = config.relayMetadata.blockedRelays ?? []; + const dmRelays = config.relayMetadata.dmRelays ?? []; const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => { // Local-only lists — never published to Nostr, so `updatedAt` is untouched. @@ -100,6 +102,47 @@ export function RelayListManager() { updateCategory(key, (config.relayMetadata[key] ?? []).filter((u) => u !== url)); }; + // DM relays (NIP-17, kind 10050) — published, unlike local/blocked. + const publishDmRelayList = (urls: string[]) => { + if (!user) return; + publishEvent( + { kind: 10050, content: '', tags: urls.map((url) => ['relay', url]) }, + { + onSuccess: () => + toast({ title: 'DM relays published', description: 'Your NIP-17 DM inbox is updated.' }), + onError: (error) => { + console.error('Failed to publish DM relay list:', error); + toast({ + title: 'Failed to publish DM relays', + variant: 'destructive', + }); + }, + }, + ); + }; + + const setDmRelays = (urls: string[], publish = true) => { + updateConfig((c) => ({ + ...c, + relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), dmRelays: urls }, + })); + if (publish) publishDmRelayList(urls); + }; + + const addDmRelay = () => { + if (!isValidRelayUrl(newDmUrl)) { + toast({ title: 'Invalid relay URL', variant: 'destructive' }); + return; + } + const normalized = normalizeRelayUrl(newDmUrl); + if (dmRelays.includes(normalized)) { + toast({ title: 'Relay already in this list', variant: 'destructive' }); + return; + } + setDmRelays([...dmRelays, normalized]); + setNewDmUrl(''); + }; + // Sync local state with config when it changes (e.g., from NostrProvider sync) useEffect(() => { setRelays(config.relayMetadata.relays); @@ -449,6 +492,58 @@ export function RelayListManager() { + {/* DM relays (NIP-17 kind 10050) */} +
+
+ +

+ Where you receive NIP-17 direct messages. Published as a kind 10050 list; + senders deliver here. Falls back to your write relays if empty. +

+
+ {dmRelays.length > 0 && ( +
+ {dmRelays.map((url) => ( + + {renderRelayUrl(url)} + + + ))} +
+ )} +
+ setNewDmUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') addDmRelay(); + }} + placeholder="wss://inbox.relay.example" + className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-9" + /> + +
+
+ {/* Local relays (app-only, never published) */}
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index b4108f7..706ed76 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -14,6 +14,8 @@ export interface RelayMetadata { localRelays?: string[]; /** App-local blocklist — excluded from every read and write. */ blockedRelays?: string[]; + /** NIP-17 DM inbox relays (kind 10050). Gift wraps are delivered here. */ + dmRelays?: string[]; } export interface AppConfig { diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 85c2d61..03d055e 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -8,6 +8,16 @@ import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; +import type { RelayMetadata } from '@/contexts/AppContext'; + +/** + * 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 read = metadata.relays.filter((r) => r.read).map((r) => r.url); + return [...new Set([...(metadata.dmRelays ?? []), ...read])]; +} /** * React Query options that keep an interactive signer (Amber) from being stormed @@ -176,13 +186,33 @@ export function useSendDirectMessage() { const allTargets = [...new Set([...recipients, user.pubkey])]; const giftWraps = await Promise.all(allTargets.map(createSealAndWrap)); - // 4. Publish to relays + // 4. Deliver to NIP-17 DM relays (kind 10050): the recipients' inboxes plus + // our own, so both sides can read from their DM relays. Fall back to the + // NIP-65 write relays when nobody has published a DM relay list. const writeRelays = config.relayMetadata.relays .filter(r => r.write) .map(r => r.url); + const dmTargets = [...new Set([...recipients, user.pubkey])]; + const deliverRelays = new Set(config.relayMetadata.dmRelays ?? []); + try { + const dmEvents = await nostr.query( + [{ kinds: [10050], authors: dmTargets, limit: dmTargets.length * 2 }], + { signal: AbortSignal.timeout(6000) }, + ); + for (const ev of dmEvents) { + for (const [name, url] of ev.tags) { + if (name === 'relay' && url) deliverRelays.add(url); + } + } + } catch (error) { + console.warn('[DM] Failed to fetch DM relays, using write relays:', error); + } + + const publishRelays = deliverRelays.size > 0 ? [...deliverRelays] : writeRelays; + // Publish all gift wraps - for (const relayUrl of writeRelays) { + for (const relayUrl of publishRelays) { try { const relay = nostr.relay(relayUrl); for (const wrap of giftWraps) { @@ -256,9 +286,8 @@ export function useConversation(otherPubkey: string) { const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - const relayUrls = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url) + const relayUrls = dmReadRelays(config.relayMetadata) + .slice() .sort() .join(','); @@ -269,9 +298,7 @@ export function useConversation(otherPubkey: string) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url); + const readRelays = dmReadRelays(config.relayMetadata); // Query for gift wraps addressed to us const allEvents: NostrEvent[] = []; @@ -372,9 +399,8 @@ export function useConversations() { const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - const relayUrls = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url) + const relayUrls = dmReadRelays(config.relayMetadata) + .slice() .sort() .join(','); @@ -385,9 +411,7 @@ export function useConversations() { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url); + const readRelays = dmReadRelays(config.relayMetadata); // Query for gift wraps addressed to us const allEvents: NostrEvent[] = []; @@ -513,9 +537,8 @@ export function useGroupConversations() { const { config } = useAppContext(); const nip44Available = checkNip44(user, logins); - const relayUrls = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url) + const relayUrls = dmReadRelays(config.relayMetadata) + .slice() .sort() .join(','); @@ -525,7 +548,7 @@ export function useGroupConversations() { if (!user?.signer?.nip44) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = config.relayMetadata.relays.filter(r => r.read).map(r => r.url); + const readRelays = dmReadRelays(config.relayMetadata); const allEvents: NostrEvent[] = []; for (const relayUrl of readRelays) { @@ -629,9 +652,8 @@ export function useGroupConversation(participants: string[]) { const groupId = getGroupId(participants); - const relayUrls = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url) + const relayUrls = dmReadRelays(config.relayMetadata) + .slice() .sort() .join(','); @@ -642,9 +664,7 @@ export function useGroupConversation(participants: string[]) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = config.relayMetadata.relays - .filter(r => r.read) - .map(r => r.url); + const readRelays = dmReadRelays(config.relayMetadata); // Query for gift wraps addressed to us const allEvents: NostrEvent[] = []; diff --git a/src/hooks/useDmRelays.ts b/src/hooks/useDmRelays.ts new file mode 100644 index 0000000..ad6fce6 --- /dev/null +++ b/src/hooks/useDmRelays.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useAppContext } from '@/hooks/useAppContext'; + +/** Parse the `relay` tags of a kind-10050 event (NIP-17) into URLs. */ +export function dmRelaysFromEvent(tags: string[][]): string[] { + return tags + .filter(([name]) => name === 'relay') + .map(([, url]) => url) + .filter((url): url is string => Boolean(url)); +} + +/** The current user's own published DM relays (kind 10050), from config. */ +export function useMyDmRelays(): string[] { + const { config } = useAppContext(); + return config.relayMetadata.dmRelays ?? []; +} + +/** + * Fetch the NIP-17 DM relay lists (kind 10050) for the given pubkeys. + * Returns a `pubkey → relayUrls` map (newest event per author wins). + */ +export function useDmRelays(pubkeys: string[]) { + const { nostr } = useNostr(); + const unique = [...new Set(pubkeys.filter(Boolean))].sort(); + + return useQuery>({ + queryKey: ['dm-relays', unique.join(',')], + enabled: unique.length > 0, + staleTime: 10 * 60 * 1000, + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + const events = await nostr.query( + [{ kinds: [10050], authors: unique, limit: unique.length * 2 }], + { signal }, + ); + const map: Record = {}; + for (const ev of [...events].sort((a, b) => b.created_at - a.created_at)) { + if (map[ev.pubkey]) continue; + const urls = dmRelaysFromEvent(ev.tags); + if (urls.length > 0) map[ev.pubkey] = urls; + } + return map; + }, + }); +}