You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

3288 lines
133 KiB

import NewNotesButton from '@/components/NewNotesButton'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import {
collectEmbeddedEventPrefetchTargets,
getReplaceableCoordinateFromEvent,
isMentioningMutedUsers,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering'
import {
isRelayUrlStrictSupersetIdentityKey,
isSpellSubRequestsSameFiltersDifferentRelays,
stableSpellFeedFilterKey
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import {
getSessionFeedSnapshot,
hardReloadPreservingFeedSnapshots,
setSessionFeedSnapshot
} from '@/services/session-feed-snapshot.service'
import type { TFeedSubRequest, TSubRequestFilter } from '@/types'
import dayjs from 'dayjs'
import { type Event, type Filter, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19'
import RelayStatusDisplay from '@/components/RelayStatusDisplay'
import {
relayOpTerminalRowsToTimelineRelayUiStatuses,
type RelayOpTerminalRow
} from '@/services/relay-operation-log.service'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction
} from 'react'
import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { usePrimaryPageOptional } from '@/contexts/primary-page-context'
import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays'
import type { TProfile } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem'
const LIMIT = 100 // Increased from 200 to load more events per request
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
/** Single-relay explore: kindless REQ cap (relay returns whatever it has, up to this many). */
const RELAY_EXPLORE_LIMIT = SINGLE_RELAY_KINDLESS_REQ_LIMIT
/**
* Vite HMR replaces this module and remounts NoteList; timeline refs reset while the subscription can briefly look
* empty, which re-triggers the “relays returned no events” toast. Suppress briefly after each HMR cycle (dev only).
*/
let suppressRelayEmptyFeedToastUntilMs = 0
if (import.meta.env.DEV && import.meta.hot) {
const bumpSuppressRelayEmptyFeedToast = () => {
suppressRelayEmptyFeedToastUntilMs = Date.now() + 6_000
}
import.meta.hot.on('vite:beforeUpdate', bumpSuppressRelayEmptyFeedToast)
import.meta.hot.on('vite:beforeFullReload', bumpSuppressRelayEmptyFeedToast)
}
const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency
/**
* When building visible rows, scan this many merged-timeline events at most. Previously we only looked at the first
* {@link showCount} events then filtered — with “posts only”, kind filters, and mutes, most of those could be hidden
* so the feed showed 2–4 notes while 100+ were already loaded (felt like a crawl).
*/
const MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE = 2500
/** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
const ONE_SHOT_MERGED_CAP =100
/** Max events kept after merging parallel full-search REQ results across relays. */
const FEED_FULL_SEARCH_MERGE_CAP = 400
/** Cap archive cursor time so progressive search does not monopolize the main thread; pub-store hits are unchanged. */
const PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS = 3_200
/** Client-side feed time window units (Day.js `.subtract` names). */
type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year'
/** Client-side “who wrote this” filter on already-loaded posts. */
type TFeedClientAuthorMode = 'everyone' | 'me' | 'npub'
const FEED_FILTER_KIND_MIN = 0
const FEED_FILTER_KIND_MAX = 40_000
/** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
const FEED_PROFILE_CHUNK = 80
function mergeEventBatchesById(
prev: Event[],
incoming: Event[],
cap: number,
preserveOrder = false
): Event[] {
if (preserveOrder) {
const incomingIds = new Set(incoming.map((e) => e.id))
const prevOnly = prev.filter((e) => !incomingIds.has(e.id))
return [...incoming, ...prevOnly].slice(0, cap)
}
const byId = new Map<string, Event>()
for (const e of prev) {
byId.set(e.id, e)
}
for (const e of incoming) {
byId.set(e.id, e)
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at)
.slice(0, cap)
}
/** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */
function mergeProgressiveSearchEvents(
prev: Event[],
incoming: Event[],
afterSort?: (a: Event, b: Event) => number
): Event[] {
const byId = new Map<string, Event>()
for (const e of prev) {
byId.set(e.id, e)
}
for (const e of incoming) {
const o = byId.get(e.id)
if (!o) {
byId.set(e.id, e)
} else if (e.created_at > o.created_at) {
byId.set(e.id, e)
}
}
const arr = Array.from(byId.values())
if (afterSort) {
arr.sort(afterSort)
} else {
arr.sort((a, b) => b.created_at - a.created_at)
}
return arr
}
function mergeKindsForProgressiveWarmup(
showKindsFromPicker: number[],
progressiveDocumentKinds: readonly number[] | undefined
): number[] {
const base = showKindsFromPicker.length > 0 ? showKindsFromPicker : [kinds.ShortTextNote]
if (!progressiveDocumentKinds?.length) return base
return Array.from(new Set([...base, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}
type ProgressiveSearchLocalLayerOpts = {
warmQ: string
isStale: () => boolean
kindsForWarm: number[]
warmMatch?: (ev: Event) => boolean
afterSort?: (a: Event, b: Event) => number
setEvents: Dispatch<SetStateAction<Event[]>>
setLoading: (loading: boolean) => void
}
/** In-memory session hits only (sync). Relay / IndexedDB run in parallel via {@link kickProgressiveSearchLocalLayers}. */
function applyProgressiveSessionSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm)
if (warmMatch) boot = boot.filter(warmMatch)
const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at)
const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs))
if (!isStale() && boot.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, finalizeOrder(boot), afterSort))
setLoading(false)
}
}
function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
void (async () => {
try {
const idbE = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
warmQ,
cap,
kindsForWarm,
{ archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS }
)
if (isStale()) return
const idbUse = warmMatch ? idbE.filter(warmMatch) : idbE
if (idbUse.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort))
setLoading(false)
}
} catch {
/* ignore */
}
})()
}
function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void {
applyProgressiveSessionSearchLayer(params)
startProgressiveIdbSearchLayer(params)
}
/** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */
function timelineFilterHasNonKindScope(f: Filter): boolean {
const search = f.search
return (
(Array.isArray(f.authors) && f.authors.length > 0) ||
(Array.isArray(f.ids) && f.ids.length > 0) ||
(Array.isArray(f['#p']) && f['#p']!.length > 0) ||
(Array.isArray(f['#e']) && f['#e']!.length > 0) ||
(typeof search === 'string' && search.trim().length > 0)
)
}
/** REQ filter for the first subrequest, matching {@link NoteList} timeline mapping (for full relay search). */
function buildNoteListMappedFilterForFullSearch(
req: TFeedSubRequest,
options: {
showKinds: number[]
useFilterAsIs: boolean
allowKindlessRelayExplore: boolean
clientSideKindFilter: boolean
seeAllFeedEvents: boolean
areAlgoRelays: boolean
}
): Filter | null {
const { urls, filter } = req
const defaultKinds = options.showKinds.length > 0 ? options.showKinds : [kinds.ShortTextNote]
const baseLimit = filter.limit ?? (options.areAlgoRelays ? ALGO_LIMIT : LIMIT)
const seeAllNoSpell = options.seeAllFeedEvents && !options.useFilterAsIs
let f: Filter
if (options.useFilterAsIs) {
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
if (options.allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) {
const finalFilter: Filter = {
...filter,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT
}
delete finalFilter.kinds
f = finalFilter
} else {
const finalFilter: Filter = { ...filter, limit: baseLimit }
if (options.clientSideKindFilter) {
if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
delete finalFilter.kinds
}
} else if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
finalFilter.kinds = defaultKinds
}
f = finalFilter
}
} else if (seeAllNoSpell) {
const { kinds: _omitKinds, ...rest } = filter
f = {
...rest,
limit: options.areAlgoRelays ? ALGO_LIMIT : LIMIT
}
} else {
f = {
...filter,
kinds: defaultKinds,
limit: options.areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
if (seeAllNoSpell) return f
const missingKinds = !f.kinds || f.kinds.length === 0
if (!missingKinds) return f
if (options.useFilterAsIs && options.clientSideKindFilter && timelineFilterHasNonKindScope(f)) return f
if (options.useFilterAsIs && options.allowKindlessRelayExplore && urls.length === 1) return f
return null
}
function eventTagValues(event: Event, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && typeof tag[1] === 'string')
.map((tag) => tag[1] as string)
}
function eventMatchesSubRequestFilter(event: Event, filter: Filter): boolean {
const ids = Array.isArray(filter.ids) ? filter.ids : undefined
if (ids && ids.length > 0 && !ids.includes(event.id)) return false
const authors = Array.isArray(filter.authors) ? filter.authors : undefined
if (authors && authors.length > 0 && !authors.includes(event.pubkey)) return false
const kindsFilter = Array.isArray(filter.kinds) ? filter.kinds : undefined
if (kindsFilter && kindsFilter.length > 0 && !kindsFilter.includes(event.kind)) return false
const tagFilterEntries = Object.entries(filter).filter(([key]) => key.startsWith('#'))
for (const [key, values] of tagFilterEntries) {
if (!Array.isArray(values) || values.length === 0) continue
const tagName = key.slice(1)
const eventValues = eventTagValues(event, tagName)
if (eventValues.length === 0) return false
const matched =
tagName.toLowerCase() === 't'
? (() => {
const allowed = new Set(values.map((v) => String(v).toLowerCase()))
return eventValues.some((v) => allowed.has(v.toLowerCase()))
})()
: (() => {
const allowed = new Set(values.map((v) => String(v)))
return eventValues.some((v) => allowed.has(v))
})()
if (!matched) return false
}
return true
}
const NoteList = forwardRef(
(
{
subRequests,
showKinds,
showKind1OPs = true,
showKind1Replies = true,
showKind1111 = true,
seeAllFeedEvents = false,
/**
* Default true: kind picker + kind-1 / 1111 splits narrow visible rows. False only when {@link showAllKinds}
* should win without listing every kind (rare).
*/
withKindFilter = true,
/**
* True on relay explorer and when KindFilter "All Events" is on (home): merged timeline is not narrowed to
* {@link showKinds} for display or live merge.
*/
showAllKinds = false,
/**
* Single-relay Explore / home chip: REQ omits `kinds`, relay limit (see `SINGLE_RELAY_KINDLESS_REQ_LIMIT`).
*/
allowKindlessRelayExplore = false,
filterMutedNotes = true,
hideReplies = false,
hideUntrustedNotes = false,
areAlgoRelays = false,
relayCapabilityReady = true,
pinnedEventIds = [],
useFilterAsIs = false,
extraShouldHideEvent,
/** When set (e.g. Spells page), timeline subscription keys off this string instead of `subRequests` reference churn. */
feedSubscriptionKey,
/**
* When true (e.g. Explore relay reviews), `subRequests` may grow after first paint (bootstrap relays → full list).
* Re-subscribe when URLs change but **merge** new timeline batches into existing rows by event id instead of clearing.
*/
preserveTimelineOnSubRequestsChange = false,
/**
* With {@link preserveTimelineOnSubRequestsChange}: when relay URLs change but each subrequest’s canonical
* filter string is unchanged (e.g. profile Medien provisional stack → NIP-65 stack), keep visible rows and
* avoid a loading reset.
*/
mergeTimelineWhenSubRequestFiltersMatch = false,
/** Home following: second {@link client.subscribeTimeline} merged into the primary composite key (delta relays / new authors). */
followingFeedDeltaSubRequests,
/**
* When set with {@link preserveTimelineOnSubRequestsChange}: home relay chip / feed mode identity.
* If this string changes (e.g. single relay → all favorites), the timeline is cleared even when the new
* relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
*/
feedTimelineScopeKey,
/** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */
spellFeedInstrumentToken,
/** Spells page: fired once when the filtered list first has rows after a picker change. */
onSpellFeedFirstPaint,
/**
* After this many ms with no forced completion, loading is cleared so empty state can show (default 15s).
* Use a larger value for slow feeds (e.g. notifications `#p` across many relays).
*/
timelineLoadingSafetyTimeoutMs,
/**
* With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none. Kindless relay feeds
* merge the full batch; {@link withKindFilter} + {@link showAllKinds} control whether {@link showKinds}
* narrows merge and visible rows. Other `useFilterAsIs` paths may still narrow merged batches to {@link showKinds}.
*/
clientSideKindFilter = false,
/**
* When true, load events with parallel {@link client.fetchEvents} per subRequest instead of
* {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
* Refresh re-fetches.
*/
oneShotFetch = false,
/** Override {@link client.fetchEvents} / query global timeout (default 14s). */
oneShotGlobalTimeoutMs = 14_000,
/** Override post-EOSE settle delay before resolving (default 2s). */
oneShotEoseTimeoutMs = 2_000,
/**
* When `false`, do not resolve shortly after the first event (lets every relay finish EOSE first).
* Use for wide multi-relay one-shot REQs so slow mirrors are not cut off.
*/
oneShotFirstRelayGraceMs,
/** Max events kept after merging one-shot REQ batches (default 100). */
oneShotMergedCap,
/** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then 2× per step). */
revealBatchSize,
/** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */
oneShotDebugLabel,
/**
* When set, session cache + IndexedDB are scanned for this string before relay REQ completes, merged into the
* timeline immediately (optional {@link progressiveWarmupMatch} narrows rows). Used for NIP-50 search + d-tag browse.
*/
progressiveWarmupQuery,
/** Optional extra filter for {@link progressiveWarmupQuery} hits (e.g. d-tag substring semantics). */
progressiveWarmupMatch,
/**
* Union these kinds into {@link showKinds} for REQ mapping, UI kind gates, progressive warmup, and load-more
* narrowing (e.g. long-form / publication kinds on d-tag + NIP-50 search feeds).
*/
progressiveDocumentKinds,
/**
* When set with {@link oneShotFetch}, sort merged one-shot results with this comparator (e.g. exact d-tag first).
*/
oneShotAfterMergeComparator,
/**
* When true (default), show the 🔍 client-side filter bar (search / from me / time window).
* Set false on feeds where it should stay hidden (e.g. main following).
*/
showFeedClientFilter = true,
/**
* When set, clear 🔍 filter + full-search results whenever this primary tab is not visible (other tabs stay
* mounted with `hidden`) or when the in-page feed identity changes — see {@link feedClientFilterScopeKey}.
*/
hostPrimaryPageName,
/**
* When {@link NormalFeed} renders Notes/Replies + kind row, it passes the slot element so the 🔍 control
* sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList.
*/
feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty,
feedTopNotice,
gridLayout = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
showKind1OPs?: boolean
showKind1Replies?: boolean
showKind1111?: boolean
/** Omit REQ kinds and skip client-side kind filtering (main feed testing). Ignored when useFilterAsIs. */
seeAllFeedEvents?: boolean
withKindFilter?: boolean
showAllKinds?: boolean
allowKindlessRelayExplore?: boolean
filterMutedNotes?: boolean
hideReplies?: boolean
hideUntrustedNotes?: boolean
areAlgoRelays?: boolean
/**
* When false (e.g. home relay feed waiting on `getRelayInfos`), skip timeline subscribe so
* `areAlgoRelays` does not flip after the first REQ and tear the subscription down.
*/
relayCapabilityReady?: boolean
pinnedEventIds?: string[]
/** When true, use filter from subRequests as-is (kinds, limit) instead of showKinds. For spell feeds. */
useFilterAsIs?: boolean
/** When provided and returns true, the event is omitted from the feed (in addition to built-in rules). */
extraShouldHideEvent?: (evt: Event) => boolean
feedSubscriptionKey?: string
preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean
followingFeedDeltaSubRequests?: TFeedSubRequest[]
feedTimelineScopeKey?: string
spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number
clientSideKindFilter?: boolean
oneShotFetch?: boolean
oneShotMergedCap?: number
revealBatchSize?: number
oneShotDebugLabel?: string
progressiveWarmupQuery?: string
progressiveWarmupMatch?: (ev: Event) => boolean
progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
oneShotGlobalTimeoutMs?: number
oneShotEoseTimeoutMs?: number
oneShotFirstRelayGraceMs?: number | false
showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void
/** Optional banner above the feed (e.g. kindless→kinds fallback). */
feedTopNotice?: ReactNode
/** When true, render events as an Instagram-style 3-column square media grid. */
gridLayout?: boolean
},
ref
) => {
const { t } = useTranslation()
const { startLogin, pubkey } = useNostr()
const { isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers, isOffline } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [events, setEvents] = useState<Event[]>([])
const eventsRef = useRef<Event[]>([])
const [feedFullSearchEvents, setFeedFullSearchEvents] = useState<Event[] | null>(null)
const [feedFullSearchLoading, setFeedFullSearchLoading] = useState(false)
const feedFullSearchEventsRef = useRef<Event[] | null>(null)
const displayTimelineSourceRef = useRef<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
/** Session/IDB/relay layers still running for {@link progressiveWarmupQuery} feeds (drives “Looking for more…”). */
const [progressiveLayersSearching, setProgressiveLayersSearching] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [feedClientFilterOpen, setFeedClientFilterOpen] = useState(false)
const [feedClientSearch, setFeedClientSearch] = useState('')
const [feedClientAuthorMode, setFeedClientAuthorMode] = useState<TFeedClientAuthorMode>('everyone')
const [feedClientAuthorNpubInput, setFeedClientAuthorNpubInput] = useState('')
const [feedClientKindInput, setFeedClientKindInput] = useState('')
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
const supportTouch = useMemo(() => isTouchDevice(), [])
const timelineEventsForFilter = feedFullSearchEvents ?? events
useEffect(() => {
feedFullSearchEventsRef.current = feedFullSearchEvents
}, [feedFullSearchEvents])
useEffect(() => {
displayTimelineSourceRef.current = timelineEventsForFilter
}, [timelineEventsForFilter])
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries
const loadMoreTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Throttle loadMore calls to prevent stuttering
/** Batched profile + embed prefetch after timeline updates (avoids N×9s profile storms while relays stream). */
const timelinePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastEventsForTimelinePrefetchRef = useRef<Event[]>([])
/**
* {@link client.subscribeTimeline} resolves asynchronously; cleanup used to only close via
* `promise.then(closer)`, so the next effect could open a new REQ before the prior closer ran.
* That stacks subscriptions on strict relays (e.g. ≤10 subs) and triggers rejections / rate limits.
*/
const timelineEstablishedCloserRef = useRef<(() => void) | null>(null)
/** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */
const timelineEffectGenerationRef = useRef(0)
/** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */
const feedPaintSessionPendingRef = useRef(false)
/** Relay / one-shot data was written to state; log once after commit. */
const feedPaintRelayPendingRef = useRef(false)
const feedPaintRelayMetaRef = useRef<Record<string, unknown> | null>(null)
/** First live `onEvents` paint per timeline init (rows or terminal EOSE). */
const feedPaintLiveRelayDoneRef = useRef(false)
/** True if any timeline `onEvents` batch had `batch.length > 0`, or one-shot fetches returned any raw events (before UI filters). */
const feedRelayReturnedAnyEventRef = useRef(false)
/** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */
const singleRelayKindlessFallbackAttemptedRef = useRef(false)
const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty)
onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty
/** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */
const kindlessEoseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */
const emptyRelayNoHitsToastKeyRef = useRef('')
/** Per-relay outcomes for the current subscribe wave (merged shards); drives empty-feed toast detail. */
const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState<RelayOpTerminalRow[]>([])
/**
* Bumped when {@link feedPaintLiveRelayDoneRef} becomes true so the empty-feed toast effect re-runs.
* (Loading clears when subscribe wires; merged EOSE arrives later.)
*/
const [feedEmptyToastGateTick, setFeedEmptyToastGateTick] = useState(0)
/**
* Bumped when live relays may have updated {@link client.getSeenEventRelayUrls} for visible rows (e.g. trending
* shard EOSE after follows — duplicates merged out of the list but seen-on metadata still changes).
* Drives recomputation of {@link eventReasonLabelMap}.
*/
const [feedReasonLabelsTick, setFeedReasonLabelsTick] = useState(0)
/**
* Mirrors {@link feedPaintLiveRelayDoneRef} in React state so the list can show a skeleton until the first
* merged `onEvents` (rows or EOSE). {@link loading} clears when subscribe wires, which is earlier than REQ/EOSE.
*/
const [feedTimelineEmptyUiReady, setFeedTimelineEmptyUiReady] = useState(false)
const [feedProfileBatch, setFeedProfileBatch] = useState<{
profiles: Map<string, TProfile>
pending: Set<string>
version: number
}>(() => ({ profiles: new Map(), pending: new Set(), version: 0 }))
const feedProfileLoadedRef = useRef<Set<string>>(new Set())
const feedProfileBatchGenRef = useRef(0)
const noteFeedProfileContextValue = useMemo<NoteFeedProfileContextValue>(
() => ({
profiles: feedProfileBatch.profiles,
pendingPubkeys: feedProfileBatch.pending,
version: feedProfileBatch.version
}),
[feedProfileBatch]
)
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => {
return JSON.stringify(
subRequests.map((req) => ({
urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
)
}, [subRequests])
const followingFeedDeltaSubRequestsKey = useMemo(
() =>
JSON.stringify(
(followingFeedDeltaSubRequests ?? []).map((req) => ({
urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
),
[followingFeedDeltaSubRequests]
)
const effectiveShowKinds = useMemo(() => {
if (!progressiveDocumentKinds?.length) return showKinds
return Array.from(new Set([...showKinds, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}, [showKinds, progressiveDocumentKinds])
const mapLiveSubRequestsForTimeline = useCallback(
(requests: TFeedSubRequest[]) => {
const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs
return requests.map(({ urls, filter }) => {
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
if (useFilterAsIs) {
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
if (allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) {
const finalFilter: Filter = {
...filter,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT
}
delete finalFilter.kinds
return { urls, filter: finalFilter }
}
const finalFilter: Filter = { ...filter, limit: baseLimit }
if (clientSideKindFilter) {
if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
delete finalFilter.kinds
}
} else if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
finalFilter.kinds = defaultKinds
}
return { urls, filter: finalFilter }
}
if (seeAllNoSpell) {
const { kinds: _omitKinds, ...rest } = filter
return {
urls,
filter: {
...rest,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
}
return {
urls,
filter: {
...filter,
kinds: defaultKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
})
},
[
allowKindlessRelayExplore,
areAlgoRelays,
clientSideKindFilter,
seeAllFeedEvents,
effectiveShowKinds,
useFilterAsIs
]
)
/** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */
const feedClientFilterScopeKey = useMemo(
() => feedTimelineScopeKey ?? feedSubscriptionKey ?? subRequestsKey,
[feedTimelineScopeKey, feedSubscriptionKey, subRequestsKey]
)
const primaryPageCtx = usePrimaryPageOptional()
const primaryPageCurrent = primaryPageCtx?.current ?? null
/** Clears text/author/time/full-search; does not change panel open state. */
const clearFeedClientSearchCriteria = useCallback(() => {
setFeedClientSearch('')
setFeedClientAuthorMode('everyone')
setFeedClientAuthorNpubInput('')
setFeedClientKindInput('')
setFeedClientTimeAmount('')
setFeedClientTimeUnit('day')
setFeedFullSearchEvents(null)
setFeedFullSearchLoading(false)
}, [])
const resetFeedClientFilterState = useCallback(() => {
clearFeedClientSearchCriteria()
setFeedClientFilterOpen(false)
}, [clearFeedClientSearchCriteria])
const onToggleFeedClientFilterPanel = useCallback(() => {
setFeedClientFilterOpen((wasOpen) => {
if (wasOpen) {
clearFeedClientSearchCriteria()
return false
}
return true
})
}, [clearFeedClientSearchCriteria])
useEffect(() => {
resetFeedClientFilterState()
}, [feedClientFilterScopeKey, resetFeedClientFilterState])
useEffect(() => {
if (hostPrimaryPageName === undefined) return
if (primaryPageCurrent !== hostPrimaryPageName) {
resetFeedClientFilterState()
}
}, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState])
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount)
const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null)
useLayoutEffect(() => {
setFeedTimelineEmptyUiReady(false)
setFeedSubscribeRelayOutcomes([])
}, [timelineSubscriptionKey, refreshCount])
useEffect(() => {
feedProfileBatchGenRef.current += 1
feedProfileLoadedRef.current.clear()
setFeedProfileBatch({ profiles: new Map(), pending: new Set(), version: 0 })
}, [timelineSubscriptionKey, refreshCount])
/** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */
useLayoutEffect(() => {
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (!p) return
const t = p.trim()
if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) {
candidates.add(t.toLowerCase())
}
}
const addPkFromEventTags = (e: Event) => {
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= 4) break
}
}
}
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
for (const e of newEvents) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
setFeedProfileBatch((prev) => {
const pending = new Set(prev.pending)
let changed = false
for (const pk of candidates) {
if (!prev.profiles.has(pk) && !pending.has(pk)) {
pending.add(pk)
changed = true
}
}
if (!changed) return prev
return { ...prev, pending, version: prev.version + 1 }
})
}, [timelineEventsForFilter, newEvents])
const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests
// Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds
// Use sorted array and JSON.stringify to create a stable key that only changes when content changes
const showKindsKey = useMemo(() => {
if (!effectiveShowKinds || effectiveShowKinds.length === 0) return ''
return JSON.stringify([...effectiveShowKinds].sort((a, b) => a - b))
}, [effectiveShowKinds])
/**
* Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows.
* Do **not** include {@link hideReplies}: Notes vs Replies only changes client-side filtering; the same
* raw timeline should restore for both tabs (otherwise Replies can show cache while Notes looks empty).
*/
const sessionSnapshotIdentityKey = useMemo(
() =>
JSON.stringify({
feed: timelineSubscriptionKey,
...(allowKindlessRelayExplore
? { relayKindless: true, showAllKinds }
: {
kinds: showKindsKey,
op: showKind1OPs,
rep: showKind1Replies,
c1111: showKind1111,
seeAll: seeAllFeedEvents
})
}),
[
timelineSubscriptionKey,
showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111,
seeAllFeedEvents,
allowKindlessRelayExplore,
showAllKinds
]
)
/** Kindless relay explore ignores the feed kind picker; avoid re-subscribing when it changes. */
const timelineResubscribeKindKey = allowKindlessRelayExplore
? 'kindless-relay-explore'
: `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}`
const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds
const effectiveShowKindsRef = useRef(effectiveShowKinds)
effectiveShowKindsRef.current = effectiveShowKinds
const progressiveDocumentKindsRef = useRef(progressiveDocumentKinds)
progressiveDocumentKindsRef.current = progressiveDocumentKinds
const progressiveWarmupQueryRef = useRef(progressiveWarmupQuery)
progressiveWarmupQueryRef.current = progressiveWarmupQuery
const progressiveWarmupMatchRef = useRef(progressiveWarmupMatch)
progressiveWarmupMatchRef.current = progressiveWarmupMatch
const oneShotAfterMergeComparatorRef = useRef(oneShotAfterMergeComparator)
oneShotAfterMergeComparatorRef.current = oneShotAfterMergeComparator
const seeAllFeedEventsRef = useRef(seeAllFeedEvents)
seeAllFeedEventsRef.current = seeAllFeedEvents
const allowKindlessRelayExploreRef = useRef(allowKindlessRelayExplore)
allowKindlessRelayExploreRef.current = allowKindlessRelayExplore
const useFilterAsIsRef = useRef(useFilterAsIs)
useFilterAsIsRef.current = useFilterAsIs
const clientSideKindFilterRef = useRef(clientSideKindFilter)
clientSideKindFilterRef.current = clientSideKindFilter
const showAllKindsRef = useRef(showAllKinds)
showAllKindsRef.current = showAllKinds
const withKindFilterRef = useRef(withKindFilter)
withKindFilterRef.current = withKindFilter
/**
* When to apply kind picker + kind-1 OP|reply / 1111 / GitRelease splits to visible rows.
* Home feeds default to {@link withKindFilter}; relay explorer and KindFilter "All Events" use {@link showAllKinds}.
*/
const applyKindPickerInUi = useMemo(
() => withKindFilter && !showAllKinds && !seeAllFeedEvents,
[withKindFilter, showAllKinds, seeAllFeedEvents]
)
const shouldHideEvent = useCallback(
(evt: Event) => {
const pinnedEventHexIdSet = new Set()
pinnedEventIds.forEach((id) => {
try {
const { type, data } = decode(id)
if (type === 'nevent') {
pinnedEventHexIdSet.add(data.id)
}
} catch {
// ignore
}
})
if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
if (filterMutedNotes && muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet)
) {
return true
}
// Filter out expired events
if (shouldFilterEvent(evt)) return true
// Filter out zap receipts below the zap-reply threshold (same rule as thread replies)
if (evt.kind === ExtendedKind.ZAP_RECEIPT && !shouldIncludeZapReceiptAtReplyThreshold(evt, zapReplyThreshold)) {
return true
}
if (extraShouldHideEvent?.(evt)) return true
return false
},
[
filterMutedNotes,
hideReplies,
hideUntrustedNotes,
hideContentMentioningMutedUsers,
mutePubkeySet,
pinnedEventIds,
isEventDeleted,
zapReplyThreshold,
extraShouldHideEvent
]
)
const shouldHideEventRef = useRef(shouldHideEvent)
useEffect(() => {
shouldHideEventRef.current = shouldHideEvent
}, [shouldHideEvent])
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
const out: Event[] = []
const target = showCount
const maxScan = Math.min(
timelineEventsForFilter.length,
Math.min(MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE, Math.max(target * 60, 400))
)
for (let i = 0; i < maxScan && out.length < target; i++) {
const evt = timelineEventsForFilter[i]
if (applyKindPickerInUi) {
if (!effectiveShowKinds.includes(evt.kind)) continue
if (evt.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(evt)
if (isReply && !showKind1Replies) continue
if (!isReply && !showKind1OPs) continue
}
if (evt.kind === ExtendedKind.COMMENT && !showKind1111) continue
if (evt.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) continue
}
if (shouldHideEvent(evt)) continue
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) continue
idSet.add(id)
out.push(evt)
}
return out
}, [
timelineEventsForFilter,
showCount,
shouldHideEvent,
showKinds,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
applyKindPickerInUi
])
useLayoutEffect(() => {
if (!feedPaintSessionPendingRef.current && !feedPaintRelayPendingRef.current) return
const shorten = (s: string, max: number) =>
s.length > max ? `${s.slice(0, max)}` : s
const feedKeyShort = shorten(timelineSubscriptionKey, 200)
const snapshotKeyShort = shorten(sessionSnapshotIdentityKey, 160)
if (feedPaintSessionPendingRef.current) {
feedPaintSessionPendingRef.current = false
logger.debug('[FeedPaint] Session cache committed (DOM)', {
feedKey: feedKeyShort,
snapshotKey: snapshotKeyShort,
eventCount: events.length,
filteredVisibleRows: filteredEvents.length,
pubkeySlice: pubkey ? `${pubkey.slice(0, 12)}` : undefined
})
}
if (feedPaintRelayPendingRef.current) {
feedPaintRelayPendingRef.current = false
const meta = feedPaintRelayMetaRef.current
feedPaintRelayMetaRef.current = null
logger.debug('[FeedPaint] Relay/network results committed (DOM)', {
feedKey: feedKeyShort,
snapshotKey: snapshotKeyShort,
committedEventCount: events.length,
filteredVisibleRows: filteredEvents.length,
pubkeySlice: pubkey ? `${pubkey.slice(0, 12)}` : undefined,
...meta
})
}
}, [
events,
filteredEvents.length,
timelineSubscriptionKey,
sessionSnapshotIdentityKey,
pubkey
])
const filteredNewEvents = useMemo(() => {
if (feedFullSearchEvents !== null) return []
const idSet = new Set<string>()
return newEvents.filter((event: Event) => {
if (applyKindPickerInUi) {
if (!effectiveShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false
}
if (shouldHideEvent(event)) return false
const id = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
if (idSet.has(id)) {
return false
}
idSet.add(id)
return true
})
}, [
feedFullSearchEvents,
newEvents,
shouldHideEvent,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
applyKindPickerInUi
])
const feedClientMinCreatedAt = useMemo(() => {
const raw = feedClientTimeAmount.trim()
const n = parseInt(raw, 10)
if (!Number.isFinite(n) || n < 1) return null
return dayjs().subtract(n, feedClientTimeUnit).unix()
}, [feedClientTimeAmount, feedClientTimeUnit])
const filterAuthorHexForRelayBootstrap = useMemo(() => {
if (feedClientAuthorMode === 'me' && pubkey) return pubkey
if (feedClientAuthorMode === 'npub') {
return inviteInputToHexPubkey(feedClientAuthorNpubInput)
}
return null
}, [feedClientAuthorMode, feedClientAuthorNpubInput, pubkey])
/**
* `null` => no kind constraint, `number` => valid kind, `undefined` => invalid non-empty input.
*/
const feedClientKindFilter = useMemo<number | null | undefined>(() => {
const raw = feedClientKindInput.trim()
if (raw.length === 0) return null
if (!/^\d+$/.test(raw)) return undefined
const parsed = Number(raw)
if (!Number.isInteger(parsed)) return undefined
if (parsed < FEED_FILTER_KIND_MIN || parsed > FEED_FILTER_KIND_MAX) return undefined
return parsed
}, [feedClientKindInput])
const applyClientFeedFilter = useCallback(
(evts: Event[]) => {
let rows = evts
if (feedClientAuthorMode === 'me' && pubkey) {
const p = pubkey.toLowerCase()
rows = rows.filter((e) => e.pubkey.toLowerCase() === p)
} else if (feedClientAuthorMode === 'npub') {
const raw = feedClientAuthorNpubInput.trim()
if (raw) {
const pk = inviteInputToHexPubkey(feedClientAuthorNpubInput)
if (pk) {
const pl = pk.toLowerCase()
rows = rows.filter((e) => e.pubkey.toLowerCase() === pl)
} else {
rows = []
}
}
}
if (feedClientMinCreatedAt !== null) {
rows = rows.filter((e) => e.created_at >= feedClientMinCreatedAt)
}
if (typeof feedClientKindFilter === 'number') {
rows = rows.filter((e) => e.kind === feedClientKindFilter)
} else if (feedClientKindFilter === undefined) {
rows = []
}
const q = feedClientSearch.trim().toLowerCase()
if (q) {
rows = rows.filter((e) => {
if (e.content?.toLowerCase().includes(q)) return true
for (const tag of e.tags) {
for (const cell of tag) {
if (typeof cell === 'string' && cell.toLowerCase().includes(q)) return true
}
}
return false
})
}
return rows
},
[
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
feedClientMinCreatedAt,
feedClientKindFilter,
feedClientSearch
]
)
const clientFilteredEvents = useMemo(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredEvents) : filteredEvents,
[showFeedClientFilter, applyClientFeedFilter, filteredEvents]
)
const clientFilteredNewEvents = useMemo(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
[showFeedClientFilter, applyClientFeedFilter, filteredNewEvents]
)
const feedClientFilterActive = useMemo(
() =>
!!(
showFeedClientFilter &&
(feedClientSearch.trim() ||
(feedClientAuthorMode === 'me' && !!pubkey) ||
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') ||
feedClientKindInput.trim() !== '' ||
feedClientMinCreatedAt !== null)
),
[
showFeedClientFilter,
feedClientSearch,
feedClientAuthorMode,
feedClientAuthorNpubInput,
feedClientKindInput,
pubkey,
feedClientMinCreatedAt
]
)
useLayoutEffect(() => {
if (!onSpellFeedFirstPaint || spellFeedInstrumentToken === undefined) return
if (filteredEvents.length === 0) return
const first = filteredEvents[0]
if (!first) return
const fpKey = `${spellFeedInstrumentToken}|${timelineSubscriptionKey ?? ''}`
if (spellFeedFirstPaintLoggedKeyRef.current === fpKey) return
spellFeedFirstPaintLoggedKeyRef.current = fpKey
onSpellFeedFirstPaint({
eventCount: filteredEvents.length,
firstEventId: first.id
})
}, [
onSpellFeedFirstPaint,
spellFeedInstrumentToken,
timelineSubscriptionKey,
filteredEvents.length,
filteredEvents[0]?.id
])
useEffect(() => {
const handle = window.setTimeout(() => {
const gen = feedProfileBatchGenRef.current
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) {
candidates.add(p.toLowerCase())
}
}
const addPkFromEventTags = (e: Event) => {
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= 4) break
}
}
}
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
for (const e of newEvents) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
if (need.length === 0) return
need.forEach((pk) => feedProfileLoadedRef.current.add(pk))
setFeedProfileBatch((prev) => {
const pending = new Set(prev.pending)
let pendingChanged = false
for (const pk of need) {
if (!pending.has(pk)) {
pending.add(pk)
pendingChanged = true
}
}
if (!pendingChanged) return prev
return { ...prev, pending, version: prev.version + 1 }
})
void (async () => {
if (gen !== feedProfileBatchGenRef.current) return
const chunks: string[][] = []
for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) {
chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
)
if (gen !== feedProfileBatchGenRef.current) return
setFeedProfileBatch((prev) => {
const next = new Map(prev.profiles)
const pend = new Set(prev.pending)
settled.forEach((res, idx) => {
const chunk = chunks[idx]!
if (res.status === 'rejected') {
chunk.forEach((pk) => feedProfileLoadedRef.current.delete(pk))
chunk.forEach((pk) => pend.delete(pk))
return
}
const profiles = res.value
for (const p of profiles) {
const pkNorm = p.pubkey.toLowerCase()
next.set(pkNorm, { ...p, pubkey: pkNorm })
pend.delete(pkNorm)
}
for (const pk of chunk) {
const pkNorm = pk.toLowerCase()
pend.delete(pkNorm)
if (!next.has(pkNorm)) {
next.set(pkNorm, {
pubkey: pkNorm,
npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm),
batchPlaceholder: true
})
}
}
})
return { profiles: next, pending: pend, version: prev.version + 1 }
})
})()
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [timelineEventsForFilter, newEvents])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
topRef.current?.scrollIntoView({ behavior, block: 'start' })
}, 20)
}, [])
const refresh = useCallback(() => {
scrollToTop()
setTimeout(() => {
setRefreshCount((count) => count + 1)
}, 500)
}, [scrollToTop])
// Re-subscribe whenever connectivity flips so we immediately switch between
// local-only (offline) and normal (online) relay sets without waiting for
// the next user-triggered refresh.
const isOfflineRef = useRef(isOffline)
useEffect(() => {
const prev = isOfflineRef.current
isOfflineRef.current = isOffline
if (prev !== isOffline) {
setRefreshCount((n) => n + 1)
}
}, [isOffline])
const onPerformFeedFullSearch = useCallback(async () => {
if (!showFeedClientFilter) return
const reqs = subRequestsRef.current
if (!reqs.length) {
toast.error(t('Feed full search invalid feed'))
return
}
const hasSearch = feedClientSearch.trim().length > 0
const hasTime = feedClientMinCreatedAt !== null
const hasKind = typeof feedClientKindFilter === 'number'
let hasAuthor = false
if (feedClientAuthorMode === 'me' && pubkey) hasAuthor = true
if (feedClientAuthorMode === 'npub' && inviteInputToHexPubkey(feedClientAuthorNpubInput)) {
hasAuthor = true
}
if (feedClientKindFilter === undefined) {
toast.error(
t('Feed filter kind invalid', {
defaultValue: `Kind must be an integer between ${FEED_FILTER_KIND_MIN} and ${FEED_FILTER_KIND_MAX}.`
})
)
return
}
if (!hasSearch && !hasTime && !hasAuthor && !hasKind) {
toast.error(t('Feed full search need constraint'))
return
}
const base = buildNoteListMappedFilterForFullSearch(reqs[0]!, {
showKinds,
useFilterAsIs,
allowKindlessRelayExplore,
clientSideKindFilter,
seeAllFeedEvents,
areAlgoRelays
})
if (!base) {
toast.error(t('Feed full search invalid feed'))
return
}
const finalFilter: Filter = { ...base }
if (hasSearch) {
finalFilter.search = feedClientSearch.trim()
}
if (feedClientAuthorMode === 'me' && pubkey) {
finalFilter.authors = [pubkey]
} else if (feedClientAuthorMode === 'npub') {
const pk = inviteInputToHexPubkey(feedClientAuthorNpubInput)
if (pk) finalFilter.authors = [pk]
}
if (feedClientMinCreatedAt !== null) {
finalFilter.since = Math.max(
feedClientMinCreatedAt,
typeof finalFilter.since === 'number' ? finalFilter.since : 0
)
}
if (hasKind) {
finalFilter.kinds = [feedClientKindFilter]
}
const hasRelayScope =
timelineFilterHasNonKindScope(finalFilter) ||
(typeof finalFilter.since === 'number' && finalFilter.since > 0) ||
(Array.isArray(finalFilter.kinds) && finalFilter.kinds.length > 0)
if (!hasRelayScope) {
toast.error(t('Feed full search need constraint'))
return
}
setFeedFullSearchLoading(true)
try {
const relayUrls = await buildFeedFullSearchRelayUrls({
viewerPubkey: pubkey ?? null,
filterAuthorHex: filterAuthorHexForRelayBootstrap,
favoriteRelays,
blockedRelays
})
if (relayUrls.length === 0) {
toast.error(t('Feed full search invalid feed'))
return
}
const raw = await client.fetchEvents(relayUrls, finalFilter, {
cache: true,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false
})
const merged = mergeEventBatchesById([], raw, FEED_FULL_SEARCH_MERGE_CAP)
setFeedFullSearchEvents(merged)
setShowCount(revealBatchSize ?? SHOW_COUNT)
scrollToTop()
} catch (e) {
logger.warn('[NoteList] Feed full search failed', { error: e })
toast.error(t('Feed full search failed'))
} finally {
setFeedFullSearchLoading(false)
}
}, [
showFeedClientFilter,
feedClientSearch,
feedClientMinCreatedAt,
feedClientKindFilter,
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
filterAuthorHexForRelayBootstrap,
favoriteRelays,
blockedRelays,
showKinds,
useFilterAsIs,
allowKindlessRelayExplore,
clientSideKindFilter,
seeAllFeedEvents,
areAlgoRelays,
revealBatchSize,
scrollToTop,
t
])
const onClearFeedFullSearch = useCallback(() => {
setFeedFullSearchEvents(null)
}, [])
const emptyFeedHardReloadLongPress = useLongPressAction(hardReloadPreservingFeedSnapshots)
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => {
const effectGen = ++timelineEffectGenerationRef.current
const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current
timelineEstablishedCloserRef.current?.()
timelineEstablishedCloserRef.current = null
const currentSubRequests = subRequestsRef.current
if (!currentSubRequests.length) {
if (oneShotDebugLabel) {
logger.info(`[${oneShotDebugLabel}] no subRequests — skipping timeline fetch`, {
feedKey: timelineSubscriptionKey
})
}
setLoading(false)
setEvents([])
// Return a no-op closer function to satisfy the cleanup function
return () => {}
}
// Offline check must come before relayCapabilityReady: for internet relay
// shards, relayCapabilityReady never becomes true while offline (NIP-11
// fetch cannot complete), so checking it first causes an infinite loading spin.
if (isOfflineRef.current && subRequestsRef.current.length > 0) {
const hasAnyLocalRelay = subRequestsRef.current.some((req) =>
req.urls.some((u) => isLocalNetworkUrl(u))
)
if (!hasAnyLocalRelay) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
setLoading(false)
setHasMore(false)
setEvents([])
return () => {}
}
}
if (!relayCapabilityReady && !oneShotFetch) {
setLoading(true)
return () => {}
}
const prevSubKey = prevSubRequestsKeyForTimelineRef.current
const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current
if (userPulledRefresh) {
timelineEffectLastRefreshCountRef.current = refreshCount
}
const prevFeedScope = feedTimelineScopePrevRef.current
const feedScopeKey = feedTimelineScopeKey
const feedScopeChanged =
feedScopeKey !== undefined &&
prevFeedScope !== undefined &&
prevFeedScope !== feedScopeKey
if (feedScopeKey !== undefined) {
feedTimelineScopePrevRef.current = feedScopeKey
} else {
feedTimelineScopePrevRef.current = undefined
}
const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange &&
!userPulledRefresh &&
!feedScopeChanged &&
(prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch &&
isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey)))
prevSubRequestsKeyForTimelineRef.current = subRequestsKey
/** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */
let effectActive = true
async function init() {
if (timelineEffectStale()) return undefined
feedPaintSessionPendingRef.current = false
feedPaintRelayPendingRef.current = false
feedPaintRelayMetaRef.current = null
feedPaintLiveRelayDoneRef.current = false
feedRelayReturnedAnyEventRef.current = false
singleRelayKindlessFallbackAttemptedRef.current = false
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
const keepRowsVisible =
preserveTimelineOnSubRequestsChange &&
keepExistingTimelineEvents &&
eventsRef.current.length > 0
const sessionSnap =
!userPulledRefresh ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) : undefined
const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length)
if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap) {
feedPaintSessionPendingRef.current = true
setEvents(sessionSnap)
lastEventsForTimelinePrefetchRef.current = sessionSnap
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(!!oneShotFetch)
} else {
if (!keepRowsVisible) setLoading(true)
setEvents([])
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
}
} else if (!keepRowsVisible) {
setLoading(true)
}
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current)
.map((req) =>
isOfflineRef.current
? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) }
: req
)
// Drop shards whose every relay was filtered out; avoids timeline-cache
// key collisions where all offline relay-specific views share the same key.
.filter((req) => req.urls.length > 0)
const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => {
if (seeAllNoSpell) return false
if (!filterMissingKinds(f)) return false
if (useFilterAsIs && clientSideKindFilter && timelineFilterHasNonKindScope(f)) return false
if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) {
return false
}
return true
})
if (invalidFilters.length > 0) {
if (oneShotDebugLabel) {
logger.warn(`[${oneShotDebugLabel}] abort: filter missing kinds`, {
subRequestsKey: timelineSubscriptionKey
})
}
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
setLoading(false)
setEvents([])
return undefined
}
/**
* Kindless relay REQ: when {@link showAllKinds} is true (explorer / "All Events"), keep the full batch;
* otherwise narrow to effectiveShowKinds so the merged timeline matches {@link applyKindPickerInUi}.
*/
const narrowLiveBatch = (evs: Event[]) => {
if (seeAllFeedEventsRef.current) return evs
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => effectiveShowKinds.includes(e.kind))
}
if (oneShotFetch) {
setHasMore(false)
try {
if (timelineEffectStale()) return undefined
const warmQOneShot = progressiveWarmupQueryRef.current?.trim()
if (warmQOneShot) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQOneShot,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQOneShot) setProgressiveLayersSearching(false)
return undefined
}
const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS
: oneShotFirstRelayGraceMs
const batches = await Promise.all(
mappedSubRequests.map(({ urls, filter }) =>
client.fetchEvents(urls, filter, {
firstRelayResultGraceMs: firstRelayGraceResolved,
globalTimeout: oneShotGlobalTimeoutMs,
eoseTimeout: oneShotEoseTimeoutMs,
cache: true
})
)
)
if (!effectActive || timelineEffectStale()) return undefined
if (batches.some((b) => b.length > 0)) {
feedRelayReturnedAnyEventRef.current = true
}
const byId = new Map<string, Event>()
for (const ev of batches.flat()) {
const prev = byId.get(ev.id)
if (!prev || ev.created_at > prev.created_at) {
byId.set(ev.id, ev)
}
}
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim()
let relayOnly = [...byId.values()].sort((a, b) => b.created_at - a.created_at)
if (!isProgressiveLayers) {
relayOnly = relayOnly.slice(0, cap)
}
if (
useFilterAsIs &&
clientSideKindFilter &&
withKindFilter &&
!seeAllFeedEventsRef.current &&
(!allowKindlessRelayExplore || !showAllKinds)
) {
relayOnly = relayOnly.filter((e) => effectiveShowKinds.includes(e.kind))
}
const mergeCmp = oneShotAfterMergeComparatorRef.current
if (isProgressiveLayers) {
setEvents((prev) => {
let next = mergeProgressiveSearchEvents(prev, relayOnly, mergeCmp)
if (sessionSnap?.length && !userPulledRefresh) {
next = mergeProgressiveSearchEvents(next, sessionSnap, mergeCmp)
}
if (mergeCmp) {
next = [...next].sort(mergeCmp)
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
let merged = relayOnly
if (sessionSnap?.length && !userPulledRefresh) {
merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP)
}
if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts,
rawTotal,
dedupedCount: byId.size,
afterCap: merged.length,
cap,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit,
...(rawTotal === 0
? {
emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).'
}
: {})
})
}
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
}
if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts,
rawTotal,
dedupedCount: byId.size,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit
})
}
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'one_shot_fetch',
mergedCount: relayOnly.length,
mergedWithPriorSession: !!(sessionSnap?.length && !userPulledRefresh)
}
} catch (err) {
if (oneShotDebugLabel) {
logger.warn(`[${oneShotDebugLabel}] one-shot fetch threw`, err)
}
if (effectActive) {
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'one_shot_fetch',
mergedCount: 0,
fetchThrew: true
}
if (!progressiveWarmupQueryRef.current?.trim()) {
setEvents([])
}
}
} finally {
if (effectActive) {
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
setLoading(false)
setHasMore(false)
setTimelineKey(undefined)
}
}
return undefined
}
const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0)
// Many relays are opened under MAX_CONCURRENT_RELAY_CONNECTIONS; a short race aborts the whole feed.
const subscribeSetupRaceMs = Math.min(
300_000,
Math.max(90_000, 25_000 + totalRelayUrls * 2_500)
)
let closer: (() => void) | undefined
let timelineKey: string | undefined
let timelineSubscribePromise:
| Promise<{ closer: () => void; timelineKey: string }>
| undefined
try {
if (timelineEffectStale()) return undefined
// Opening many relay subs can exceed 2s on spell feeds; a short race
// rejects, the catch closes the late subscription, and the list stays empty after refresh.
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`subscribeTimeline timeout after ${subscribeSetupRaceMs}ms`))
}, subscribeSetupRaceMs)
})
const eventCap = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
// New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends.
setFeedSubscribeRelayOutcomes([])
const warmQLive = progressiveWarmupQueryRef.current?.trim()
if (warmQLive) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQLive,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQLive) setProgressiveLayersSearching(false)
return undefined
}
// Kindless single-relay mode: fall back to explicit kinds if EOSE is too slow.
// Relays that can't efficiently handle a filter with no kinds clause may hang for tens
// of seconds; the timeout fires the same fallback as the empty-EOSE path so the user
// sees content without waiting indefinitely.
if (
allowKindlessRelayExploreRef.current &&
useFilterAsIsRef.current &&
mappedSubRequests.length === 1 &&
mappedSubRequests[0] &&
mappedSubRequests[0].urls.length === 1 &&
!singleRelayKindlessFallbackAttemptedRef.current &&
onSingleRelayKindlessEmptyRef.current
) {
if (kindlessEoseTimeoutRef.current) clearTimeout(kindlessEoseTimeoutRef.current)
kindlessEoseTimeoutRef.current = setTimeout(() => {
kindlessEoseTimeoutRef.current = null
if (!effectActive) return
if (singleRelayKindlessFallbackAttemptedRef.current) return
const reqs = subRequestsRef.current
const f0 = reqs[0]
if (
reqs.length === 1 &&
f0 &&
f0.urls.length === 1 &&
allowKindlessRelayExploreRef.current &&
useFilterAsIsRef.current &&
(!f0.filter.kinds || (f0.filter.kinds as unknown[]).length === 0)
) {
singleRelayKindlessFallbackAttemptedRef.current = true
onSingleRelayKindlessEmptyRef.current?.()
}
}, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS)
}
timelineSubscribePromise = client.subscribeTimeline(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{
onEvents: (batch: Event[], eosed: boolean) => {
if (!effectActive) return
if (batch.length > 0) {
feedRelayReturnedAnyEventRef.current = true
}
// EOSE arrived — cancel the kindless timeout so the fallback doesn't fire afterwards.
if (eosed && kindlessEoseTimeoutRef.current) {
clearTimeout(kindlessEoseTimeoutRef.current)
kindlessEoseTimeoutRef.current = null
}
const narrowed = narrowLiveBatch(batch)
const paintDoneBefore = feedPaintLiveRelayDoneRef.current
if (!feedPaintLiveRelayDoneRef.current) {
if (narrowed.length > 0) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'rows',
narrowedInBatch: narrowed.length,
batchIncoming: batch.length,
eosed
}
} else if (eosed) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'eose_no_visible_rows',
batchIncoming: batch.length,
eosed
}
}
}
if (!paintDoneBefore && feedPaintLiveRelayDoneRef.current) {
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
if (batch.length > 0) {
if (narrowed.length > 0) {
setEvents((prev) => {
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
prev,
narrowed,
oneShotAfterMergeComparatorRef.current
)
: mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
lastEventsForTimelinePrefetchRef.current = next
return next
})
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false)
// Defer profile + embed prefetch: streaming timelines fire onEvents often; starting
// fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks.
if (timelinePrefetchDebounceRef.current) {
clearTimeout(timelinePrefetchDebounceRef.current)
}
timelinePrefetchDebounceRef.current = setTimeout(() => {
timelinePrefetchDebounceRef.current = null
if (!effectActive) return
const evs = lastEventsForTimelinePrefetchRef.current
if (evs.length === 0) return
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(evs.slice(0, 50))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length > 0 || nip19ToFetch.length > 0) {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
}
}, 450)
} else if (eosed) {
setLoading(false)
}
} else if (eosed) {
setLoading(false)
}
if (areAlgoRelays) {
// Algorithm feeds typically return all results at once
setHasMore(false)
} else if (eosed) {
setLoading(false)
// CRITICAL FIX: For non-algo feeds, always assume there might be more events
// The initial load might only return a few events due to filtering or relay limits
// We should still try to load more on scroll - the loadMore logic will handle stopping
// Only set to false if we explicitly know there are no more events (handled in loadMore)
// If we got a full limit of events, there's likely more available
if (batch.length >= (areAlgoRelays ? ALGO_LIMIT : LIMIT)) {
setHasMore(true)
} else {
// Even with fewer events, there might be more (filtering, slow relays, etc.)
// Let loadMore determine if we've reached the end
setHasMore(true)
}
}
// Single-relay home chip: kindless REQ returned nothing — parent re-subscribes with explicit kinds.
if (
eosed &&
effectActive &&
onSingleRelayKindlessEmptyRef.current &&
!singleRelayKindlessFallbackAttemptedRef.current &&
!feedRelayReturnedAnyEventRef.current
) {
const reqs = subRequestsRef.current
const f0 = reqs[0]
if (
reqs.length === 1 &&
f0 &&
f0.urls.length === 1 &&
allowKindlessRelayExploreRef.current &&
useFilterAsIsRef.current &&
clientSideKindFilterRef.current
) {
const f = f0.filter as Filter
const noKinds = !f.kinds || f.kinds.length === 0
if (noKinds) {
singleRelayKindlessFallbackAttemptedRef.current = true
onSingleRelayKindlessEmptyRef.current()
}
}
}
if (
effectActive &&
eosed &&
subRequestsRef.current.some(
(r) => r.reasonLabelIfSeenOnRelay && r.reasonLabel?.trim()
)
) {
setFeedReasonLabelsTick((n) => n + 1)
}
},
onNew: (event: Event) => {
if (!effectActive) return
feedRelayReturnedAnyEventRef.current = true
if (!seeAllFeedEventsRef.current && withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if (
clientSideKindFilterRef.current &&
useFilterAsIsRef.current &&
!effectiveShowKindsRef.current.includes(event.kind)
)
return
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return
if (!isReply && !showKind1OPs) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return
}
}
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
// If the new event is from the current user, insert it directly into the feed
setEvents((oldEvents) =>
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents]
)
} else {
// Otherwise, buffer it and show the New Notes button
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
},
},
{
startLogin,
needSort: !areAlgoRelays,
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS,
onRelaySubscribeWaveComplete: (rows) => {
if (!effectActive) return
setFeedSubscribeRelayOutcomes(rows)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
}
}
)
const result = await Promise.race([timelineSubscribePromise, timeoutPromise])
if (!effectActive || timelineEffectStale()) {
result.closer()
return undefined
}
closer = result.closer
timelineEstablishedCloserRef.current = closer
timelineKey = result.timelineKey
setTimelineKey(timelineKey)
// subscribeTimeline resolves once shards are wired; EOSE / merge callbacks can be delayed or
// skipped on edge paths (all relays fail, strict NOTICE closes, etc.). Do not keep the global
// skeleton until the first onEvents(..., eosed) — that can freeze the feed indefinitely.
setLoading(false)
return closer
} catch (_error) {
setLoading(false)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
if (effectActive) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
// Race timeout or subscribe failure: if the timeline promise later resolves, close or subs leak (relay slots + stale setEvents).
if (timelineSubscribePromise) {
void timelineSubscribePromise
.then((r) => {
r.closer()
})
.catch(() => {})
}
return undefined
}
}
const promise = init()
const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => {
effectActive = false
setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
if (kindlessEoseTimeoutRef.current) {
clearTimeout(kindlessEoseTimeoutRef.current)
kindlessEoseTimeoutRef.current = null
}
if (timelinePrefetchDebounceRef.current) {
clearTimeout(timelinePrefetchDebounceRef.current)
timelinePrefetchDebounceRef.current = null
}
const syncClose = timelineEstablishedCloserRef.current
timelineEstablishedCloserRef.current = null
syncClose?.()
void promise.then((fallbackClose) => {
if (fallbackClose && fallbackClose !== syncClose) {
fallbackClose()
}
})
}
}, [
timelineSubscriptionKey,
sessionSnapshotIdentityKey,
subRequestsKey,
preserveTimelineOnSubRequestsChange,
mergeTimelineWhenSubRequestFiltersMatch,
feedTimelineScopeKey,
refreshCount,
timelineResubscribeKindKey,
seeAllFeedEvents,
useFilterAsIs,
areAlgoRelays,
relayCapabilityReady,
oneShotFetch,
oneShotMergedCap,
revealBatchSize,
oneShotDebugLabel,
oneShotGlobalTimeoutMs,
oneShotEoseTimeoutMs,
oneShotFirstRelayGraceMs,
clientSideKindFilter,
allowKindlessRelayExplore,
showAllKinds,
withKindFilter,
onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline,
progressiveWarmupQuery
])
useEffect(() => {
if (oneShotFetch) return
const deltas = followingFeedDeltaSubRequests ?? []
if (deltas.length === 0) {
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
return
}
const tk = timelineKey
if (!tk) return
let deltaActive = true
const mappedDelta = mapLiveSubRequestsForTimeline(deltas)
const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidDelta = mappedDelta.filter(({ urls, filter: f }) => {
if (seeAllNoSpellDelta) return false
if (!filterMissingKindsDelta(f)) return false
if (useFilterAsIs && clientSideKindFilter && timelineFilterHasNonKindScope(f)) return false
if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) return false
return true
})
if (invalidDelta.length > 0) {
logger.warn('[NoteList] following feed delta: invalid filters, skipping wave', {
invalidCount: invalidDelta.length
})
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
return
}
const eventCapDelta = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const narrowDeltaBatch = (evs: Event[]) => {
if (seeAllFeedEventsRef.current) return evs
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
}
void (async () => {
try {
const { closer, timelineKey: deltaTk } = await client.subscribeTimeline(
mappedDelta as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{
onEvents: (batch: Event[], eosed: boolean) => {
if (!deltaActive) return
if (batch.length > 0) {
feedRelayReturnedAnyEventRef.current = true
}
const narrowed = narrowDeltaBatch(batch)
const paintDoneBefore = feedPaintLiveRelayDoneRef.current
if (!feedPaintLiveRelayDoneRef.current) {
if (narrowed.length > 0) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'rows',
narrowedInBatch: narrowed.length,
batchIncoming: batch.length,
eosed
}
} else if (eosed) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'eose_no_visible_rows',
batchIncoming: batch.length,
eosed
}
}
}
if (!paintDoneBefore && feedPaintLiveRelayDoneRef.current) {
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
if (batch.length > 0) {
if (narrowed.length > 0) {
setEvents((prev) => {
const next = mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays)
lastEventsForTimelinePrefetchRef.current = next
return next
})
setLoading(false)
} else if (eosed) {
setLoading(false)
}
} else if (eosed) {
setLoading(false)
}
if (!areAlgoRelays && eosed) {
setHasMore(true)
}
if (
deltaActive &&
eosed &&
subRequestsRef.current.some(
(r) => r.reasonLabelIfSeenOnRelay && r.reasonLabel?.trim()
)
) {
setFeedReasonLabelsTick((n) => n + 1)
}
},
onNew: (event: Event) => {
if (!deltaActive) return
feedRelayReturnedAnyEventRef.current = true
if (!seeAllFeedEventsRef.current && withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if (
clientSideKindFilterRef.current &&
useFilterAsIsRef.current &&
!effectiveShowKindsRef.current.includes(event.kind)
)
return
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return
if (!isReply && !showKind1OPs) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return
}
}
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
setEvents((oldEvents) =>
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents]
)
} else {
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
}
},
{
startLogin,
needSort: !areAlgoRelays,
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
}
)
if (!deltaActive) {
closer()
return
}
const addedLeaves = client.appendTimelinesToComposite(tk, deltaTk)
const innerClose = closer
const tkForLeafRemoval = tk
followingFeedDeltaCloserRef.current = () => {
innerClose()
if (tkForLeafRemoval && addedLeaves.length > 0) {
client.removeTimelineLeavesFromComposite(tkForLeafRemoval, addedLeaves)
}
}
} catch (e) {
logger.warn('[NoteList] following feed delta subscribe failed', { error: e })
}
})()
return () => {
deltaActive = false
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
}
}, [
followingFeedDeltaSubRequestsKey,
timelineKey,
oneShotFetch,
mapLiveSubRequestsForTimeline,
areAlgoRelays,
allowKindlessRelayExplore,
useFilterAsIs,
clientSideKindFilter,
startLogin,
pubkey,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111
])
const oneShotDebugPrevLoadingRef = useRef(false)
useEffect(() => {
if (!oneShotDebugLabel || !oneShotFetch) return
const wasLoading = oneShotDebugPrevLoadingRef.current
oneShotDebugPrevLoadingRef.current = loading
if (!wasLoading || loading) return
const kind1s = events.filter((e) => e.kind === kinds.ShortTextNote)
const kind1HiddenByExtra = kind1s.filter((e) => extraShouldHideEvent?.(e) === true).length
const kindCounts: Record<number, number> = {}
for (const e of events) {
kindCounts[e.kind] = (kindCounts[e.kind] ?? 0) + 1
}
logger.info(`[${oneShotDebugLabel}] one-shot load settled (UI filters)`, {
timelineSubscriptionKey,
eventsInState: events.length,
filteredVisibleRows: filteredEvents.length,
showCount,
kindCounts,
kind1Count: kind1s.length,
kind1HiddenByExtraShouldHide: kind1HiddenByExtra
})
}, [
oneShotDebugLabel,
oneShotFetch,
loading,
events,
filteredEvents.length,
showCount,
timelineSubscriptionKey,
extraShouldHideEvent
])
useEffect(() => {
eventsRef.current = events
}, [events])
const loadingSafetyMs = timelineLoadingSafetyTimeoutMs ?? 15_000
useEffect(() => {
if (!subRequestsRef.current.length) return
let cancelled = false
const timer = window.setTimeout(() => {
if (cancelled) return
setLoading((prev) => (prev ? false : prev))
// hasMore defaults true; if timeline never sends eosed (slow/hung relays), we would keep a
// bottom skeleton forever while loading is false — unblock empty state / reload.
if (eventsRef.current.length === 0) {
setHasMore(false)
}
}, loadingSafetyMs)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [timelineSubscriptionKey, refreshCount, loadingSafetyMs])
// Use refs to avoid dependency issues and ensure latest values in async callbacks
const showCountRef = useRef(showCount)
const loadingRef = useRef(loading)
const hasMoreRef = useRef(hasMore)
const timelineKeyRef = useRef(timelineKey)
const blankFeedHiddenAtRef = useRef<number | null>(null)
useEffect(() => {
showCountRef.current = showCount
}, [showCount])
useEffect(() => {
loadingRef.current = loading
}, [loading])
useEffect(() => {
if (loading || events.length > 0) return
if (!subRequests.length) return
// Do not toast until merged timeline reports first paint or all shards EOSE (see subscribeTimeline
// `allEosed`); `loading` is cleared earlier when the subscribe promise resolves.
if (!feedPaintLiveRelayDoneRef.current) return
/**
* Outcomes are cleared in layout when the subscription key changes; `onRelaySubscribeWaveComplete`
* runs only after every shard’s relay batch ends (often 10–30s on slow / NIP-42 relays). Without this
* guard, `uiStatuses.length === 0` and the toast fires ~900ms after the first empty paint — not after
* relays actually respond. One-shot fetches never populate outcomes; they are excluded here.
*/
if (!oneShotFetch && feedSubscribeRelayOutcomes.length === 0) return
const toastKey = `${timelineSubscriptionKey}|${refreshCount}`
const debounceMs = 900
const timer = window.setTimeout(() => {
if (loadingRef.current) return
if (eventsRef.current.length > 0) return
if (!subRequestsRef.current.length) return
if (!feedPaintLiveRelayDoneRef.current) return
if (!oneShotFetch && feedSubscribeRelayOutcomes.length === 0) return
if (feedRelayReturnedAnyEventRef.current) return
if (Date.now() < suppressRelayEmptyFeedToastUntilMs) return
if (emptyRelayNoHitsToastKeyRef.current === toastKey) return
emptyRelayNoHitsToastKeyRef.current = toastKey
const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
const successCount = uiStatuses.filter((s) => s.success).length
const title = t(
'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.'
)
if (uiStatuses.length === 0) {
toast.error(title, { duration: 8000 })
} else {
toast.error(
<div className="w-full min-w-0">
<div className="flex items-center gap-2 mb-3">
<CircleAlert className="w-5 h-5 text-red-500 shrink-0" />
<div className="font-semibold">{title}</div>
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Per-relay timeline results ({{count}} connections)', {
count: uiStatuses.length
})}
</div>
<RelayStatusDisplay
relayStatuses={uiStatuses}
successCount={successCount}
totalCount={uiStatuses.length}
aggregateSummary={false}
/>
</div>,
{ duration: 12_000, className: 'max-w-lg w-full' }
)
}
}, debounceMs)
return () => window.clearTimeout(timer)
}, [
loading,
events.length,
subRequests.length,
timelineSubscriptionKey,
refreshCount,
feedEmptyToastGateTick,
feedSubscribeRelayOutcomes,
oneShotFetch,
t
])
useEffect(() => {
hasMoreRef.current = hasMore
}, [hasMore])
useEffect(() => {
timelineKeyRef.current = timelineKey
}, [timelineKey])
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
blankFeedHiddenAtRef.current = Date.now()
return
}
const hidAt = blankFeedHiddenAtRef.current
blankFeedHiddenAtRef.current = null
const hiddenMs = hidAt != null ? Date.now() - hidAt : 0
if (hiddenMs < 1500) return
if (loadingRef.current) return
if (eventsRef.current.length > 0) return
if (!subRequestsRef.current.length) return
logger.info('[NoteList] Blank feed — auto-retry after tab resume', { hiddenMs })
refresh()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}, [refresh])
useEffect(() => {
const options: IntersectionObserverInit = {
root: null,
// Trigger when user is 400px from the bottom so we start loading before they reach the end
rootMargin: '0px 0px 400px 0px',
threshold: 0
}
const loadMore = async (): Promise<void> => {
const currentEvents = displayTimelineSourceRef.current
const currentShowCount = showCountRef.current
const currentLoading = loadingRef.current
const currentHasMore = hasMoreRef.current
const currentTimelineKey = timelineKeyRef.current
// CRITICAL: Throttle loadMore calls to prevent stuttering during rapid scrolling
if (loadMoreTimeoutRef.current) {
return // Already scheduled, skip
}
// Show more events immediately if we have them cached
if (currentShowCount < currentEvents.length) {
const remaining = currentEvents.length - currentShowCount
const step = revealBatchSize ?? SHOW_COUNT * 2
const increment = Math.min(step, remaining)
setShowCount((prev) => prev + increment)
// Only preload more if we have plenty cached (more than 3/4 of LIMIT)
// BUT: Always try to load more if we have very few events (might be due to filtering)
if (currentEvents.length - currentShowCount > LIMIT * 0.75 && currentEvents.length >= 50) {
return
}
// If we have very few events, always try to load more (might be aggressive filtering)
if (currentEvents.length < 50) {
// Continue to loadMore below even if we have cached events
// This ensures we keep loading when filtering is aggressive
}
}
if (feedFullSearchEventsRef.current !== null) return
const canLoadFromTimeline = !!currentTimelineKey && currentHasMore
if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return
// Schedule loadMore with a small delay to throttle rapid calls
loadMoreTimeoutRef.current = setTimeout(async () => {
loadMoreTimeoutRef.current = null
const latestEvents = eventsRef.current
const latestTimelineKey = timelineKeyRef.current
const latestLoading = loadingRef.current
const latestHasMore = hasMoreRef.current
if (!latestTimelineKey || latestLoading || !latestHasMore) return
setLoading(true)
let newEvents: Event[] = []
try {
const until = latestEvents.length ? latestEvents[latestEvents.length - 1].created_at - 1 : dayjs().unix()
newEvents = await client.loadMoreTimeline(
latestTimelineKey,
until,
LIMIT
)
// CRITICAL FIX: Be extremely conservative about stopping the feed
// Only stop if we're absolutely certain there are no more events
if (newEvents.length === 0) {
// Check if timeline has more cached refs that we haven't loaded yet
const hasMoreCached = client.hasMoreTimelineEvents?.(latestTimelineKey, until) ?? false
if (hasMoreCached) {
// There are more cached events, keep hasMore true and try again
setLoading(false)
// Retry after a short delay to allow IndexedDB to catch up
setTimeout(() => {
if (hasMoreRef.current && !loadingRef.current) {
loadMore()
}
}, 300)
return
}
// No cached events and network returned empty
// Be VERY patient - don't stop too early, especially when we have few events
// This prevents stopping due to temporary relay issues or slow relays
consecutiveEmptyRef.current += 1
// CRITICAL FIX: Only stop if we have MANY consecutive empty results AND we have a reasonable number of events
// This ensures we don't stop prematurely when relays are slow or filtering is aggressive
// If we have very few events (< 50), keep trying longer in case filtering is aggressive
const eventCount = latestEvents.length
const shouldStop = consecutiveEmptyRef.current >= (eventCount < 50 ? 30 : 15)
if (shouldStop) {
// After many consecutive empty results, assume we've reached the end
setHasMore(false)
}
// Otherwise, keep hasMore true to allow retry on next scroll
// This ensures the feed continues trying even if relays are slow
setLoading(false)
return
}
let fetchBatch = newEvents
const narrowLoadMore =
useFilterAsIsRef.current &&
clientSideKindFilterRef.current &&
withKindFilterRef.current &&
!seeAllFeedEventsRef.current &&
(!allowKindlessRelayExploreRef.current || !showAllKindsRef.current)
let toAppend = narrowLoadMore
? fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
: fetchBatch
if (
narrowLoadMore &&
toAppend.length === 0 &&
fetchBatch.length > 0
) {
let skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) {
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
toAppend = fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (toAppend.length > 0) break
skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
}
}
if (toAppend.length === 0) {
consecutiveEmptyRef.current += 1
const eventCount = latestEvents.length
const shouldStop = consecutiveEmptyRef.current >= (eventCount < 50 ? 30 : 15)
if (shouldStop) {
setHasMore(false)
}
setLoading(false)
return
}
consecutiveEmptyRef.current = 0
setEvents((oldEvents) => [...oldEvents, ...toAppend])
// After appending, the bottom sentinel may have moved below the fold. Re-check after
// paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll.
setTimeout(() => {
const bottomEl = bottomRef.current
if (bottomEl && hasMoreRef.current && !loadingRef.current) {
const rect = bottomEl.getBoundingClientRect()
if (rect.top < window.innerHeight + 200) {
loadMore()
}
}
}, 150)
// NEVER automatically set hasMore to false based on result count
// Only stop when we get consecutive empty results
// This ensures the feed continues loading even with partial results
// CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering)
// Only prefetch if we're not currently loading to avoid blocking scroll
if (toAppend.length > 0 && !loadingRef.current) {
// Use requestIdleCallback if available, otherwise setTimeout with longer delay
const schedulePrefetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 300)
}
}
schedulePrefetch(() => {
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(toAppend.slice(0, 30))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
})
}
} catch (_error) {
// On error, don't set hasMore to false - might be temporary network issue
consecutiveEmptyRef.current += 1
// Only stop after MANY consecutive errors - be very patient with network issues
// This prevents stopping when relays are temporarily down or slow
if (consecutiveEmptyRef.current >= 25) {
// Increased from 15 to 25 to be even more patient with network issues
setHasMore(false)
}
} finally {
setLoading(false)
}
}, 50) // Reduced delay from 100ms to 50ms for more responsive scrolling
}
const observerInstance = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting || loadingRef.current) return
const ev = eventsRef.current
const sc = showCountRef.current
if (sc < ev.length || hasMoreRef.current) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
// Clean up timeout on unmount
if (loadMoreTimeoutRef.current) {
clearTimeout(loadMoreTimeoutRef.current)
loadMoreTimeoutRef.current = null
}
}
// Dependencies are handled via refs to avoid stale closures in async callbacks
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content)
// This ensures embedded events are ready before user scrolls to them
const prefetchedEventIdsRef = useRef<Set<string>>(new Set())
const prefetchEmbeddedEventsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const mergePrefetchTargetsFromEvents = useCallback((evts: Event[]) => {
const hex = new Set<string>()
const nip19 = new Set<string>()
for (const e of evts) {
const t = collectEmbeddedEventPrefetchTargets(e)
t.hexIds.forEach((id) => hex.add(id))
t.nip19Pointers.forEach((p) => nip19.add(p))
}
return { hexIds: Array.from(hex), nip19Pointers: Array.from(nip19) }
}, [])
// CRITICAL: Prefetch embedded events for visible events
useEffect(() => {
// Throttle embedded event prefetching to reduce frequency during rapid scrolling
// Clear any existing timeout
if (prefetchEmbeddedEventsTimeoutRef.current) {
clearTimeout(prefetchEmbeddedEventsTimeoutRef.current)
}
// Debounce embedded event prefetching by 400ms to reduce frequency during rapid scrolling
prefetchEmbeddedEventsTimeoutRef.current = setTimeout(() => {
const visibleTargets = mergePrefetchTargetsFromEvents(clientFilteredEvents.slice(0, 40))
const upcomingTargets = mergePrefetchTargetsFromEvents(events.slice(0, 80))
const hexIds = Array.from(
new Set([...visibleTargets.hexIds, ...upcomingTargets.hexIds])
)
const nip19Pointers = Array.from(
new Set([...visibleTargets.nip19Pointers, ...upcomingTargets.nip19Pointers])
)
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const scheduleFetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 0)
}
}
scheduleFetch(() => {
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
})
}, 400) // Debounce by 400ms to reduce frequency during rapid scrolling
return () => {
if (prefetchEmbeddedEventsTimeoutRef.current) {
clearTimeout(prefetchEmbeddedEventsTimeoutRef.current)
prefetchEmbeddedEventsTimeoutRef.current = null
}
}
}, [clientFilteredEvents, events, mergePrefetchTargetsFromEvents])
// Also prefetch when loading more events (scrolling down)
// Throttled to reduce frequency during rapid scrolling
const prefetchNewEventsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (loading || !hasMore) return
// Clear any existing timeout
if (prefetchNewEventsTimeoutRef.current) {
clearTimeout(prefetchNewEventsTimeoutRef.current)
}
// Debounce embedded-event prefetch for newly revealed rows (profiles use NoteFeed batcher above)
prefetchNewEventsTimeoutRef.current = setTimeout(() => {
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(
events.slice(showCount, showCount + 50)
)
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const scheduleFetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 0)
}
}
scheduleFetch(() => {
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
})
}, 400) // Debounce by 400ms to reduce frequency during rapid scrolling
return () => {
if (prefetchNewEventsTimeoutRef.current) {
clearTimeout(prefetchNewEventsTimeoutRef.current)
prefetchNewEventsTimeoutRef.current = null
}
}
}, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents])
const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents])
setNewEvents([])
setTimeout(() => {
scrollToTop('smooth')
}, 0)
}
const useFeedFilterTabRowPortal =
showFeedClientFilter && typeof feedClientFilterTabRowHost !== 'undefined'
const feedClientFilterPanelSurfaceClass =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? 'mt-1 w-[min(100vw-1rem,28rem)] max-w-[calc(100vw-1rem)] space-y-3 rounded-lg border border-border bg-background p-3 shadow-lg'
: 'space-y-3 border-t border-border/60 px-2 py-3'
const feedClientFilterSectionClass = 'space-y-2 rounded-md border border-border/60 bg-muted/25 p-2.5'
const feedClientFilterChrome = (
<>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-lg leading-none"
aria-expanded={feedClientFilterOpen}
aria-controls="feed-client-filter-panel"
aria-label={t('Feed filter')}
title={t('Feed filter')}
onClick={onToggleFeedClientFilterPanel}
>
<span aria-hidden>🔍</span>
</Button>
</div>
{feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
<div className={feedClientFilterSectionClass}>
<Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')}
</Label>
<Input
id="feed-client-search"
value={feedClientSearch}
onChange={(e) => setFeedClientSearch(e.target.value)}
placeholder={t('Filter loaded posts placeholder')}
autoComplete="off"
className="w-full"
/>
</div>
<div className={feedClientFilterSectionClass}>
<Label htmlFor="feed-client-kind" className="text-sm font-medium">
{t('Feed filter kind', { defaultValue: 'Event kind' })}
</Label>
<Input
id="feed-client-kind"
inputMode="numeric"
min={FEED_FILTER_KIND_MIN}
max={FEED_FILTER_KIND_MAX}
value={feedClientKindInput}
onChange={(e) => {
const v = e.target.value.trim()
if (v === '' || /^\d+$/.test(v)) setFeedClientKindInput(v)
}}
placeholder={t('Feed filter kind placeholder', { defaultValue: 'e.g. 30023' })}
className="w-full sm:max-w-[11rem]"
aria-invalid={feedClientKindFilter === undefined ? true : undefined}
/>
<p className="text-xs text-muted-foreground">
{t('Feed filter kind hint', {
defaultValue: `Integer ${FEED_FILTER_KIND_MIN}-${FEED_FILTER_KIND_MAX}.`
})}
</p>
</div>
<div className={feedClientFilterSectionClass}>
<Label className="text-sm font-medium">{t('Feed filter author')}</Label>
<RadioGroup
value={feedClientAuthorMode}
onValueChange={(v) => setFeedClientAuthorMode(v as TFeedClientAuthorMode)}
className="grid gap-2"
>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="everyone" id="feed-client-author-everyone" />
<span>{t('Feed filter author everyone')}</span>
</label>
<label
className={`flex cursor-pointer items-center gap-2 text-sm ${!pubkey ? 'cursor-not-allowed opacity-60' : ''}`}
title={!pubkey ? t('Feed filter author me needs login') : undefined}
>
<RadioGroupItem value="me" id="feed-client-author-me" disabled={!pubkey} />
<span>{t('Feed filter author me')}</span>
</label>
<div className="space-y-1.5">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="npub" id="feed-client-author-npub" />
<span>{t('Feed filter author npub')}</span>
</label>
{feedClientAuthorMode === 'npub' ? (
<div className="grid gap-1.5 pl-6">
<span className="text-sm text-muted-foreground">
{t('Feed filter author npub from prefix')}
</span>
<Input
id="feed-client-author-npub-input"
value={feedClientAuthorNpubInput}
onChange={(e) => setFeedClientAuthorNpubInput(e.target.value)}
placeholder={t('Feed filter author npub placeholder')}
autoComplete="off"
className="w-full"
aria-invalid={
feedClientAuthorNpubInput.trim() !== '' &&
!inviteInputToHexPubkey(feedClientAuthorNpubInput)
? true
: undefined
}
/>
</div>
) : null}
</div>
</RadioGroup>
</div>
<div className={feedClientFilterSectionClass}>
<div className="grid grid-cols-[minmax(0,8rem)_minmax(0,1fr)] items-end gap-2">
<div className="grid min-w-0 gap-1.5">
<Label htmlFor="feed-client-time-n" className="text-sm font-medium">
{t('Within the last')}
</Label>
<Input
id="feed-client-time-n"
inputMode="numeric"
min={1}
value={feedClientTimeAmount}
onChange={(e) => {
const v = e.target.value
if (v === '' || /^\d+$/.test(v)) setFeedClientTimeAmount(v)
}}
placeholder="1"
className="w-full"
/>
</div>
<div className="grid min-w-0 gap-1.5">
<Label htmlFor="feed-client-time-unit" className="text-sm font-medium">
{t('Time unit')}
</Label>
<Select
value={feedClientTimeUnit}
onValueChange={(v) => setFeedClientTimeUnit(v as TFeedClientTimeUnit)}
>
<SelectTrigger id="feed-client-time-unit" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minute">{t('Minutes')}</SelectItem>
<SelectItem value="day">{t('Days')}</SelectItem>
<SelectItem value="week">{t('Weeks')}</SelectItem>
<SelectItem value="month">{t('Months')}</SelectItem>
<SelectItem value="year">{t('Years')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<p className="px-0.5 text-xs leading-relaxed text-muted-foreground">
{t('Feed filter client-side hint')}
</p>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8"
disabled={feedFullSearchLoading}
onClick={() => void onPerformFeedFullSearch()}
>
{feedFullSearchLoading ? t('Feed full search running') : t('Feed full search')}
</Button>
{feedFullSearchEvents !== null ? (
<Button type="button" variant="outline" size="sm" className="h-8" onClick={onClearFeedFullSearch}>
{t('Feed full search clear')}
</Button>
) : null}
</div>
{feedFullSearchEvents !== null ? (
<p className="text-xs text-muted-foreground">{t('Feed full search active hint')}</p>
) : null}
</div>
) : null}
</>
)
const feedClientFilterBarEmbedded = (
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80">
{feedClientFilterChrome}
</div>
)
const feedClientFilterBar =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? createPortal(
<div className="flex flex-col items-end gap-0">{feedClientFilterChrome}</div>,
feedClientFilterTabRowHost
)
: useFeedFilterTabRowPortal && !feedClientFilterTabRowHost
? null
: feedClientFilterBarEmbedded
const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null
const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim()
const showRelaySubscribeWavePendingBanner =
!oneShotFetch &&
!feedFullSearchActive &&
subRequests.length > 0 &&
relayCapabilityReady &&
timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady
const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner =
showRelaySubscribeWavePendingBanner || showProgressiveLayersPendingBanner
const relayWavePendingBannerEl = showLookingForMoreEventsBanner ? (
<div
className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground"
role="status"
aria-live="polite"
>
{t('Looking for more events…')}
</div>
) : null
const eventReasonLabelMap = useMemo(() => {
const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0)
if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>()
const map = new Map<string, string>()
for (const event of clientFilteredEvents) {
const labels: string[] = []
for (const req of reqs) {
if (!eventMatchesSubRequestFilter(event, req.filter as Filter)) continue
if (req.reasonLabelIfSeenOnRelay) {
const target = normalizeUrl(req.reasonLabelIfSeenOnRelay) || req.reasonLabelIfSeenOnRelay
const seenNorm = client
.getSeenEventRelayUrls(event.id)
.map((u) => normalizeUrl(u) || u)
if (!seenNorm.includes(target)) continue
}
labels.push(req.reasonLabel as string)
}
if (labels.length) {
map.set(event.id, Array.from(new Set(labels)).join(' · '))
}
}
return map
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick])
const list = (
<div className="min-h-screen">
{relayWavePendingBannerEl}
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
{t('No loaded posts match your filters.')}
</div>
) : null}
{feedFullSearchActive && listSourceEvents.length === 0 && !feedFullSearchLoading ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
{t('Feed full search empty')}
</div>
) : null}
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{clientFilteredEvents.map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
clientFilteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/>
))
)}
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div
ref={bottomRef}
className={gridLayout ? 'grid grid-cols-3 gap-0.5 pr-4 min-h-[40vh]' : 'min-h-[40vh] space-y-2 px-1 py-4'}
role="status"
aria-live="polite"
aria-busy="true"
>
{gridLayout
? Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="aspect-square animate-pulse bg-muted" />
))
: Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
) : listSourceEvents.length > 0 &&
(feedFullSearchActive ? showCount < listSourceEvents.length : hasMore) ? (
<div
ref={bottomRef}
className={
filteredEvents.length === 0 && !loading
? 'min-h-[35vh] py-4'
: loading
? 'min-h-8'
: 'min-h-4'
}
>
{loading ? <NoteCardLoadingSkeleton /> : null}
</div>
) : listSourceEvents.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : listSourceEvents.length === 0 &&
!feedFullSearchActive &&
!loading &&
feedTimelineEmptyUiReady &&
subRequests.length > 0 ? (
<div
ref={bottomRef}
className="mt-6 flex min-h-[35vh] flex-col items-center justify-start gap-4 px-4 text-center text-sm text-muted-foreground"
role="status"
>
<p>{t('No posts loaded for this feed. Try refreshing.')}</p>
<Button
type="button"
variant="outline"
size="sm"
title={t('refresh.longPressHardReload')}
onPointerDown={emptyFeedHardReloadLongPress.onPointerDown}
onPointerUp={emptyFeedHardReloadLongPress.onPointerUp}
onPointerLeave={emptyFeedHardReloadLongPress.onPointerLeave}
onPointerCancel={emptyFeedHardReloadLongPress.onPointerCancel}
onClick={() => {
if (emptyFeedHardReloadLongPress.consumeIfLongPress()) return
refresh()
}}
>
{t('Refresh')}
</Button>
</div>
) : (
<div ref={bottomRef} className="mt-2 min-h-4" aria-hidden />
)}
</div>
)
return (
<div>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? (
<PullToRefresh
onRefresh={async () => {
refresh()
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
<div>
{feedTopNotice ? (
<div
className="mb-2 rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-sm text-muted-foreground"
role="note"
>
{feedTopNotice}
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
{list}
</div>
</PullToRefresh>
) : (
<div>
{feedTopNotice ? (
<div
className="mb-2 rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-sm text-muted-foreground"
role="note"
>
{feedTopNotice}
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
{list}
</div>
)}
</NoteFeedProfileContext.Provider>
<div className="h-40" />
{clientFilteredNewEvents.length > 0 && (
<NewNotesButton newEvents={clientFilteredNewEvents} onClick={showNewEvents} />
)}
</div>
)
}
)
NoteList.displayName = 'NoteList'
export default NoteList
export type TNoteListRef = {
scrollToTop: (behavior?: ScrollBehavior) => void
refresh: () => void
}