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
This commit is contained in:
Gigi
2025-11-02 21:03:10 +01:00
parent 8a39258d8e
commit aaddd0ef6b
2 changed files with 150 additions and 26 deletions

View File

@@ -3,7 +3,7 @@ import { Hooks } from 'applesauce-react'
import { Helpers, IEventStore } from 'applesauce-core' import { Helpers, IEventStore } from 'applesauce-core'
import { getContentPointers } from 'applesauce-factory/helpers' import { getContentPointers } from 'applesauce-factory/helpers'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { fetchProfiles } from '../services/profileService' import { fetchProfiles, loadCachedProfiles } from '../services/profileService'
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
@@ -52,32 +52,58 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null):
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(new Map()) const [profileLabels, setProfileLabels] = useState<Map<string, string>>(new Map())
const lastLoggedSize = useRef<number>(0) const lastLoggedSize = useRef<number>(0)
// Build initial labels from eventStore, then fetch missing profiles // Build initial labels: localStorage cache -> eventStore -> fetch from relays
useEffect(() => { useEffect(() => {
const startTime = Date.now() const startTime = Date.now()
console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) 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<string, string>() const labels = new Map<string, string>()
const pubkeysToFetch: string[] = [] const pubkeysToFetch: string[] = []
profileData.forEach(({ encoded, pubkey }) => { profileData.forEach(({ encoded, pubkey }) => {
if (eventStore) { let profileEvent: { content: string } | null = null
const profileEvent = eventStore.getEvent(pubkey + ':0') let foundSource = ''
if (profileEvent) {
try { // Check localStorage cache first
const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } const cachedProfile = cachedProfiles.get(pubkey)
const displayName = profileData.display_name || profileData.name || profileData.nip05 if (cachedProfile) {
if (displayName) { profileEvent = cachedProfile
labels.set(encoded, `@${displayName}`) foundSource = 'localStorage cache'
console.log(`[${ts()}] [npub-resolve] Found in eventStore:`, encoded, '->', displayName) } else if (eventStore) {
} else { // Then check EventStore (in-memory from current session)
pubkeysToFetch.push(pubkey) const eventStoreProfile = eventStore.getEvent(pubkey + ':0')
} if (eventStoreProfile) {
} catch { 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) pubkeysToFetch.push(pubkey)
} }
} else { } catch {
pubkeysToFetch.push(pubkey) pubkeysToFetch.push(pubkey)
} }
} else { } 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)) setProfileLabels(new Map(labels))
// Fetch missing profiles asynchronously // Fetch missing profiles asynchronously

View File

@@ -6,9 +6,86 @@ import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { rebroadcastEvents } from './rebroadcastService' import { rebroadcastEvents } from './rebroadcastService'
import { UserSettings } from './settingsService' 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<string, NostrEvent> {
const cached = new Map<string, NostrEvent>()
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 * 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 ( export const fetchProfiles = async (
relayPool: RelayPool, relayPool: RelayPool,
@@ -22,26 +99,45 @@ export const fetchProfiles = async (
} }
const uniquePubkeys = Array.from(new Set(pubkeys)) const uniquePubkeys = Array.from(new Set(pubkeys))
// First, check localStorage cache for all requested profiles
const cachedProfiles = loadCachedProfiles(uniquePubkeys)
const profilesByPubkey = new Map<string, NostrEvent>()
// 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 relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls) const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Keep only the most recent profile for each pubkey
const profilesByPubkey = new Map<string, NostrEvent>()
const processEvent = (event: NostrEvent) => { const processEvent = (event: NostrEvent) => {
const existing = profilesByPubkey.get(event.pubkey) const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) { if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event) profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately // Store in event store immediately
eventStore.add(event) eventStore.add(event)
// Cache to localStorage for future use
cacheProfile(event)
} }
} }
const local$ = localRelays.length > 0 const local$ = localRelays.length > 0
? relayPool ? relayPool
.req(localRelays, { kinds: [0], authors: uniquePubkeys }) .req(localRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe( .pipe(
onlyEvents(), onlyEvents(),
tap((event: NostrEvent) => processEvent(event)), tap((event: NostrEvent) => processEvent(event)),
@@ -52,7 +148,7 @@ export const fetchProfiles = async (
const remote$ = remoteRelays.length > 0 const remote$ = remoteRelays.length > 0
? relayPool ? relayPool
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys }) .req(remoteRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe( .pipe(
onlyEvents(), onlyEvents(),
tap((event: NostrEvent) => processEvent(event)), 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). // Only the logged-in user's profile image is preloaded (in SidebarHeader).
// Rebroadcast profiles to local/all relays based on settings // Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) { // Only rebroadcast newly fetched profiles, not cached ones
await rebroadcastEvents(profiles, relayPool, settings) const newlyFetchedProfiles = profiles.filter(p => pubkeysToFetch.includes(p.pubkey))
if (newlyFetchedProfiles.length > 0) {
await rebroadcastEvents(newlyFetchedProfiles, relayPool, settings)
} }
return profiles return profiles