mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
feat: add cache hydration and ACTIVE_USER_KINDS config
Implement configuration-driven cache hydration and active user sync: - Add ACTIVE_USER_KINDS constant [3, 10002, 10063] for configurable sync - Add hydrateEventStore() method to load cached events on startup - Refactor useAccountSync to loop through ACTIVE_USER_KINDS dynamically - Remove deprecated useRelayListCacheSync/useBlossomServerCacheSync hooks - Call hydration in AppShell on mount to solve "orphaned cache" problem Benefits: - Adding new kinds is just updating ACTIVE_USER_KINDS array - Cache is immediately available on startup (no wasted bandwidth) - Cleaner, more maintainable code with single loop - Kind 3 (contacts) now auto-synced for $contacts alias
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
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 { useReplaceableEventCacheSync } from "@/hooks/useReplaceableEventCacheSync";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useEventStore } from "applesauce-react/hooks";
|
||||
import relayStateManager from "@/services/relay-state-manager";
|
||||
import replaceableEventCache from "@/services/replaceable-event-cache";
|
||||
import { TabBar } from "../TabBar";
|
||||
import CommandLauncher from "../CommandLauncher";
|
||||
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
|
||||
@@ -20,19 +20,21 @@ interface AppShellProps {
|
||||
|
||||
export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
|
||||
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
|
||||
const eventStore = useEventStore();
|
||||
|
||||
// Sync active account and fetch relay lists
|
||||
useAccountSync();
|
||||
|
||||
// Auto-cache kind:10002 relay lists from EventStore to Dexie
|
||||
useRelayListCacheSync();
|
||||
|
||||
// Auto-cache kind:10063 blossom server lists from EventStore to Dexie
|
||||
useBlossomServerCacheSync();
|
||||
// Hydrate EventStore from Dexie cache on startup (solves orphaned cache problem)
|
||||
useEffect(() => {
|
||||
replaceableEventCache.hydrateEventStore(eventStore).catch((err) => {
|
||||
console.error("Failed to hydrate EventStore from cache:", err);
|
||||
});
|
||||
}, [eventStore]);
|
||||
|
||||
// Auto-cache generic replaceable events (contacts, relay lists, blossom servers, emoji lists, etc.)
|
||||
useReplaceableEventCacheSync();
|
||||
|
||||
// Sync active account and fetch configured kinds
|
||||
useAccountSync();
|
||||
|
||||
// Initialize global relay state manager
|
||||
useEffect(() => {
|
||||
relayStateManager.initialize().catch((err) => {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEventStore, use$ } from "applesauce-react/hooks";
|
||||
import type { Subscription } from "rxjs";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { addressLoader } from "@/services/loaders";
|
||||
import { ACTIVE_USER_KINDS } from "@/services/replaceable-event-cache";
|
||||
import type { RelayInfo } from "@/types/app";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { getServersFromEvent } from "@/services/blossom";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
|
||||
* Hook that syncs active account with Grimoire state and fetches configured replaceable events
|
||||
* Automatically fetches and watches all kinds in ACTIVE_USER_KINDS
|
||||
*/
|
||||
export function useAccountSync() {
|
||||
const {
|
||||
@@ -26,103 +30,93 @@ export function useAccountSync() {
|
||||
setActiveAccount(activeAccount?.pubkey);
|
||||
}, [activeAccount?.pubkey, setActiveAccount]);
|
||||
|
||||
// Fetch and watch relay list (kind 10002) when account changes
|
||||
// Fetch and watch all configured kinds for active user
|
||||
useEffect(() => {
|
||||
if (!activeAccount?.pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey = activeAccount.pubkey;
|
||||
let lastRelayEventId: string | undefined;
|
||||
const subscriptions: Subscription[] = [];
|
||||
const lastEventIds = new Map<number, string>();
|
||||
|
||||
// Subscribe to kind 10002 (relay list)
|
||||
const subscription = addressLoader({
|
||||
kind: 10002,
|
||||
pubkey,
|
||||
identifier: "",
|
||||
}).subscribe();
|
||||
// Subscribe to all configured kinds
|
||||
for (const kind of ACTIVE_USER_KINDS) {
|
||||
// Fetch from relays
|
||||
const fetchSub = addressLoader({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier: "",
|
||||
}).subscribe();
|
||||
|
||||
// Watch for relay list event in store
|
||||
const storeSubscription = eventStore
|
||||
.replaceable(10002, pubkey, "")
|
||||
.subscribe((relayListEvent) => {
|
||||
if (!relayListEvent) return;
|
||||
// Watch for updates in EventStore
|
||||
const storeSub = eventStore
|
||||
.replaceable(kind, pubkey, "")
|
||||
.subscribe((event: NostrEvent | undefined) => {
|
||||
if (!event) return;
|
||||
|
||||
// Only process if this is a new event
|
||||
if (relayListEvent.id === lastRelayEventId) return;
|
||||
lastRelayEventId = relayListEvent.id;
|
||||
// Only process if this is a new event
|
||||
if (event.id === lastEventIds.get(kind)) return;
|
||||
lastEventIds.set(kind, event.id);
|
||||
|
||||
// Parse relays from tags (NIP-65 format)
|
||||
// Tag format: ["r", "relay-url", "read|write"]
|
||||
// If no marker, relay is used for both read and write
|
||||
const relays: RelayInfo[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const tag of relayListEvent.tags) {
|
||||
if (tag[0] === "r" && tag[1]) {
|
||||
try {
|
||||
const url = normalizeRelayURL(tag[1]);
|
||||
if (seenUrls.has(url)) continue;
|
||||
seenUrls.add(url);
|
||||
|
||||
const marker = tag[2];
|
||||
relays.push({
|
||||
url,
|
||||
read: !marker || marker === "read",
|
||||
write: !marker || marker === "write",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Handle specific kinds
|
||||
if (kind === 10002) {
|
||||
// Parse relay list (NIP-65)
|
||||
const relays = parseRelayList(event);
|
||||
setActiveAccountRelays(relays);
|
||||
} else if (kind === 10063) {
|
||||
// Parse blossom server list (BUD-03)
|
||||
const servers = getServersFromEvent(event);
|
||||
setActiveAccountBlossomServers(servers);
|
||||
}
|
||||
}
|
||||
// Kind 3 (contacts) is auto-cached but doesn't need UI state updates
|
||||
// Kind 10030 (emoji list) is auto-cached but doesn't need UI state updates
|
||||
});
|
||||
|
||||
setActiveAccountRelays(relays);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
storeSubscription.unsubscribe();
|
||||
};
|
||||
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
|
||||
|
||||
// Fetch and watch blossom server list (kind 10063) when account changes
|
||||
useEffect(() => {
|
||||
if (!activeAccount?.pubkey) {
|
||||
return;
|
||||
subscriptions.push(fetchSub, storeSub);
|
||||
}
|
||||
|
||||
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();
|
||||
subscriptions.forEach((sub) => sub.unsubscribe());
|
||||
};
|
||||
}, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]);
|
||||
}, [
|
||||
activeAccount?.pubkey,
|
||||
eventStore,
|
||||
setActiveAccountRelays,
|
||||
setActiveAccountBlossomServers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse relay list event (NIP-65 format)
|
||||
* Tag format: ["r", "relay-url", "read|write"]
|
||||
* If no marker, relay is used for both read and write
|
||||
*/
|
||||
function parseRelayList(event: NostrEvent): RelayInfo[] {
|
||||
const relays: RelayInfo[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "r" && tag[1]) {
|
||||
try {
|
||||
const url = normalizeRelayURL(tag[1]);
|
||||
if (seenUrls.has(url)) continue;
|
||||
seenUrls.add(url);
|
||||
|
||||
const marker = tag[2];
|
||||
relays.push({
|
||||
url,
|
||||
read: !marker || marker === "read",
|
||||
write: !marker || marker === "write",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Skipping invalid relay URL in kind:10002 event: ${tag[1]}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,20 @@ const MAX_MEMORY_CACHE = 200; // LRU cache size
|
||||
*/
|
||||
export const CACHED_KINDS = [3, 10002, 10063, 10030];
|
||||
|
||||
/**
|
||||
* Kinds to always fetch and keep synced for active user
|
||||
* These will be:
|
||||
* - Hydrated from cache on startup
|
||||
* - Auto-fetched from relays when user logs in
|
||||
* - Kept up-to-date via addressLoader subscriptions
|
||||
*/
|
||||
export const ACTIVE_USER_KINDS = [
|
||||
3, // Contacts - for $contacts alias resolution
|
||||
10002, // Relay list - for outbox relay selection
|
||||
10063, // Blossom servers - for media uploads
|
||||
// 10030, // Emoji list - optional, uncomment to enable
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a kind is parameterized replaceable (30000-39999)
|
||||
*/
|
||||
@@ -83,6 +97,48 @@ class ReplaceableEventCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate EventStore with fresh cached events on startup
|
||||
* Only loads events newer than TTL to avoid stale data
|
||||
* This solves the "orphaned cache" problem where Dexie has data but EventStore doesn't
|
||||
*/
|
||||
async hydrateEventStore(eventStore: IEventStore): Promise<void> {
|
||||
try {
|
||||
const cutoff = Date.now() - this.ttl;
|
||||
|
||||
const fresh = await db.replaceableEvents
|
||||
.where("updatedAt")
|
||||
.above(cutoff)
|
||||
.toArray();
|
||||
|
||||
console.log(
|
||||
`[ReplaceableEventCache] Hydrating EventStore with ${fresh.length} cached events`,
|
||||
);
|
||||
|
||||
// Add all fresh events to EventStore
|
||||
for (const entry of fresh) {
|
||||
await eventStore.add(entry.event);
|
||||
|
||||
// Also populate memory cache for fast access
|
||||
const cacheKey = buildCacheKey(entry.pubkey, entry.kind, entry.d);
|
||||
this.memoryCache.set(cacheKey, entry);
|
||||
this.cacheOrder.push(cacheKey);
|
||||
}
|
||||
|
||||
// Clean up excess memory cache entries
|
||||
this.evictOldest();
|
||||
|
||||
console.log(
|
||||
`[ReplaceableEventCache] Hydration complete. Memory cache: ${this.memoryCache.size} entries`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ReplaceableEventCache] Error hydrating EventStore:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached event for a pubkey+kind (and optional d-tag)
|
||||
* Returns undefined if not cached or stale
|
||||
|
||||
Reference in New Issue
Block a user