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.
 
 
 
 

4720 lines
194 KiB

import NewNotesButton from '@/components/NewNotesButton'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_MEDIA_TAB_KINDS,
SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants'
import {
getNip18RepostTargetId,
getReplaceableCoordinateFromEvent,
isMentioningMutedUsers,
isNip18RepostKind,
isReplaceableEvent,
isReplyNoteEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering'
import {
isRelayUrlStrictSupersetIdentityKey,
isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } 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 noteStatsService from '@/services/note-stats.service'
import indexedDb from '@/services/indexed-db.service'
import {
getSessionFeedSnapshot,
hardReloadPreservingFeedSnapshots,
setSessionFeedSnapshot
} from '@/services/session-feed-snapshot.service'
import type { TFeedSubRequest, TNoteListMode, 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 {
getProfileAuthorWarmupSpec,
getProfileTimelineFetchRelayUrls,
isProfileTimelineSubscriptionKey
} from '@/lib/profile-author-warmup-spec'
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'
import {
buildFeedSessionSnapshotKey,
createFeedDescriptor,
legacyFeedSubscriptionKey,
stableFeedKindKey
} from '@/features/feed/descriptor'
import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests'
import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
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 = 36 // Initial visible-row quota (filtered); higher = more rows on first paint
/** Extra visible-row quota each time the user reaches the bottom while draining an already-loaded timeline. */
const REVEAL_BATCH_STEP = 96
/**
* One “load more” chains relay pages until at least this many **new** events (after kind filter + id de-dupe) are
* collected, so sparse kind filters do not feel stuck at ~10 rows per scroll.
*/
const LOAD_MORE_MIN_NEW_EVENTS = 22
const LOAD_MORE_MAX_CHAIN_PAGES = 12
/** Wall-clock cap for chained load-more fetches (sparse filters + slow relays). */
const LOAD_MORE_CHAIN_BUDGET_MS = 5_000
/**
* IntersectionObserver: extend the viewport root downward so the bottom sentinel can fire load-more while the
* user is still well above the physical list end (px).
*/
const LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX = 3200
/**
* When the user scrolls down inside the feed scroll container and is within this distance of the bottom (px),
* start load-more (uses viewport height of that container, with a floor).
*/
const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35
const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
/** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180
/** When the scroll container is within this many px of the top, auto-merge pending live notes (see {@link NewNotesButton}). */
const AUTO_MERGE_NEW_EVENTS_TOP_PX = 280
function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
if (!node) return null
let el: HTMLElement | null = node.parentElement
while (el && el !== document.documentElement) {
const { overflowY } = getComputedStyle(el)
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return el
el = el.parentElement
}
return null
}
function distanceFromScrollBottom(scrollRoot: HTMLElement | Window): number {
if (scrollRoot === window) {
const doc = document.documentElement
return doc.scrollHeight - window.scrollY - window.innerHeight
}
const el = scrollRoot as HTMLElement
return el.scrollHeight - el.scrollTop - el.clientHeight
}
function scrollRootClientHeight(scrollRoot: HTMLElement | Window): number {
return scrollRoot === window ? window.innerHeight : (scrollRoot as HTMLElement).clientHeight
}
/**
* 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
/** Debounce rapid timeline updates so profile batches do not stack on every streaming EVENT. */
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 400
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
const FEED_PROFILE_CHUNK = 80
function normalizeFeedRepostTargetKey(id: string): string {
const t = id.trim()
if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase()
return normalizeReplaceableCoordinateString(t)
}
function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined, rows: Event[]): boolean {
if (!targetId) return false
const want = normalizeFeedRepostTargetKey(targetId)
for (const e of rows) {
if (normalizeFeedRepostTargetKey(e.id) === want) return true
if (isNip18RepostKind(e.kind)) {
const rt = getNip18RepostTargetId(e)
if (rt && normalizeFeedRepostTargetKey(rt) === want) return true
}
if (isReplaceableEvent(e.kind)) {
const c = getReplaceableCoordinateFromEvent(e)
if (normalizeFeedRepostTargetKey(c) === want) return true
}
}
return false
}
/**
* `mergeEventBatchesById` only dedupes by event id; multiple kind-6/16 reposts of the same target stay
* separate. Collapse to one timeline row per target (first row in array order wins — live merges are
* newest-first). Dropped rows still update `noteStatsService` for “boosted by” aggregation, same as
* `onNew` / `showNewEvents`.
*/
function collapseDuplicateNip18RepostTimelineRows(sortedNewestFirst: Event[]): Event[] {
const kept: Event[] = []
const statsOnly: Event[] = []
for (const e of sortedNewestFirst) {
if (isNip18RepostKind(e.kind)) {
const t = getNip18RepostTargetId(e)
if (t && feedTimelineAlreadyRepresentsNip18Target(t, kept)) {
statsOnly.push(e)
continue
}
kept.push(e)
continue
}
const idKey = normalizeFeedRepostTargetKey(e.id)
const coveredByRepost = kept.some((k) => {
if (!isNip18RepostKind(k.kind)) return false
const rt = getNip18RepostTargetId(k)
return Boolean(rt && normalizeFeedRepostTargetKey(rt) === idKey)
})
if (coveredByRepost) {
statsOnly.push(e)
continue
}
if (isReplaceableEvent(e.kind)) {
const coord = getReplaceableCoordinateFromEvent(e)
const coordNorm = normalizeFeedRepostTargetKey(coord)
const coveredByCoordRepost = kept.some((k) => {
if (!isNip18RepostKind(k.kind)) return false
const rt = getNip18RepostTargetId(k)
return Boolean(rt && normalizeFeedRepostTargetKey(rt) === coordNorm)
})
if (coveredByCoordRepost) {
statsOnly.push(e)
continue
}
}
kept.push(e)
}
if (statsOnly.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
}
return kept
}
const FEED_PROFILE_PREFETCH_MAX_P_TAGS = 64
const FEED_STATS_PROFILE_REPOSTS_CAP = 48
const FEED_STATS_PROFILE_LIKES_PER_NOTE = 8
function addLowerHexPubkeyCandidate(candidates: Set<string>, raw: string | undefined) {
if (!raw) return
const t = raw.trim()
if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) {
candidates.add(t.toLowerCase())
}
}
/** Kind-0 prefetch targets for feed rows: author, mentions, `e`/`E` pubkey hints, NIP-18 embedded author. */
function collectProfilePrefetchPubkeysFromEvent(e: Event, candidates: Set<string>) {
addLowerHexPubkeyCandidate(candidates, e.pubkey)
let pCount = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addLowerHexPubkeyCandidate(candidates, tag[1])
pCount++
if (pCount >= FEED_PROFILE_PREFETCH_MAX_P_TAGS) break
}
if ((tag[0] === 'e' || tag[0] === 'E') && tag[4]) {
addLowerHexPubkeyCandidate(candidates, tag[4])
}
}
if (!isNip18RepostKind(e.kind)) return
const raw = e.content?.trim()
if (!raw) return
try {
const emb = JSON.parse(raw) as { pubkey?: string; pubKey?: string }
const pk = emb.pubkey ?? emb.pubKey
if (pk) addLowerHexPubkeyCandidate(candidates, pk)
} catch {
/* ignore */
}
}
function collectProfilePrefetchPubkeysFromNoteStats(
st: { reposts?: { pubkey: string }[]; likes?: { pubkey: string }[] } | undefined,
candidates: Set<string>
) {
if (!st) return
if (st.reposts?.length) {
for (const r of st.reposts.slice(0, FEED_STATS_PROFILE_REPOSTS_CAP)) {
addLowerHexPubkeyCandidate(candidates, r.pubkey)
}
}
if (st.likes?.length) {
for (const l of st.likes.slice(0, FEED_STATS_PROFILE_LIKES_PER_NOTE)) {
addLowerHexPubkeyCandidate(candidates, l.pubkey)
}
}
}
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)
boot = boot.filter((ev) => eventMatchesNip50LocalFullTextQuery(ev, warmQ))
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 local = await collectLocalEventsForTextSearch({
query: warmQ,
allowedKinds: kindsForWarm,
sessionCap: 0,
idbMergedLimit: cap,
archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS,
includeOtherStoresFullText: true,
fullTextStoreHitCap: Math.min(400, Math.max(cap, 120))
})
if (isStale()) return
const idbUse = warmMatch ? local.filter(warmMatch) : local
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 rest = { ...filter }
delete rest.kinds
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 comparableLocalTagValue(tagName: string, value: unknown): string {
const text = String(value).trim()
const tagKey = tagName.toLowerCase()
if (tagKey === 't') return text.toLowerCase()
if ((tagKey === 'p' || tagKey === 'e') && /^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase()
return text
}
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 allowed = new Set(values.map((v) => comparableLocalTagValue(tagName, v)))
const matched = eventValues.some((v) => allowed.has(comparableLocalTagValue(tagName, v)))
if (!matched) return false
}
return true
}
/** Same as {@link eventMatchesSubRequestFilter} plus `since` / `until` / substring `search` (local warm-up only). */
function eventMatchesSubRequestFilterWithWindow(event: Event, filter: Filter): boolean {
if (typeof filter.since === 'number' && event.created_at < filter.since) return false
if (typeof filter.until === 'number' && event.created_at > filter.until) return false
const searchRaw = typeof filter.search === 'string' ? filter.search.trim() : ''
if (searchRaw.length > 0) {
const needle = searchRaw.toLowerCase()
const hay = `${event.content ?? ''} ${(event.tags ?? []).flat().join(' ')}`.toLowerCase()
if (!hay.includes(needle)) return false
}
return eventMatchesSubRequestFilter(event, filter)
}
function unionKindsForSpellLocalWarmup(
shardFilters: Filter[],
fallbackKinds: readonly number[]
): number[] {
const kindUnion = new Set<number>()
for (const f of shardFilters) {
const kk = Array.isArray(f.kinds) ? f.kinds : []
for (const k of kk) kindUnion.add(k as number)
}
if (kindUnion.size > 0) return Array.from(kindUnion).sort((a, b) => a - b)
return fallbackKinds.length > 0 ? [...fallbackKinds] : [kinds.ShortTextNote]
}
function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefined {
const sinceCandidates = shardFilters
.map((f) => (typeof f.since === 'number' ? f.since : undefined))
.filter((n): n is number => n !== undefined)
return sinceCandidates.length > 0 ? Math.max(...sinceCandidates) : undefined
}
/** Union of `filter.kinds` across mapped REQ shards; empty if any shard omits kinds (caller should not use fallback). */
function filterEvsToMappedTimelineReqKinds(
evs: Event[],
mapped: Array<{ urls: string[]; filter: Filter }>
): Event[] {
const kindSet = new Set<number>()
for (const { filter } of mapped) {
const ks = filter.kinds
if (!Array.isArray(ks) || ks.length === 0) {
return []
}
for (const k of ks) kindSet.add(k)
}
return evs.filter((e) => kindSet.has(e.kind))
}
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,
/**
* Home {@link NormalFeed} surface: Notes / Replies / Gallery. Gallery uses fixed media REQ kinds; without
* this, {@link timelineResubscribeKindKey} still tracks the Notes kind picker and tears the live sub on
* unrelated picker churn — stale grid + refresh feeling broken.
*/
homeFeedListMode,
/** Home favorites: relays allowed for “Seen on” + stats on the Notes tab (favorites + trending). */
homeFeedSeenOnAllowlistOp,
/** Home favorites: wider stack for Replies / Gallery (adds NIP-65, cache, HTTP index). */
homeFeedSeenOnAllowlistReplies,
/** 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
* and similar one-shot feeds. 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 {@link REVEAL_BATCH_STEP} per step unless overridden). */
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,
/**
* When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one
* {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only.
*/
timelinePublicReadFallback = false,
/**
* Explore single-relay feed: paint only live events from that relay’s REQ — no session snapshot, no disk
* prime merge, no {@link ClientService.subscribeTimeline} IndexedDB hydrate / persist, no profile prefetch
* fan-out to other relays.
*/
relayAuthoritativeFeedOnly = false,
/**
* When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query
* (hashtag / d-tag browse from {@link NormalFeed}).
*/
alexandriaEmptyUrl = null
}: {
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
homeFeedListMode?: TNoteListMode
homeFeedSeenOnAllowlistOp?: string[]
homeFeedSeenOnAllowlistReplies?: 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
timelinePublicReadFallback?: boolean
/** Single-relay explore: only show events returned by that relay (no session/IDB/local merge). */
relayAuthoritativeFeedOnly?: boolean
/** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */
alexandriaEmptyUrl?: string | null
},
ref
) => {
const { t } = useTranslation()
const { startLogin, pubkey } = useNostr()
const { isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const contentPolicy = useContentPolicyOptional()
const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false
const isOffline =
contentPolicy?.isOffline ??
(!navigator.onLine || (navigator as Navigator & { connection?: { type?: string } }).connection?.type === 'none')
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 newEventsRef = useRef<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)
/** List root for intersection / load-more wiring (outer NoteList shell). */
const feedRootRef = 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
/**
* True when the scan for {@link filteredEvents} reached the end of the loaded timeline but still has fewer
* than {@link showCount} visible rows (aggressive kind/reply/mute filters). {@link loadMore} must not skip
* relay pagination based on raw `events.length - showCount` — that difference is not “unrevealed buffer”.
*/
const bufferExhaustedForVisibleQuotaRef = useRef(false)
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[]>([])
/** One-shot per timeline init: after an all-failed relay wave, try {@link FAST_READ_RELAY_URLS}. */
const publicReadFallbackAttemptedRef = useRef(false)
/** Profile feeds: defer empty-state paint until session / IndexedDB priming finishes (incl. relay-stack refinement). */
const profileLocalPrimingPendingRef = useRef(false)
/** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */
const blankFeedVisibilityResumeRetryAtRef = useRef(0)
const refreshScheduleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const relayAuthoritativeFeedOnlyRef = useRef(relayAuthoritativeFeedOnly)
relayAuthoritativeFeedOnlyRef.current = relayAuthoritativeFeedOnly
/**
* 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)
/** Dedupes layout-time pending sync so a new `events` array reference alone cannot loop setState. */
const lastProfilePrefetchPubkeysKeyRef = useRef('')
const clientFilteredVisibleCountRef = useRef(0)
const noteFeedProfileContextValue = useMemo<NoteFeedProfileContextValue>(
() => ({
profiles: feedProfileBatch.profiles,
pendingPubkeys: feedProfileBatch.pending,
version: feedProfileBatch.version
}),
[feedProfileBatch.profiles, feedProfileBatch.pending, feedProfileBatch.version]
)
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const followingFeedDeltaSubRequestsKey = useMemo(
() =>
legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []),
[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]
return mapNoteListSubRequestsForTimeline(requests, {
defaultKinds,
seeAllFeedEvents,
useFilterAsIs,
areAlgoRelays,
allowKindlessRelayExplore,
clientSideKindFilter,
limit: LIMIT,
algoLimit: ALGO_LIMIT,
relayExploreLimit: RELAY_EXPLORE_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
const primaryPanelFrozen = primaryPageCtx?.frozen ?? false
/** Only pause timelines on the active primary page feed — not secondary-panel profiles, search, etc. */
const pauseTimelineForPrimaryFreeze =
primaryPanelFrozen &&
hostPrimaryPageName != null &&
hostPrimaryPageName === primaryPageCurrent
/** 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 homeFeedActiveSeenOnAllowlist = useMemo(() => {
if (feedSubscriptionKey !== 'home-all-favorites') return undefined
if (homeFeedListMode === 'postsAndReplies' || homeFeedListMode === 'media') {
return homeFeedSeenOnAllowlistReplies?.length ? homeFeedSeenOnAllowlistReplies : undefined
}
return homeFeedSeenOnAllowlistOp?.length ? homeFeedSeenOnAllowlistOp : undefined
}, [
feedSubscriptionKey,
homeFeedListMode,
homeFeedSeenOnAllowlistOp,
homeFeedSeenOnAllowlistReplies
])
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)
/**
* After `setEvents([])` / session restore / disk prime, React may not have flushed before the relay emits.
* Without this, `mergeEventBatchesById(prev, …)` can merge the new relay into the previous feed's rows.
*/
const timelineMergeBootstrapRef = useRef<Event[] | null>(null)
useLayoutEffect(() => {
publicReadFallbackAttemptedRef.current = false
if (!pauseTimelineForPrimaryFreeze) {
setFeedTimelineEmptyUiReady(false)
setFeedSubscribeRelayOutcomes([])
}
}, [timelineSubscriptionKey, subRequestsKey, refreshCount, pauseTimelineForPrimaryFreeze])
useEffect(() => {
feedProfileBatchGenRef.current += 1
feedProfileLoadedRef.current.clear()
lastProfilePrefetchPubkeysKeyRef.current = ''
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>()
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
const pubkeysKey = [...candidates].sort().join('\n')
if (pubkeysKey === lastProfilePrefetchPubkeysKeyRef.current) return
lastProfilePrefetchPubkeysKeyRef.current = pubkeysKey
setFeedProfileBatch((prev) => {
const pending = new Set(prev.pending)
let changed = false
for (const pk of candidates) {
if (
prev.profiles.has(pk) ||
pending.has(pk) ||
feedProfileLoadedRef.current.has(pk)
) {
continue
}
pending.add(pk)
changed = true
}
if (!changed) return prev
// Do not bump `version` here — only the debounced batch + profile merges should notify
// `useFetchProfile` (via profiles map / pending membership), not every pending-key sync.
return { ...prev, pending }
})
})
const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests
const showKindsKey = useMemo(() => stableFeedKindKey(effectiveShowKinds), [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(
() =>
buildFeedSessionSnapshotKey({
feedKey: timelineSubscriptionKey,
homeSurface: homeFeedListMode,
allowKindlessRelayExplore,
showAllKinds,
kindsKey: showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111,
seeAllFeedEvents
}),
[
timelineSubscriptionKey,
homeFeedListMode,
showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111,
seeAllFeedEvents,
allowKindlessRelayExplore,
showAllKinds
]
)
/** Kindless relay explore ignores the feed kind picker; avoid re-subscribing when it changes. */
const timelineResubscribeKindKey = useMemo(() => {
if (allowKindlessRelayExplore) return 'kindless-relay-explore'
if (homeFeedListMode === 'media') return 'home-surface-media'
return `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}`
}, [
allowKindlessRelayExplore,
homeFeedListMode,
showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111
])
const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds
const effectiveShowKindsRef = useRef(effectiveShowKinds)
effectiveShowKindsRef.current = effectiveShowKinds
const showKind1OPsRef = useRef(showKind1OPs)
showKind1OPsRef.current = showKind1OPs
const showKind1RepliesRef = useRef(showKind1Replies)
showKind1RepliesRef.current = showKind1Replies
const showKind1111Ref = useRef(showKind1111)
showKind1111Ref.current = showKind1111
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
const hostPrimaryPageNameRef = useRef(hostPrimaryPageName)
hostPrimaryPageNameRef.current = hostPrimaryPageName
const gridLayoutRef = useRef(gridLayout)
gridLayoutRef.current = gridLayout
const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (withKindFilterRef.current && !showAllKindsRef.current) {
return evs.filter((e) =>
eventPassesNoteListKindPicker(
e,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
)
}
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
}
/**
* When to apply kind picker + kind-1 OP|reply / 1111 / GitRelease splits to visible rows.
* Home feeds default to {@link withKindFilter}. Relay explorer sets {@link showAllKinds} explicitly (kindless
* firehose). {@link seeAllFeedEvents} widens REQ when applicable; merged batches and live rows still respect the
* picker unless {@link showAllKinds} is true with kindless explore.
*/
const applyKindPickerInUi = useMemo(
() => withKindFilter && !showAllKinds,
[withKindFilter, showAllKinds]
)
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
if (
homeFeedActiveSeenOnAllowlist &&
homeFeedListMode === 'posts' &&
!eventSeenOnMatchesAllowlist(
client.getSeenEventRelayUrls(evt.id),
homeFeedActiveSeenOnAllowlist
)
) {
return true
}
return false
},
[
filterMutedNotes,
hideReplies,
hideUntrustedNotes,
hideContentMentioningMutedUsers,
mutePubkeySet,
pinnedEventIds,
isEventDeleted,
zapReplyThreshold,
extraShouldHideEvent,
homeFeedActiveSeenOnAllowlist,
homeFeedListMode
]
)
const shouldHideEventRef = useRef(shouldHideEvent)
useEffect(() => {
shouldHideEventRef.current = shouldHideEvent
}, [shouldHideEvent])
const { items: filteredEvents, bufferExhaustedForVisibleQuota } = 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))
)
let i = 0
for (; i < maxScan && i < timelineEventsForFilter.length && out.length < target; i++) {
const evt = timelineEventsForFilter[i]!
if (applyKindPickerInUi) {
if (!eventPassesNoteListKindPicker(evt, effectiveShowKinds, showKind1OPs, showKind1Replies, showKind1111)) {
continue
}
}
if (shouldHideEvent(evt)) continue
// Mosaic: one tile per event id. Replaceable-coordinate dedup (correct for profile lists) collapses
// multiple NIP-71 addressable revisions / instances to a single cell — looks like "extra images flash then vanish".
const dedupeKey = gridLayout
? evt.id
: isReplaceableEvent(evt.kind)
? getReplaceableCoordinateFromEvent(evt) || evt.id
: evt.id
if (idSet.has(dedupeKey)) continue
idSet.add(dedupeKey)
out.push(evt)
}
const scannedToEndOfBuffer = i >= timelineEventsForFilter.length
const exhausted = out.length < target && scannedToEndOfBuffer
return { items: out, bufferExhaustedForVisibleQuota: exhausted }
}, [
timelineEventsForFilter,
showCount,
shouldHideEvent,
showKinds,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
applyKindPickerInUi,
gridLayout
])
useEffect(() => {
bufferExhaustedForVisibleQuotaRef.current = bufferExhaustedForVisibleQuota
}, [bufferExhaustedForVisibleQuota])
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 (
!eventPassesNoteListKindPicker(
event,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111
)
) {
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]
)
useEffect(() => {
clientFilteredVisibleCountRef.current = clientFilteredEvents.length
}, [clientFilteredEvents.length])
const visibleNoteIdsForStatsPrefetchKey = useMemo(
() =>
clientFilteredEvents
.slice(0, Math.min(120, Math.max(showCount + 64, 64)))
.map((e) => e.id)
.join('\n'),
[clientFilteredEvents, showCount]
)
const enqueueFeedProfilePubkeys = useCallback((need: string[]) => {
if (need.length === 0) return
const gen = feedProfileBatchGenRef.current
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 }
})
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 }
})
})()
}, [])
const statsProfilePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingStatsProfilePubkeysRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!visibleNoteIdsForStatsPrefetchKey) return
const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean)
const flushStatsProfiles = () => {
statsProfilePrefetchDebounceRef.current = null
const need = [...pendingStatsProfilePubkeysRef.current].filter(
(pk) => !feedProfileLoadedRef.current.has(pk)
)
pendingStatsProfilePubkeysRef.current.clear()
enqueueFeedProfilePubkeys(need)
}
const onStatsUpdate = (noteId: string) => {
const candidates = new Set<string>()
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(noteId), candidates)
for (const pk of candidates) {
if (!feedProfileLoadedRef.current.has(pk)) {
pendingStatsProfilePubkeysRef.current.add(pk)
}
}
if (pendingStatsProfilePubkeysRef.current.size === 0) return
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
}
statsProfilePrefetchDebounceRef.current = setTimeout(
flushStatsProfiles,
FEED_PROFILE_BATCH_DEBOUNCE_MS
)
}
const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, () => onStatsUpdate(id)))
return () => {
unsubs.forEach((u) => u())
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
statsProfilePrefetchDebounceRef.current = null
}
pendingStatsProfilePubkeysRef.current.clear()
}
}, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys])
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 candidates = new Set<string>()
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) {
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates)
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
enqueueFeedProfilePubkeys(need)
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [
timelineEventsForFilter,
newEvents,
clientFilteredEvents,
showCount,
enqueueFeedProfilePubkeys
])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
topRef.current?.scrollIntoView({ behavior, block: 'start' })
}, 20)
}, [])
const refresh = useCallback(() => {
if (refreshScheduleTimeoutRef.current) {
clearTimeout(refreshScheduleTimeoutRef.current)
refreshScheduleTimeoutRef.current = null
}
blankFeedVisibilityResumeRetryAtRef.current = 0
publicReadFallbackAttemptedRef.current = false
scrollToTop()
setLoading(true)
setFeedTimelineEmptyUiReady(false)
setRefreshCount((count) => count + 1)
}, [scrollToTop])
const flushPendingNewEventsIntoTimeline = useCallback(() => {
const pending = newEventsRef.current
if (pending.length === 0) return
setEvents((oldEvents) => {
const pool: Event[] = [...oldEvents]
const statsOnly: Event[] = []
const kept: Event[] = []
for (const ev of pending) {
if (
isNip18RepostKind(ev.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool)
) {
statsOnly.push(ev)
continue
}
kept.push(ev)
pool.push(ev)
}
if (statsOnly.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
}
return [...kept, ...oldEvents]
})
setNewEvents([])
}, [])
const flushPendingNewEventsIntoTimelineRef = useRef(flushPendingNewEventsIntoTimeline)
flushPendingNewEventsIntoTimelineRef.current = flushPendingNewEventsIntoTimeline
useEffect(() => {
if (oneShotFetchRef.current) return
if (newEvents.length === 0) return
const anchor = feedRootRef.current
const parent = getNearestScrollableAncestor(anchor)
const root: HTMLElement | Window = parent ?? window
const top = root === window ? window.scrollY : (root as HTMLElement).scrollTop
if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return
flushPendingNewEventsIntoTimeline()
}, [newEvents.length, flushPendingNewEventsIntoTimeline])
// 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)
const oneShotFetchRef = useRef(oneShotFetch)
oneShotFetchRef.current = oneShotFetch
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 runtime = new FeedRuntime({
descriptorKey: `feed-full-search:${timelineSubscriptionKey}`,
sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
cap: FEED_FULL_SEARCH_MERGE_CAP
})
const runtimeSnapshot = await runtime.load(
createFetchEventsFeedRuntimeLoader(client, {
subRequests: [{ urls: relayUrls, filter: finalFilter }],
cache: true,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false
})
)
logFeedDiagnostics(
'feed-full-search',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'search',
id: timelineSubscriptionKey,
mode: 'one-shot',
requests: [{ urls: relayUrls, filter: finalFilter }],
pagination: { enabled: false }
}),
relayPolicy: { urls: relayUrls, dropped: [] },
runtime: runtimeSnapshot
})
)
const merged = mergeEventBatchesById([], runtimeSnapshot.rows, 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,
timelineSubscriptionKey,
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
if (pauseTimelineForPrimaryFreeze) {
setLoading(false)
if (eventsRef.current.length > 0) {
setFeedTimelineEmptyUiReady(true)
}
return () => {}
}
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)
let diskPrimeCancelled = false
const primeDiskWhileAwaitingRelayProbe = async () => {
const strictSingleRelayAuthoritative =
subRequestsRef.current.length === 1 &&
subRequestsRef.current[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
try {
const mapped = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
mapLiveSubRequestsForTimeline(subRequestsRef.current)
)
.map((req) =>
isOfflineRef.current
? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) }
: req
)
.filter((req) => req.urls.length > 0)
if (mapped.length === 0) return
const disk = await client.getTimelineDiskSnapshotEvents(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return
const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT
const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays))
if (merged.length > 0) {
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setLoading(false)
}
} catch {
/* best-effort */
}
}
void primeDiskWhileAwaitingRelayProbe()
return () => {
diskPrimeCancelled = true
}
}
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 profileRelayStackRefinement =
preserveTimelineOnSubRequestsChange &&
mergeTimelineWhenSubRequestFiltersMatch &&
!userPulledRefresh &&
!feedScopeChanged &&
prevSubKey != null &&
(isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))
const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange &&
!userPulledRefresh &&
!feedScopeChanged &&
(eventsRef.current.length > 0 || profileRelayStackRefinement) &&
(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
if (!profileRelayStackRefinement) {
timelineMergeBootstrapRef.current = null
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 seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
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 strictSingleRelayAuthoritativeEarly =
mappedSubRequests.length === 1 &&
mappedSubRequests[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
const sessionSnap =
!userPulledRefresh &&
(!relayAuthoritativeFeedOnlyRef.current || strictSingleRelayAuthoritativeEarly)
? getSessionFeedSnapshot(sessionSnapshotIdentityKey)
: undefined
const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length)
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
}
const isProfileTimelineFeed =
hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey)
const profileMappedForRefresh = isProfileTimelineFeed
? (mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>)
: null
const profileAuthorWarmSpecForRefresh = profileMappedForRefresh
? getProfileAuthorWarmupSpec(profileMappedForRefresh)
: null
/**
* Relay kindless firehose: keep the full batch. Else when the kind picker applies, narrow like
* {@link applyKindPickerInUi}. Remaining spell paths use kinds-only narrowing when client-side kind filter runs.
*/
const narrowLiveBatch = (evs: Event[]) => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (withKindFilterRef.current && !showAllKindsRef.current) {
const out = evs.filter((e) =>
eventPassesNoteListKindPicker(
e,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
)
if (out.length > 0 || !isProfileTimelineFeed || mappedSubRequests.length === 0) {
return out
}
return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests)
}
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
const byPicker = evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (byPicker.length > 0 || !isProfileTimelineFeed || mappedSubRequests.length === 0) {
return byPicker
}
return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests)
}
const eventMatchesProfileTimelineRequest = (event: Event) =>
isProfileTimelineFeed &&
mappedSubRequests.some(({ filter }) =>
eventMatchesSubRequestFilterWithWindow(event, filter as Filter)
)
const eventCapEarly = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
/** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */
const runProfileTimelineNetworkFetch = (variant: string) => {
if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
publicReadFallbackAttemptedRef.current = true
const primeRelays = getProfileTimelineFetchRelayUrls(profileMappedForRefresh)
void (async () => {
try {
const fetched = await client.fetchEvents(
primeRelays,
{
authors: [profileAuthorWarmSpecForRefresh.author],
kinds: profileAuthorWarmSpecForRefresh.kinds,
limit: eventCapEarly
},
{
cache: true,
eoseTimeout: 3500,
globalTimeout: 22_000,
firstRelayResultGraceMs: false,
foreground: true
}
)
if (!effectActive || timelineEffectStale()) return
if (fetched.length === 0) return
const narrowedFetch = narrowLiveBatch(fetched)
if (narrowedFetch.length === 0) return
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice()
}
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
feedRelayReturnedAnyEventRef.current = true
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant,
mergedCount: narrowedFetch.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} catch (e) {
logger.warn('[NoteList] Profile timeline network fetch failed', { variant, error: e })
}
})()
}
if (isProfileTimelineFeed && profileAuthorWarmSpecForRefresh && profileMappedForRefresh) {
runProfileTimelineNetworkFetch(
userPulledRefresh ? 'profile_pull_refresh' : 'profile_initial_fetch'
)
}
const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
/**
* Session + IndexedDB hydration without blocking relay REQ/subscribe. Merges the same way as live
* {@link onEvents} so rows appear as soon as local sources resolve.
*/
const startNonBlockingTimelineDiskPrime = () => {
const strictSingleRelayAuthoritative =
mappedSubRequests.length === 1 &&
mappedSubRequests[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
if (oneShotFetch || mappedSubRequests.length === 0) return
if (isSpellPageLocalWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const strictSingleRelayShard =
mappedSubRequests.length === 1 &&
mappedSubRequests[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
void client
.getLocalFeedEvents(diskReq, {
strictRelayShardSourcesOnly: strictSingleRelayShard
})
.then((diskRaw) => {
if (!effectActive || timelineEffectStale()) return
const diskNarrowed = narrowLiveBatch(diskRaw)
if (diskNarrowed.length === 0) return
setEvents((prev) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
base,
diskNarrowed,
oneShotAfterMergeComparatorRef.current
)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (next.length > 0) {
timelineMergeBootstrapRef.current = next.slice()
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'disk_snapshot_async',
mergedCount: diskNarrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
})
.catch(() => {
/* best-effort */
})
}
/**
* Home Galerie: paint session + IndexedDB media hits immediately so the grid is not blank while relay
* waves stall (dead localhost relay, NIP-42, etc.). Merges before/alongside disk timeline prime.
*/
const startHomeGalleryLocalWarmup = () => {
if (!gridLayoutRef.current) return
if (hostPrimaryPageNameRef.current !== 'feed') return
if (oneShotFetch || mappedSubRequests.length === 0) return
const mergeLayer = (incoming: Event[], variant: string) => {
if (!effectActive || timelineEffectStale()) return
const narrowed = narrowLiveBatch(incoming)
if (!narrowed.length) return
setEvents((prev) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, narrowed, eventCapEarly, areAlgoRelays)
)
if (next.length > 0) {
timelineMergeBootstrapRef.current = next.slice()
lastEventsForTimelinePrefetchRef.current = next
}
return next
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant,
mergedCount: narrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
try {
const hits = client.eventService.listSessionEventsByKinds([...PROFILE_MEDIA_TAB_KINDS], {
limit: 800
})
mergeLayer(hits as Event[], 'gallery_session_local')
} catch {
/* ignore */
}
void (async () => {
try {
const since = dayjs().subtract(120, 'day').unix()
const rows = await indexedDb.scanEventArchiveByKinds({
kinds: [...PROFILE_MEDIA_TAB_KINDS],
since,
maxRowsScanned: 28_000,
maxMatches: 220
})
if (!effectActive || timelineEffectStale()) return
if (!gridLayoutRef.current || hostPrimaryPageNameRef.current !== 'feed') return
mergeLayer(rows as Event[], 'gallery_archive_local')
} catch {
/* ignore */
}
})()
}
if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap && sessionSnap.length > 0) {
feedPaintSessionPendingRef.current = true
const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap)
timelineMergeBootstrapRef.current = restored.slice()
setEvents(restored)
lastEventsForTimelinePrefetchRef.current = restored
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(!!oneShotFetch)
} else {
let primedFromDisk = false
let spellLocalMergeBase: Event[] = []
if (isSpellPageLocalWarmup) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesSpellLocal = (ev: Event) =>
shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f))
const kindsForScan = unionKindsForSpellLocalWarmup(
shardFilters,
effectiveShowKindsRef.current
)
const sinceTightest = tightestSinceFromSpellFilters(shardFilters)
const localLayerCap = Math.min(
FEED_FULL_SEARCH_MERGE_CAP,
Math.max(eventCapEarly, 200)
)
const sessionScanCap = Math.min(800, localLayerCap * 4)
const sessionHits = client
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
.filter(matchesSpellLocal)
.sort((a, b) => b.created_at - a.created_at)
if (!timelineEffectStale() && sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits)
if (narrowedS.length > 0) {
const mergedS = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (mergedS.length > 0) {
spellLocalMergeBase = mergedS
timelineMergeBootstrapRef.current = mergedS.slice()
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'spell_local_session',
mergedCount: mergedS.length
}
primedFromDisk = true
}
}
}
void (async () => {
try {
const filterAwareDiskReq = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const [diskRaw, filterAwareLocalRaw, fromPub, fromArch] = await Promise.all([
client.getTimelineDiskSnapshotEvents(filterAwareDiskReq),
client.getLocalFeedEvents(filterAwareDiskReq, {
maxRowsScanned: 50_000,
maxMatches: localLayerCap * 3
}),
indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan, {
scanBudget: 50_000
}),
indexedDb.scanEventArchiveByKinds({
kinds: kindsForScan,
since: sinceTightest,
maxRowsScanned: 50_000,
maxMatches: localLayerCap * 2
})
])
if (!effectActive || timelineEffectStale()) return
const seen = new Set<string>()
const combinedRaw: Event[] = []
for (const ev of diskRaw) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of filterAwareLocalRaw) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromPub) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
for (const ev of fromArch) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
seen.add(ev.id)
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length === 0) return
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length === 0) return
timelineMergeBootstrapRef.current = merged.slice()
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant:
spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot',
mergedCount: merged.length
}
} catch {
/* spell local + disk snapshot is best-effort */
}
})()
} else {
const profileMapped = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) {
profileLocalPrimingPendingRef.current = true
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy(
profileAuthorWarmSpec.author,
{ kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit }
)
if (sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits as Event[])
if (narrowedS.length > 0) {
const mergedS = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
)
if (mergedS.length > 0) {
timelineMergeBootstrapRef.current = mergedS.slice()
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_session',
mergedCount: mergedS.length
}
primedFromDisk = true
}
}
}
void (async () => {
try {
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
kinds: profileAuthorWarmSpec.kinds,
maxRowsScanned: 16_000,
maxMatches: archiveCap
}),
client.getTimelineDiskSnapshotEvents(diskReq),
client.getLocalFeedEvents(diskReq, {
maxRowsScanned: 16_000,
maxMatches: archiveCap
})
])
if (!effectActive || timelineEffectStale()) return
const premerged = mergeEventBatchesById(
[],
[...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])],
archiveCap,
areAlgoRelays
)
if (premerged.length > 0) {
const narrowed = narrowLiveBatch(premerged)
if (narrowed.length > 0) {
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice()
}
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_archive',
mergedCount: narrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
}
const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0) {
const fetched = await client.fetchEvents(
relayUrls,
{
authors: [profileAuthorWarmSpec.author],
kinds: profileAuthorWarmSpec.kinds,
limit: 200
},
{
cache: true,
eoseTimeout: 4500,
globalTimeout: 18_000,
replaceableRace: true,
foreground: true
}
)
if (!effectActive || timelineEffectStale()) return
if (fetched.length > 0) {
const narrowedFetch = narrowLiveBatch(fetched)
if (narrowedFetch.length > 0) {
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice()
}
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
feedRelayReturnedAnyEventRef.current = true
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
}
}
} catch {
/* profile local archive is best-effort */
} finally {
profileLocalPrimingPendingRef.current = false
if (!effectActive || timelineEffectStale()) return
if (!feedPaintLiveRelayDoneRef.current) {
feedPaintLiveRelayDoneRef.current = true
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
})()
}
}
if (!primedFromDisk && !profileRelayStackRefinement) {
if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = []
setEvents([])
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
} else if (!keepRowsVisible && !profileRelayStackRefinement) {
setLoading(true)
}
}
} else if (!keepRowsVisible) {
setLoading(true)
}
if (!oneShotFetch && mappedSubRequests.length > 0) {
startHomeGalleryLocalWarmup()
startNonBlockingTimelineDiskPrime()
}
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
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
}
if (!warmQOneShot && mappedSubRequests.length > 0) {
const capDisk = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const diskReqOneShot = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const strictSingleRelayShardOneShot =
mappedSubRequests.length === 1 &&
mappedSubRequests[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
void client
.getLocalFeedEvents(diskReqOneShot, {
strictRelayShardSourcesOnly: strictSingleRelayShardOneShot
})
.then((diskRaw) => {
if (!effectActive || timelineEffectStale()) return
if (diskRaw.length === 0) return
const narrowed = narrowLiveBatch(diskRaw)
if (narrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowed, capDisk, areAlgoRelays)
)
if (merged.length === 0) return
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setLoading(false)
feedRelayReturnedAnyEventRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'disk_snapshot_one_shot',
mergedCount: merged.length
}
})
.catch(() => {
/* best-effort */
})
}
const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS
: oneShotFirstRelayGraceMs
const runtime = new FeedRuntime({
descriptorKey: timelineSubscriptionKey,
sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)
})
const runtimeSnapshot = await runtime.load(
createFetchEventsFeedRuntimeLoader(client, {
subRequests: mappedSubRequests,
cache: true,
globalTimeout: oneShotGlobalTimeoutMs,
eoseTimeout: oneShotEoseTimeoutMs,
firstRelayResultGraceMs: firstRelayGraceResolved
}),
userPulledRefresh
)
if (!effectActive || timelineEffectStale()) return undefined
logFeedDiagnostics(
oneShotDebugLabel ?? 'note-list-one-shot',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'custom',
id: timelineSubscriptionKey,
mode: 'one-shot',
requests: mappedSubRequests,
pagination: { enabled: false }
}),
relayPolicy: {
urls: Array.from(new Set(mappedSubRequests.flatMap((request) => request.urls))),
dropped: []
},
runtime: runtimeSnapshot
})
)
if (runtimeSnapshot.rawCount > 0) {
feedRelayReturnedAnyEventRef.current = true
}
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim()
let relayOnly = [...runtimeSnapshot.rows]
if (!isProgressiveLayers) {
relayOnly = relayOnly.slice(0, cap)
}
if (
useFilterAsIs &&
clientSideKindFilter &&
withKindFilter &&
(!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)
}
next = collapseDuplicateNip18RepostTimelineRows(next)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
const capForOneShot = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
rawTotal: runtimeSnapshot.rawCount,
dedupedCount: runtimeSnapshot.rawCount,
hiddenByRuntime: runtimeSnapshot.hiddenCount,
emptyReason: runtimeSnapshot.emptyReason,
afterCap: relayOnly.length,
cap,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit,
...(runtimeSnapshot.rawCount === 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 native media kinds only: picture, NIP-71 video regular/addressable, voice).'
}
: {})
})
}
setEvents((prev) => {
const base =
sessionSnap?.length && !userPulledRefresh
? mergeEventBatchesById(sessionSnap, prev, capForOneShot)
: prev
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, relayOnly, capForOneShot, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
}
if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter
logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
rawTotal: runtimeSnapshot.rawCount,
dedupedCount: runtimeSnapshot.rawCount,
hiddenByRuntime: runtimeSnapshot.hiddenCount,
emptyReason: runtimeSnapshot.emptyReason,
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 = isProfileTimelineFeed
? Math.min(45_000, Math.max(20_000, 12_000 + totalRelayUrls * 1_500))
: 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 &&
!(isProfileTimelineFeed && profileLocalPrimingPendingRef.current)
) {
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 boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
base,
narrowed,
oneShotAfterMergeComparatorRef.current
)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, narrowed, eventCap, areAlgoRelays)
)
if (boot !== null && narrowed.length > 0) {
timelineMergeBootstrapRef.current = null
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false)
// Embeds: fetch with each timeline batch (parent relay hints), not on a debounced follow-up.
if (narrowed.length > 0) {
client.prefetchEmbeddedEventsForParents(narrowed, {
relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
})
}
} 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)
}
if (eosed && timelineMergeBootstrapRef.current !== null) {
timelineMergeBootstrapRef.current = null
}
},
onNew: (event: Event) => {
if (!effectActive) return
feedRelayReturnedAnyEventRef.current = true
if (withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!showAllKindsRef.current) {
if (
!eventPassesNoteListKindPicker(
event,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
) {
return
}
} else {
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 && !showKind1RepliesRef.current) return
if (!isReply && !showKind1OPsRef.current) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPsRef.current) return
}
}
}
if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)) {
setEvents((oldEvents) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : oldEvents
if (base.some((e) => e.id === event.id)) {
return boot !== null ? base : oldEvents
}
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return boot !== null ? base : oldEvents
}
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
return [event, ...base]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
// Primary home relay feeds: merge live EVENTs into the timeline immediately. The generic path
// buffered everyone else's notes in `newEvents` until scroll-to-top — that felt like no streaming.
setEvents((oldEvents) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : oldEvents
if (base.some((e) => e.id === event.id)) {
return boot !== null ? base : oldEvents
}
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return boot !== null ? base : oldEvents
}
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
const cap = allowKindlessRelayExploreRef.current
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, [event], cap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
const pool = [...eventsRef.current, ...oldEvents]
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
})
}
},
},
{
startLogin,
needSort: !areAlgoRelays,
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS,
relayAuthoritativeTimeline: relayAuthoritativeFeedOnlyRef.current,
connectionSlotPriority: isProfileTimelineFeed,
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 {
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
profileLocalPrimingPendingRef.current = false
timelineMergeBootstrapRef.current = null
setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
const strictSingleRelayAuthoritativeCleanup =
subRequestsRef.current.length === 1 &&
subRequestsRef.current[0]!.urls.length === 1 &&
(hostPrimaryPageNameRef.current === 'relay' ||
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (!relayAuthoritativeFeedOnlyRef.current || strictSingleRelayAuthoritativeCleanup) {
setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
}
if (kindlessEoseTimeoutRef.current) {
clearTimeout(kindlessEoseTimeoutRef.current)
kindlessEoseTimeoutRef.current = null
}
const syncClose = timelineEstablishedCloserRef.current
timelineEstablishedCloserRef.current = null
syncClose?.()
void promise.then((fallbackClose) => {
if (fallbackClose && fallbackClose !== syncClose) {
fallbackClose()
}
})
}
}, [
timelineSubscriptionKey,
feedSubscriptionKey,
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,
hostPrimaryPageName,
relayAuthoritativeFeedOnly,
pauseTimelineForPrimaryFreeze
])
useEffect(() => {
if (oneShotFetch) return
if (pauseTimelineForPrimaryFreeze) {
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
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 = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
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 (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (withKindFilterRef.current && !showAllKindsRef.current) {
return evs.filter((e) =>
eventPassesNoteListKindPicker(
e,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
)
}
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
}
const eventMatchesProfileDeltaRequest = (event: Event) =>
(hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey)) &&
mappedDelta.some(({ filter }) =>
eventMatchesSubRequestFilterWithWindow(event, filter as Filter)
)
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 &&
!(
(hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey)) &&
profileLocalPrimingPendingRef.current
)
) {
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 = collapseDuplicateNip18RepostTimelineRows(
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 (withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!showAllKindsRef.current) {
if (
!eventPassesNoteListKindPicker(
event,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
) {
return
}
} else {
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 && !showKind1RepliesRef.current) return
if (!isReply && !showKind1OPsRef.current) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPsRef.current) return
}
}
}
if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)) {
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === event.id)) return oldEvents
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === event.id)) return oldEvents
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(oldEvents, [event], eventCapDelta, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
const pool = [...eventsRef.current, ...oldEvents]
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
})
}
}
},
{
startLogin,
needSort: !areAlgoRelays,
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS,
relayAuthoritativeTimeline: relayAuthoritativeFeedOnlyRef.current
}
)
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,
feedSubscriptionKey,
mapLiveSubRequestsForTimeline,
areAlgoRelays,
allowKindlessRelayExplore,
useFilterAsIs,
clientSideKindFilter,
startLogin,
pubkey,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
pauseTimelineForPrimaryFreeze
])
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])
useEffect(() => {
newEventsRef.current = newEvents
}, [newEvents])
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)
}
const profileFeedWaitingOnLocalPrime =
profileLocalPrimingPendingRef.current &&
(hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey))
if (profileFeedWaitingOnLocalPrime) {
return
}
// Main feed skeleton also requires `feedTimelineEmptyUiReady` (first onEvents or EOSE). If
// subscribe never wires that path (wedged setup, relay pool churn), `loading` alone going
// false still leaves an infinite skeleton — hard-refresh “fixes” by resetting connections.
let unblockedPaint = false
setFeedTimelineEmptyUiReady((ready) => {
if (ready) return ready
unblockedPaint = true
return true
})
if (unblockedPaint) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
}
}, 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)
const lastNewNotesAutoFlushMsRef = useRef(0)
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(() => {
if (relayAuthoritativeFeedOnly) return
if (!timelinePublicReadFallback) return
if (feedSubscriptionKey === 'home-all-favorites') return
if (oneShotFetch || areAlgoRelays) return
if (!navigator.onLine) return
if (feedFullSearchEvents !== null) return
if (feedSubscribeRelayOutcomes.length === 0) return
if (publicReadFallbackAttemptedRef.current) return
const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current)
if (!mapped.length) return
// Skip fallback for d-tag / layered warmup feeds where the live REQ has no NIP-50 `search`
// (merging unfiltered FAST_READ would flood the list). Nostr text search passes `search` on
// the same filter as {@link progressiveWarmupQuery} — allow fallback there.
const warm = progressiveWarmupQuery?.trim()
if (warm) {
const primaryFilter = mapped[0]!.filter as Filter
const hasNip50Search =
typeof primaryFilter.search === 'string' && primaryFilter.search.trim().length > 0
if (!hasNip50Search) return
}
publicReadFallbackAttemptedRef.current = true
const profileWarm =
hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey)
? getProfileAuthorWarmupSpec(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
: null
/** EOSE with zero hits still counts as success; profile feeds need fallback until rows are visible. */
if (!profileWarm && uiStatuses.some((s) => s.success)) return
if (profileWarm && eventsRef.current.length > 0) return
const filter: Filter = profileWarm
? {
authors: [profileWarm.author],
kinds: profileWarm.kinds,
limit: LIMIT
}
: { ...(mapped[0]!.filter as Filter) }
if (!filter.kinds?.length) {
filter.kinds = effectiveShowKinds.length > 0 ? [...effectiveShowKinds] : [kinds.ShortTextNote]
}
filter.limit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
const eventCap = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const fallbackRelays = profileWarm
? getProfileTimelineFetchRelayUrls(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
: FAST_READ_RELAY_URLS
void (async () => {
try {
const raw = await client.fetchEvents(fallbackRelays, filter, {
cache: true,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false,
foreground: true
})
if (raw.length === 0) return
const narrowed = narrowLiveBatchUsingRefs(raw)
if (narrowed.length === 0) return
logger.info('[NoteList] Public read fallback merged after all relays failed', {
timelineSubscriptionKey,
fetched: raw.length,
mergedVisible: narrowed.length
})
setEvents((prev) => {
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
prev,
narrowed,
oneShotAfterMergeComparatorRef.current
)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
feedRelayReturnedAnyEventRef.current = true
} catch (e) {
logger.warn('[NoteList] timeline public read fallback failed', { error: e })
}
})()
}, [
timelinePublicReadFallback,
feedSubscriptionKey,
oneShotFetch,
areAlgoRelays,
progressiveWarmupQuery,
feedFullSearchEvents,
feedSubscribeRelayOutcomes,
mapLiveSubRequestsForTimeline,
effectiveShowKinds,
allowKindlessRelayExplore,
timelineSubscriptionKey,
relayAuthoritativeFeedOnly
])
useEffect(() => {
hasMoreRef.current = hasMore
}, [hasMore])
useEffect(() => {
timelineKeyRef.current = timelineKey
}, [timelineKey])
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
blankFeedHiddenAtRef.current = Date.now()
return
}
if (
!oneShotFetchRef.current &&
feedFullSearchEventsRef.current === null &&
newEventsRef.current.length > 0
) {
flushPendingNewEventsIntoTimelineRef.current()
}
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
const now = Date.now()
if (now - blankFeedVisibilityResumeRetryAtRef.current < 45_000) return
blankFeedVisibilityResumeRetryAtRef.current = now
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,
rootMargin: `0px 0px ${LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX}px 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 ?? REVEAL_BATCH_STEP
const increment = Math.min(step, remaining)
const exhausted = bufferExhaustedForVisibleQuotaRef.current
const noVisibleRowsYet = clientFilteredVisibleCountRef.current === 0
// Revealing more raw buffer rows cannot surface visible cards (aggressive filters / seen-on gate).
if (!(exhausted && noVisibleRowsYet)) {
setShowCount((prev) => prev + increment)
}
// `showCount` is a *visible-row quota*, not an offset into the raw merged timeline. Skipping relay
// fetch when `events.length - showCount` is large breaks sparse feeds (e.g. only zap receipts): the
// buffer can hold many raw events while every visible row is already shown — we must still REQ.
if (
!exhausted &&
currentEvents.length >= 50 &&
currentEvents.length - currentShowCount > LIMIT * 0.75
) {
return
}
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()
const pageRuntime = new FeedRuntime({
descriptorKey: `timeline:${latestTimelineKey}`,
sortEvents: (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)
})
pageRuntime.seed(latestEvents, { hasMore: latestHasMore, nextCursor: until })
const pageSnapshot = await pageRuntime.loadMore(
async ({ cursor }) => {
newEvents = await client.loadMoreTimeline(latestTimelineKey, cursor ?? until, LIMIT)
return {
relayEvents: newEvents,
hasMore: newEvents.length > 0,
nextCursor: newEvents.length
? Math.min(...newEvents.map((event) => event.created_at)) - 1
: cursor
}
}
)
logFeedDiagnostics(
'note-list-load-more',
buildFeedDiagnosticsSnapshot({
descriptor: createFeedDescriptor({
surface: 'custom',
id: latestTimelineKey,
mode: 'live',
requests: subRequestsRef.current,
pagination: { enabled: true }
}),
relayPolicy: {
urls: Array.from(new Set(subRequestsRef.current.flatMap((request) => request.urls))),
dropped: []
},
runtime: pageSnapshot
})
)
// 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
}
const narrowLoadMore =
useFilterAsIsRef.current &&
clientSideKindFilterRef.current &&
withKindFilterRef.current &&
(!allowKindlessRelayExploreRef.current || !showAllKindsRef.current)
const existingIds = new Set(latestEvents.map((e) => e.id))
const kindPasses = (e: Event) => {
if (withKindFilterRef.current && !showAllKindsRef.current) {
return eventPassesNoteListKindPicker(
e,
effectiveShowKindsRef.current,
showKind1OPsRef.current,
showKind1RepliesRef.current,
showKind1111Ref.current
)
}
return !narrowLoadMore || effectiveShowKindsRef.current.includes(e.kind)
}
const noveltyFromBatch = (batch: Event[]) => {
const out: Event[] = []
for (const e of batch) {
if (!kindPasses(e)) continue
if (existingIds.has(e.id)) continue
existingIds.add(e.id)
out.push(e)
}
return out
}
let fetchBatch: Event[] = newEvents
const accumulated: Event[] = noveltyFromBatch(fetchBatch)
const chainDeadlineMs = Date.now() + LOAD_MORE_CHAIN_BUDGET_MS
for (
let chain = 0;
chain < LOAD_MORE_MAX_CHAIN_PAGES && accumulated.length < LOAD_MORE_MIN_NEW_EVENTS;
chain++
) {
if (fetchBatch.length === 0) break
if (Date.now() >= chainDeadlineMs) break
const skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
accumulated.push(...noveltyFromBatch(fetchBatch))
}
const toAppend = accumulated
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) =>
collapseDuplicateNip18RepostTimelineRows([...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) {
client.prefetchEmbeddedEventsForParents(toAppend.slice(0, 30), {
relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
})
}
} catch {
// 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
}
let scrollPrefetchTarget: HTMLElement | Window | null = null
let scrollPrefetchRafId = 0
let lastScrollTopForPrefetchDir = 0
let lastScrollPrefetchInvokeMs = 0
const onScrollFlushNewNotesAtTop = () => {
if (oneShotFetchRef.current) return
if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget
if (!t) return
const top = t === window ? window.scrollY : (t as HTMLElement).scrollTop
if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return
if (newEventsRef.current.length === 0) return
const now = Date.now()
if (now - lastNewNotesAutoFlushMsRef.current < 350) return
lastNewNotesAutoFlushMsRef.current = now
flushPendingNewEventsIntoTimelineRef.current()
}
const onScrollPrefetch = () => {
if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => {
scrollPrefetchRafId = 0
const now = Date.now()
if (now - lastScrollPrefetchInvokeMs < LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS) return
if (loadingRef.current) return
if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget
if (!t) return
const top = t === window ? window.scrollY : (t as HTMLElement).scrollTop
if (top <= lastScrollTopForPrefetchDir + 6) {
lastScrollTopForPrefetchDir = top
return
}
lastScrollTopForPrefetchDir = top
const ch = scrollRootClientHeight(t)
const threshold = Math.max(
LOAD_MORE_SCROLL_PREFETCH_MIN_PX,
ch * LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT
)
if (distanceFromScrollBottom(t) >= threshold) return
lastScrollPrefetchInvokeMs = now
const ev = eventsRef.current
const sc = showCountRef.current
if (sc < ev.length || hasMoreRef.current) {
loadMore()
}
})
}
const wireScrollPrefetch = () => {
const anchor = feedRootRef.current
const parent = getNearestScrollableAncestor(anchor)
const next: HTMLElement | Window = parent ?? window
if (scrollPrefetchTarget && scrollPrefetchTarget !== next) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop)
}
scrollPrefetchTarget = next
lastScrollTopForPrefetchDir =
next === window ? window.scrollY : (next as HTMLElement).scrollTop
next.addEventListener('scroll', onScrollPrefetch, { passive: true })
next.addEventListener('scroll', onScrollFlushNewNotesAtTop, { passive: true })
}
const wireScrollPrefetchSoonId = window.setTimeout(() => {
wireScrollPrefetch()
}, 0)
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) {
if (
sc < ev.length &&
!hasMoreRef.current &&
bufferExhaustedForVisibleQuotaRef.current &&
clientFilteredVisibleCountRef.current === 0
) {
return
}
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (scrollPrefetchRafId) {
cancelAnimationFrame(scrollPrefetchRafId)
scrollPrefetchRafId = 0
}
window.clearTimeout(wireScrollPrefetchSoonId)
if (scrollPrefetchTarget) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop)
scrollPrefetchTarget = null
}
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
// Clean up timeout on unmount
if (loadMoreTimeoutRef.current) {
clearTimeout(loadMoreTimeoutRef.current)
loadMoreTimeoutRef.current = null
}
}
}, [timelineSubscriptionKey])
// Eager embed prefetch for visible rows (deduped in EventService; ingest also prefetches on add).
useEffect(() => {
if (loading) return
const slice = clientFilteredEvents.slice(0, Math.max(showCount, 40))
if (slice.length === 0) return
client.prefetchEmbeddedEventsForParents(slice, {
relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
})
}, [clientFilteredEvents, showCount, loading])
const showNewEvents = () => {
flushPendingNewEventsIntoTimeline()
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()
// Relay-op rows arrive only after every relay in the wave reports terminal state. A slow or
// wedged connection (e.g. NIP-42 re-auth) can delay that indefinitely while events already stream
// in — without this guard the "Looking for more events…" banner never clears.
const showRelaySubscribeWavePendingBanner =
!oneShotFetch &&
!feedFullSearchActive &&
subRequests.length > 0 &&
relayCapabilityReady &&
timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady &&
timelineEventsForFilter.length === 0
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-0 w-full">
{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)}
deferAuthorAvatar
seenOnAllowlist={homeFeedActiveSeenOnAllowlist}
/>
))
)}
{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 ? (
clientFilteredEvents.length > 0 ? (
<div className="mx-2 h-2 max-w-md rounded-full bg-muted/60 animate-pulse" aria-hidden />
) : (
<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>
{alexandriaEmptyUrl ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyUrl} /> : null}
<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 ref={feedRootRef} className="relative">
<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
}