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.
|
(tiny amounts) before trusting it with real money.
|
||||||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||||||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
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 —
|
- **Relays**: NIP-65 read/write (kind 10002), NIP-17 **DM inbox relays**
|
||||||
`localRelays` (never published; added to reads+writes) and `blockedRelays`
|
(kind 10050; set in the Relays UI, fetched on login by `NostrSync`, delivered
|
||||||
(excluded everywhere). `resolveRelays()` in `src/lib/relays.ts` applies them;
|
to on send and read from in `useDirectMessages`), plus two **app-local**
|
||||||
`NostrProvider` uses it for its req/event routers, and the Relays settings UI
|
categories — `localRelays` (never published; added to reads+writes) and
|
||||||
(`RelayListManager`) has Local/Blocked editors. Deliberately **not** shipped:
|
`blockedRelays` (excluded everywhere). `resolveRelays()` in `src/lib/relays.ts`
|
||||||
KeyPackage relays (`10051`, no MLS consumer yet) and Amethyst's proxy/broadcast/
|
applies them; `NostrProvider` uses it for its req/event routers.
|
||||||
private-home (we don't have that relay-discovery model) — don't add UI for
|
Deliberately **not** shipped: KeyPackage relays (`10051`, no MLS consumer yet),
|
||||||
features we don't implement.
|
"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`
|
- ⚠️ A few hooks build relay lists directly from `config.relayMetadata.relays`
|
||||||
(DMs, listing reports, seller info, skate relays, zaps) and therefore do NOT
|
(listing reports, seller info, skate relays, zaps) and therefore do NOT yet
|
||||||
yet honour the blocklist — migrate them to `useRelays()` when touched.
|
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`).
|
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,11 @@ export function NostrSync() {
|
||||||
if (fetchedRelays.length > 0) {
|
if (fetchedRelays.length > 0) {
|
||||||
updateConfig(c => ({
|
updateConfig(c => ({
|
||||||
...c,
|
...c,
|
||||||
relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at },
|
relayMetadata: {
|
||||||
|
...(c.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
||||||
|
relays: fetchedRelays,
|
||||||
|
updatedAt: event.created_at,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +156,11 @@ export function NostrSync() {
|
||||||
if (config.relayMetadata.updatedAt === 0) {
|
if (config.relayMetadata.updatedAt === 0) {
|
||||||
updateConfig(c => ({
|
updateConfig(c => ({
|
||||||
...c,
|
...c,
|
||||||
relayMetadata: { relays: NEW_USER_RELAYS, updatedAt: 0 },
|
relayMetadata: {
|
||||||
|
...(c.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
||||||
|
relays: NEW_USER_RELAYS,
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -163,6 +171,32 @@ export function NostrSync() {
|
||||||
return false;
|
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) ───────────────
|
// ── 1. Citrine (local relay — fastest, no internet needed) ───────────────
|
||||||
const citrineAvailable = await isCitrineAvailable();
|
const citrineAvailable = await isCitrineAvailable();
|
||||||
if (citrineAvailable) {
|
if (citrineAvailable) {
|
||||||
|
|
@ -193,6 +227,9 @@ export function NostrSync() {
|
||||||
if (!profileSynced) profileSynced = p;
|
if (!profileSynced) profileSynced = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DM relays (kind 10050) are optional — fetch without gating sync on them.
|
||||||
|
void fetchDmRelays(nostr);
|
||||||
|
|
||||||
const success = profileSynced && relaysSynced;
|
const success = profileSynced && relaysSynced;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,11 @@ export function RelayListManager() {
|
||||||
const [newRelayUrl, setNewRelayUrl] = useState('');
|
const [newRelayUrl, setNewRelayUrl] = useState('');
|
||||||
const [newLocalUrl, setNewLocalUrl] = useState('');
|
const [newLocalUrl, setNewLocalUrl] = useState('');
|
||||||
const [newBlockedUrl, setNewBlockedUrl] = useState('');
|
const [newBlockedUrl, setNewBlockedUrl] = useState('');
|
||||||
|
const [newDmUrl, setNewDmUrl] = useState('');
|
||||||
|
|
||||||
const localRelays = config.relayMetadata.localRelays ?? [];
|
const localRelays = config.relayMetadata.localRelays ?? [];
|
||||||
const blockedRelays = config.relayMetadata.blockedRelays ?? [];
|
const blockedRelays = config.relayMetadata.blockedRelays ?? [];
|
||||||
|
const dmRelays = config.relayMetadata.dmRelays ?? [];
|
||||||
|
|
||||||
const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => {
|
const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => {
|
||||||
// Local-only lists — never published to Nostr, so `updatedAt` is untouched.
|
// 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));
|
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)
|
// Sync local state with config when it changes (e.g., from NostrProvider sync)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRelays(config.relayMetadata.relays);
|
setRelays(config.relayMetadata.relays);
|
||||||
|
|
@ -449,6 +492,58 @@ export function RelayListManager() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) */}
|
{/* Local relays (app-only, never published) */}
|
||||||
<div className="space-y-2 pt-4 border-t border-white/8">
|
<div className="space-y-2 pt-4 border-t border-white/8">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export interface RelayMetadata {
|
||||||
localRelays?: string[];
|
localRelays?: string[];
|
||||||
/** App-local blocklist — excluded from every read and write. */
|
/** App-local blocklist — excluded from every read and write. */
|
||||||
blockedRelays?: string[];
|
blockedRelays?: string[];
|
||||||
|
/** NIP-17 DM inbox relays (kind 10050). Gift wraps are delivered here. */
|
||||||
|
dmRelays?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useCurrentUser } from './useCurrentUser';
|
import { useCurrentUser } from './useCurrentUser';
|
||||||
import { useAppContext } from './useAppContext';
|
import { useAppContext } from './useAppContext';
|
||||||
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
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
|
* 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 allTargets = [...new Set([...recipients, user.pubkey])];
|
||||||
const giftWraps = await Promise.all(allTargets.map(createSealAndWrap));
|
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
|
const writeRelays = config.relayMetadata.relays
|
||||||
.filter(r => r.write)
|
.filter(r => r.write)
|
||||||
.map(r => r.url);
|
.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
|
// Publish all gift wraps
|
||||||
for (const relayUrl of writeRelays) {
|
for (const relayUrl of publishRelays) {
|
||||||
try {
|
try {
|
||||||
const relay = nostr.relay(relayUrl);
|
const relay = nostr.relay(relayUrl);
|
||||||
for (const wrap of giftWraps) {
|
for (const wrap of giftWraps) {
|
||||||
|
|
@ -256,9 +286,8 @@ export function useConversation(otherPubkey: string) {
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
const nip44Available = checkNip44(user, logins);
|
const nip44Available = checkNip44(user, logins);
|
||||||
|
|
||||||
const relayUrls = config.relayMetadata.relays
|
const relayUrls = dmReadRelays(config.relayMetadata)
|
||||||
.filter(r => r.read)
|
.slice()
|
||||||
.map(r => r.url)
|
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
|
|
||||||
|
|
@ -269,9 +298,7 @@ export function useConversation(otherPubkey: string) {
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
||||||
|
|
||||||
const readRelays = config.relayMetadata.relays
|
const readRelays = dmReadRelays(config.relayMetadata);
|
||||||
.filter(r => r.read)
|
|
||||||
.map(r => r.url);
|
|
||||||
|
|
||||||
// Query for gift wraps addressed to us
|
// Query for gift wraps addressed to us
|
||||||
const allEvents: NostrEvent[] = [];
|
const allEvents: NostrEvent[] = [];
|
||||||
|
|
@ -372,9 +399,8 @@ export function useConversations() {
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
const nip44Available = checkNip44(user, logins);
|
const nip44Available = checkNip44(user, logins);
|
||||||
|
|
||||||
const relayUrls = config.relayMetadata.relays
|
const relayUrls = dmReadRelays(config.relayMetadata)
|
||||||
.filter(r => r.read)
|
.slice()
|
||||||
.map(r => r.url)
|
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
|
|
||||||
|
|
@ -385,9 +411,7 @@ export function useConversations() {
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
||||||
|
|
||||||
const readRelays = config.relayMetadata.relays
|
const readRelays = dmReadRelays(config.relayMetadata);
|
||||||
.filter(r => r.read)
|
|
||||||
.map(r => r.url);
|
|
||||||
|
|
||||||
// Query for gift wraps addressed to us
|
// Query for gift wraps addressed to us
|
||||||
const allEvents: NostrEvent[] = [];
|
const allEvents: NostrEvent[] = [];
|
||||||
|
|
@ -513,9 +537,8 @@ export function useGroupConversations() {
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
const nip44Available = checkNip44(user, logins);
|
const nip44Available = checkNip44(user, logins);
|
||||||
|
|
||||||
const relayUrls = config.relayMetadata.relays
|
const relayUrls = dmReadRelays(config.relayMetadata)
|
||||||
.filter(r => r.read)
|
.slice()
|
||||||
.map(r => r.url)
|
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
|
|
||||||
|
|
@ -525,7 +548,7 @@ export function useGroupConversations() {
|
||||||
if (!user?.signer?.nip44) return [];
|
if (!user?.signer?.nip44) return [];
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
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[] = [];
|
const allEvents: NostrEvent[] = [];
|
||||||
for (const relayUrl of readRelays) {
|
for (const relayUrl of readRelays) {
|
||||||
|
|
@ -629,9 +652,8 @@ export function useGroupConversation(participants: string[]) {
|
||||||
|
|
||||||
const groupId = getGroupId(participants);
|
const groupId = getGroupId(participants);
|
||||||
|
|
||||||
const relayUrls = config.relayMetadata.relays
|
const relayUrls = dmReadRelays(config.relayMetadata)
|
||||||
.filter(r => r.read)
|
.slice()
|
||||||
.map(r => r.url)
|
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
|
|
||||||
|
|
@ -642,9 +664,7 @@ export function useGroupConversation(participants: string[]) {
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
|
||||||
|
|
||||||
const readRelays = config.relayMetadata.relays
|
const readRelays = dmReadRelays(config.relayMetadata);
|
||||||
.filter(r => r.read)
|
|
||||||
.map(r => r.url);
|
|
||||||
|
|
||||||
// Query for gift wraps addressed to us
|
// Query for gift wraps addressed to us
|
||||||
const allEvents: NostrEvent[] = [];
|
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