diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 3f82958..d82bbb9 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, ReactNode } from "react"; import { Terminal } from "lucide-react"; import { useAccountSync } from "@/hooks/useAccountSync"; import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync"; +import { useDMRelayListCacheSync } from "@/hooks/useDMRelayListCacheSync"; import { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync"; import { useRelayState } from "@/hooks/useRelayState"; import relayStateManager from "@/services/relay-state-manager"; @@ -26,6 +27,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { // Auto-cache kind:10002 relay lists from EventStore to Dexie useRelayListCacheSync(); + // Auto-cache kind:10050 DM relay lists from EventStore to Dexie + useDMRelayListCacheSync(); + // Auto-cache kind:10063 blossom server lists from EventStore to Dexie useBlossomServerCacheSync(); diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 972810a..6c48b33 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User, HardDrive, Palette, Mail } from "lucide-react"; +import { User, HardDrive, Palette, Mail, Lock } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -7,6 +7,7 @@ import { useGrimoire } from "@/core/state"; import { Button } from "@/components/ui/button"; import { useLiveQuery } from "dexie-react-hooks"; import giftWrapLoader from "@/services/gift-wrap-loader"; +import { dmRelayListCache } from "@/services/dm-relay-list-cache"; import { DropdownMenu, DropdownMenuContent, @@ -64,6 +65,12 @@ export default function UserMenu() { const [showSettings, setShowSettings] = useState(false); const [showLogin, setShowLogin] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); + // Get DM relays (kind 10050) for active user + const dmRelays = useLiveQuery(async () => { + if (!account?.pubkey) return null; + const relays = await dmRelayListCache.get(account.pubkey); + return relays.length > 0 ? relays : null; + }, [account?.pubkey]); // Get pending gift wrap count const pendingCount = useLiveQuery(async () => { @@ -143,6 +150,26 @@ export default function UserMenu() { )} + {dmRelays && dmRelays.length > 0 && ( + <> + + + + + Private Inbox Relays + + {dmRelays.map((relay) => ( + + + + ))} + + + )} + {blossomServers && blossomServers.length > 0 && ( <> diff --git a/src/hooks/useDMRelayListCacheSync.ts b/src/hooks/useDMRelayListCacheSync.ts new file mode 100644 index 0000000..4f3bec0 --- /dev/null +++ b/src/hooks/useDMRelayListCacheSync.ts @@ -0,0 +1,24 @@ +/** + * Hook to keep DM relay list cache in sync with EventStore + * + * Subscribes to kind:10050 events and automatically caches them in Dexie. + * Should be used once at app root level. + */ + +import { useEffect } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import { dmRelayListCache } from "@/services/dm-relay-list-cache"; + +export function useDMRelayListCacheSync() { + const eventStore = useEventStore(); + + useEffect(() => { + // Subscribe to EventStore for auto-caching + dmRelayListCache.subscribeToEventStore(eventStore); + + // Cleanup on unmount + return () => { + dmRelayListCache.unsubscribe(); + }; + }, [eventStore]); +} diff --git a/src/services/db.ts b/src/services/db.ts index d65d0d6..3e33312 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -45,6 +45,13 @@ export interface CachedRelayList { updatedAt: number; } +export interface CachedDMRelayList { + pubkey: string; + event: NostrEvent; + relays: string[]; // DM inbox relays from kind 10050 + updatedAt: number; +} + export interface RelayLivenessEntry { url: string; state: "online" | "offline" | "dead"; @@ -140,6 +147,7 @@ class GrimoireDb extends Dexie { relayInfo!: Table; relayAuthPreferences!: Table; relayLists!: Table; + dmRelayLists!: Table; relayLiveness!: Table; blossomServers!: Table; spells!: Table; @@ -404,6 +412,26 @@ class GrimoireDb extends Dexie { conversations: "&id, recipientPubkey, [recipientPubkey+lastMessageCreatedAt]", }); + + // Version 17: Add DM relay list cache (kind 10050) + this.version(17).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + dmRelayLists: "&pubkey, updatedAt", // NIP-17 DM relay lists (kind 10050) + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + giftWraps: "&id, recipientPubkey, [recipientPubkey+status], receivedAt", + decryptedRumors: + "&giftWrapId, recipientPubkey, senderPubkey, [senderPubkey+rumorCreatedAt], [recipientPubkey+senderPubkey], rumorCreatedAt", + conversations: + "&id, recipientPubkey, [recipientPubkey+lastMessageCreatedAt]", + }); } } diff --git a/src/services/dm-relay-list-cache.ts b/src/services/dm-relay-list-cache.ts new file mode 100644 index 0000000..bed407e --- /dev/null +++ b/src/services/dm-relay-list-cache.ts @@ -0,0 +1,271 @@ +/** + * DM Relay List Cache Service + * + * Caches NIP-17 DM relay lists (kind:10050) in Dexie for fast access. + * Fetches from user's relays + discovery relays when not in cache. + * + * Auto-caches kind:10050 events from EventStore when subscribed. + */ + +import type { NostrEvent } from "@/types/nostr"; +import { normalizeRelayURL } from "@/lib/relay-url"; +import db, { CachedDMRelayList } from "./db"; +import type { IEventStore } from "applesauce-core/event-store"; +import type { Subscription } from "rxjs"; +import pool from "./relay-pool"; +import { relayListCache } from "./relay-list-cache"; +import { AGGREGATOR_RELAYS } from "./loaders"; + +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +class DMRelayListCache { + private eventStoreSubscription: Subscription | null = null; + private memoryCache = new Map(); + + /** + * Subscribe to EventStore to auto-cache kind:10050 events + */ + subscribeToEventStore(eventStore: IEventStore): void { + if (this.eventStoreSubscription) { + console.warn("[DMRelayListCache] Already subscribed to EventStore"); + return; + } + + // Subscribe to kind:10050 events + this.eventStoreSubscription = eventStore + .filters({ kinds: [10050] }) + .subscribe((event: NostrEvent) => { + // Cache each kind:10050 event as it arrives + void this.set(event); + }); + + console.log( + "[DMRelayListCache] Subscribed to EventStore for kind:10050 events", + ); + } + + /** + * Unsubscribe from EventStore + */ + unsubscribe(): void { + if (this.eventStoreSubscription) { + this.eventStoreSubscription.unsubscribe(); + this.eventStoreSubscription = null; + console.log("[DMRelayListCache] Unsubscribed from EventStore"); + } + } + + /** + * Get cached DM relay list for a pubkey + * If not cached or stale, fetches from relays + */ + async get(pubkey: string): Promise { + // Check memory cache first + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + console.debug( + `[DMRelayListCache] Memory cache hit for ${pubkey.slice(0, 8)}`, + ); + return memCached.relays; + } + + // Check Dexie cache + try { + const cached = await db.dmRelayLists.get(pubkey); + if (cached && Date.now() - cached.updatedAt < CACHE_TTL) { + console.debug( + `[DMRelayListCache] Dexie cache hit for ${pubkey.slice(0, 8)}`, + ); + this.memoryCache.set(pubkey, cached); + return cached.relays; + } + } catch (error) { + console.error( + `[DMRelayListCache] Error reading cache for ${pubkey.slice(0, 8)}:`, + error, + ); + } + + // Cache miss - fetch from relays + console.log( + `[DMRelayListCache] Cache miss for ${pubkey.slice(0, 8)}, fetching from relays`, + ); + return this.fetchAndCache(pubkey); + } + + /** + * Fetch kind 10050 from relays and cache it + */ + private async fetchAndCache(pubkey: string): Promise { + try { + // Get user's outbox relays to query for their kind 10050 + let queryRelays = await relayListCache.getOutboxRelays(pubkey); + + // If no outbox relays, use aggregator relays + if (!queryRelays || queryRelays.length === 0) { + console.log( + `[DMRelayListCache] No outbox relays for ${pubkey.slice(0, 8)}, using aggregator relays`, + ); + queryRelays = AGGREGATOR_RELAYS; + } else { + // Add aggregator relays for better discovery + queryRelays = [...queryRelays, ...AGGREGATOR_RELAYS]; + } + + console.log( + `[DMRelayListCache] Fetching kind 10050 for ${pubkey.slice(0, 8)} from ${queryRelays.length} relays`, + ); + + // Fetch kind 10050 from relays + const filter = { + kinds: [10050], + authors: [pubkey], + limit: 1, + }; + + // Use pool.subscription to fetch from relays + const obs = pool.subscription(queryRelays, [filter], {}); + + return new Promise((resolve) => { + let resolved = false; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + console.warn( + `[DMRelayListCache] Timeout fetching kind 10050 for ${pubkey.slice(0, 8)}`, + ); + resolve([]); + } + }, 5000); // 5 second timeout + + const sub = obs.subscribe({ + next: (response) => { + if (typeof response === "string") { + // EOSE received + if (!resolved) { + resolved = true; + clearTimeout(timeout); + console.log( + `[DMRelayListCache] EOSE - no kind 10050 found for ${pubkey.slice(0, 8)}`, + ); + sub.unsubscribe(); + resolve([]); + } + } else { + // Event received + const event = response as NostrEvent; + if ( + !resolved && + event.kind === 10050 && + event.pubkey === pubkey + ) { + resolved = true; + clearTimeout(timeout); + sub.unsubscribe(); + + // Cache the event + void this.set(event); + + // Parse relays from event + const relays = event.tags + .filter((t) => t[0] === "relay" && t[1]) + .map((t) => t[1]); + + console.log( + `[DMRelayListCache] Found kind 10050 for ${pubkey.slice(0, 8)} with ${relays.length} relays`, + ); + resolve(relays); + } + } + }, + error: (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + console.error( + `[DMRelayListCache] Subscription error for ${pubkey.slice(0, 8)}:`, + err, + ); + resolve([]); + } + }, + complete: () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve([]); + } + }, + }); + }); + } catch (error) { + console.error( + `[DMRelayListCache] Error fetching kind 10050 for ${pubkey.slice(0, 8)}:`, + error, + ); + return []; + } + } + + /** + * Store DM relay list event in cache + */ + async set(event: NostrEvent): Promise { + try { + if (event.kind !== 10050) { + console.warn( + `[DMRelayListCache] Attempted to cache non-10050 event (kind ${event.kind})`, + ); + return; + } + + // Parse relays from event tags + const relays = event.tags + .filter((t) => t[0] === "relay" && t[1]) + .map((t) => t[1]); + + // Normalize URLs and filter invalid ones + const normalizedRelays = relays + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn(`[DMRelayListCache] Invalid relay URL: ${url}`); + return null; + } + }) + .filter((url): url is string => url !== null); + + // Store in Dexie and memory cache + const cachedEntry: CachedDMRelayList = { + pubkey: event.pubkey, + event, + relays: normalizedRelays, + updatedAt: Date.now(), + }; + + await db.dmRelayLists.put(cachedEntry); + this.memoryCache.set(event.pubkey, cachedEntry); + + console.debug( + `[DMRelayListCache] Cached DM relay list for ${event.pubkey.slice(0, 8)} (${normalizedRelays.length} relays)`, + ); + } catch (error) { + console.error( + `[DMRelayListCache] Error caching DM relay list for ${event.pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Clear all cached DM relay lists + */ + async clearAll(): Promise { + await db.dmRelayLists.clear(); + this.memoryCache.clear(); + console.log("[DMRelayListCache] Cleared all cached DM relay lists"); + } +} + +export const dmRelayListCache = new DMRelayListCache(); diff --git a/src/services/gift-wrap-loader.ts b/src/services/gift-wrap-loader.ts index 585eeb1..b72eb8f 100644 --- a/src/services/gift-wrap-loader.ts +++ b/src/services/gift-wrap-loader.ts @@ -22,6 +22,7 @@ import { createTimelineLoader } from "applesauce-loaders/loaders"; import pool from "./relay-pool"; import eventStore from "./event-store"; import { relayListCache } from "./relay-list-cache"; +import { dmRelayListCache } from "./dm-relay-list-cache"; import { processGiftWrap, getPendingGiftWraps } from "./gift-wrap"; import db from "./db"; @@ -368,33 +369,31 @@ class GiftWrapLoader { * Gets DM relays for a user (kind 10050 per NIP-17) */ private async getInboxRelays(pubkey: string): Promise { - // Try to get from event store - kind 10050 DM relay list - const event = eventStore.getReplaceable(10050, pubkey, ""); - if (event) { - // Parse relay URLs from tags - const relays = event.tags - .filter((t) => t[0] === "relay" && t[1]) - .map((t) => t[1]); + // Try to get kind 10050 DM relay list (NIP-17) + // This will fetch from relays if not in cache + const dmRelays = await dmRelayListCache.get(pubkey); - if (relays.length > 0) { - return relays; - } + if (dmRelays && dmRelays.length > 0) { + console.log( + `[GiftWrapLoader] Using ${dmRelays.length} DM relays from kind 10050`, + ); + return dmRelays; } - // Fallback: try inbox relays from kind 10002 - let relays = await relayListCache.getInboxRelays(pubkey); + // Fallback: try inbox relays from kind 10002 (NIP-65) + const inboxRelays = await relayListCache.getInboxRelays(pubkey); - if (!relays || relays.length === 0) { - // Try to get from event store - const fallbackEvent = eventStore.getReplaceable(10002, pubkey, ""); - if (fallbackEvent) { - // Cache it - relayListCache.set(fallbackEvent); - relays = await relayListCache.getInboxRelays(pubkey); - } + if (inboxRelays && inboxRelays.length > 0) { + console.log( + `[GiftWrapLoader] Fallback to ${inboxRelays.length} inbox relays from kind 10002`, + ); + return inboxRelays; } - return relays || []; + console.warn( + `[GiftWrapLoader] No DM relays or inbox relays found for ${pubkey.slice(0, 8)}`, + ); + return []; } /**