Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
38681c701d
  1. 84
      src/components/NoteList/index.tsx
  2. 239
      src/hooks/useFetchProfile.tsx
  3. 27
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  4. 66
      src/services/client.service.ts

84
src/components/NoteList/index.tsx

@ -209,17 +209,34 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => { useEffect(() => {
if (!subRequests.length) return logger.info('[NoteList] useEffect triggered', {
subRequestsLength: subRequests.length,
subRequests: subRequests.map(({ urls, filter }) => ({
urls: urls.slice(0, 2),
filterKeys: Object.keys(filter)
}))
})
if (!subRequests.length) {
logger.warn('[NoteList] subRequests is empty, not initializing')
return
}
async function init() { async function init() {
logger.debug('[NoteList] init called', {
subRequestsCount: subRequests.length,
showKindsLength: showKinds.length,
showKinds,
useFilterAsIs,
areAlgoRelays
})
setLoading(true) setLoading(true)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true) setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh consecutiveEmptyRef.current = 0 // Reset counter on refresh
const { closer, timelineKey } = await client.subscribeTimeline( const mappedSubRequests = subRequests.map(({ urls, filter }) => ({
subRequests.map(({ urls, filter }) => ({
urls, urls,
filter: useFilterAsIs filter: useFilterAsIs
? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) } ? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) }
@ -229,9 +246,36 @@ const NoteList = forwardRef(
kinds: showKinds.length > 0 ? showKinds : [kinds.ShortTextNote], kinds: showKinds.length > 0 ? showKinds : [kinds.ShortTextNote],
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
} }
})), }))
logger.debug('[NoteList] Subscribing with filters', {
subRequestCount: mappedSubRequests.length,
filters: mappedSubRequests.map(({ urls, filter }) => ({
urls: urls.slice(0, 2), // Log first 2 URLs
kinds: filter.kinds,
limit: filter.limit
}))
})
logger.info('[NoteList] About to call subscribeTimeline', {
mappedSubRequestsCount: mappedSubRequests.length
})
let closer: (() => void) | undefined
let timelineKey: string | undefined
try {
const result = await client.subscribeTimeline(
mappedSubRequests,
{ {
onEvents: (events, eosed) => { onEvents: (events: Event[], eosed: boolean) => {
logger.debug('[NoteList] onEvents called', {
eventCount: events.length,
eosed,
showKindsLength: showKinds.length,
subRequestsCount: subRequests.length
})
if (events.length > 0) { if (events.length > 0) {
setEvents(events) setEvents(events)
@ -239,7 +283,7 @@ const NoteList = forwardRef(
// This ensures profiles are ready before user starts scrolling // This ensures profiles are ready before user starts scrolling
// Reduced from 300 to 150 to reduce initial load time // Reduced from 300 to 150 to reduce initial load time
const initialPubkeys = Array.from( const initialPubkeys = Array.from(
new Set(events.slice(0, 150).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) new Set(events.slice(0, 150).map((ev: Event) => ev.pubkey).filter((p: string) => p?.length === 64))
) )
if (initialPubkeys.length > 0) { if (initialPubkeys.length > 0) {
// Filter out already prefetched pubkeys // Filter out already prefetched pubkeys
@ -258,9 +302,9 @@ const NoteList = forwardRef(
// CRITICAL: Prefetch embedded events for initial events // CRITICAL: Prefetch embedded events for initial events
// Extract embedded event IDs from initial events // Extract embedded event IDs from initial events
const initialEmbeddedEventIds = new Set<string>() const initialEmbeddedEventIds = new Set<string>()
events.slice(0, 150).forEach((ev) => { events.slice(0, 150).forEach((ev: Event) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id) => initialEmbeddedEventIds.add(id)) embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id))
}) })
const eventIdsToFetch = Array.from(initialEmbeddedEventIds).filter( const eventIdsToFetch = Array.from(initialEmbeddedEventIds).filter(
(id) => !prefetchedEventIdsRef.current.has(id) (id) => !prefetchedEventIdsRef.current.has(id)
@ -274,7 +318,13 @@ const NoteList = forwardRef(
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
}) })
} }
} else if (eosed) {
// No events received but EOSE - set empty events array and stop loading
logger.debug('[NoteList] EOSE with no events, stopping loading')
setEvents([])
setLoading(false)
} }
if (areAlgoRelays) { if (areAlgoRelays) {
setHasMore(false) setHasMore(false)
} }
@ -287,7 +337,7 @@ const NoteList = forwardRef(
setHasMore(true) setHasMore(true)
} }
}, },
onNew: (event) => { onNew: (event: Event) => {
if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (!useFilterAsIs && !showKinds.includes(event.kind)) return
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event) const isReply = isReplyNoteEvent(event)
@ -307,7 +357,7 @@ const NoteList = forwardRef(
) )
} }
}, },
onClose: (url, reason) => { onClose: (url: string, reason: string) => {
if (!showRelayCloseReason) return if (!showRelayCloseReason) return
// ignore reasons from nostr-tools // ignore reasons from nostr-tools
if ( if (
@ -345,8 +395,22 @@ const NoteList = forwardRef(
useCache: false // Main feeds should always fetch fresh from relays, not use cache useCache: false // Main feeds should always fetch fresh from relays, not use cache
} }
) )
closer = result.closer
timelineKey = result.timelineKey
logger.info('[NoteList] subscribeTimeline completed', {
hasTimelineKey: !!timelineKey,
hasCloser: !!closer
})
setTimelineKey(timelineKey) setTimelineKey(timelineKey)
return closer return closer
} catch (error) {
logger.error('[NoteList] Error in subscribeTimeline', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
})
setLoading(false)
throw error
}
} }
const promise = init() const promise = init()

