Files
siftlode/frontend/src/modules/channels/WatchedChannels.tsx
T
peter b5f9cf14db feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
2026-10-24 00:00:50 +02:00

228 lines
7.5 KiB
TypeScript

import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { EyeOff, History, UserPlus } from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { api, type WatchedChannel } from "../../lib/api";
import { formatDate } from "../../lib/format";
import { focusSettingsTab } from "../../lib/settingsTab";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import Tooltip from "../../components/ui/Tooltip";
import DataTable, { type Column } from "../../components/ui/DataTable";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { Button } from "../../components/ui/Button";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { subsColumn } from "./channelColumns";
import { useChannelLayout } from "./channelsContext";
import { useWatchedChannelActions } from "./useWatchedChannelActions";
const WatchedVideosDialog = lazy(() => import("./WatchedVideosDialog"));
// How many channels the list shows at first, and how many more each "Show more" adds. The list is a
// ranking — the most watched first — so a short top is the point: past it, the reader asks for more.
const WATCHED_PAGE = 20;
// The Channel manager's "Discover from your history" tab: the channels the user's Takeout watch
// history shows them watching often but not following, the most watched first. A row's count opens
// the videos it counts, ready to play — a quick look at what the channel was, before following it.
export default function WatchedChannels({
canWrite,
search,
onOpenWizard,
onViewChannel,
}: {
canWrite: boolean;
// Client-side name filter from the shared top SearchBar (matches title + handle).
search: string;
onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const layout = useChannelLayout();
const { setPage } = useNavigationActions();
const [shown, setShown] = useState(WATCHED_PAGE);
const [open, setOpen] = useState<WatchedChannel | null>(null);
const actions = useWatchedChannelActions(onOpenWizard);
const query = useQuery({ queryKey: qk.watchedChannels(), queryFn: api.watchedChannels });
const q = search.trim().toLowerCase();
const rows = (query.data?.channels ?? []).filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
);
const visible = rows.slice(0, shown);
// The order is the ranking itself, so no column sorts: a header sort would only reorder the top
// twenty and hide that it is a top.
const columns: Column<WatchedChannel>[] = [
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
cardLabel: false,
render: (c) => (
<div className="flex items-center gap-1.5">
<Tooltip
always={!canWrite}
hint={
canWrite
? t("channels.discovery.subscribeHint")
: t("channels.discovery.needWriteHint")
}
>
<Button
variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => void actions.onSubscribe(c)}
aria-disabled={!canWrite}
loading={actions.subscribing}
>
{t("channels.discovery.subscribe")}
</Button>
</Tooltip>
<Tooltip hint={t("channels.watched.dismissHint")}>
<Button variant="ghost" size="xs" icon={<EyeOff />} onClick={() => actions.dismiss(c)}>
{t("channels.watched.dismiss")}
</Button>
</Tooltip>
</div>
),
},
{
key: "channel",
header: t("channels.cols.channel"),
cardPrimary: true,
render: (c) => (
<div className="max-w-[18rem]">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onViewChannel(c.id, c.title ?? c.id)}
/>
</div>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{ ...subsColumn<WatchedChannel>(t, lang), sortable: false },
{
key: "watched",
header: t("channels.watched.cols.watched"),
align: "right",
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.watched.cols.watchedHint", { count: c.watched })}>
<Button
variant="link"
size="xs"
className="tabular-nums"
icon={<History />}
onClick={() => setOpen(c)}
>
{c.watched}
</Button>
</Tooltip>
),
},
{
key: "last",
header: t("channels.watched.cols.last"),
align: "right",
nowrap: true,
render: (c) => (
<span className="text-muted tabular-nums">{formatDate(c.last_watched_at, lang)}</span>
),
},
];
const pending = query.data?.pending ?? 0;
const more = Math.min(WATCHED_PAGE, rows.length - visible.length);
const body =
query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState shape="list" label={t("channels.watched.loading")} />
) : !query.data?.imported ? (
// The message and the way to the upload, one line: StateMessage lays its content in a row.
<StateMessage>
{t("channels.watched.notImported")}
<Button
size="sm"
icon={<History />}
onClick={() => {
focusSettingsTab("account");
setPage("settings");
}}
>
{t("channels.watched.openTakeout")}
</Button>
</StateMessage>
) : layout === "table" ? (
<DataTable
rows={visible}
columns={columns}
rowKey={(c) => c.id}
pager={false}
emptyText={t("channels.watched.empty")}
keyboardNav
narrowedBy={q}
/>
) : (
<ChannelLayoutGrid
rows={visible}
columns={columns}
rowKey={(c) => c.id}
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.watched.empty")}
/>
);
return (
<div className="px-4 pb-4 max-w-384 mx-auto">
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.watched.intro")}</p>
{pending > 0 && (
<p className="text-xs text-muted leading-relaxed pt-1" role="status">
{t("channels.watched.pending", { count: pending })}
</p>
)}
{body}
{rows.length > 0 && (
<div className="flex items-center justify-between gap-3 pt-3 text-xs text-muted">
<span className="tabular-nums">
{t("channels.watched.shown", { shown: visible.length, total: rows.length })}
</span>
{more > 0 && (
<Button size="sm" onClick={() => setShown((n) => n + WATCHED_PAGE)}>
{t("channels.watched.more", { count: more })}
</Button>
)}
</div>
)}
{open && (
<Suspense fallback={null}>
<WatchedVideosDialog
channel={open}
canWrite={canWrite}
actions={actions}
onClose={() => setOpen(null)}
/>
</Suspense>
)}
</div>
);
}