Three low-risk wins for large filtered feeds: - content-visibility:auto on cards (.cv-card/.cv-row) so the browser skips layout/paint for off-screen cards; contain-intrinsic-size keeps the scrollbar stable and is remembered per card after first render. - memo(VideoCard) + stable onState/onChannelFilter callbacks (onState reads the loaded list via a ref) so appending a page only renders the ~60 new cards instead of reconciling every card already on screen. - Prefetch the next page earlier (sentinel rootMargin 800px → 1500px) so the 'Loading more…' flash is far less likely during fast scrolling.
178 lines
6 KiB
TypeScript
178 lines
6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||
import { notify } from "../lib/notifications";
|
||
import VideoCard from "./VideoCard";
|
||
import PlayerModal from "./PlayerModal";
|
||
|
||
const PAGE = 60;
|
||
|
||
function matchesView(status: string, show: string): boolean {
|
||
switch (show) {
|
||
case "hidden":
|
||
return status === "hidden";
|
||
case "watched":
|
||
return status === "watched";
|
||
case "saved":
|
||
return status === "saved";
|
||
case "unwatched":
|
||
return status !== "watched" && status !== "hidden";
|
||
default:
|
||
return status !== "hidden"; // all
|
||
}
|
||
}
|
||
|
||
export default function Feed({
|
||
filters,
|
||
setFilters,
|
||
view,
|
||
}: {
|
||
filters: FeedFilters;
|
||
setFilters: (f: FeedFilters) => void;
|
||
view: "grid" | "list";
|
||
}) {
|
||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||
const qc = useQueryClient();
|
||
|
||
const query = useInfiniteQuery({
|
||
queryKey: ["feed", filters],
|
||
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
|
||
initialPageParam: 0,
|
||
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
|
||
});
|
||
|
||
// 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({}), [filters]);
|
||
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
|
||
|
||
const countQuery = useQuery({
|
||
queryKey: ["feed-count", filters],
|
||
queryFn: () => api.feedCount(filters),
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||
useEffect(() => {
|
||
const el = sentinel.current;
|
||
if (!el) return;
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||
fetchNextPage();
|
||
}
|
||
},
|
||
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
|
||
);
|
||
io.observe(el);
|
||
return () => io.disconnect();
|
||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||
|
||
// 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[]>([]);
|
||
|
||
const onState = useCallback(
|
||
(id: string, status: string) => {
|
||
setOverrides((o) => ({ ...o, [id]: status }));
|
||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||
api
|
||
.setState(id, status)
|
||
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
||
.catch(() => {});
|
||
if (status === "hidden") {
|
||
const v = loadedRef.current.find((x) => x.id === id);
|
||
notify({
|
||
message: v?.title ? `Hidden “${v.title}”` : "Video hidden",
|
||
action: { label: "Undo", onClick: () => onState(id, "new") },
|
||
meta: {
|
||
kind: "video-hidden",
|
||
videoId: id,
|
||
title: v?.title ?? "this video",
|
||
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 ? `Marked watched “${v.title}”` : "Marked watched",
|
||
action: { label: "Unwatch", onClick: () => onState(id, "new") },
|
||
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
|
||
});
|
||
}
|
||
},
|
||
[qc]
|
||
);
|
||
|
||
const onChannelFilter = useCallback(
|
||
(channelId: string, channelName: string) => {
|
||
setFilters({ ...filters, channelId, channelName });
|
||
},
|
||
[filters, setFilters]
|
||
);
|
||
|
||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||
loadedRef.current = loaded;
|
||
const items: Video[] = loaded
|
||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||
.filter((v) => matchesView(v.status, filters.show));
|
||
|
||
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||
if (items.length === 0)
|
||
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
||
|
||
return (
|
||
<div className="p-4">
|
||
<div className="pb-3 text-sm text-muted">
|
||
{countQuery.data
|
||
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
|
||
: " "}
|
||
</div>
|
||
{view === "grid" ? (
|
||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||
{items.map((v) => (
|
||
<VideoCard
|
||
key={v.id}
|
||
video={v}
|
||
view="grid"
|
||
onState={onState}
|
||
onChannelFilter={onChannelFilter}
|
||
onOpen={setActiveVideo}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="max-w-4xl mx-auto flex flex-col gap-1">
|
||
{items.map((v) => (
|
||
<VideoCard
|
||
key={v.id}
|
||
video={v}
|
||
view="list"
|
||
onState={onState}
|
||
onChannelFilter={onChannelFilter}
|
||
onOpen={setActiveVideo}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{activeVideo && (
|
||
<PlayerModal
|
||
video={activeVideo}
|
||
onClose={() => setActiveVideo(null)}
|
||
onState={onState}
|
||
/>
|
||
)}
|
||
<div ref={sentinel} className="h-10" />
|
||
{isFetchingNextPage && (
|
||
<div className="text-center text-muted py-4">Loading more…</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|