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.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user