239
src/hooks/useFetchProfile.tsx

@ -6,6 +6,11 @@ import { TProfile } from '@/types'
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
// CRITICAL: Global deduplication - shared across ALL hook instances
// This prevents multiple components from fetching the same profile simultaneously
const globalFetchPromises = new Map<string, Promise<TProfile | null>>()
const globalFetchingPubkeys = new Set<string>()
export function useFetchProfile(id?: string, skipCache = false) { export function useFetchProfile(id?: string, skipCache = false) {
// CRITICAL: Reduce logging to prevent performance issues during infinite loops // CRITICAL: Reduce logging to prevent performance issues during infinite loops
// Only log if we're actually going to process (not just checking) // Only log if we're actually going to process (not just checking)
@ -25,10 +30,10 @@ export function useFetchProfile(id?: string, skipCache = false) {
const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops) const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops)
const initializedPubkeysRef = useRef<Set<string>>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed) const initializedPubkeysRef = useRef<Set<string>>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed)
// Function to check for profile updates // Function to check for profile updates with GLOBAL deduplication
// fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays)
// Memoize to prevent recreation on every render // Memoize to prevent recreation on every render
const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }) => { const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise<TProfile | null> => {
// CRITICAL: Reduce logging during rapid scrolling to prevent performance issues // CRITICAL: Reduce logging during rapid scrolling to prevent performance issues
// Only log at debug level during normal operations // Only log at debug level during normal operations
logger.debug('[useFetchProfile] checkProfile called', { logger.debug('[useFetchProfile] checkProfile called', {
@ -38,15 +43,137 @@ export function useFetchProfile(id?: string, skipCache = false) {
}) })
if (cancelled.current) { if (cancelled.current) {
logger.debug('[useFetchProfile] Already cancelled, returning false') logger.debug('[useFetchProfile] Already cancelled, returning null')
return false return null
}
// CRITICAL: Check if another hook instance is already fetching this pubkey
// If so, wait for that fetch to complete instead of starting a new one
// Add timeout protection to prevent infinite waits
const existingPromise = globalFetchPromises.get(pubkey)
if (existingPromise) {
logger.debug('[useFetchProfile] Reusing existing fetch promise', {
pubkey: pubkey.substring(0, 8)
})
try {
// Add timeout to prevent waiting forever on a stuck promise
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.warn('[useFetchProfile] Existing promise timeout, will start new fetch', {
pubkey: pubkey.substring(0, 8)
})
resolve(null)
}, 5000) // 5 seconds
})
const existingProfile = await Promise.race([existingPromise, timeoutPromise])
if (cancelled.current) return null
// If timeout won, existingProfile will be null and we'll continue to start new fetch
if (existingProfile === null && !cancelled.current) {
// Timeout occurred, clear the stuck promise and start fresh
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
// Fall through to start new fetch
} else if (existingProfile) {
// Update state for this instance
setProfile(existingProfile)
setIsFetching(false)
initializedPubkeysRef.current.add(pubkey)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(pubkey)
return existingProfile
} else {
setIsFetching(false)
return null
}
} catch (err) {
// If the existing promise failed, we'll try again below
logger.debug('[useFetchProfile] Existing promise failed, will retry', {
pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err)
})
// Clear the failed promise so we can start fresh
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
}
}
// Mark as fetching globally to prevent other instances from starting
if (globalFetchingPubkeys.has(pubkey)) {
// Another instance is fetching, wait a bit and check again
await new Promise(resolve => setTimeout(resolve, 50))
const retryPromise = globalFetchPromises.get(pubkey)
if (retryPromise) {
try {
// Add timeout protection here too
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.warn('[useFetchProfile] Retry promise timeout, will start new fetch', {
pubkey: pubkey.substring(0, 8)
})
resolve(null)
}, 5000) // 5 seconds
})
const retryProfile = await Promise.race([retryPromise, timeoutPromise])
if (cancelled.current) return null
if (retryProfile === null && !cancelled.current) {
// Timeout occurred, clear and start fresh
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
// Fall through to start new fetch
} else if (retryProfile) {
// Update state for this instance
setProfile(retryProfile)
setIsFetching(false)
initializedPubkeysRef.current.add(pubkey)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(pubkey)
return retryProfile
} else {
setIsFetching(false)
return null
}
} catch (err) {
logger.debug('[useFetchProfile] Retry promise failed', {
pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err)
})
// Clear the failed promise
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
// Fall through to start our own fetch
}
}
} }
// Create a new fetch promise with timeout protection
const fetchPromise = (async (): Promise<TProfile | null> => {
try { try {
globalFetchingPubkeys.add(pubkey)
const startTime = Date.now() const startTime = Date.now()
// CRITICAL: Add timeout to prevent infinite hangs
// Use Promise.race to timeout after 5 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Profile fetch timeout after 5s for pubkey ${pubkey.substring(0, 8)}`))
}, 5000) // 5 second timeout
})
// Use fetchProfileEvent which includes author's relay list for better profile discovery // Use fetchProfileEvent which includes author's relay list for better profile discovery
const profileEvent = await replaceableEventService.fetchProfileEvent(pubkey, skipCache) const profileEvent = await Promise.race([
replaceableEventService.fetchProfileEvent(pubkey, skipCache),
timeoutPromise
])
const fetchTime = Date.now() - startTime const fetchTime = Date.now() - startTime
// Only log at info level if profile was found or if fetch took a long time // Only log at info level if profile was found or if fetch took a long time
@ -61,7 +188,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
if (cancelled.current) { if (cancelled.current) {
logger.info('[useFetchProfile] Fetch cancelled after fetch', { pubkey }) logger.info('[useFetchProfile] Fetch cancelled after fetch', { pubkey })
return false return null
} }
if (profileEvent) { if (profileEvent) {
@ -74,19 +201,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
hasAvatar: !!newProfile.avatar, hasAvatar: !!newProfile.avatar,
fetchTime: `${fetchTime}ms` fetchTime: `${fetchTime}ms`
}) })
setProfile(newProfile) return newProfile
setIsFetching(false)
// Mark as initialized
initializedPubkeysRef.current.add(pubkey)
// Keep processingPubkeyRef set so we don't re-fetch
// Clear interval once we have a profile
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
// Clear run count when profile is found
effectRunCountRef.current.delete(pubkey)
return true
} }
// Only log warnings for missing profiles if skipCache is true (user explicitly requested) // Only log warnings for missing profiles if skipCache is true (user explicitly requested)
if (skipCache) { if (skipCache) {
@ -95,19 +210,69 @@ export function useFetchProfile(id?: string, skipCache = false) {
fetchTime: `${fetchTime}ms` fetchTime: `${fetchTime}ms`
}) })
} }
return false return null
} catch (err) { } catch (err) {
const isTimeout = err instanceof Error && err.message.includes('timeout')
if (isTimeout) {
logger.warn('[useFetchProfile] Profile fetch timed out', {
pubkey: pubkey.substring(0, 8),
error: err.message
})
// Return null on timeout instead of throwing - allows UI to show fallback
return null
}
logger.error('[useFetchProfile] Profile fetch error', { logger.error('[useFetchProfile] Profile fetch error', {
pubkey, pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined, stack: err instanceof Error ? err.stack : undefined,
cancelled: cancelled.current cancelled: cancelled.current
}) })
// For non-timeout errors, still throw to allow retry logic
throw err
} finally {
// Clean up global tracking
globalFetchingPubkeys.delete(pubkey)
// Keep promise in cache for a short time to allow other instances to reuse it
// But remove it immediately on timeout/error to allow retries
setTimeout(() => {
globalFetchPromises.delete(pubkey)
}, 1000) // 1 second cache retention
}
})()
// Store the promise globally so other instances can reuse it
globalFetchPromises.set(pubkey, fetchPromise)
try {
const profile = await fetchPromise
if (cancelled.current) return null
if (profile) {
setProfile(profile)
setIsFetching(false)
// Mark as initialized
initializedPubkeysRef.current.add(pubkey)
// Keep processingPubkeyRef set so we don't re-fetch
// Clear interval once we have a profile
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
// Clear run count when profile is found
effectRunCountRef.current.delete(pubkey)
return profile
}
if (!cancelled.current) {
setIsFetching(false)
}
return null
} catch (err) {
if (!cancelled.current) { if (!cancelled.current) {
setError(err as Error) setError(err as Error)
setIsFetching(false) setIsFetching(false)
} }
return false return null
} }
}, [skipCache]) }, [skipCache])
@ -289,13 +454,14 @@ export function useFetchProfile(id?: string, skipCache = false) {
setError(null) setError(null)
// Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network // Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network
const found = await checkProfile(extractedPubkey, cancelled) // checkProfile now returns the profile directly (or null) and handles global deduplication
const profile = await checkProfile(extractedPubkey, cancelled)
// Only log if profile was found or if cancelled (important events) // Only log if profile was found or if cancelled (important events)
if (found || cancelled.current) { if (profile || cancelled.current) {
logger.debug('[useFetchProfile] checkProfile completed', { logger.debug('[useFetchProfile] checkProfile completed', {
pubkey: extractedPubkey?.substring(0, 8), pubkey: extractedPubkey?.substring(0, 8),
found, found: !!profile,
cancelled: cancelled.current cancelled: cancelled.current
}) })
} }
@ -306,8 +472,9 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
if (found) { if (profile) {
// Profile found (from cache or network), we're done // Profile found (from cache or network), we're done
// checkProfile already set the profile state, so we're done
return return
} }
@ -327,8 +494,24 @@ export function useFetchProfile(id?: string, skipCache = false) {
// This reduces memory usage when many profiles are being fetched (e.g., trending page) // This reduces memory usage when many profiles are being fetched (e.g., trending page)
let checkCount = 0 let checkCount = 0
const maxChecks = 3 // Reduced from 4 to further reduce load const maxChecks = 3 // Reduced from 4 to further reduce load
const startTime = Date.now()
const maxTotalTime = 20000 // 20 seconds total timeout (3 checks * ~5s + buffer)
checkIntervalRef.current = setInterval(async () => { checkIntervalRef.current = setInterval(async () => {
// CRITICAL: Check for timeout to prevent infinite retries
const elapsed = Date.now() - startTime
if (elapsed > maxTotalTime) {
logger.warn('[useFetchProfile] Retry interval timeout reached, stopping retries', {
pubkey: extractedPubkey?.substring(0, 8),
elapsed: `${elapsed}ms`
})
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
return
}
if (cancelled.current || checkCount >= maxChecks) { if (cancelled.current || checkCount >= maxChecks) {
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
@ -338,8 +521,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
} }
checkCount++ checkCount++
const found = await checkProfile(extractedPubkey, cancelled) const profile = await checkProfile(extractedPubkey, cancelled)
if (found || cancelled.current) { if (profile || cancelled.current) {
// Profile found or cancelled, stop checking // Profile found or cancelled, stop checking
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)

27
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -30,9 +30,34 @@ export default function RelaysFeed({
setIsReady(false) setIsReady(false)
return return
} }
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
// Add timeout to prevent hanging if getRelayInfos is slow
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('getRelayInfos timeout after 5 seconds'))
}, 5000)
})
try {
const relayInfos = await Promise.race([
relayInfoService.getRelayInfos(relayUrls),
timeoutPromise
])
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
setIsReady(true) setIsReady(true)
logger.debug('RelaysFeed: Initialized successfully', {
relayCount: relayUrls.length,
areAlgoRelays: relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
})
} catch (error) {
logger.warn('RelaysFeed: Failed to get relay infos, proceeding anyway', {
error: error instanceof Error ? error.message : String(error),
relayUrls
})
// Proceed anyway - we can still show the feed even without relay info
setAreAlgoRelays(false)
setIsReady(true)
}
} }
init() init()
}, [relayUrls]) }, [relayUrls])

