From aaddd0ef6b751163ed4aa5a5339d84aaaeb1a579 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:03:10 +0100 Subject: [PATCH] feat: add localStorage caching for profile resolution - Add localStorage caching functions to profileService.ts following articleService.ts pattern - getCachedProfile: get single cached profile with TTL validation (30 days) - cacheProfile: save profile to localStorage with error handling - loadCachedProfiles: batch load multiple profiles from cache - Modify fetchProfiles() to check localStorage cache first, only fetch missing/expired profiles, and cache fetched profiles - Update useProfileLabels hook to check localStorage before EventStore, add cached profiles to EventStore for consistency - Update logging to show cache hits from localStorage - Benefits: instant profile resolution on page reload, reduced relay queries, offline support for previously-seen profiles --- src/hooks/useProfileLabels.ts | 62 ++++++++++++------ src/services/profileService.ts | 114 ++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 26 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 4a002a4d..f4399013 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -3,7 +3,7 @@ import { Hooks } from 'applesauce-react' import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' import { RelayPool } from 'applesauce-relay' -import { fetchProfiles } from '../services/profileService' +import { fetchProfiles, loadCachedProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers @@ -52,32 +52,58 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const [profileLabels, setProfileLabels] = useState>(new Map()) const lastLoggedSize = useRef(0) - // Build initial labels from eventStore, then fetch missing profiles + // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { const startTime = Date.now() console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) - // First, get profiles from eventStore synchronously + // Extract all pubkeys + const allPubkeys = profileData.map(({ pubkey }) => pubkey) + + // First, check localStorage cache (synchronous, instant) + const cachedProfiles = loadCachedProfiles(allPubkeys) + console.log(`[${ts()}] [npub-resolve] Found in localStorage cache:`, cachedProfiles.size, 'out of', allPubkeys.length) + + // Add cached profiles to EventStore for consistency + if (eventStore) { + for (const profile of cachedProfiles.values()) { + eventStore.add(profile) + } + } + + // Build initial labels from localStorage cache and eventStore const labels = new Map() const pubkeysToFetch: string[] = [] profileData.forEach(({ encoded, pubkey }) => { - if (eventStore) { - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - labels.set(encoded, `@${displayName}`) - console.log(`[${ts()}] [npub-resolve] Found in eventStore:`, encoded, '->', displayName) - } else { - pubkeysToFetch.push(pubkey) - } - } catch { + let profileEvent: { content: string } | null = null + let foundSource = '' + + // Check localStorage cache first + const cachedProfile = cachedProfiles.get(pubkey) + if (cachedProfile) { + profileEvent = cachedProfile + foundSource = 'localStorage cache' + } else if (eventStore) { + // Then check EventStore (in-memory from current session) + const eventStoreProfile = eventStore.getEvent(pubkey + ':0') + if (eventStoreProfile) { + profileEvent = eventStoreProfile + foundSource = 'eventStore' + } + } + + if (profileEvent) { + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + labels.set(encoded, `@${displayName}`) + console.log(`[${ts()}] [npub-resolve] Found in ${foundSource}:`, encoded.slice(0, 30) + '...', '->', displayName) + } else { pubkeysToFetch.push(pubkey) } - } else { + } catch { pubkeysToFetch.push(pubkey) } } else { @@ -85,7 +111,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - // Update labels with what we found in eventStore + // Update labels with what we found in localStorage cache and eventStore setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously diff --git a/src/services/profileService.ts b/src/services/profileService.ts index f3f912f2..021a1032 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -6,9 +6,86 @@ import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { rebroadcastEvents } from './rebroadcastService' import { UserSettings } from './settingsService' +interface CachedProfile { + event: NostrEvent + timestamp: number +} + +const PROFILE_CACHE_TTL = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds (profiles change less frequently than articles) +const PROFILE_CACHE_PREFIX = 'profile_cache_' + +function getProfileCacheKey(pubkey: string): string { + return `${PROFILE_CACHE_PREFIX}${pubkey}` +} + +/** + * Get a cached profile from localStorage + * Returns null if not found, expired, or on error + */ +export function getCachedProfile(pubkey: string): NostrEvent | null { + try { + const cacheKey = getProfileCacheKey(pubkey) + const cached = localStorage.getItem(cacheKey) + if (!cached) { + return null + } + + const { event, timestamp }: CachedProfile = JSON.parse(cached) + const age = Date.now() - timestamp + + if (age > PROFILE_CACHE_TTL) { + localStorage.removeItem(cacheKey) + return null + } + + return event + } catch (err) { + // Silently handle cache read errors + return null + } +} + +/** + * Cache a profile to localStorage + * Handles errors gracefully (quota exceeded, invalid data, etc.) + */ +export function cacheProfile(profile: NostrEvent): void { + try { + if (profile.kind !== 0) return // Only cache kind:0 (profile) events + + const cacheKey = getProfileCacheKey(profile.pubkey) + const cached: CachedProfile = { + event: profile, + timestamp: Date.now() + } + localStorage.setItem(cacheKey, JSON.stringify(cached)) + } catch (err) { + // Silently fail - don't block the UI if caching fails + // Handles quota exceeded, invalid data, and other errors gracefully + } +} + +/** + * Batch load multiple profiles from localStorage cache + * Returns a Map of pubkey -> NostrEvent for all found profiles + */ +export function loadCachedProfiles(pubkeys: string[]): Map { + const cached = new Map() + + for (const pubkey of pubkeys) { + const profile = getCachedProfile(pubkey) + if (profile) { + cached.set(pubkey, profile) + } + } + + return cached +} + /** * Fetches profile metadata (kind:0) for a list of pubkeys - * Stores profiles in the event store and optionally to local relays + * Checks localStorage cache first, then fetches from relays for missing/expired profiles + * Stores profiles in the event store and caches to localStorage */ export const fetchProfiles = async ( relayPool: RelayPool, @@ -22,26 +99,45 @@ export const fetchProfiles = async ( } const uniquePubkeys = Array.from(new Set(pubkeys)) + + // First, check localStorage cache for all requested profiles + const cachedProfiles = loadCachedProfiles(uniquePubkeys) + const profilesByPubkey = new Map() + + // Add cached profiles to the map and EventStore + for (const [pubkey, profile] of cachedProfiles.entries()) { + profilesByPubkey.set(pubkey, profile) + // Ensure cached profiles are also in EventStore for consistency + eventStore.add(profile) + } + + // Determine which pubkeys need to be fetched from relays + const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey)) + + // If all profiles are cached, return early + if (pubkeysToFetch.length === 0) { + return Array.from(profilesByPubkey.values()) + } + // Fetch missing profiles from relays const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const prioritized = prioritizeLocalRelays(relayUrls) const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) - // Keep only the most recent profile for each pubkey - const profilesByPubkey = new Map() - const processEvent = (event: NostrEvent) => { const existing = profilesByPubkey.get(event.pubkey) if (!existing || event.created_at > existing.created_at) { profilesByPubkey.set(event.pubkey, event) // Store in event store immediately eventStore.add(event) + // Cache to localStorage for future use + cacheProfile(event) } } const local$ = localRelays.length > 0 ? relayPool - .req(localRelays, { kinds: [0], authors: uniquePubkeys }) + .req(localRelays, { kinds: [0], authors: pubkeysToFetch }) .pipe( onlyEvents(), tap((event: NostrEvent) => processEvent(event)), @@ -52,7 +148,7 @@ export const fetchProfiles = async ( const remote$ = remoteRelays.length > 0 ? relayPool - .req(remoteRelays, { kinds: [0], authors: uniquePubkeys }) + .req(remoteRelays, { kinds: [0], authors: pubkeysToFetch }) .pipe( onlyEvents(), tap((event: NostrEvent) => processEvent(event)), @@ -70,8 +166,10 @@ export const fetchProfiles = async ( // Only the logged-in user's profile image is preloaded (in SidebarHeader). // Rebroadcast profiles to local/all relays based on settings - if (profiles.length > 0) { - await rebroadcastEvents(profiles, relayPool, settings) + // Only rebroadcast newly fetched profiles, not cached ones + const newlyFetchedProfiles = profiles.filter(p => pubkeysToFetch.includes(p.pubkey)) + if (newlyFetchedProfiles.length > 0) { + await rebroadcastEvents(newlyFetchedProfiles, relayPool, settings) } return profiles