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.
5075 lines
208 KiB
5075 lines
208 KiB
import NewNotesButton from '@/components/NewNotesButton' |
|
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' |
|
import { FeedRelaysIconRow } from '@/components/FeedRelaysIconRow' |
|
import { |
|
ExtendedKind, |
|
FAST_READ_RELAY_URLS, |
|
FIRST_RELAY_RESULT_GRACE_MS, |
|
HOME_GALLERY_TAB_KINDS, |
|
HOME_GALLERY_TAB_KIND_SET, |
|
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 { collectReactionAuthorPubkeysForEmojiPrefetch } from '@/lib/reaction-display' |
|
import { prefetchAuthorNip30EmojisForPubkeys } from '@/lib/nip30-author-emojis' |
|
import { shouldFilterEvent } from '@/lib/event-filtering' |
|
import { |
|
isRelayUrlStrictSupersetIdentityKey, |
|
isSpellSubRequestsFilterSuperset, |
|
isSpellSubRequestsSameFiltersDifferentRelays |
|
} from '@/lib/spell-feed-request-identity' |
|
import logger from '@/lib/logger' |
|
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal' |
|
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' |
|
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' |
|
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' |
|
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' |
|
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' |
|
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch' |
|
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' |
|
import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds' |
|
import { shouldIncludePaymentInFeed } from '@/lib/superchat' |
|
import { scrollActivity } from '@/lib/scroll-activity.service' |
|
import { isTouchDevice } from '@/lib/utils' |
|
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
|
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider' |
|
import { useMuteList } from '@/contexts/mute-list-context' |
|
import { muteSetHas } from '@/lib/mute-set' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
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 { detailedPublishToastsEnabled } from '@/lib/publishing-feedback' |
|
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 { 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 { |
|
ensureHomeFeedTrendingRelay, |
|
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 = 64 |
|
/** |
|
* 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 |
|
/** Coalesce live `onNew` timeline updates to one React commit per frame burst. */ |
|
const LIVE_ON_NEW_FLUSH_MS = 72 |
|
|
|
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 startProgressiveArchiveKindWarmMatchLayer(params: ProgressiveSearchLocalLayerOpts): void { |
|
if (!params.warmMatch) return |
|
const { warmMatch, isStale, kindsForWarm, afterSort, setEvents, setLoading } = params |
|
void (async () => { |
|
try { |
|
const since = Math.floor(Date.now() / 1000) - 30 * 24 * 3600 |
|
const evs = await indexedDb.scanEventArchiveByKinds({ |
|
kinds: kindsForWarm, |
|
since, |
|
maxRowsScanned: 22_000, |
|
maxMatches: 400 |
|
}) |
|
if (isStale()) return |
|
const matched = evs.filter(warmMatch) |
|
if (matched.length) { |
|
setEvents((prev) => mergeProgressiveSearchEvents(prev, matched, afterSort)) |
|
setLoading(false) |
|
} |
|
} catch { |
|
/* ignore */ |
|
} |
|
})() |
|
} |
|
|
|
function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void { |
|
applyProgressiveSessionSearchLayer(params) |
|
startProgressiveIdbSearchLayer(params) |
|
startProgressiveArchiveKindWarmMatchLayer(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) || |
|
(Array.isArray(f['#d']) && f['#d']!.length > 0) || |
|
(typeof search === 'string' && search.trim().length > 0) |
|
) |
|
} |
|
|
|
/** Show d-tag filter when the feed REQ or loaded rows include NIP-33 addressable kinds. */ |
|
function feedIncludesAddressableKinds( |
|
subRequests: readonly TFeedSubRequest[], |
|
showKindsList: readonly number[], |
|
loadedEvents: readonly Event[] |
|
): boolean { |
|
for (const req of subRequests) { |
|
const reqKinds = req.filter?.kinds |
|
if (Array.isArray(reqKinds) && reqKinds.some((k) => kinds.isAddressableKind(k))) { |
|
return true |
|
} |
|
} |
|
if (showKindsList.some((k) => kinds.isAddressableKind(k))) return true |
|
return loadedEvents.some((e) => kinds.isAddressableKind(e.kind)) |
|
} |
|
|
|
/** 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 |
|
} |
|
|
|
/** `#p` mention filters (notifications spell) — used for fast IndexedDB payment-notification warm-up. */ |
|
function recipientPubkeysFromSpellFilters(shardFilters: Filter[]): string[] { |
|
const out = new Set<string>() |
|
for (const f of shardFilters) { |
|
const pTags = f['#p'] |
|
if (!Array.isArray(pTags)) continue |
|
for (const pk of pTags) { |
|
if (typeof pk !== 'string') continue |
|
const norm = pk.trim().toLowerCase() |
|
if (/^[0-9a-f]{64}$/.test(norm)) out.add(norm) |
|
} |
|
} |
|
return [...out] |
|
} |
|
|
|
/** 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, |
|
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, |
|
/** |
|
* When true, live `onNew` events merge into the visible timeline immediately (home feed behavior). |
|
* Default false on Spells faux feeds: new rows go to {@link NewNotesButton} until the user scrolls near the top. |
|
* Enable for notifications so mentions/replies appear without tapping “Show n new notes”. |
|
*/ |
|
mergeLiveEventsImmediately = false, |
|
/** |
|
* 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, |
|
/** When set with {@link feedClientFilterTabRowHost}, portaled filter panel renders here (e.g. profile: above pins). */ |
|
feedClientFilterPanelHost, |
|
onSingleRelayKindlessEmpty, |
|
onSingleRelayBrowseEmpty, |
|
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, |
|
/** Notifications feed: show attest-superchat bar on incoming payment cards. */ |
|
showPaymentAttestationAction = false, |
|
/** Notifications feed: show unattested kind 9734 / 9735 / 9740 / 9736 / 1814 addressed to this pubkey. */ |
|
incomingPaymentRecipientPubkey = 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 |
|
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 |
|
mergeLiveEventsImmediately?: boolean |
|
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 |
|
feedClientFilterPanelHost?: HTMLElement | null |
|
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ |
|
onSingleRelayKindlessEmpty?: () => void |
|
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */ |
|
onSingleRelayBrowseEmpty?: () => 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 |
|
showPaymentAttestationAction?: boolean |
|
incomingPaymentRecipientPubkey?: string | null |
|
}, |
|
ref |
|
) => { |
|
const { t } = useTranslation() |
|
const { startLogin, pubkey } = useNostr() |
|
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 } = useDeletedEventSafe() |
|
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 [feedClientDTagInput, setFeedClientDTagInput] = 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 singleRelayBrowseFallbackAttemptedRef = useRef(false) |
|
const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty) |
|
onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty |
|
const onSingleRelayBrowseEmptyRef = useRef(onSingleRelayBrowseEmpty) |
|
onSingleRelayBrowseEmptyRef.current = onSingleRelayBrowseEmpty |
|
/** 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 liveOnNewPendingRef = useRef< |
|
Array<{ event: Event; route: 'profile' | 'home' | 'pending' }> |
|
>([]) |
|
const liveOnNewFlushTimerRef = useRef<number | null>(null) |
|
const liveOnNewFlushRef = useRef<() => void>(() => {}) |
|
const scheduleLiveOnNewFlush = useCallback(() => { |
|
if (liveOnNewFlushTimerRef.current != null) return |
|
liveOnNewFlushTimerRef.current = window.setTimeout(() => { |
|
liveOnNewFlushTimerRef.current = null |
|
liveOnNewFlushRef.current() |
|
}, LIVE_ON_NEW_FLUSH_MS) |
|
}, []) |
|
|
|
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 feedRelayUrls = useMemo(() => { |
|
const urls = uniqueRelayUrlsFromSubRequests(subRequests) |
|
if (feedSubscriptionKey === 'home-all-favorites') { |
|
return ensureHomeFeedTrendingRelay(urls) |
|
} |
|
return urls |
|
}, [subRequestsKey, feedSubscriptionKey]) |
|
|
|
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls) |
|
const feedAttestedSuperchatIdsRef = useRef(feedAttestedSuperchatIds) |
|
useEffect(() => { |
|
feedAttestedSuperchatIdsRef.current = feedAttestedSuperchatIds |
|
}, [feedAttestedSuperchatIds]) |
|
|
|
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 |
|
] |
|
) |
|
const mapLiveSubRequestsForTimelineRef = useRef(mapLiveSubRequestsForTimeline) |
|
mapLiveSubRequestsForTimelineRef.current = mapLiveSubRequestsForTimeline |
|
|
|
/** 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 |
|
const primaryFeedDisplayed = primaryPageCtx?.display ?? true |
|
/** |
|
* Pause timelines only when the active primary feed is hidden (e.g. mobile note takeover, |
|
* single-pane sheet). Double-pane and mobile feed overlay keep `display` true — keep loading. |
|
*/ |
|
const pauseTimelineForPrimaryFreeze = |
|
primaryPanelFrozen && |
|
!primaryFeedDisplayed && |
|
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 homeFeedSeenOnAllowlistOpKey = useMemo( |
|
() => |
|
homeFeedSeenOnAllowlistOp?.length |
|
? [...homeFeedSeenOnAllowlistOp] |
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
|
.filter(Boolean) |
|
.sort() |
|
.join('|') |
|
: '', |
|
[homeFeedSeenOnAllowlistOp] |
|
) |
|
const homeFeedSeenOnAllowlistRepliesKey = useMemo( |
|
() => |
|
homeFeedSeenOnAllowlistReplies?.length |
|
? [...homeFeedSeenOnAllowlistReplies] |
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
|
.filter(Boolean) |
|
.sort() |
|
.join('|') |
|
: '', |
|
[homeFeedSeenOnAllowlistReplies] |
|
) |
|
|
|
const homeFeedActiveSeenOnAllowlist = useMemo(() => { |
|
if (feedSubscriptionKey !== 'home-all-favorites') return undefined |
|
if (homeFeedListMode === 'postsAndReplies' || homeFeedListMode === 'media') { |
|
return homeFeedSeenOnAllowlistRepliesKey ? homeFeedSeenOnAllowlistReplies : undefined |
|
} |
|
return homeFeedSeenOnAllowlistOpKey ? homeFeedSeenOnAllowlistOp : undefined |
|
}, [ |
|
feedSubscriptionKey, |
|
homeFeedListMode, |
|
homeFeedSeenOnAllowlistOpKey, |
|
homeFeedSeenOnAllowlistRepliesKey, |
|
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>() |
|
const emojiAuthors = new Set<string>() |
|
for (const e of timelineEventsForFilter) { |
|
collectProfilePrefetchPubkeysFromEvent(e, candidates) |
|
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) |
|
} |
|
for (const e of newEvents) { |
|
collectProfilePrefetchPubkeysFromEvent(e, candidates) |
|
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) |
|
} |
|
if (emojiAuthors.size > 0) { |
|
prefetchAuthorNip30EmojisForPubkeys([...emojiAuthors]) |
|
} |
|
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 mergeLiveEventsImmediatelyRef = useRef(mergeLiveEventsImmediately) |
|
mergeLiveEventsImmediatelyRef.current = mergeLiveEventsImmediately |
|
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 pinnedEventHexIdSet = useMemo(() => { |
|
const set = new Set<string>() |
|
pinnedEventIds.forEach((id) => { |
|
try { |
|
const { type, data } = decode(id) |
|
if (type === 'nevent') { |
|
set.add(data.id) |
|
} |
|
} catch { |
|
// ignore |
|
} |
|
}) |
|
return set |
|
}, [pinnedEventIds]) |
|
|
|
const shouldHideEvent = useCallback( |
|
(evt: Event) => { |
|
if (pinnedEventHexIdSet.has(evt.id)) return true |
|
if (isEventDeleted(evt)) return true |
|
if (hideReplies && isReplyNoteEvent(evt)) 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 |
|
|
|
// Attested superchats only (9741), except incoming payments in notifications. |
|
if ( |
|
!shouldIncludePaymentInFeed( |
|
evt, |
|
feedAttestedSuperchatIdsRef.current, |
|
incomingPaymentRecipientPubkey |
|
) |
|
) { |
|
return true |
|
} |
|
|
|
if (extraShouldHideEvent?.(evt)) return true |
|
|
|
if (homeFeedListMode === 'media' && !HOME_GALLERY_TAB_KIND_SET.has(evt.kind)) { |
|
return true |
|
} |
|
|
|
if ( |
|
homeFeedActiveSeenOnAllowlist && |
|
homeFeedListMode === 'posts' && |
|
!eventSeenOnMatchesAllowlist( |
|
client.getSeenEventRelayUrls(evt.id), |
|
homeFeedActiveSeenOnAllowlist |
|
) |
|
) { |
|
return true |
|
} |
|
|
|
return false |
|
}, |
|
[ |
|
filterMutedNotes, |
|
hideReplies, |
|
hideContentMentioningMutedUsers, |
|
mutePubkeySet, |
|
pinnedEventHexIdSet, |
|
isEventDeleted, |
|
incomingPaymentRecipientPubkey, |
|
extraShouldHideEvent, |
|
homeFeedActiveSeenOnAllowlist, |
|
homeFeedListMode |
|
] |
|
) |
|
|
|
const shouldHideEventRef = useRef(shouldHideEvent) |
|
useEffect(() => { |
|
shouldHideEventRef.current = shouldHideEvent |
|
}, [shouldHideEvent]) |
|
|
|
/** Paint the author's own publishes into the open feed without waiting for relay echo or "new notes". */ |
|
useEffect(() => { |
|
const onOwnPublish = (data: globalThis.Event) => { |
|
const evt = (data as CustomEvent<Event>).detail |
|
if (!evt?.id || !pubkey || evt.pubkey !== pubkey) return |
|
if (shouldHideEventRef.current(evt)) return |
|
|
|
const mapped = stripNostrLandAggrFromTimelineSubRequests( |
|
feedSubscriptionKey, |
|
mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current) |
|
).filter((req) => req.urls.length > 0) |
|
if (mapped.length === 0) return |
|
if ( |
|
!mapped.some(({ filter }) => |
|
eventMatchesSubRequestFilterWithWindow(evt, filter as Filter) |
|
) |
|
) { |
|
return |
|
} |
|
|
|
const narrowed = narrowLiveBatchUsingRefs([evt]) |
|
if (narrowed.length === 0) return |
|
|
|
if (eventsRef.current.some((e) => e.id === evt.id)) return |
|
|
|
const cap = allowKindlessRelayExplore |
|
? RELAY_EXPLORE_LIMIT |
|
: areAlgoRelays |
|
? ALGO_LIMIT |
|
: LIMIT |
|
setEvents((oldEvents) => { |
|
if (oldEvents.some((e) => e.id === evt.id)) return oldEvents |
|
const next = collapseDuplicateNip18RepostTimelineRows( |
|
mergeEventBatchesById(oldEvents, narrowed, cap, areAlgoRelays) |
|
) |
|
lastEventsForTimelinePrefetchRef.current = next |
|
return next |
|
}) |
|
setNewEvents((pending) => pending.filter((e) => e.id !== evt.id)) |
|
setLoading(false) |
|
client.prefetchEmbeddedEventsForParents(narrowed, { |
|
relayHintsOnly: relayAuthoritativeFeedOnlyRef.current |
|
}) |
|
} |
|
|
|
client.addEventListener('newEvent', onOwnPublish) |
|
return () => client.removeEventListener('newEvent', onOwnPublish) |
|
}, [ |
|
pubkey, |
|
feedSubscriptionKey, |
|
allowKindlessRelayExplore, |
|
areAlgoRelays, |
|
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 showFeedDTagFilter = useMemo( |
|
() => feedIncludesAddressableKinds(subRequests, effectiveShowKinds, timelineEventsForFilter), |
|
[subRequests, effectiveShowKinds, timelineEventsForFilter] |
|
) |
|
|
|
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 |
|
}) |
|
} |
|
const dTagQuery = feedClientDTagInput.trim().toLowerCase() |
|
if (dTagQuery) { |
|
rows = rows.filter((e) => |
|
eventTagValues(e, 'd').some((d) => d.toLowerCase().includes(dTagQuery)) |
|
) |
|
} |
|
return rows |
|
}, |
|
[ |
|
feedClientAuthorMode, |
|
feedClientAuthorNpubInput, |
|
pubkey, |
|
feedClientMinCreatedAt, |
|
feedClientKindFilter, |
|
feedClientSearch, |
|
feedClientDTagInput |
|
] |
|
) |
|
|
|
const clientFilteredEvents = useMemo( |
|
() => |
|
showFeedClientFilter ? applyClientFeedFilter(filteredEvents) : filteredEvents, |
|
[showFeedClientFilter, applyClientFeedFilter, filteredEvents] |
|
) |
|
|
|
useEffect(() => { |
|
clientFilteredVisibleCountRef.current = clientFilteredEvents.length |
|
}, [clientFilteredEvents.length]) |
|
|
|
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) => fetchProfilesMetadataBatch(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 clientFilteredNewEvents = useMemo( |
|
() => |
|
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, |
|
[showFeedClientFilter, applyClientFeedFilter, filteredNewEvents] |
|
) |
|
|
|
const feedClientFilterActive = useMemo( |
|
() => |
|
!!( |
|
showFeedClientFilter && |
|
(feedClientSearch.trim() || |
|
(feedClientAuthorMode === 'me' && !!pubkey) || |
|
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') || |
|
feedClientKindInput.trim() !== '' || |
|
feedClientDTagInput.trim() !== '' || |
|
feedClientMinCreatedAt !== null) |
|
), |
|
[ |
|
showFeedClientFilter, |
|
feedClientSearch, |
|
feedClientAuthorMode, |
|
feedClientAuthorNpubInput, |
|
feedClientKindInput, |
|
feedClientDTagInput, |
|
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>() |
|
const emojiAuthors = new Set<string>() |
|
const profilePrefetchCap = Math.min(120, Math.max(showCount + 64, 64)) |
|
for (const e of filteredEvents.slice(0, profilePrefetchCap)) { |
|
collectProfilePrefetchPubkeysFromEvent(e, candidates) |
|
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) |
|
} |
|
for (const e of newEvents.slice(0, 32)) { |
|
collectProfilePrefetchPubkeysFromEvent(e, candidates) |
|
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors) |
|
} |
|
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) { |
|
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates) |
|
} |
|
|
|
if (emojiAuthors.size > 0) { |
|
prefetchAuthorNip30EmojisForPubkeys([...emojiAuthors]) |
|
} |
|
|
|
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) |
|
enqueueFeedProfilePubkeys(need) |
|
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) |
|
return () => window.clearTimeout(handle) |
|
}, [ |
|
filteredEvents, |
|
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 hasDTag = feedClientDTagInput.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 && !hasDTag) { |
|
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] |
|
} |
|
if (hasDTag) { |
|
finalFilter['#d'] = [feedClientDTagInput.trim()] |
|
} |
|
|
|
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, |
|
feedClientDTagInput, |
|
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]) |
|
|
|
/** Single key for live timeline REQ identity — effect deps must not exceed this or subscriptions churn. */ |
|
const timelineLiveIdentityKey = useMemo( |
|
() => |
|
[ |
|
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live', |
|
timelineSubscriptionKey, |
|
feedSubscriptionKey ?? '', |
|
sessionSnapshotIdentityKey, |
|
subRequestsKey, |
|
timelineResubscribeKindKey, |
|
seeAllFeedEvents ? '1' : '0', |
|
useFilterAsIs ? '1' : '0', |
|
areAlgoRelays ? '1' : '0', |
|
allowKindlessRelayExplore ? '1' : '0', |
|
clientSideKindFilter ? '1' : '0', |
|
showAllKinds ? '1' : '0', |
|
withKindFilter ? '1' : '0', |
|
feedTimelineScopeKey ?? '', |
|
String(refreshCount), |
|
relayCapabilityReady ? '1' : '0' |
|
].join('\x1e'), |
|
[ |
|
pauseTimelineForPrimaryFreeze, |
|
timelineSubscriptionKey, |
|
feedSubscriptionKey, |
|
sessionSnapshotIdentityKey, |
|
subRequestsKey, |
|
timelineResubscribeKindKey, |
|
seeAllFeedEvents, |
|
useFilterAsIs, |
|
areAlgoRelays, |
|
allowKindlessRelayExplore, |
|
clientSideKindFilter, |
|
showAllKinds, |
|
withKindFilter, |
|
feedTimelineScopeKey, |
|
refreshCount, |
|
relayCapabilityReady |
|
] |
|
) |
|
|
|
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, |
|
mapLiveSubRequestsForTimelineRef.current(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) || |
|
isSpellSubRequestsFilterSuperset(prevSubKey, subRequestsKey)) |
|
|
|
const keepExistingTimelineEvents = |
|
preserveTimelineOnSubRequestsChange && |
|
!userPulledRefresh && |
|
!feedScopeChanged && |
|
(eventsRef.current.length > 0 || profileRelayStackRefinement) && |
|
(prevSubKey === subRequestsKey || |
|
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || |
|
(mergeTimelineWhenSubRequestFiltersMatch && |
|
(isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey) || |
|
isSpellSubRequestsFilterSuperset(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 |
|
singleRelayBrowseFallbackAttemptedRef.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, |
|
mapLiveSubRequestsForTimelineRef.current(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 |
|
} |
|
if ( |
|
useFilterAsIs && |
|
urls.length === 1 && |
|
relayAuthoritativeFeedOnlyRef.current && |
|
hostPrimaryPageNameRef.current === 'relay' |
|
) { |
|
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) |
|
) |
|
|
|
liveOnNewFlushRef.current = () => { |
|
if (!effectActive) return |
|
const batch = liveOnNewPendingRef.current.splice(0) |
|
if (batch.length === 0) return |
|
|
|
const profileBatch = batch.filter((row) => row.route === 'profile').map((row) => row.event) |
|
const homeBatch = batch.filter((row) => row.route === 'home').map((row) => row.event) |
|
const pendingBatch = batch.filter((row) => row.route === 'pending').map((row) => row.event) |
|
|
|
if (profileBatch.length > 0 || homeBatch.length > 0) { |
|
setEvents((oldEvents) => { |
|
let base = timelineMergeBootstrapRef.current ?? oldEvents |
|
let changed = false |
|
const statsOnly: Event[] = [] |
|
|
|
for (const event of profileBatch) { |
|
if (base.some((e) => e.id === event.id)) continue |
|
if ( |
|
isNip18RepostKind(event.kind) && |
|
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base) |
|
) { |
|
statsOnly.push(event) |
|
continue |
|
} |
|
if (timelineMergeBootstrapRef.current !== null) { |
|
timelineMergeBootstrapRef.current = null |
|
} |
|
base = [event, ...base] |
|
changed = true |
|
} |
|
|
|
for (const event of homeBatch) { |
|
if (base.some((e) => e.id === event.id)) continue |
|
if ( |
|
isNip18RepostKind(event.kind) && |
|
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base) |
|
) { |
|
statsOnly.push(event) |
|
continue |
|
} |
|
if (timelineMergeBootstrapRef.current !== null) { |
|
timelineMergeBootstrapRef.current = null |
|
} |
|
const cap = allowKindlessRelayExploreRef.current |
|
? RELAY_EXPLORE_LIMIT |
|
: areAlgoRelays |
|
? ALGO_LIMIT |
|
: LIMIT |
|
base = collapseDuplicateNip18RepostTimelineRows( |
|
mergeEventBatchesById(base, [event], cap, areAlgoRelays) |
|
) |
|
changed = true |
|
} |
|
|
|
if (statsOnly.length > 0) { |
|
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined) |
|
} |
|
if (!changed) { |
|
return timelineMergeBootstrapRef.current !== null ? base : oldEvents |
|
} |
|
lastEventsForTimelinePrefetchRef.current = base |
|
return base |
|
}) |
|
} |
|
|
|
if (pendingBatch.length > 0) { |
|
setNewEvents((oldEvents) => { |
|
const pool: Event[] = [...eventsRef.current, ...oldEvents] |
|
const statsOnly: Event[] = [] |
|
const kept: Event[] = [] |
|
for (const ev of pendingBatch) { |
|
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) |
|
} |
|
if (kept.length === 0) return oldEvents |
|
return [...kept, ...oldEvents].sort((a, b) => b.created_at - a.created_at) |
|
}) |
|
} |
|
} |
|
|
|
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([...HOME_GALLERY_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: [...HOME_GALLERY_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 mergeSpellLocalDiskLayer = (incoming: Event[], variant: string) => { |
|
if (!effectActive || timelineEffectStale()) return |
|
const narrowed = narrowLiveBatch(incoming) |
|
if (narrowed.length === 0) return |
|
const merged = collapseDuplicateNip18RepostTimelineRows( |
|
mergeEventBatchesById(spellLocalMergeBase, narrowed, eventCapEarly, areAlgoRelays) |
|
) |
|
if (merged.length === 0) return |
|
spellLocalMergeBase = merged |
|
timelineMergeBootstrapRef.current = merged.slice() |
|
setEvents(merged) |
|
lastEventsForTimelinePrefetchRef.current = merged |
|
setNewEvents([]) |
|
setShowCount(revealBatchSize ?? SHOW_COUNT) |
|
setLoading(false) |
|
feedPaintRelayPendingRef.current = true |
|
feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length } |
|
setFeedEmptyToastGateTick((n) => n + 1) |
|
setFeedTimelineEmptyUiReady(true) |
|
} |
|
|
|
const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters) |
|
if (mentionRecipients.length === 1) { |
|
try { |
|
const paymentNotifications = await indexedDb.getPaymentNotificationsForRecipient( |
|
mentionRecipients[0]!, |
|
localLayerCap |
|
) |
|
mergeSpellLocalDiskLayer( |
|
paymentNotifications.filter(matchesSpellLocal), |
|
'spell_payment_notifications_idb' |
|
) |
|
} catch { |
|
/* best-effort */ |
|
} |
|
} |
|
|
|
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 (Gallery uses kinds 20, 21, 22, 34235 only).' |
|
} |
|
: {}) |
|
}) |
|
} |
|
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() |
|
} |
|
} |
|
} |
|
|
|
// Relay explore: explicit kinds returned nothing — parent retries kindless once. |
|
if ( |
|
eosed && |
|
effectActive && |
|
onSingleRelayBrowseEmptyRef.current && |
|
!singleRelayBrowseFallbackAttemptedRef.current && |
|
!feedRelayReturnedAnyEventRef.current && |
|
relayAuthoritativeFeedOnlyRef.current && |
|
hostPrimaryPageNameRef.current === 'relay' |
|
) { |
|
const reqs = subRequestsRef.current |
|
const f0 = reqs[0] |
|
if (reqs.length === 1 && f0 && f0.urls.length === 1) { |
|
const f = f0.filter as Filter |
|
const hasKinds = Array.isArray(f.kinds) && f.kinds.length > 0 |
|
if (hasKinds) { |
|
singleRelayBrowseFallbackAttemptedRef.current = true |
|
onSingleRelayBrowseEmptyRef.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 (shouldHideEventRef.current(event)) return |
|
const isOwnPublish = Boolean(pubkey && event.pubkey === pubkey) |
|
const route: 'profile' | 'home' | 'pending' = |
|
mergeLiveEventsImmediatelyRef.current || isOwnPublish |
|
? 'home' |
|
: eventMatchesProfileTimelineRequest(event) |
|
? 'profile' |
|
: hostPrimaryPageNameRef.current === 'feed' |
|
? 'home' |
|
: 'pending' |
|
liveOnNewPendingRef.current.push({ event, route }) |
|
scheduleLiveOnNewFlush() |
|
}, |
|
}, |
|
{ |
|
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 |
|
if (liveOnNewFlushTimerRef.current != null) { |
|
clearTimeout(liveOnNewFlushTimerRef.current) |
|
liveOnNewFlushTimerRef.current = null |
|
} |
|
liveOnNewPendingRef.current = [] |
|
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() |
|
} |
|
}) |
|
} |
|
}, [timelineLiveIdentityKey, pauseTimelineForPrimaryFreeze, oneShotFetch]) |
|
|
|
const followingFeedDeltaIdentityKey = useMemo( |
|
() => |
|
[ |
|
followingFeedDeltaSubRequestsKey, |
|
timelineKey ?? '', |
|
feedSubscriptionKey ?? '', |
|
areAlgoRelays ? '1' : '0', |
|
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live' |
|
].join('\x1e'), |
|
[ |
|
followingFeedDeltaSubRequestsKey, |
|
timelineKey, |
|
feedSubscriptionKey, |
|
areAlgoRelays, |
|
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, |
|
mapLiveSubRequestsForTimelineRef.current(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 (shouldHideEventRef.current(event)) return |
|
const route: 'profile' | 'home' | 'pending' = |
|
(pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event) |
|
? 'profile' |
|
: hostPrimaryPageNameRef.current === 'feed' |
|
? 'home' |
|
: 'pending' |
|
liveOnNewPendingRef.current.push({ event, route }) |
|
scheduleLiveOnNewFlush() |
|
} |
|
}, |
|
{ |
|
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 |
|
} |
|
}, [followingFeedDeltaIdentityKey, oneShotFetch]) |
|
|
|
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 || !detailedPublishToastsEnabled()) { |
|
toast.error(title, { duration: 8000 }) |
|
} else { |
|
toast.error( |
|
<div className="w-full min-w-0"> |
|
<div className="font-semibold mb-3">{title}</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', icon: null } |
|
) |
|
} |
|
}, debounceMs) |
|
return () => window.clearTimeout(timer) |
|
}, [ |
|
loading, |
|
events.length, |
|
subRequests.length, |
|
timelineSubscriptionKey, |
|
refreshCount, |
|
feedEmptyToastGateTick, |
|
feedSubscribeRelayOutcomes, |
|
oneShotFetch, |
|
t |
|
]) |
|
|
|
useEffect(() => { |
|
if (relayAuthoritativeFeedOnly) return |
|
if (!timelinePublicReadFallback) return |
|
if (isMetadataRelaysOnlyPolicyActive()) 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 = mapLiveSubRequestsForTimelineRef.current(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, |
|
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 = () => { |
|
scrollActivity.markScrolling() |
|
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 = () => { |
|
scrollActivity.markScrolling() |
|
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 feedClientFilterPanelPortalMode = |
|
useFeedFilterTabRowPortal && feedClientFilterTabRowHost |
|
|
|
const feedClientFilterPanelSurfaceClass = feedClientFilterPanelPortalMode |
|
? 'space-y-3 border-b border-border/80 bg-background/95 px-2 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80' |
|
: '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 feedClientFilterToggleButton = ( |
|
<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> |
|
) |
|
|
|
const feedRelayToolbarRow = |
|
feedRelayUrls.length > 0 ? ( |
|
<FeedRelaysIconRow |
|
urls={feedRelayUrls} |
|
compact |
|
className="min-w-0 flex-1 overflow-x-auto scrollbar-hide" |
|
/> |
|
) : null |
|
|
|
const feedClientFilterPanel = 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> |
|
{showFeedDTagFilter ? ( |
|
<div className={feedClientFilterSectionClass}> |
|
<Label htmlFor="feed-client-d-tag" className="text-sm font-medium"> |
|
{t('Feed filter d-tag', { defaultValue: 'Repository id (d-tag)' })} |
|
</Label> |
|
<Input |
|
id="feed-client-d-tag" |
|
value={feedClientDTagInput} |
|
onChange={(e) => setFeedClientDTagInput(e.target.value)} |
|
placeholder={t('Feed filter d-tag placeholder', { defaultValue: 'Filter by d-tag…' })} |
|
autoComplete="off" |
|
className="w-full" |
|
/> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('Feed filter d-tag hint', { |
|
defaultValue: 'Substring match on the d tag of addressable events.' |
|
})} |
|
</p> |
|
</div> |
|
) : null} |
|
<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-col gap-2 pt-0.5 sm:flex-row sm:flex-wrap sm:items-center"> |
|
<Button |
|
type="button" |
|
variant="secondary" |
|
size="sm" |
|
className="h-auto min-h-8 max-w-full whitespace-normal px-3 py-1.5 text-left sm:text-center" |
|
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-auto min-h-8 max-w-full whitespace-normal px-3 py-1.5 text-left sm:text-center" |
|
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 feedClientFilterChrome = feedClientFilterPanelPortalMode ? ( |
|
<div className="flex min-w-0 w-full flex-nowrap items-center gap-1"> |
|
{feedRelayToolbarRow} |
|
<div className="shrink-0">{feedClientFilterToggleButton}</div> |
|
</div> |
|
) : ( |
|
<> |
|
<div className="flex min-w-0 flex-nowrap items-center gap-1 px-0.5"> |
|
{feedRelayToolbarRow} |
|
<div className="ml-auto shrink-0">{feedClientFilterToggleButton}</div> |
|
</div> |
|
{feedClientFilterPanel} |
|
</> |
|
) |
|
|
|
const feedClientFilterPanelPortaled = |
|
feedClientFilterPanelPortalMode && |
|
feedClientFilterPanelHost && |
|
feedClientFilterPanel |
|
? createPortal(feedClientFilterPanel, feedClientFilterPanelHost) |
|
: null |
|
|
|
/** Tab-row portal: toggle in header; panel in {@link feedClientFilterPanelHost} or above the list. */ |
|
const feedClientFilterPanelInList = |
|
feedClientFilterPanelPortalMode && !feedClientFilterPanelHost |
|
? feedClientFilterPanel |
|
: 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(feedClientFilterChrome, 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 showFeedInitialLoading = |
|
listSourceEvents.length === 0 && |
|
!feedFullSearchActive && |
|
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) |
|
|
|
const showRelaySubscribeWavePendingBanner = |
|
!oneShotFetch && |
|
!feedFullSearchActive && |
|
subRequests.length > 0 && |
|
relayCapabilityReady && |
|
timelineKey != null && |
|
timelineEventsForFilter.length === 0 && |
|
(loading || |
|
!feedTimelineEmptyUiReady || |
|
(feedSubscribeRelayOutcomes.length === 0 && feedTimelineEmptyUiReady)) |
|
const showProgressiveLayersPendingBanner = |
|
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive |
|
const showLookingForMoreEventsBanner = |
|
showRelaySubscribeWavePendingBanner || showProgressiveLayersPendingBanner |
|
const relayWavePendingBannerEl = showLookingForMoreEventsBanner ? ( |
|
<div |
|
className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground" |
|
role="status" |
|
aria-live="polite" |
|
> |
|
{t('Looking for more events…')} |
|
</div> |
|
) : null |
|
const eventReasonLabelMap = useMemo(() => { |
|
const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) |
|
if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>() |
|
const map = new Map<string, string>() |
|
const labelEvents = clientFilteredEvents.slice(0, Math.min(showCount + 24, clientFilteredEvents.length)) |
|
for (const event of labelEvents) { |
|
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, showCount]) |
|
|
|
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} |
|
showPaymentAttestationAction={showPaymentAttestationAction} |
|
/> |
|
)) |
|
)} |
|
{showFeedInitialLoading ? ( |
|
<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" |
|
> |
|
<p className="col-span-full px-2 pb-2 text-center text-sm text-muted-foreground"> |
|
{t('Loading feed…')} |
|
</p> |
|
{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)]" /> |
|
{feedClientFilterPanelPortaled} |
|
<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} |
|
{feedClientFilterPanelInList} |
|
{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} |
|
{feedClientFilterPanelInList} |
|
{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 |
|
}
|
|
|