import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ArrowLeft, Columns2, Grid3x3, Info, LayoutGrid, List, RefreshCw, Rows3, Trash2, Youtube, type LucideIcon, } from "lucide-react"; import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api"; import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import { buildSort, DIRECTIONLESS, parseSort, SORT_DEFAULT_DIR, SORT_KEYS, type SortKey, } from "../lib/feedSort"; import { type SortOption } from "../lib/columnSort"; import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; import SortControl from "./SortControl"; import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. const PlayerModal = lazy(() => import("./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 SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; 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 } } export default function Feed({ filters, setFilters, view, setView, canRead, isDemo = false, 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; isDemo?: 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. Search affordances are hidden for demo. 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 } = useTranslation(); // "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); const sortKeys = channelScoped ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") : SORT_KEYS; const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // 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(); const openVideo = useCallback( (video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }), [], ); // Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown/scroll-lock 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), []); 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); // 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") }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [qEmpty]); // 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. useEffect(() => { setOverrides({}); setSavedOverrides({}); }, [filters]); // ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's // rows are unchanged, so clearing overrides there would make a just-hidden card flash back. // Only a real refetch (page count doesn't grow) is authoritative. const pagesLenRef = useRef(0); useEffect(() => { const len = query.data?.pages?.length ?? 0; const appended = len > pagesLenRef.current; pagesLenRef.current = len; if (!appended) { setOverrides({}); setSavedOverrides({}); } }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: qk.feedCount(queryFilters), queryFn: () => api.feedCount(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([]); const onState = useCallback( (id: string, status: VideoStatus) => { setOverrides((o) => ({ ...o, [id]: status })); // 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). api .setState(id, status) .then(() => { qc.invalidateQueries({ queryKey: qk.feed() }); if (status === "hidden") { const v = loadedRef.current.find((x) => x.id === id); notify({ message: v?.title ? i18n.t("feed.hiddenNamed", { title: v.title }) : i18n.t("feed.hidden"), action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, meta: { kind: "video-hidden", videoId: id, title: v?.title ?? i18n.t("feed.thisVideo"), channelId: v?.channel_id ?? "", channelName: v?.channel_title ?? "", }, }); } else if (status === "watched") { const v = loadedRef.current.find((x) => x.id === id); notify({ message: v?.title ? i18n.t("feed.markedWatchedNamed", { title: v.title }) : i18n.t("feed.markedWatched"), action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, meta: { kind: "video-watched", videoId: id, title: v?.title ?? i18n.t("feed.thisVideo"), channelId: v?.channel_id ?? "", channelName: v?.channel_title ?? "", }, }); } else if (status === "new") { // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve // any stale hide/watch notice for this video so it doesn't linger with a dead action. resolveVideo(id); } }) .catch(() => // Server rejected the change — drop the optimistic override so the card // reverts to the real (unchanged) state instead of staying phantom-hidden. setOverrides((o) => { const next = { ...o }; delete next[id]; return next; }), ); }, [qc], ); 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. setOverrides((o) => ({ ...o, [id]: "new" })); api .clearState(id) .then(() => qc.invalidateQueries({ queryKey: qk.feed() })) .catch(() => setOverrides((o) => { const next = { ...o }; delete next[id]; return next; }), ); }, [qc], ); const onToggleSave = useCallback( (id: string, saved: boolean) => { setSavedOverrides((o) => ({ ...o, [id]: saved })); (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) .then(() => { qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlist() }); }) .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); }, [qc], ); const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; const withOverrides = (list: Video[]): Video[] => list .map((v) => { const status = overrides[v.id]; // read once so the narrowing sticks return status ? { ...v, status } : v; }) .map((v) => { const saved = savedOverrides[v.id]; return saved !== undefined ? { ...v, saved } : 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.feedCount() }); qc.removeQueries({ queryKey: qk.facets() }); }; // --- 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 ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = ytLoaded; const ytItems: Video[] = withOverrides(ytLoaded); 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 (
{ytItems.length > 0 && (
{t("feed.yt.ephemeralNote")}
)}
{ytQuery.isLoading ? (
{t("feed.yt.searching")}
) : ytError ? (
{ytError}
) : ytItems.length === 0 ? (
{t("feed.yt.noResults")}
) : ( <> {}} /> {ytQuery.hasNextPage && (
)} )} {activeVideo && ( )}
); } if (query.isLoading) return ; if (query.isError && !query.data) return ( query.refetch()}> {t("feed.loadError")} ); // Empty "my" feed with no YouTube access. The shared demo account can never connect // YouTube, so it gets a gentle nudge into the shared library instead of the connect // wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless). if (items.length === 0 && !canRead && filters.scope === "my") return (

{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}

{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}

{isDemo ? ( ) : ( <> )}
); const { key: sortKey, dir: sortDir } = parseSort(filters.sort); // The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only // ranks meaningfully with a search term (offered then); shuffle/relevance are directionless. const sortOptions: SortOption[] = ( filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys ).map((k) => ({ key: k, label: t("feed.sortKey." + k), directionless: DIRECTIONLESS.includes(k), defaultDir: DIRECTIONLESS.includes(k) ? undefined : SORT_DEFAULT_DIR[k as Exclude], })); const toolbar = (
({ value: id, label: t("sidebar.show." + id), testId: `feed-show-${id}`, }))} value={filters.show} onChange={(show) => setFilters({ ...filters, show })} />
); return (
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you applied" when it's just the page being itself. */}
{toolbar} {!channelScoped && ( setFilters(clearedFilters(filters))} /> )}
{items.length === 0 ? (

{t("feed.noMatches")}

{filters.q.trim() && !isDemo && ( )}
) : ( )} {activeVideo && ( )} {isFetchingNextPage && (
{t("feed.loadingMore")}
)}
); }