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:
Alejandro
2026-01-21 13:38:59 +01:00
committed by GitHub
parent 94982ca7f4
commit b1fb569250
3 changed files with 88 additions and 75 deletions

View File

@@ -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);
}
/**

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

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