import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; import i18n from "../i18n"; 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 "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, canRead, onOpenWizard, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; view: "grid" | "list"; canRead: boolean; onOpenWizard: () => void; }) { const { t } = useTranslation(); 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 }), [] ); 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({}); setSavedOverrides({}); }, [filters]); useEffect(() => { setOverrides({}); setSavedOverrides({}); }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: ["feed-count", filters], queryFn: () => api.feedCount(filters), staleTime: 30_000, }); const sentinel = useRef(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([]); 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 ? 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 ?? "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 ? 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 ?? "this video" }, }); } }, [qc] ); const onChannelFilter = useCallback( (channelId: string, channelName: string) => { setFilters({ ...filters, channelId, channelName }); }, [filters, setFilters] ); const onToggleSave = useCallback( (id: string, saved: boolean) => { setSavedOverrides((o) => ({ ...o, [id]: saved })); (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) .then(() => { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist"] }); }) .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); }, [qc] ); 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)) .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; if (items.length === 0) { // No YouTube connection and looking at their own (empty) subscriptions: offer both // connecting YouTube and just browsing the shared library (no read scope needed). if (!canRead && filters.scope === "my") return (

{t("feed.emptyTitle")}

{t("feed.emptyBody")}

); return
{t("feed.noMatches")}
; } return (
{countQuery.data ? t("feed.videoCount", { count: countQuery.data.count, formattedCount: countQuery.data.count.toLocaleString(), }) : " "}
{view === "grid" ? (
{items.map((v) => ( ))}
) : (
{items.map((v) => ( ))}
)} {activeVideo && ( setActiveVideo(null)} onState={onState} /> )}
{isFetchingNextPage && (
{t("feed.loadingMore")}
)}
); }