66
src/services/client.service.ts

@ -1259,14 +1259,18 @@ class ClientService extends EventTarget {
onNew(evt) onNew(evt)
} }
// Only update timeline cache if caching is enabled // Update timeline refs for pagination tracking (even when useCache is false)
if (!useCache) return // This is needed for loadMoreTimeline to know what events have been loaded
const timeline = that.timelines[key] const timeline = that.timelines[key]
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) { if (!timeline || Array.isArray(timeline)) {
return return
} }
// Initialize refs if empty (needed for pagination even when not using cache)
if (!timeline.refs || timeline.refs.length === 0) {
timeline.refs = []
}
// find the right position to insert // find the right position to insert
let idx = 0 let idx = 0
for (const ref of timeline.refs) { for (const ref of timeline.refs) {
@ -1334,7 +1338,28 @@ class ClientService extends EventTarget {
onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true) onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true)
} }
} else { } else {
// No caching - just return events directly // No caching for initial load, but still need to initialize timeline.refs for loadMoreTimeline pagination
const timeline = that.timelines[key]
if (!timeline || Array.isArray(timeline)) {
// Initialize timeline with refs for pagination (even though we don't use cache for initial load)
that.timelines[key] = {
refs: events.map((evt) => [evt.id, evt.created_at]),
filter,
urls
}
} else {
// Update refs with new events for pagination tracking
const firstRefCreatedAt = timeline.refs.length > 0 ? timeline.refs[0][1] : dayjs().unix()
const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
if (events.length >= filter.limit) {
timeline.refs = newRefs
} else {
timeline.refs = newRefs.concat(timeline.refs)
}
}
// Return events directly (no cache concatenation)
onEvents([...events], true) onEvents([...events], true)
} }
}, },
@ -1360,18 +1385,23 @@ class ClientService extends EventTarget {
if (!timeline || Array.isArray(timeline)) return [] if (!timeline || Array.isArray(timeline)) return []
const { filter, urls, refs } = timeline const { filter, urls, refs } = timeline
// Only try to load from cache if refs exist and we have cached events
// When useCache is false, refs might be empty or we might not want to use cache
let cachedEvents: NEvent[] = []
if (refs && refs.length > 0) {
const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until)
const cachedEvents = if (startIdx >= 0) {
startIdx >= 0 cachedEvents = (
? ((
await Promise.all( await Promise.all(
refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventService.fetchEvent(id)) refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventService.fetchEvent(id))
) )
).filter((evt): evt is NEvent => !!evt) as NEvent[]) ).filter((evt): evt is NEvent => !!evt) as NEvent[]
: [] }
if (cachedEvents.length >= limit) { if (cachedEvents.length >= limit) {
return cachedEvents return cachedEvents
} }
}
until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until
limit = limit - cachedEvents.length limit = limit - cachedEvents.length
@ -1381,13 +1411,27 @@ class ClientService extends EventTarget {
}) })
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, limit) events = events.sort((a, b) => b.created_at - a.created_at).slice(0, limit)
// Update refs for pagination tracking (even when useCache is false)
// Initialize refs if empty
if (!timeline.refs) {
timeline.refs = []
}
// Prevent concurrent requests from duplicating the same event // Prevent concurrent requests from duplicating the same event
const lastRefCreatedAt = refs.length > 0 ? refs[refs.length - 1][1] : dayjs().unix() // Only filter by lastRefCreatedAt if refs exist and have items
if (timeline.refs.length > 0) {
const lastRefCreatedAt = timeline.refs[timeline.refs.length - 1][1]
timeline.refs.push( timeline.refs.push(
...events ...events
.filter((evt) => evt.created_at < lastRefCreatedAt) .filter((evt) => evt.created_at < lastRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef) .map((evt) => [evt.id, evt.created_at] as TTimelineRef)
) )
} else {
// No existing refs, add all events
timeline.refs.push(
...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
)
}
return [...cachedEvents, ...events] return [...cachedEvents, ...events]
} }

Loading…
Cancel
Save