import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { nextPageOnClick } from "../../lib/nextPage"; import { ArrowLeft, Columns2, Grid3x3, Info, LayoutGrid, Library, List, RefreshCw, Rows3, Trash2, type LucideIcon, } from "lucide-react"; import { YouTubeIcon } from "../../lib/brandIcons"; import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api"; import i18n from "../../i18n"; import { notifyActionError } from "../../lib/actionErrors"; import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice"; import { notify } from "../../lib/notifications"; import { useMe } from "../../lib/useMe"; import { useDebounced } from "../../lib/useDebounced"; import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache"; import { buildSort, feedSortOptions, parseSort, SORT_KEYS, sortYtResults, YT_SORT_KEYS, type SortKey, } from "../../lib/feedSort"; import { type SortOption, type SortState } from "../../lib/columnSort"; import { usePref } from "../../lib/usePref"; import { SHOW_IDS } from "./feedFiltersInit"; import { FEED_VIEWS, type FeedView } from "../../lib/feedView"; import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters"; import ActiveFilterChips from "../../features/filters/ActiveFilterChips"; import SelectedViewChip from "../../features/filters/SelectedViewChip"; import { Button } from "../../components/ui/Button"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { PageToolbar } from "../../components/ui/PageShell"; import { PageMenu } from "../../components/ui/PageMenu"; import { menuGroups } from "../../components/ui/menuGroups"; import { choiceSubmenu, multiSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { useWizard } from "../../features/wizard/wizardContext"; import SortControl from "../../components/ui/SortControl"; import FilterSelect from "../../components/ui/FilterSelect"; import ItemCount from "../../components/ui/ItemCount"; import { FilterFoldButton } from "../../components/ui/FilterFold"; import { useFilterFold } from "../../lib/useFilterFold"; import ViewSwitcher from "../../components/ui/ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; import SegmentedToggle from "../../components/ui/SegmentedToggle"; import { IconButton } from "../../components/ui/IconButton"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PAGE = 60; // The "Show" view filter, content-type filter and ordering live in the feed toolbar // (above the cards), not the filter sidebar. const CONTENT = [ { key: "includeNormal", label: "sidebar.content.normal" }, { key: "includeShorts", label: "sidebar.content.shorts" }, { key: "includeLive", label: "sidebar.content.live" }, ] as const; const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); // Icon per view mode; the labels are i18n'd at render (see feed.view.*). const VIEW_ICON: Record = { cards: LayoutGrid, cardsSmall: Grid3x3, rows: Rows3, rowsCompact: List, tiles: Columns2, }; // Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings // (which encode both). One entry per concept; a single arrow flips the direction. // "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts. function matchesView(status: VideoStatus, show: string): boolean { switch (show) { case "hidden": return status === "hidden"; case "watched": return status === "watched"; case "unwatched": case "in_progress": // (in_progress is further narrowed server-side by resume position; here we only // need to drop a card once it's optimistically marked watched/hidden.) return status !== "watched" && status !== "hidden"; default: return status !== "hidden"; // all } } // An optimistic value and the write that made it. A refetch that lands is the server's word only on // the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer // than its answer, and so is one sent just before it whose answer had not come back (the server may // read the list first). Dropping either put a card marked watched back into an Unwatched view. type Stamped = { value: T; seq: number }; type Overrides = Record>; const unconfirmed = (o: Overrides, answered: ReadonlySet): Overrides => Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq))); // A failed write takes back only its OWN value: a later write for the same video stands. const withoutWrite = (o: Overrides, id: string, seq: number): Overrides => { if (o[id]?.seq !== seq) return o; const next = { ...o }; delete next[id]; return next; }; // How many live-search results one fetch asks for; `Load more` pulls another batch of the same size. const YT_COUNTS = [20, 40, 60, 100] as const; export default function Feed({ filters, setFilters, view, setView, canRead, ytSearch, onYtSearch, onExitYtSearch, channelScoped, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; // The view mode is an account pref, not a filter: it persists on pick, so it isn't part of // FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller // (FeedViewProvider on the feed page; ChannelPage threads its own). view: FeedView; setView: (v: FeedView) => void; canRead: boolean; // Live YouTube search: the active search term (null = normal feed). Entering a search and // leaving it both step browser history (the search is a feed sub-view with its own history // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via // onExitYtSearch. ytSearch: string | null; onYtSearch: (q: string) => void; onExitYtSearch: () => void; // True when this feed is scoped to a single channel (the channel page). Drops sort options // that are constant within one channel (subscribers, priority) — they'd be meaningless. channelScoped?: boolean; }) { const { t, i18n: ui } = useTranslation(); const lang = ui.language; // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected). const { openWizard: onOpenWizard } = useWizard(); // Same list the panel's badge counts — see lib/useActiveFilters. const activeFilters = useActiveFilters(filters, setFilters); // B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold). const fold = useFilterFold(); const sortKeys = channelScoped ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") : SORT_KEYS; const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // The write order the stamps above count in, the writes the server has answered, and which of // them it had answered when the running refetch set out. const writeSeq = useRef(0); const answered = useRef(new Set()); const answeredAtRefetch = useRef>(new Set()); // A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out // before the server took the reset puts the old position back: written again on its landing. const resets = useRef(new Map()); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( null, ); const qc = useQueryClient(); // Live search turned off on this instance: the empty feed offers no search that would be refused. const searchOn = useMe().search_enabled; // What changed state while the player was open, and the writes still in flight: closing it // refetches the list by its filters once those land. Marked watched (by hand, or by the player at // the end) a card leaves an Unwatched view through the override at once, but the list, its count // and the cache stayed the pre-session server's until a reload (owner). const playerSession = useRef({ open: false, changed: false, pending: new Set>(), }); const openVideo = useCallback((video: Video, startAt: number | null = null) => { playerSession.current.open = true; // Its player writes the next position: a reset no longer speaks for it. resets.current.delete(video.id); setActiveVideo({ video, startAt }); }, []); // Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect // depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners, // re-ran focusModal() under the open player, dropped its in-flight timers and flushed the // debounced volume write on each pass. const closePlayer = useCallback(() => setActiveVideo(null), []); // The session ends once the player has UNMOUNTED, not on the close click: its progress hook's // cleanup marks a clip closed near its end watched, a write that lands after the click. React runs // every unmount cleanup before any effect's setup, so here that write is already in the session, // and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the // list read the older position and, landing after the player's patch, put it back. const playerOpen = activeVideo !== null; useEffect(() => { const session = playerSession.current; if (playerOpen || !session.open) return; session.open = false; if (!session.changed) return; session.changed = false; void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => { void qc.invalidateQueries({ queryKey: qk.feed() }); void qc.invalidateQueries({ queryKey: qk.feedSummary() }); }); }, [playerOpen, qc]); const ytActive = !!ytSearch; // How many live-search results to gather (the count selector replaces a manual "load more"; // the free scrape source pages through continuations until this many are collected). const [ytCount, setYtCount] = useState(20); // How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page // must not silently re-sort the library you go back to. Default `relevance` — the provider's // ranking, which is what the view always showed before it had a control. const [ytSort, setYtSort] = usePref>("ytSearchSort", { key: "relevance", dir: "desc", }); // Debounce only the search term feeding the queries: the input still updates instantly (it // owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch — // and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers. const debouncedQ = useDebounced(filters.q, 300); const queryFilters: FeedFilters = { ...filters, q: debouncedQ }; const query = useInfiniteQuery({ queryKey: qk.feed(queryFilters), queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, enabled: !ytActive, // don't load the normal feed while showing live YouTube results placeholderData: keepPreviousData, }); // Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an // explicit button (each api-source page spends 100 quota units; scrape-source pages are free). // retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a // revisited search from re-spending. const ytQuery = useInfiniteQuery({ queryKey: qk.ytSearch(ytSearch, ytCount), queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, enabled: ytActive, staleTime: 5 * 60_000, retry: false, }); // Remember (in history.state) that this live search was served by the zero-quota scrape source, // so a reload restores the results instead of dropping to the feed — re-fetching scrape pages // costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units). useEffect(() => { if (!ytActive) return; const st = window.history.state; if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) { window.history.replaceState({ ...st, _ytScrape: true }, ""); } }, [ytActive, ytSearch, ytQuery.data]); // Switching to the relevance sort when a search starts happens atomically in the header's // input onChange (race-free with the query update). Here we only handle the reverse: when // the term is cleared, fall back to the default sort — relevance has no dropdown option and // doesn't rank without a query. const qEmpty = !filters.q.trim(); useEffect(() => { if (qEmpty && parseSort(filters.sort).key === "relevance") { setFilters({ ...filters, sort: buildSort("date", "desc") }); } // Naming `filters` is safe precisely because the write below falsifies the condition above: the // sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was // guarding against a loop that the guard already prevents. }, [qEmpty, filters, setFilters]); // Drop optimistic status overrides when filters change OR fresh server data // arrives — after a refetch the server is authoritative, so a stale override // (e.g. a video reverted to "new" from the notification center) won't keep it // filtered out of the current view. const [overridesFor, setOverridesFor] = useState(filters); if (overridesFor !== filters) { setOverridesFor(filters); setOverrides({}); setSavedOverrides({}); } // ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its // page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into // the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides, // and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it. // And only the overrides whose write it had answered when it set out (`Stamped`). const refetching = query.isFetching && !query.isFetchingNextPage; const wasRefetching = useRef(false); useEffect(() => { const started = !wasRefetching.current && refetching; const landed = wasRefetching.current && !refetching && query.status === "success"; wasRefetching.current = refetching; if (started) answeredAtRefetch.current = new Set(answered.current); if (!landed) return; const known = answeredAtRefetch.current; setOverrides((o) => unconfirmed(o, known)); setSavedOverrides((o) => unconfirmed(o, known)); for (const seq of known) answered.current.delete(seq); const zero = new Map(); for (const [id, seq] of resets.current) { if (known.has(seq)) resets.current.delete(id); else zero.set(id, 0); } patchCachedProgress(qc, zero); }, [refetching, query.status, qc]); // One shared query with the sidebar (same key + filters -> React Query dedupes to a single request): // the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets). const summaryQuery = useQuery({ queryKey: qk.feedSummary(queryFilters), queryFn: () => api.feedSummary(queryFilters), staleTime: 30_000, enabled: !ytActive, placeholderData: keepPreviousData, }); const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; // Keep the loaded videos in a ref so onState can stay referentially stable // (it needs the list only to look up a title for the notification). A stable // onState lets memo(VideoCard) skip re-rendering existing cards on page append. const loadedRef = useRef([]); // The toast's Undo re-enters onState, a forward reference: through this ref (set below). const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {}); // A write made while the player is open joins its session: the list refetches once the player // has closed and every such write has landed (the effect above). The player's own related list // writes through this too — its marks reach videos this list may hold. const trackSessionWrite = useCallback((write: Promise) => { const session = playerSession.current; if (!session.open) return; session.changed = true; session.pending.add(write); const settled = () => void session.pending.delete(write); write.then(settled, settled); }, []); const onState = useCallback( (id: string, status: VideoStatus, video?: NoticeVideo) => { const seq = ++writeSeq.current; setOverrides((o) => ({ ...o, [id]: { value: status, seq } })); const write = api.setState(id, status); trackSessionWrite(write); // Refetch once the server has the change so other views (e.g. Hidden) are in sync. // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden" // notice (or resolving a stale one) is a claim of success, so firing it optimistically // would falsely report a change that the .catch below is about to roll back (e.g. when // the API is unreachable). write .then(() => { // Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter // already show the correct state (a hidden/watched card drops out immediately), so refetching // every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch // storm (PERF-2). The next filter change / remount refetches fresh from the server. answered.current.add(seq); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); patchRelated(qc, id, { status }); // The video the player is on may be none of this list's (a related one): it names itself. const v = video ?? loadedRef.current.find((x) => x.id === id); announceVideoState(id, status, v, () => onStateRef.current(id, "new")); }) .catch((e: unknown) => { // Server rejected the change — drop the optimistic override so the card // reverts to the real (unchanged) state instead of staying phantom-hidden, and say so: // a card that silently snaps back reads as a misclick. notifyActionError(e, i18n.t("errors.writeFailed.videoState")); setOverrides((o) => withoutWrite(o, id, seq)); }); }, [qc, trackSessionWrite], ); const onResetState = useCallback( (id: string) => { // Reset to pristine (drop the whole VideoState row, incl. resume position) — the // un-watch toggle can't clear an in-progress position, this can. const seq = ++writeSeq.current; setOverrides((o) => ({ ...o, [id]: { value: "new", seq } })); // The position goes into the cached lists, where the player's own closing patch writes the // next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first // checkpoint wrote over the progress just made. const before = loadedRef.current.find((v) => v.id === id)?.position_seconds; patchCachedProgress(qc, new Map([[id, 0]])); resets.current.set(id, seq); api .clearState(id) // Stale-mark only (see setState above): the optimistic override already reset the card. .then(() => { answered.current.add(seq); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); }) .catch((e: unknown) => { notifyActionError(e, i18n.t("errors.writeFailed.videoReset")); setOverrides((o) => withoutWrite(o, id, seq)); if (resets.current.get(id) !== seq) return; resets.current.delete(id); if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]])); }); }, [qc], ); const onToggleSave = useCallback( (id: string, saved: boolean) => { const seq = ++writeSeq.current; setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } })); (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) .then(() => { answered.current.add(seq); qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlist() }); }) .catch((e: unknown) => { notifyActionError( e, i18n.t( saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove", ), ); setSavedOverrides((o) => withoutWrite(o, id, seq)); }); }, [qc], ); const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items); // onState's refs follow each commit: itself, and what the cards show (live search results if on). useLayoutEffect(() => { onStateRef.current = onState; loadedRef.current = ytActive ? ytLoaded : loaded; }); const withOverrides = (list: Video[]): Video[] => list .map((v) => { const status = overrides[v.id]; // read once so the narrowing sticks return status ? { ...v, status: status.value } : v; }) .map((v) => { const saved = savedOverrides[v.id]; return saved ? { ...v, saved: saved.value } : v; }); const merged: Video[] = withOverrides(loaded); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // watch-state view filter is NOT applied — so marking the current video watched keeps it in the // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that // goes hidden still drops out ("visible affects"). const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden"); // A live search ingests new catalog videos, so the disabled feed queries still hold their stale // pre-search cache. Drop them so the feed re-fetches fresh on return. const dropFeedCaches = () => { qc.removeQueries({ queryKey: qk.feed() }); qc.removeQueries({ queryKey: qk.feedSummary() }); }; // --- Live YouTube search mode ------------------------------------------------------------- // A separate data source rendered in the same cards/player. No watch-state view filter (the // user explicitly searched, so show everything) and no auto-pagination (each page costs quota). if (ytActive) { const ytItems: Video[] = sortYtResults( withOverrides(ytLoaded), ytSort.key as SortKey, ytSort.dir, lang, ); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytError = ytQuery.error instanceof HttpError ? ytQuery.error.detail || t("feed.yt.error") : ytQuery.isError ? t("feed.yt.error") : null; // The backend reports which source served the search; scrape spends no search quota, so we // drop the quota warning + "uses quota" wording in that mode. const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape"; return (