mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user