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:
parent
e75c4a8355
commit
30ef511e78
6 changed files with 239 additions and 36 deletions
23
AGENTS.md
23
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
46
src/hooks/useDmRelays.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue