diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx
index 946ba81..3f82958 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 { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync";
import { useRelayState } from "@/hooks/useRelayState";
import relayStateManager from "@/services/relay-state-manager";
import { TabBar } from "../TabBar";
@@ -25,6 +26,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
// Auto-cache kind:10002 relay lists from EventStore to Dexie
useRelayListCacheSync();
+ // Auto-cache kind:10063 blossom server lists from EventStore to Dexie
+ useBlossomServerCacheSync();
+
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index f6dd4ed..ac3ae8f 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -1,4 +1,4 @@
-import { User } from "lucide-react";
+import { User, HardDrive, ExternalLink } from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -54,6 +54,7 @@ export default function UserMenu() {
const account = use$(accounts.active$);
const { state, addWindow } = useGrimoire();
const relays = state.activeAccount?.relays;
+ const blossomServers = state.activeAccount?.blossomServers;
const [showSettings, setShowSettings] = useState(false);
const [showLogin, setShowLogin] = useState(false);
@@ -123,6 +124,36 @@ export default function UserMenu() {
>
)}
+ {blossomServers && blossomServers.length > 0 && (
+ <>
+
+
+
+
+ Blossom Servers
+ ({blossomServers.length})
+
+ {blossomServers.map((server) => (
+
+
+
+ {server}
+
+
+ ))}
+
+ >
+ )}
+
{/* setShowSettings(true)}
diff --git a/src/core/logic.ts b/src/core/logic.ts
index 9694947..349a520 100644
--- a/src/core/logic.ts
+++ b/src/core/logic.ts
@@ -295,6 +295,31 @@ export const setActiveAccountRelays = (
};
};
+/**
+ * Updates the blossom server list for the active account.
+ */
+export const setActiveAccountBlossomServers = (
+ state: GrimoireState,
+ blossomServers: string[],
+): GrimoireState => {
+ if (!state.activeAccount) {
+ return state;
+ }
+
+ // If blossom servers reference hasn't changed, return state unchanged
+ if (state.activeAccount.blossomServers === blossomServers) {
+ return state;
+ }
+
+ return {
+ ...state,
+ activeAccount: {
+ ...state.activeAccount,
+ blossomServers,
+ },
+ };
+};
+
/**
* Deletes a workspace by ID.
* Cannot delete the last remaining workspace.
diff --git a/src/core/state.ts b/src/core/state.ts
index dd5e37f..a23302b 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -275,6 +275,15 @@ export const useGrimoire = () => {
[setState],
);
+ const setActiveAccountBlossomServers = useCallback(
+ (blossomServers: string[]) => {
+ setState((prev) =>
+ Logic.setActiveAccountBlossomServers(prev, blossomServers),
+ );
+ },
+ [setState],
+ );
+
const updateLayoutConfig = useCallback(
(layoutConfig: Partial) => {
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig));
@@ -351,6 +360,7 @@ export const useGrimoire = () => {
setActiveWorkspace,
setActiveAccount,
setActiveAccountRelays,
+ setActiveAccountBlossomServers,
updateLayoutConfig,
applyPresetLayout,
updateWorkspaceLabel,
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
index fe4bd60..bc7f96f 100644
--- a/src/hooks/useAccountSync.ts
+++ b/src/hooks/useAccountSync.ts
@@ -5,12 +5,17 @@ import { useGrimoire } from "@/core/state";
import { addressLoader } from "@/services/loaders";
import type { RelayInfo } from "@/types/app";
import { normalizeRelayURL } from "@/lib/relay-url";
+import { getServersFromEvent } from "@/services/blossom";
/**
- * Hook that syncs active account with Grimoire state and fetches relay lists
+ * Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
*/
export function useAccountSync() {
- const { setActiveAccount, setActiveAccountRelays } = useGrimoire();
+ const {
+ setActiveAccount,
+ setActiveAccountRelays,
+ setActiveAccountBlossomServers,
+ } = useGrimoire();
const eventStore = useEventStore();
// Watch active account from accounts service
@@ -83,4 +88,41 @@ export function useAccountSync() {
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
+
+ // Fetch and watch blossom server list (kind 10063) when account changes
+ useEffect(() => {
+ if (!activeAccount?.pubkey) {
+ return;
+ }
+
+ const pubkey = activeAccount.pubkey;
+ let lastBlossomEventId: string | undefined;
+
+ // Subscribe to kind 10063 (blossom server list)
+ const subscription = addressLoader({
+ kind: 10063,
+ pubkey,
+ identifier: "",
+ }).subscribe();
+
+ // Watch for blossom server list event in store
+ const storeSubscription = eventStore
+ .replaceable(10063, pubkey, "")
+ .subscribe((blossomListEvent) => {
+ if (!blossomListEvent) return;
+
+ // Only process if this is a new event
+ if (blossomListEvent.id === lastBlossomEventId) return;
+ lastBlossomEventId = blossomListEvent.id;
+
+ // Parse servers from event
+ const servers = getServersFromEvent(blossomListEvent);
+ setActiveAccountBlossomServers(servers);
+ });
+
+ return () => {
+ subscription.unsubscribe();
+ storeSubscription.unsubscribe();
+ };
+ }, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]);
}
diff --git a/src/hooks/useBlossomServerCacheSync.ts b/src/hooks/useBlossomServerCacheSync.ts
new file mode 100644
index 0000000..68aacfe
--- /dev/null
+++ b/src/hooks/useBlossomServerCacheSync.ts
@@ -0,0 +1,24 @@
+/**
+ * Hook to keep blossom server cache in sync with EventStore
+ *
+ * Subscribes to kind:10063 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 blossomServerCache from "@/services/blossom-server-cache";
+
+export function useBlossomServerCacheSync() {
+ const eventStore = useEventStore();
+
+ useEffect(() => {
+ // Subscribe to EventStore for auto-caching
+ blossomServerCache.subscribeToEventStore(eventStore);
+
+ // Cleanup on unmount
+ return () => {
+ blossomServerCache.unsubscribe();
+ };
+ }, [eventStore]);
+}
diff --git a/src/services/blossom-server-cache.ts b/src/services/blossom-server-cache.ts
new file mode 100644
index 0000000..23ddc09
--- /dev/null
+++ b/src/services/blossom-server-cache.ts
@@ -0,0 +1,285 @@
+/**
+ * Blossom Server Cache Service
+ *
+ * Caches BUD-03 blossom server lists (kind:10063) in Dexie for fast access.
+ * Reduces network requests and improves cold start performance.
+ *
+ * Auto-caches kind:10063 events from EventStore when subscribed.
+ */
+
+import type { NostrEvent } from "@/types/nostr";
+import { getServersFromEvent } from "./blossom";
+import db, { CachedBlossomServerList } from "./db";
+import type { IEventStore } from "applesauce-core/event-store";
+import type { Subscription } from "rxjs";
+
+const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
+const MAX_MEMORY_CACHE = 100; // LRU cache size
+
+class BlossomServerCache {
+ private eventStoreSubscription: Subscription | null = null;
+ private memoryCache = new Map();
+ private cacheOrder: string[] = [];
+
+ /**
+ * Subscribe to EventStore to auto-cache kind:10063 events
+ */
+ subscribeToEventStore(eventStore: IEventStore): void {
+ if (this.eventStoreSubscription) {
+ console.warn("[BlossomServerCache] Already subscribed to EventStore");
+ return;
+ }
+
+ // Subscribe to kind:10063 events
+ this.eventStoreSubscription = eventStore
+ .filters({ kinds: [10063] })
+ .subscribe((event: NostrEvent) => {
+ // Cache each kind:10063 event as it arrives
+ this.set(event);
+ });
+
+ console.log(
+ "[BlossomServerCache] Subscribed to EventStore for kind:10063 events",
+ );
+ }
+
+ /**
+ * Unsubscribe from EventStore
+ */
+ unsubscribe(): void {
+ if (this.eventStoreSubscription) {
+ this.eventStoreSubscription.unsubscribe();
+ this.eventStoreSubscription = null;
+ console.log("[BlossomServerCache] Unsubscribed from EventStore");
+ }
+ }
+
+ /**
+ * Get cached blossom server list for a pubkey
+ * Returns undefined if not cached or stale
+ */
+ async get(pubkey: string): Promise {
+ try {
+ const cached = await db.blossomServers.get(pubkey);
+
+ // Check if stale (>24 hours)
+ if (cached && Date.now() - cached.updatedAt < CACHE_TTL) {
+ return cached;
+ }
+
+ // Stale or not found
+ if (cached) {
+ console.debug(
+ `[BlossomServerCache] Cached server list for ${pubkey.slice(0, 8)} is stale (${Math.floor((Date.now() - cached.updatedAt) / 1000 / 60)}min old)`,
+ );
+ }
+
+ return undefined;
+ } catch (error) {
+ console.error(
+ `[BlossomServerCache] Error reading cache for ${pubkey.slice(0, 8)}:`,
+ error,
+ );
+ return undefined;
+ }
+ }
+
+ /**
+ * Store blossom server list event in cache
+ */
+ async set(event: NostrEvent): Promise {
+ try {
+ if (event.kind !== 10063) {
+ console.warn(
+ `[BlossomServerCache] Attempted to cache non-10063 event (kind ${event.kind})`,
+ );
+ return;
+ }
+
+ // Parse servers from event
+ const servers = getServersFromEvent(event);
+
+ // Store in Dexie and memory cache
+ const cachedEntry: CachedBlossomServerList = {
+ pubkey: event.pubkey,
+ event,
+ servers,
+ updatedAt: Date.now(),
+ };
+
+ await db.blossomServers.put(cachedEntry);
+
+ // Also populate memory cache
+ this.memoryCache.set(event.pubkey, cachedEntry);
+ this.cacheOrder.push(event.pubkey);
+ this.evictOldest();
+
+ console.debug(
+ `[BlossomServerCache] Cached ${servers.length} server(s) for ${event.pubkey.slice(0, 8)}`,
+ );
+ } catch (error) {
+ console.error(
+ `[BlossomServerCache] Error caching server list for ${event.pubkey.slice(0, 8)}:`,
+ error,
+ );
+ }
+ }
+
+ /**
+ * Update LRU order for a pubkey
+ */
+ private updateLRU(pubkey: string): void {
+ const index = this.cacheOrder.indexOf(pubkey);
+ if (index > -1) {
+ this.cacheOrder.splice(index, 1);
+ }
+ this.cacheOrder.push(pubkey);
+ }
+
+ /**
+ * Evict oldest entries from memory cache if over limit
+ */
+ private evictOldest(): void {
+ while (this.cacheOrder.length > MAX_MEMORY_CACHE) {
+ const oldest = this.cacheOrder.shift();
+ if (oldest) {
+ this.memoryCache.delete(oldest);
+ }
+ }
+ }
+
+ /**
+ * Get blossom servers from memory cache only (synchronous, fast)
+ * Used for real-time operations where async Dexie lookup would be too slow
+ * Returns null if not in memory cache
+ */
+ getServersSync(pubkey: string): string[] | null {
+ const memCached = this.memoryCache.get(pubkey);
+ if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) {
+ this.updateLRU(pubkey);
+ return memCached.servers;
+ }
+ return null;
+ }
+
+ /**
+ * Get blossom servers for a pubkey from cache
+ */
+ async getServers(pubkey: string): Promise {
+ // Check memory cache first (< 1ms)
+ const memCached = this.memoryCache.get(pubkey);
+ if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) {
+ this.updateLRU(pubkey);
+ return memCached.servers;
+ }
+
+ // Then check Dexie (5-10ms)
+ const cached = await this.get(pubkey);
+ if (cached) {
+ // Populate memory cache for next time
+ this.memoryCache.set(pubkey, cached);
+ this.cacheOrder.push(pubkey);
+ this.evictOldest();
+ return cached.servers;
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if we have a valid cache entry for a pubkey
+ */
+ async has(pubkey: string): Promise {
+ const cached = await this.get(pubkey);
+ return cached !== undefined;
+ }
+
+ /**
+ * Invalidate (delete) cache entry for a pubkey
+ */
+ async invalidate(pubkey: string): Promise {
+ try {
+ await db.blossomServers.delete(pubkey);
+ // Also remove from memory cache
+ this.memoryCache.delete(pubkey);
+ const index = this.cacheOrder.indexOf(pubkey);
+ if (index > -1) {
+ this.cacheOrder.splice(index, 1);
+ }
+ console.debug(
+ `[BlossomServerCache] Invalidated cache for ${pubkey.slice(0, 8)}`,
+ );
+ } catch (error) {
+ console.error(
+ `[BlossomServerCache] Error invalidating cache for ${pubkey.slice(0, 8)}:`,
+ error,
+ );
+ }
+ }
+
+ /**
+ * Clear all cached blossom server lists
+ */
+ async clear(): Promise {
+ try {
+ await db.blossomServers.clear();
+ // Also clear memory cache
+ this.memoryCache.clear();
+ this.cacheOrder = [];
+ console.log("[BlossomServerCache] Cleared all cached server lists");
+ } catch (error) {
+ console.error("[BlossomServerCache] Error clearing cache:", error);
+ }
+ }
+
+ /**
+ * Get cache statistics
+ */
+ async getStats(): Promise<{
+ count: number;
+ oldestEntry: number | null;
+ newestEntry: number | null;
+ memoryCacheSize: number;
+ memoryCacheLimit: number;
+ }> {
+ try {
+ const count = await db.blossomServers.count();
+ const all = await db.blossomServers.toArray();
+
+ if (all.length === 0) {
+ return {
+ count: 0,
+ oldestEntry: null,
+ newestEntry: null,
+ memoryCacheSize: this.memoryCache.size,
+ memoryCacheLimit: MAX_MEMORY_CACHE,
+ };
+ }
+
+ const timestamps = all.map((entry) => entry.updatedAt);
+ const oldest = Math.min(...timestamps);
+ const newest = Math.max(...timestamps);
+
+ return {
+ count,
+ oldestEntry: oldest,
+ newestEntry: newest,
+ memoryCacheSize: this.memoryCache.size,
+ memoryCacheLimit: MAX_MEMORY_CACHE,
+ };
+ } catch (error) {
+ console.error("[BlossomServerCache] Error getting stats:", error);
+ return {
+ count: 0,
+ oldestEntry: null,
+ newestEntry: null,
+ memoryCacheSize: this.memoryCache.size,
+ memoryCacheLimit: MAX_MEMORY_CACHE,
+ };
+ }
+ }
+}
+
+// Singleton instance
+export const blossomServerCache = new BlossomServerCache();
+export default blossomServerCache;
diff --git a/src/services/db.ts b/src/services/db.ts
index 0bb2fa9..6e9ee79 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -54,6 +54,13 @@ export interface RelayLivenessEntry {
backoffUntil?: number;
}
+export interface CachedBlossomServerList {
+ pubkey: string;
+ event: NostrEvent;
+ servers: string[];
+ updatedAt: number;
+}
+
export interface LocalSpell {
id: string; // UUID for local-only spells, or event ID for published spells
alias?: string; // Optional local-only quick name (e.g., "btc")
@@ -88,6 +95,7 @@ class GrimoireDb extends Dexie {
relayAuthPreferences!: Table;
relayLists!: Table;
relayLiveness!: Table;
+ blossomServers!: Table;
spells!: Table;
spellbooks!: Table;
@@ -311,6 +319,20 @@ class GrimoireDb extends Dexie {
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
});
+
+ // Version 15: Add blossom server list caching
+ this.version(15).stores({
+ profiles: "&pubkey",
+ nip05: "&nip05",
+ nips: "&id",
+ relayInfo: "&url",
+ relayAuthPreferences: "&url",
+ relayLists: "&pubkey, updatedAt",
+ relayLiveness: "&url",
+ blossomServers: "&pubkey, updatedAt",
+ spells: "&id, alias, createdAt, isPublished, deletedAt",
+ spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
+ });
}
}
diff --git a/src/types/app.ts b/src/types/app.ts
index 6c42a94..ac99533 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -87,6 +87,7 @@ export interface GrimoireState {
activeAccount?: {
pubkey: string;
relays?: RelayInfo[];
+ blossomServers?: string[];
};
compactModeKinds?: number[];
locale?: {