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.
299 lines
12 KiB
299 lines
12 KiB
import NoteList, { TNoteListRef } from '@/components/NoteList' |
|
import { RefreshButton } from '@/components/RefreshButton' |
|
import Tabs, { TabDefinition } from '@/components/Tabs' |
|
import { useKindFilter } from '@/providers/KindFilterProvider' |
|
import { useUserTrust } from '@/contexts/user-trust-context' |
|
import storage from '@/services/local-storage.service' |
|
import type { TPrimaryPageName } from '@/PageManager' |
|
import { TFeedSubRequest, TNoteListMode } from '@/types' |
|
import { cn } from '@/lib/utils' |
|
import type { Event } from 'nostr-tools' |
|
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' |
|
import KindFilter from '../KindFilter' |
|
|
|
const NormalFeed = forwardRef<TNoteListRef, { |
|
subRequests: TFeedSubRequest[] |
|
areAlgoRelays?: boolean |
|
/** When false, NoteList waits before opening timeline REQs (relay algo probe). */ |
|
relayCapabilityReady?: boolean |
|
isMainFeed?: boolean |
|
/** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */ |
|
setSubHeader?: (node: React.ReactNode) => void |
|
/** Shown in the subHeader row to the left of the kind filter (mobile primary feed). */ |
|
onSubHeaderRefresh?: () => void |
|
/** |
|
* When true with {@link mergeTimelineWhenSubRequestFiltersMatch}, relay URL list can change (e.g. favorites |
|
* hydrate after load) without clearing rows — same REQ shape, merge new stream into existing events. |
|
*/ |
|
preserveTimelineOnSubRequestsChange?: boolean |
|
mergeTimelineWhenSubRequestFiltersMatch?: boolean |
|
/** Home following: second subscribe wave (delta relays / new authors); see {@link NoteList}. */ |
|
followingFeedDeltaSubRequests?: TFeedSubRequest[] |
|
/** Stable subscription identity; see {@link NoteList} `feedSubscriptionKey`. */ |
|
feedSubscriptionKey?: string |
|
/** Home favorite-relays chip scope; see {@link NoteList} `feedTimelineScopeKey`. */ |
|
feedTimelineScopeKey?: string |
|
/** Single-relay Explore / chip: kindless REQ (see `SINGLE_RELAY_KINDLESS_REQ_LIMIT` in constants). */ |
|
useFilterAsIs?: boolean |
|
clientSideKindFilter?: boolean |
|
allowKindlessRelayExplore?: boolean |
|
/** |
|
* Default true (home following, favorites, sets, single-relay chip): kind picker narrows visible rows. |
|
* Ignored when {@link showAllKinds} is effectively true. |
|
*/ |
|
withKindFilter?: boolean |
|
/** |
|
* When true (relay explorer page), list shows the full relay batch. When omitted, uses KindFilter "All Events" |
|
* ({@link useKindFilter} / persisted bypass) on home feeds. |
|
*/ |
|
showAllKinds?: boolean |
|
/** |
|
* Client-side 🔍 feed filter. When omitted: hidden on main following, shown on relay explore and non-main feeds. |
|
*/ |
|
showFeedClientFilter?: boolean |
|
/** When set, {@link NoteList} clears 🔍 filters when another primary tab is shown (mounted-but-hidden pages). */ |
|
hostPrimaryPageName?: TPrimaryPageName |
|
/** Single-relay kindless wave EOSEd with no events: parent re-subscribes with explicit kinds. */ |
|
onSingleRelayKindlessEmpty?: () => void |
|
/** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */ |
|
feedTopNotice?: ReactNode |
|
/** Passed through to {@link NoteList} (d-tag browse one-shot). */ |
|
oneShotFetch?: boolean |
|
progressiveWarmupQuery?: string |
|
progressiveWarmupMatch?: (ev: Event) => boolean |
|
/** Union into kind picker kinds for REQ + UI when set (e.g. document kinds on search / d-tag feeds). */ |
|
progressiveDocumentKinds?: readonly number[] |
|
oneShotAfterMergeComparator?: (a: Event, b: Event) => number |
|
extraShouldHideEvent?: (ev: Event) => boolean |
|
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */ |
|
oneShotMergedCap?: number |
|
}>(function NormalFeed( |
|
{ |
|
subRequests, |
|
areAlgoRelays = false, |
|
relayCapabilityReady = true, |
|
isMainFeed = false, |
|
setSubHeader, |
|
onSubHeaderRefresh, |
|
preserveTimelineOnSubRequestsChange = false, |
|
mergeTimelineWhenSubRequestFiltersMatch = false, |
|
followingFeedDeltaSubRequests, |
|
feedSubscriptionKey, |
|
feedTimelineScopeKey, |
|
useFilterAsIs = false, |
|
clientSideKindFilter = false, |
|
allowKindlessRelayExplore = false, |
|
withKindFilter = true, |
|
showAllKinds: showAllKindsProp, |
|
showFeedClientFilter: showFeedClientFilterProp, |
|
hostPrimaryPageName, |
|
onSingleRelayKindlessEmpty, |
|
feedTopNotice, |
|
oneShotFetch = false, |
|
progressiveWarmupQuery, |
|
progressiveWarmupMatch, |
|
progressiveDocumentKinds, |
|
oneShotAfterMergeComparator, |
|
extraShouldHideEvent, |
|
oneShotMergedCap |
|
}, |
|
ref |
|
) { |
|
const { hideUntrustedNotes } = useUserTrust() |
|
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = |
|
useKindFilter() |
|
const [listMode, setListMode] = useState<TNoteListMode>(() => { |
|
const storedMode = storage.getNoteListMode() |
|
if (isMainFeed) { |
|
if (storedMode === 'posts' || storedMode === 'postsAndReplies') { |
|
return storedMode |
|
} |
|
return 'posts' |
|
} |
|
return storedMode || 'posts' |
|
}) |
|
const internalNoteListRef = useRef<TNoteListRef>(null) |
|
const noteListRef = ref || internalNoteListRef |
|
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null) |
|
const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => { |
|
setFeedFilterTabRowHost(node) |
|
}, []) |
|
|
|
const MEDIA_KINDS = useMemo(() => [20, 21, 22, 1222], []) |
|
|
|
const tabs = useMemo( |
|
(): TabDefinition[] => { |
|
const base: TabDefinition[] = [ |
|
{ value: 'posts', label: 'Notes' }, |
|
{ value: 'postsAndReplies', label: 'Replies' } |
|
] |
|
if (isMainFeed) base.push({ value: 'media', label: 'Gallery' }) |
|
return base |
|
}, |
|
[isMainFeed] |
|
) |
|
|
|
/** When in media mode, replace each shard's kinds with the media set. */ |
|
const effectiveSubRequests = useMemo(() => { |
|
if (listMode !== 'media') return subRequests |
|
return subRequests.map((req) => ({ |
|
...req, |
|
filter: { ...req.filter, kinds: MEDIA_KINDS } |
|
})) |
|
}, [listMode, subRequests, MEDIA_KINDS]) |
|
|
|
const handleListModeChange = useCallback( |
|
(mode: TNoteListMode | string) => { |
|
const noteListMode = mode as TNoteListMode |
|
setListMode(noteListMode) |
|
if (isMainFeed) { |
|
storage.setNoteListMode(noteListMode) |
|
window.dispatchEvent(new CustomEvent('noteListModeChanged')) |
|
} |
|
if (noteListRef && typeof noteListRef !== 'function') { |
|
noteListRef.current?.scrollToTop('smooth') |
|
} |
|
}, |
|
[isMainFeed, noteListRef] |
|
) |
|
|
|
const handleShowKindsChange = useCallback((_newShowKinds: number[]) => { |
|
if (noteListRef && typeof noteListRef !== 'function') { |
|
noteListRef.current?.scrollToTop() |
|
} |
|
}, [noteListRef]) |
|
|
|
const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds]) |
|
|
|
/** Relay detail + kindless home chip use {@link useFilterAsIs}; include it so the 🔍 row is not dropped if only one flag is set. */ |
|
const showFeedClientFilter = useMemo( |
|
() => |
|
showFeedClientFilterProp ?? |
|
(!isMainFeed || allowKindlessRelayExplore || useFilterAsIs), |
|
[showFeedClientFilterProp, isMainFeed, allowKindlessRelayExplore, useFilterAsIs] |
|
) |
|
|
|
const listShowAllKinds = showAllKindsProp ?? feedKindFilterBypass |
|
|
|
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */ |
|
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${listShowAllKinds ? 'all' : 'k'}` |
|
|
|
const tabsElement = useMemo( |
|
() => ( |
|
<Tabs |
|
value={listMode} |
|
tabs={tabs} |
|
onTabChange={handleListModeChange} |
|
options={ |
|
<div className="flex items-center gap-1"> |
|
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />} |
|
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} /> |
|
</div> |
|
} |
|
/> |
|
), |
|
[ |
|
listMode, |
|
tabs, |
|
handleListModeChange, |
|
showKinds, |
|
onSubHeaderRefresh, |
|
handleShowKindsChange |
|
] |
|
) |
|
|
|
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore |
|
|
|
const mergeFilterWithTabsRow = |
|
showFeedClientFilter && ((isMainFeed && !!setSubHeader) || renderTabsInFeed) |
|
|
|
/** Same row for multi-relay and single-relay chips: Notes/Replies + refresh + kind picker (REQ may stay kindless for single relay; NoteList filters client-side). */ |
|
useLayoutEffect(() => { |
|
if (!isMainFeed || !setSubHeader) return |
|
if (mergeFilterWithTabsRow) { |
|
setSubHeader( |
|
<div className="flex w-full min-w-0 flex-wrap items-end gap-x-2 gap-y-1 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80"> |
|
<div className="min-w-0 flex-1">{tabsElement}</div> |
|
<div |
|
ref={onFeedFilterTabRowSlotRef} |
|
className="flex shrink-0 flex-col items-end justify-center self-center" |
|
/> |
|
</div> |
|
) |
|
} else { |
|
setSubHeader(tabsElement) |
|
} |
|
return () => setSubHeader(null) |
|
}, [ |
|
isMainFeed, |
|
setSubHeader, |
|
listMode, |
|
subHeaderFilterDepsKey, |
|
onSubHeaderRefresh, |
|
allowKindlessRelayExplore, |
|
mergeFilterWithTabsRow, |
|
tabsElement, |
|
onFeedFilterTabRowSlotRef |
|
]) |
|
|
|
return ( |
|
<> |
|
{renderTabsInFeed && |
|
(mergeFilterWithTabsRow ? ( |
|
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80"> |
|
<div className="flex w-full min-w-0 flex-wrap items-end gap-x-2 gap-y-1"> |
|
<div className="min-w-0 flex-1">{tabsElement}</div> |
|
<div |
|
ref={onFeedFilterTabRowSlotRef} |
|
className="flex shrink-0 flex-col items-end justify-center self-center" |
|
/> |
|
</div> |
|
</div> |
|
) : ( |
|
tabsElement |
|
))} |
|
<div |
|
className={cn('min-w-0', mergeFilterWithTabsRow && renderTabsInFeed ? 'pt-0' : 'pt-2')} |
|
> |
|
<NoteList |
|
ref={noteListRef} |
|
showKinds={showKinds} |
|
showKind1OPs={showKind1OPs} |
|
showKind1Replies={showKind1Replies} |
|
showKind1111={showKind1111} |
|
seeAllFeedEvents={feedKindFilterBypass} |
|
withKindFilter={withKindFilter} |
|
subRequests={effectiveSubRequests} |
|
hideReplies={listMode === 'posts'} |
|
hideUntrustedNotes={hideUntrustedNotes} |
|
areAlgoRelays={areAlgoRelays} |
|
relayCapabilityReady={relayCapabilityReady} |
|
feedSubscriptionKey={feedSubscriptionKey} |
|
preserveTimelineOnSubRequestsChange={preserveTimelineOnSubRequestsChange} |
|
mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch} |
|
followingFeedDeltaSubRequests={followingFeedDeltaSubRequests} |
|
feedTimelineScopeKey={feedTimelineScopeKey} |
|
gridLayout={listMode === 'media'} |
|
useFilterAsIs={listMode === 'media' ? true : useFilterAsIs} |
|
clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter} |
|
allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore} |
|
showAllKinds={listMode === 'media' ? true : listShowAllKinds} |
|
showFeedClientFilter={showFeedClientFilter} |
|
hostPrimaryPageName={hostPrimaryPageName} |
|
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined} |
|
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty} |
|
feedTopNotice={feedTopNotice} |
|
oneShotFetch={oneShotFetch} |
|
progressiveWarmupQuery={progressiveWarmupQuery} |
|
progressiveWarmupMatch={progressiveWarmupMatch} |
|
progressiveDocumentKinds={progressiveDocumentKinds} |
|
oneShotAfterMergeComparator={oneShotAfterMergeComparator} |
|
extraShouldHideEvent={extraShouldHideEvent} |
|
oneShotMergedCap={oneShotMergedCap} |
|
/> |
|
</div> |
|
</> |
|
) |
|
}) |
|
|
|
export default NormalFeed
|
|
|