Merge: smoother long-feed scrolling
This commit is contained in:
commit
9163a5f68e
3 changed files with 70 additions and 39 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
|
@ -66,48 +66,60 @@ export default function Feed({
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "800px" }
|
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
|
||||||
);
|
);
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
function onState(id: string, status: string) {
|
// Keep the loaded videos in a ref so onState can stay referentially stable
|
||||||
setOverrides((o) => ({ ...o, [id]: status }));
|
// (it needs the list only to look up a title for the notification). A stable
|
||||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
|
||||||
api
|
const loadedRef = useRef<Video[]>([]);
|
||||||
.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) {
|
const onState = useCallback(
|
||||||
setFilters({ ...filters, channelId, channelName });
|
(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 items: Video[] = (query.data?.pages ?? [])
|
const onChannelFilter = useCallback(
|
||||||
.flatMap((p) => p.items)
|
(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))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
.filter((v) => matchesView(v.status, filters.show));
|
.filter((v) => matchesView(v.status, filters.show));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from "react";
|
||||||
import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react";
|
import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
@ -136,7 +137,7 @@ function Thumb({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoCard({
|
function VideoCard({
|
||||||
video,
|
video,
|
||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
|
|
@ -172,7 +173,7 @@ export default function VideoCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -198,7 +199,7 @@ export default function VideoCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
|
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -227,3 +228,8 @@ export default function VideoCard({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoized so appending a feed page only renders the new cards, not every existing
|
||||||
|
// one (the callbacks from Feed are stable; non-overridden video objects keep their
|
||||||
|
// identity across renders).
|
||||||
|
export default memo(VideoCard);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,19 @@ html[data-perf="1"] body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while
|
||||||
|
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
|
||||||
|
stays stable; `auto` lets the browser remember each card's real size after it
|
||||||
|
has rendered once. */
|
||||||
|
.cv-card {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 320px;
|
||||||
|
}
|
||||||
|
.cv-row {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 96px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Motion ===== */
|
/* ===== Motion ===== */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue