mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
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:
@@ -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,25 +52,54 @@ 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 = ''
|
||||||
|
|
||||||
|
// 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) {
|
if (profileEvent) {
|
||||||
try {
|
try {
|
||||||
const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||||
const displayName = profileData.display_name || profileData.name || profileData.nip05
|
const displayName = profileData.display_name || profileData.name || profileData.nip05
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
labels.set(encoded, `@${displayName}`)
|
labels.set(encoded, `@${displayName}`)
|
||||||
console.log(`[${ts()}] [npub-resolve] Found in eventStore:`, encoded, '->', displayName)
|
console.log(`[${ts()}] [npub-resolve] Found in ${foundSource}:`, encoded.slice(0, 30) + '...', '->', displayName)
|
||||||
} else {
|
} else {
|
||||||
pubkeysToFetch.push(pubkey)
|
pubkeysToFetch.push(pubkey)
|
||||||
}
|
}
|
||||||
@@ -80,12 +109,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null):
|
|||||||
} else {
|
} else {
|
||||||
pubkeysToFetch.push(pubkey)
|
pubkeysToFetch.push(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
pubkeysToFetch.push(pubkey)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -23,25 +100,44 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user