From 6f28ccb51f22ae3eeae2664bc4d739e097166762 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 12:35:21 +0000 Subject: [PATCH] refactor: make ProfileSearchService a singleton for shared profile lookups Instead of creating a separate ProfileCache service, refactored ProfileSearchService to be a singleton that: - Auto-initializes on module load - Loads profiles from IndexedDB for instant startup - Subscribes to EventStore for new profiles This allows both the paste handler and mention autocomplete to share the same profile cache, eliminating duplicate data and subscriptions. Removed the now-unnecessary profile-cache.ts. --- .../editor/extensions/nostr-paste-handler.ts | 8 +- src/hooks/useProfileSearch.ts | 45 ++------- src/services/profile-cache.ts | 93 ------------------- src/services/profile-search.ts | 86 ++++++++++++++--- 4 files changed, 84 insertions(+), 148 deletions(-) delete mode 100644 src/services/profile-cache.ts diff --git a/src/components/editor/extensions/nostr-paste-handler.ts b/src/components/editor/extensions/nostr-paste-handler.ts index 3f87447..c3af884 100644 --- a/src/components/editor/extensions/nostr-paste-handler.ts +++ b/src/components/editor/extensions/nostr-paste-handler.ts @@ -1,17 +1,17 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { nip19 } from "nostr-tools"; -import profileCache from "@/services/profile-cache"; +import profileSearch from "@/services/profile-search"; import { getDisplayName } from "@/lib/nostr-utils"; /** * Helper to get display name for a pubkey (synchronous lookup from cache) */ function getDisplayNameForPubkey(pubkey: string): string { - // Check profile cache first (includes Dexie + EventStore profiles) - const cachedProfile = profileCache.get(pubkey); + // Check profile search cache (includes Dexie + EventStore profiles) + const cachedProfile = profileSearch.getByPubkey(pubkey); if (cachedProfile) { - return getDisplayName(pubkey, cachedProfile); + return cachedProfile.displayName; } // Fallback to placeholder format diff --git a/src/hooks/useProfileSearch.ts b/src/hooks/useProfileSearch.ts index 830d6f9..48a5b1b 100644 --- a/src/hooks/useProfileSearch.ts +++ b/src/hooks/useProfileSearch.ts @@ -1,54 +1,25 @@ -import { useEffect, useMemo, useRef } from "react"; -import { - ProfileSearchService, +import { useMemo } from "react"; +import profileSearch, { type ProfileSearchResult, } from "@/services/profile-search"; -import eventStore from "@/services/event-store"; /** - * Hook to provide profile search functionality with automatic indexing - * of profiles from the event store + * Hook to provide profile search functionality. + * Uses the singleton ProfileSearchService which auto-initializes + * from IndexedDB and subscribes to EventStore. */ export function useProfileSearch() { - const serviceRef = useRef(null); - - // Create service instance (singleton per component mount) - if (!serviceRef.current) { - serviceRef.current = new ProfileSearchService(); - } - - const service = serviceRef.current; - - // Subscribe to profile events from the event store - useEffect(() => { - const subscription = eventStore - .timeline([{ kinds: [0], limit: 1000 }]) - .subscribe({ - next: (events) => { - service.addProfiles(events); - }, - error: (error) => { - console.error("Failed to load profiles for search:", error); - }, - }); - - return () => { - subscription.unsubscribe(); - service.clear(); // Clean up indexed profiles - }; - }, [service]); - // Memoize search function const searchProfiles = useMemo( () => async (query: string): Promise => { - return await service.search(query, { limit: 20 }); + return await profileSearch.search(query, { limit: 20 }); }, - [service], + [], ); return { searchProfiles, - service, + service: profileSearch, }; } diff --git a/src/services/profile-cache.ts b/src/services/profile-cache.ts deleted file mode 100644 index b89948a..0000000 --- a/src/services/profile-cache.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { NostrEvent } from "nostr-tools"; -import { - getProfileContent, - type ProfileContent, -} from "applesauce-core/helpers"; -import eventStore from "./event-store"; -import db from "./db"; - -/** - * Simple singleton profile cache for synchronous display name lookups. - * Used by paste handlers and other places that need instant profile access. - */ -class ProfileCache { - private profiles = new Map(); - private initialized = false; - - /** - * Initialize the cache by: - * 1. Loading profiles from Dexie (IndexedDB) - * 2. Subscribing to EventStore for new kind 0 events - */ - async init(): Promise { - if (this.initialized) return; - this.initialized = true; - - // Load from Dexie first (persisted profiles) - try { - const cachedProfiles = await db.profiles.toArray(); - for (const profile of cachedProfiles) { - const { pubkey, created_at: _created_at, ...content } = profile; - this.profiles.set(pubkey, content as ProfileContent); - } - console.debug( - `[ProfileCache] Loaded ${cachedProfiles.length} profiles from IndexedDB`, - ); - } catch (err) { - console.warn("[ProfileCache] Failed to load from IndexedDB:", err); - } - - // Subscribe to EventStore for new kind 0 events - eventStore.timeline([{ kinds: [0] }]).subscribe({ - next: (events) => { - for (const event of events) { - this.addFromEvent(event); - } - }, - error: (err) => { - console.warn("[ProfileCache] EventStore subscription error:", err); - }, - }); - } - - /** - * Add a profile from a kind 0 event - */ - addFromEvent(event: NostrEvent): void { - if (event.kind !== 0) return; - - const content = getProfileContent(event); - if (content) { - this.profiles.set(event.pubkey, content); - } - } - - /** - * Get profile content for a pubkey (synchronous) - */ - get(pubkey: string): ProfileContent | undefined { - return this.profiles.get(pubkey); - } - - /** - * Check if a profile is cached - */ - has(pubkey: string): boolean { - return this.profiles.has(pubkey); - } - - /** - * Get the number of cached profiles - */ - get size(): number { - return this.profiles.size; - } -} - -// Singleton instance -const profileCache = new ProfileCache(); - -// Auto-initialize on module load -profileCache.init(); - -export default profileCache; diff --git a/src/services/profile-search.ts b/src/services/profile-search.ts index 9f4c840..0aa186c 100644 --- a/src/services/profile-search.ts +++ b/src/services/profile-search.ts @@ -2,6 +2,8 @@ import { Index } from "flexsearch"; import type { NostrEvent } from "nostr-tools"; import { getProfileContent } from "applesauce-core/helpers"; import { getDisplayName } from "@/lib/nostr-utils"; +import eventStore from "./event-store"; +import db from "./db"; export interface ProfileSearchResult { pubkey: string; @@ -12,9 +14,16 @@ export interface ProfileSearchResult { event?: NostrEvent; } -export class ProfileSearchService { +/** + * Singleton service for profile search and synchronous profile lookups. + * Auto-initializes on module load by: + * 1. Loading profiles from IndexedDB (fast startup) + * 2. Subscribing to EventStore for new profiles + */ +class ProfileSearchService { private index: Index; private profiles: Map; + private initialized = false; constructor() { this.profiles = new Map(); @@ -25,6 +34,59 @@ export class ProfileSearchService { }); } + /** + * Initialize the service by loading from IndexedDB and subscribing to EventStore + */ + async init(): Promise { + if (this.initialized) return; + this.initialized = true; + + // Load from Dexie first (persisted profiles for fast startup) + try { + const cachedProfiles = await db.profiles.toArray(); + for (const profile of cachedProfiles) { + const { pubkey, created_at, ...metadata } = profile; + const result: ProfileSearchResult = { + pubkey, + displayName: getDisplayName(pubkey, metadata), + username: metadata?.name, + nip05: metadata?.nip05, + picture: metadata?.picture, + }; + this.profiles.set(pubkey, result); + + // Add to search index + const searchText = [ + result.displayName, + result.username, + result.nip05, + pubkey, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + this.index.add(pubkey, searchText); + } + console.debug( + `[ProfileSearch] Loaded ${cachedProfiles.length} profiles from IndexedDB`, + ); + } catch (err) { + console.warn("[ProfileSearch] Failed to load from IndexedDB:", err); + } + + // Subscribe to EventStore for new kind 0 events + eventStore.timeline([{ kinds: [0] }]).subscribe({ + next: (events) => { + for (const event of events) { + this.addProfile(event); + } + }, + error: (err) => { + console.warn("[ProfileSearch] EventStore subscription error:", err); + }, + }); + } + /** * Add a profile to the search index */ @@ -109,24 +171,12 @@ export class ProfileSearchService { } /** - * Get profile by pubkey + * Get profile by pubkey (synchronous) */ getByPubkey(pubkey: string): ProfileSearchResult | undefined { return this.profiles.get(pubkey); } - /** - * Clear all profiles - */ - clear(): void { - this.profiles.clear(); - this.index = new Index({ - tokenize: "forward", - cache: true, - resolution: 9, - }); - } - /** * Get total number of indexed profiles */ @@ -134,3 +184,11 @@ export class ProfileSearchService { return this.profiles.size; } } + +// Singleton instance +const profileSearch = new ProfileSearchService(); + +// Auto-initialize on module load +profileSearch.init(); + +export default profileSearch;