mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-10 14:48:48 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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