The feed kept per-video optimistic status overrides until filters changed, so a status reverted elsewhere (e.g. Unwatch from the notification center) was masked by the stale override and stayed filtered out of the current view. Also clear overrides when fresh feed data arrives — the server is then authoritative.
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import { 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: "800px" }
|
||
);
|
||
io.observe(el);
|
||
return () => io.disconnect();
|
||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||
|
||
function onState(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 = (query.data?.pages ?? []).flatMap((p) => p.items).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 = (query.data?.pages ?? []).flatMap((p) => p.items).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" },
|
||
});
|
||
}
|
||
}
|
||
|
||
function onChannelFilter(channelId: string, channelName: string) {
|
||
setFilters({ ...filters, channelId, channelName });
|
||
}
|
||
|
||
const items: Video[] = (query.data?.pages ?? [])
|
||
.flatMap((p) => p.items)
|
||
.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>
|
||
);
|
||
}
|