E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
934 lines
38 KiB
TypeScript
934 lines
38 KiB
TypeScript
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 { notify, resolveVideo } from "../lib/notifications";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
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 "./ActiveFilterChips";
|
|
import SelectedViewChip from "./SelectedViewChip";
|
|
import { Button } from "./ui/Button";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { PageToolbar } from "./ui/PageShell";
|
|
import { PageMenu } from "./ui/PageMenu";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
|
import { useWizard } from "./wizardContext";
|
|
import SortControl from "./ui/SortControl";
|
|
import FilterSelect from "./ui/FilterSelect";
|
|
import ItemCount from "./ui/ItemCount";
|
|
import { FilterFoldButton } from "./ui/FilterFold";
|
|
import { useFilterFold } from "../lib/useFilterFold";
|
|
import ViewSwitcher from "./ViewSwitcher";
|
|
import VirtualFeed from "./VirtualFeed";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { IconButton } from "./ui/IconButton";
|
|
// 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 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<FeedView, LucideIcon> = {
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
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, 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<Record<string, VideoStatus>>({});
|
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
|
// A reset drops the resume position too, which a status override cannot say: without this the card
|
|
// kept its progress bar and its Continue button until the next refetch.
|
|
const [resetIds, setResetIds] = useState<Record<string, true>>({});
|
|
// 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 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);
|
|
// 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<NonNullable<SortState>>("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({});
|
|
setResetIds({});
|
|
}
|
|
// ...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);
|
|
const pagesLen = query.data?.pages?.length ?? 0;
|
|
useEffect(() => {
|
|
const appended = pagesLen > pagesLenRef.current;
|
|
pagesLenRef.current = pagesLen;
|
|
if (!appended) {
|
|
setOverrides({});
|
|
setSavedOverrides({});
|
|
setResetIds({});
|
|
}
|
|
}, [query.dataUpdatedAt, pagesLen]);
|
|
|
|
// 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<Video[]>([]);
|
|
|
|
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
|
|
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
|
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(() => {
|
|
// 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.
|
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
|
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: () => onStateRef.current(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: () => onStateRef.current(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((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) => {
|
|
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" }));
|
|
setResetIds((r) => ({ ...r, [id]: true }));
|
|
api
|
|
.clearState(id)
|
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
|
.catch((e: unknown) => {
|
|
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
|
setOverrides((o) => {
|
|
const next = { ...o };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
setResetIds((r) => {
|
|
const next = { ...r };
|
|
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((e: unknown) => {
|
|
notifyActionError(
|
|
e,
|
|
i18n.t(
|
|
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
|
),
|
|
);
|
|
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
|
|
});
|
|
},
|
|
[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 } : v;
|
|
})
|
|
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : 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.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 (
|
|
<div className="p-4">
|
|
<div className="pb-3 mb-1 border-b border-border">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
icon={<ArrowLeft />}
|
|
onClick={() => {
|
|
// Surface the just-searched videos in the feed you return to: switch the Source
|
|
// filter to "search" (your own search finds) and rank them by relevance.
|
|
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
|
onExitYtSearch();
|
|
dropFeedCaches();
|
|
}}
|
|
>
|
|
{t("feed.yt.back")}
|
|
</Button>
|
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
|
<YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
|
|
<span className="text-sm font-semibold truncate">
|
|
{t("feed.yt.resultsFor", { query: ytSearch })}
|
|
</span>
|
|
<span className="text-xs text-muted">
|
|
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
|
</span>
|
|
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
|
|
pick, 2026-09-19). The caption is the group's name, the hint its description. */}
|
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
|
<span aria-hidden="true">{t("feed.yt.show")}</span>
|
|
<SegmentedToggle
|
|
size="md"
|
|
ariaLabel={t("feed.yt.show")}
|
|
title={t("feed.yt.showHint")}
|
|
value={String(ytCount)}
|
|
onChange={(v) => setYtCount(Number(v))}
|
|
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
|
|
/>
|
|
</div>
|
|
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
|
|
costs nothing and works the same on both search sources. */}
|
|
<SortControl
|
|
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
|
|
sort={ytSort}
|
|
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
|
|
label={t("feed.sortLabel")}
|
|
ascLabel={t("feed.dirAsc")}
|
|
descLabel={t("feed.dirDesc")}
|
|
/>
|
|
</div>
|
|
{ytItems.length > 0 && (
|
|
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
|
<Info className="w-3.5 h-3.5 shrink-0" />
|
|
<span>{t("feed.yt.ephemeralNote")}</span>
|
|
<Button
|
|
variant="outline-danger"
|
|
size="xs"
|
|
icon={<Trash2 />}
|
|
onClick={() => {
|
|
const ids = ytItems.map((v) => v.id);
|
|
api
|
|
.clearSearch(ids)
|
|
.then((r) =>
|
|
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
|
)
|
|
.catch((e: unknown) =>
|
|
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
|
);
|
|
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
|
onExitYtSearch();
|
|
dropFeedCaches();
|
|
qc.removeQueries({ queryKey: qk.ytSearch() });
|
|
}}
|
|
>
|
|
{t("feed.yt.clearNow")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{ytQuery.isLoading ? (
|
|
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
|
) : ytError ? (
|
|
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
|
|
) : ytItems.length === 0 ? (
|
|
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
|
|
) : (
|
|
<>
|
|
<VirtualFeed
|
|
items={ytItems}
|
|
view={view}
|
|
onState={onState}
|
|
onToggleSave={onToggleSave}
|
|
onResetState={onResetState}
|
|
onOpen={openVideo}
|
|
hasNextPage={false}
|
|
isFetching={false}
|
|
fetchNextPage={() => {}}
|
|
/>
|
|
{ytQuery.hasNextPage && (
|
|
<div className="text-center pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() =>
|
|
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
|
|
}
|
|
loading={ytQuery.isFetchingNextPage}
|
|
>
|
|
{ytQuery.isFetchingNextPage
|
|
? t("feed.loadingMore")
|
|
: zeroQuota
|
|
? t("feed.yt.loadMoreFree")
|
|
: t("feed.yt.loadMore")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeVideo && (
|
|
<Suspense fallback={null}>
|
|
<PlayerModal
|
|
video={activeVideo.video}
|
|
startAt={activeVideo.startAt}
|
|
queue={ytPlayerQueue}
|
|
onClose={closePlayer}
|
|
onState={onState}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
|
|
if (query.isError && !query.data)
|
|
return (
|
|
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
|
{t("feed.loadError")}
|
|
</StateMessage>
|
|
);
|
|
// 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 (
|
|
<div className="p-8 grid place-items-center text-center">
|
|
<div className="max-w-sm">
|
|
<h2 className="text-lg font-semibold">
|
|
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
|
</h2>
|
|
<p className="text-sm text-muted mt-2 mb-4">
|
|
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
|
</p>
|
|
{isDemo ? (
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
>
|
|
{t("feed.browseLibrary")}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
|
{t("feed.setUp")}
|
|
</Button>
|
|
<Button
|
|
variant="link-muted"
|
|
className="mx-auto mt-3 flex text-sm"
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
>
|
|
{t("feed.browseShared")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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[] = feedSortOptions(
|
|
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys,
|
|
(k) => t("feed.sortKey." + k),
|
|
);
|
|
|
|
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
|
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
|
// same controls render on both the feed and channel-scoped rows.
|
|
// One setter per toolbar control, shared with the page's background menu (the same choices, the
|
|
// same effect, two doors).
|
|
const setSort = (next: SortState) => {
|
|
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
|
setFilters({
|
|
...filters,
|
|
sort: buildSort(next.key as SortKey, next.dir),
|
|
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
|
});
|
|
};
|
|
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
|
|
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
|
|
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
|
|
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
|
|
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
|
|
const setContent = (next: string[]) => {
|
|
const patch = { ...filters };
|
|
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
|
setFilters(patch);
|
|
};
|
|
const sourceOptions = [
|
|
{ value: "organic", label: t("feed.source.organic") },
|
|
{ value: "all", label: t("feed.source.all") },
|
|
{ value: "search", label: t("feed.source.only") },
|
|
];
|
|
const setSource = (source: string) =>
|
|
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
|
|
const viewOptions = FEED_VIEWS.map((id) => ({
|
|
id,
|
|
label: t("feed.view." + id),
|
|
icon: VIEW_ICON[id],
|
|
}));
|
|
|
|
const sortView = (
|
|
<>
|
|
<SortControl
|
|
options={sortOptions}
|
|
sort={{ key: sortKey, dir: sortDir }}
|
|
onChange={setSort}
|
|
label={t("feed.sortLabel")}
|
|
ascLabel={t("feed.dirAsc")}
|
|
descLabel={t("feed.dirDesc")}
|
|
trailing={
|
|
sortKey === "shuffle" ? (
|
|
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
|
|
<RefreshCw />
|
|
</IconButton>
|
|
) : undefined
|
|
}
|
|
/>
|
|
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
|
<ViewSwitcher
|
|
value={view}
|
|
options={viewOptions}
|
|
onChange={setView}
|
|
label={t("feed.viewLabel")}
|
|
// The mode's name only earns its place once the window is wide.
|
|
labelClassName="hidden xl:inline"
|
|
/>
|
|
</>
|
|
);
|
|
|
|
const toolbar = (
|
|
<div className="pb-1.5">
|
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
|
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
|
never shoves the controls beside it (see ItemCount). */}
|
|
<ItemCount
|
|
count={summaryQuery.data?.count}
|
|
stale={summaryQuery.isPlaceholderData}
|
|
failed={summaryQuery.isError && !summaryQuery.data}
|
|
/>
|
|
<FilterFoldButton
|
|
fold={fold}
|
|
label={t("sidebar.filters")}
|
|
count={channelScoped ? 0 : activeFilters.length}
|
|
/>
|
|
{!fold.folded && (
|
|
<>
|
|
{/* Watch state is single-select (always exactly one); content types are an
|
|
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
|
<FilterSelect
|
|
label={t("feed.showLabel")}
|
|
testId="feed-show"
|
|
options={showOptions}
|
|
selected={[filters.show]}
|
|
onChange={(next) => next[0] && setShow(next[0])}
|
|
/>
|
|
<FilterSelect
|
|
label={t("feed.contentLabel")}
|
|
testId="feed-content"
|
|
multiple
|
|
options={contentOptions}
|
|
selected={contentSelected}
|
|
onChange={setContent}
|
|
/>
|
|
{/* The Source filter (organic / include-search / search-only) is a global-catalog
|
|
concept — it selects how videos ENTERED the library. On a channel-scoped view we
|
|
already show all of this one channel's videos (librarySource is pinned to "all"), so
|
|
the selector would only offer a near-empty, confusing slice — hide it here. */}
|
|
{!channelScoped && (
|
|
<FilterSelect
|
|
label={t("feed.source.label")}
|
|
testId="feed-source"
|
|
options={sourceOptions}
|
|
selected={[filters.librarySource ?? "organic"]}
|
|
onChange={(next) => next[0] && setSource(next[0])}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
{/* Folded, the band is one row: Sort + View join it on the right. */}
|
|
{fold.folded && (
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
|
const pageMenuItems = menuGroups(
|
|
[
|
|
sortSubmenu({
|
|
id: "sort",
|
|
label: t("feed.sortLabel"),
|
|
options: sortOptions,
|
|
sort: { key: sortKey, dir: sortDir },
|
|
onChange: setSort,
|
|
ascLabel: t("feed.dirAsc"),
|
|
descLabel: t("feed.dirDesc"),
|
|
}),
|
|
...(sortKey === "shuffle"
|
|
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
|
|
: []),
|
|
choiceSubmenu({
|
|
id: "view",
|
|
label: t("feed.viewLabel"),
|
|
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
|
|
selected: view,
|
|
onPick: setView,
|
|
}),
|
|
],
|
|
[
|
|
choiceSubmenu({
|
|
id: "show",
|
|
label: t("feed.showLabel"),
|
|
options: showOptions,
|
|
selected: filters.show,
|
|
onPick: setShow,
|
|
}),
|
|
multiSubmenu({
|
|
id: "content",
|
|
label: t("feed.contentLabel"),
|
|
options: contentOptions,
|
|
selected: contentSelected,
|
|
onChange: setContent,
|
|
}),
|
|
...(channelScoped
|
|
? []
|
|
: [
|
|
choiceSubmenu({
|
|
id: "source",
|
|
label: t("feed.source.label"),
|
|
options: sourceOptions,
|
|
selected: filters.librarySource ?? "organic",
|
|
onPick: setSource,
|
|
}),
|
|
]),
|
|
],
|
|
!channelScoped && activeFilters.length > 0
|
|
? [
|
|
{
|
|
id: "clear",
|
|
label: t("sidebar.clearAll"),
|
|
onSelect: () => setFilters(clearedFilters(filters)),
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
|
|
return (
|
|
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
|
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
|
{/* 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, the watch state the channel page remembers), so
|
|
each would read as a "filter you applied" when it's just the page being itself. */}
|
|
<PageToolbar>
|
|
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
|
{toolbar}
|
|
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
|
|
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
|
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
|
applied"), but still get the right-pinned Sort/View. */}
|
|
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
|
|
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
|
|
{!fold.folded && (
|
|
<div className="flex flex-wrap items-center gap-2 pb-2">
|
|
{!channelScoped && (
|
|
<>
|
|
{/* The active personal view (if any) leads the applied-filter row. */}
|
|
<SelectedViewChip />
|
|
<ActiveFilterChips
|
|
filters={activeFilters}
|
|
onClearAll={() => setFilters(clearedFilters(filters))}
|
|
className="flex-1"
|
|
/>
|
|
</>
|
|
)}
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageToolbar>
|
|
{items.length === 0 ? (
|
|
<div className="py-16 text-center text-muted">
|
|
<p>{t("feed.noMatches")}</p>
|
|
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
|
|
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
|
|
Library. Point there rather than leaving a bare "no matches". */}
|
|
{filters.channelIds.length > 0 && filters.scope === "my" && (
|
|
<div className="mt-4">
|
|
<p className="text-sm">{t("feed.channelScopeHint")}</p>
|
|
<Button
|
|
className="mt-3"
|
|
icon={<Library />}
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
>
|
|
{t("feed.viewInLibrary")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{filters.q.trim() && !isDemo && (
|
|
<Button
|
|
className="mt-4"
|
|
icon={<YouTubeIcon />}
|
|
onClick={() => onYtSearch(filters.q.trim())}
|
|
>
|
|
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<VirtualFeed
|
|
items={items}
|
|
view={view}
|
|
onState={onState}
|
|
onToggleSave={onToggleSave}
|
|
onResetState={onResetState}
|
|
onOpen={openVideo}
|
|
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
|
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
|
|
// below says so and retries on a click.
|
|
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
|
|
isFetching={query.isFetching}
|
|
fetchNextPage={fetchNextPage}
|
|
/>
|
|
)}
|
|
{activeVideo && (
|
|
<Suspense fallback={null}>
|
|
<PlayerModal
|
|
video={activeVideo.video}
|
|
startAt={activeVideo.startAt}
|
|
queue={playerQueue}
|
|
onClose={closePlayer}
|
|
onState={onState}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
{isFetchingNextPage && (
|
|
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
|
)}
|
|
{query.isFetchNextPageError && !isFetchingNextPage && (
|
|
<StateMessage
|
|
tone="error"
|
|
compact
|
|
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
|
>
|
|
{t("common.loadMoreFailed")}
|
|
</StateMessage>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|