feat(relays): NIP-17 DM inbox relays (kind 10050)

- RelayMetadata.dmRelays; Relays UI to add/remove, publishing kind 10050
- NostrSync fetches the user's 10050 on login (alongside 10002)
- DMs deliver gift wraps to recipients' + own DM relays (fallback: write relays)
  and read from own DM relays + NIP-65 read relays
- useDmRelays() to look up others' DM relay lists

Also fixes a latent data-loss bug: NostrSync's 10002 fetch replaced
relayMetadata wholesale, which would wipe the new local/blocked/dm lists on
login — it now merges. 'Trusted' category intentionally skipped (no distinct
behaviour -> no empty UI); documented in AGENTS.md.
This commit is contained in:
SkateSpots 2026-09-26 21:32:53 +07:00
parent e75c4a8355
commit 30ef511e78
6 changed files with 239 additions and 36 deletions

View file

@ -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

View file

@ -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<typeof nostr.group>) => {
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) {

View file

@ -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() {
</button>
</div>
{/* DM relays (NIP-17 kind 10050) */}
<div className="space-y-2 pt-4 border-t border-white/8">
<div>
<Label className="text-xs font-black uppercase tracking-wider text-muted-foreground">
DM inbox relays
</Label>
<p className="text-[11px] text-muted-foreground mt-0.5">
Where you receive NIP-17 direct messages. Published as a kind 10050 list;
senders deliver here. Falls back to your write relays if empty.
</p>
</div>
{dmRelays.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{dmRelays.map((url) => (
<span
key={url}
className="inline-flex items-center gap-1 rounded-full border border-cyan-500/25 bg-cyan-500/10 px-2 py-0.5 font-mono text-[11px] text-cyan-300"
>
{renderRelayUrl(url)}
<button
onClick={() => setDmRelays(dmRelays.filter((u) => u !== url))}
className="text-cyan-300/70 hover:text-white"
aria-label={`Remove ${url}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<Input
value={newDmUrl}
onChange={(e) => 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"
/>
<Button
variant="outline"
size="sm"
className="h-9 rounded-xl border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10"
onClick={addDmRelay}
disabled={!newDmUrl.trim()}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Local relays (app-only, never published) */}
<div className="space-y-2 pt-4 border-t border-white/8">
<div>

View file

@ -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 {

View file

@ -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<string>(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[] = [];

46
src/hooks/useDmRelays.ts Normal file
View file

@ -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<Record<string, string[]>>({
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<string, string[]> = {};
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;
},
});
}