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.
This commit is contained in:
Claude
2026-01-21 12:35:21 +00:00
parent 5aed48d0d4
commit 6f28ccb51f
4 changed files with 84 additions and 148 deletions

View File

@@ -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

View File

@@ -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<ProfileSearchService | null>(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<ProfileSearchResult[]> => {
return await service.search(query, { limit: 20 });
return await profileSearch.search(query, { limit: 20 });
},
[service],
[],
);
return {
searchProfiles,
service,
service: profileSearch,
};
}

View File

@@ -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<string, ProfileContent>();
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<void> {
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;

View File

@@ -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<string, ProfileSearchResult>;
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<void> {
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;