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 [];
}
/**