From b1fb5692505383ae9c9a9a1f56869684d6f7de8f Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 21 Jan 2026 13:38:59 +0100 Subject: [PATCH] feat: user profile search for resolving usernames when pasting (#184) * fix: show display names when pasting npub/nprofile in editors Previously, pasting npub/nprofile strings would only check the EventStore's in-memory cache for profiles. If a profile was cached in IndexedDB but not yet loaded into the EventStore, it would show a hex preview instead. This adds a ProfileCache service that: - Loads profiles from IndexedDB on startup for instant access - Subscribes to EventStore for new profiles as they arrive - Provides synchronous lookups for the paste handler Also uses consistent fallback format (XXXX:YYYY) when no profile is found. * 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. --------- Co-authored-by: Claude --- .../editor/extensions/nostr-paste-handler.ts | 32 ++----- src/hooks/useProfileSearch.ts | 45 ++-------- src/services/profile-search.ts | 86 ++++++++++++++++--- 3 files changed, 88 insertions(+), 75 deletions(-) diff --git a/src/components/editor/extensions/nostr-paste-handler.ts b/src/components/editor/extensions/nostr-paste-handler.ts index 08d6986..c3af884 100644 --- a/src/components/editor/extensions/nostr-paste-handler.ts +++ b/src/components/editor/extensions/nostr-paste-handler.ts @@ -1,37 +1,21 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { nip19 } from "nostr-tools"; -import eventStore from "@/services/event-store"; -import { getProfileContent } from "applesauce-core/helpers"; +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 { - try { - // Try to get profile from event store (check if it's a BehaviorSubject with .value) - const profile$ = eventStore.replaceable(0, pubkey) as any; - if (profile$ && profile$.value) { - const profileEvent = profile$.value; - if (profileEvent) { - const content = getProfileContent(profileEvent); - if (content) { - // Use the Grimoire helper which handles fallbacks - return getDisplayName(pubkey, content); - } - } - } - } catch (err) { - // Ignore errors, fall through to default - console.debug( - "[NostrPasteHandler] Could not get profile for", - pubkey.slice(0, 8), - err, - ); + // Check profile search cache (includes Dexie + EventStore profiles) + const cachedProfile = profileSearch.getByPubkey(pubkey); + if (cachedProfile) { + return cachedProfile.displayName; } - // Fallback to short pubkey - return pubkey.slice(0, 8); + + // Fallback to placeholder format + return getDisplayName(pubkey, undefined); } /** 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-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;