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:
@@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import WatchedChannels from "./WatchedChannels";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import {
|
||||
useChannelLayout,
|
||||
@@ -183,6 +184,8 @@ export default function Channels() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// A blocked channel is off the watched-channels list; unblocked, it may be back on it.
|
||||
qc.invalidateQueries({ queryKey: qk.watchedChannels() });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -530,7 +533,7 @@ export default function Channels() {
|
||||
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||
);
|
||||
|
||||
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
||||
// Table / cards density — shared by the subscribed + both discovery tables (blocked is a chip list, no
|
||||
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
||||
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
||||
id,
|
||||
@@ -546,7 +549,7 @@ export default function Channels() {
|
||||
labelClassName="hidden lg:inline"
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
const hasLayout = view === "subscribed" || view === "discovery" || view === "watched";
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
@@ -612,7 +615,7 @@ export default function Channels() {
|
||||
|
||||
const viewTabs: TabDef[] = useMemo(
|
||||
() =>
|
||||
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||
(["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||
id: v,
|
||||
label: t(`channels.tabs.${v}`),
|
||||
})),
|
||||
@@ -778,6 +781,13 @@ export default function Channels() {
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "watched" ? (
|
||||
<WatchedChannels
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
|
||||
@@ -48,7 +48,9 @@ import {
|
||||
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||
|
||||
function clampView(v: string | null): ChannelsView {
|
||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||
return v === "discovery" || v === "watched" || v === "blocked" || v === "hidden"
|
||||
? v
|
||||
: "subscribed";
|
||||
}
|
||||
|
||||
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Channel manager's "Discover from your history" tab: what it says before a history was
|
||||
// uploaded and while the list is still being made, its top twenty and "show more", taking a channel
|
||||
// off the list with an Undo, and a row's count opening the channel's watched videos to play.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
confirm: vi.fn(async () => true),
|
||||
setPage: vi.fn(),
|
||||
focusSettingsTab: vi.fn(),
|
||||
player: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && "count" in o ? `${key}:${String(o.count)}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ setPage: h.setPage }),
|
||||
}));
|
||||
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
|
||||
vi.mock("../../features/player/PlayerModal", () => ({
|
||||
default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => {
|
||||
h.player(
|
||||
p.video.id,
|
||||
p.queue.map((v) => v.id),
|
||||
p.startIndex,
|
||||
);
|
||||
return <div data-testid="player" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import WatchedChannels from "./WatchedChannels";
|
||||
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
for (const fn of Object.values(h)) fn.mockReset();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
const channel = (n: number, over: Partial<WatchedChannel> = {}): WatchedChannel => ({
|
||||
id: `UC${n}`,
|
||||
title: `Channel ${n}`,
|
||||
handle: null,
|
||||
thumbnail_url: null,
|
||||
subscriber_count: 1000,
|
||||
video_count: 50,
|
||||
description: null,
|
||||
watched: 30 - n,
|
||||
last_watched_at: "2026-09-24T10:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
const video = (id: string): Video & { takeout_watched_at: string } => ({
|
||||
id,
|
||||
title: `Video ${id}`,
|
||||
channel_id: "UC1",
|
||||
channel_title: "Channel 1",
|
||||
channel_thumbnail: null,
|
||||
channel_url: "",
|
||||
published_at: null,
|
||||
thumbnail_url: null,
|
||||
duration_seconds: 600,
|
||||
view_count: null,
|
||||
is_short: false,
|
||||
live_status: "none",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
saved: false,
|
||||
watch_url: "",
|
||||
takeout_watched_at: "2026-09-24T10:00:00Z",
|
||||
});
|
||||
|
||||
function mount(list: List) {
|
||||
vi.spyOn(api, "watchedChannels").mockResolvedValue(list);
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<WatchedChannels canWrite search="" onOpenWizard={() => {}} onViewChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
describe("WatchedChannels", () => {
|
||||
it("sends a user with no history uploaded to the upload, not to an empty list", async () => {
|
||||
mount({ imported: false, pending: 0, channels: [] });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.openTakeout" }));
|
||||
expect(h.focusSettingsTab).toHaveBeenCalledWith("account");
|
||||
expect(h.setPage).toHaveBeenCalledWith("settings");
|
||||
expect(screen.queryByText("channels.watched.empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("says when more channels may still join, and when there is nothing to suggest", async () => {
|
||||
mount({ imported: true, pending: 3, channels: [] });
|
||||
expect(await screen.findByText("channels.watched.empty")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toBe("channels.watched.pending:3");
|
||||
});
|
||||
|
||||
it("shows the top twenty, the most watched first, and twenty more on asking", async () => {
|
||||
mount({
|
||||
imported: true,
|
||||
pending: 0,
|
||||
channels: Array.from({ length: 27 }, (_, i) => channel(i)),
|
||||
});
|
||||
await screen.findByText("Channel 0");
|
||||
expect(screen.getByText("Channel 19")).toBeTruthy();
|
||||
expect(screen.queryByText("Channel 20")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.watched.more:7" }));
|
||||
expect(screen.getByText("Channel 26")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /channels\.watched\.more/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("takes a channel off the list with an Undo that puts it back", async () => {
|
||||
const dismiss = vi.spyOn(api, "dismissWatchedChannel").mockResolvedValue({ ok: true });
|
||||
const restore = vi.spyOn(api, "restoreWatchedChannel").mockResolvedValue({ ok: true });
|
||||
mount({ imported: true, pending: 0, channels: [channel(1)] });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" }));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
||||
expect(dismiss).toHaveBeenCalledWith("UC1");
|
||||
const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } };
|
||||
expect(toast.action.label).toBe("common.undo");
|
||||
act(() => toast.action.onClick());
|
||||
await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1"));
|
||||
});
|
||||
|
||||
it("opens a row's watched videos from its count, plays one and plays them all", async () => {
|
||||
const videos = vi
|
||||
.spyOn(api, "watchedChannelVideos")
|
||||
.mockResolvedValue({ ok: true, items: [video("a"), video("b"), video("c")], gone: 2 });
|
||||
mount({ imported: true, pending: 0, channels: [channel(1)] });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "29" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await within(dialog).findByText("Video b");
|
||||
expect(videos).toHaveBeenCalledWith("UC1");
|
||||
expect(dialog.textContent).toContain("channels.watched.videos.gone:2");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "card.play — Video b" }));
|
||||
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("b", ["a", "b", "c"], 1));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "playlists.playAll" }));
|
||||
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0));
|
||||
});
|
||||
|
||||
it("follows a channel from its videos after the confirm, and closes", async () => {
|
||||
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
|
||||
ok: true,
|
||||
items: [video("a")],
|
||||
gone: 0,
|
||||
});
|
||||
const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true });
|
||||
mount({ imported: true, pending: 0, channels: [channel(1)] });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "29" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" }));
|
||||
await waitFor(() => expect(subscribe).toHaveBeenCalledWith("UC1"));
|
||||
expect(h.confirm).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("follows nothing and stays open when the confirm is declined", async () => {
|
||||
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
|
||||
ok: true,
|
||||
items: [video("a")],
|
||||
gone: 0,
|
||||
});
|
||||
const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true });
|
||||
h.confirm.mockImplementation(async () => false);
|
||||
mount({ imported: true, pending: 0, channels: [channel(1)] });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "29" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" }));
|
||||
await waitFor(() => expect(h.confirm).toHaveBeenCalled());
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EyeOff, Play, UserPlus } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
|
||||
import { formatDate, formatDuration } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||
import DataTable, { type Column } from "../../components/ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import type { WatchedChannelActions } from "./useWatchedChannelActions";
|
||||
|
||||
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
||||
|
||||
type WatchedVideo = Video & { takeout_watched_at: string | null };
|
||||
|
||||
// The videos of one channel that the watched-channels list counts, last watched first, each ready to
|
||||
// play — and Play all through them, as on a playlist. Opening it explores the channel for the user
|
||||
// (the server brings in the videos the catalog lacks as the exploration's), so it is asked for once
|
||||
// per opening, not refetched behind the reader's back.
|
||||
export default function WatchedVideosDialog({
|
||||
channel,
|
||||
canWrite,
|
||||
actions,
|
||||
onClose,
|
||||
}: {
|
||||
channel: WatchedChannel;
|
||||
canWrite: boolean;
|
||||
actions: WatchedChannelActions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const name = channel.title ?? channel.id;
|
||||
const key = qk.watchedChannelVideos(channel.id);
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.watchedChannelVideos(channel.id),
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
const items = query.data?.items ?? [];
|
||||
const gone = query.data?.gone ?? 0;
|
||||
const [playing, setPlaying] = useState<number | null>(null);
|
||||
|
||||
// The player's watched / in-progress marks: saved, then shown on this list and the feed's.
|
||||
const onState = useStableCallback((id: string, status: VideoStatus) => {
|
||||
api
|
||||
.setState(id, status)
|
||||
.then(() => {
|
||||
qc.setQueryData(
|
||||
key,
|
||||
(old: typeof query.data) =>
|
||||
old && {
|
||||
...old,
|
||||
items: old.items.map((v) => (v.id === id ? { ...v, status } : v)),
|
||||
},
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
});
|
||||
const closePlayer = useStableCallback(() => setPlaying(null));
|
||||
|
||||
const columns: Column<WatchedVideo>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: t("channels.watched.videos.colTitle"),
|
||||
width: "100%",
|
||||
cardPrimary: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Pressable
|
||||
data-nav-open
|
||||
onClick={() => setPlaying(items.indexOf(v))}
|
||||
aria-label={`${t("card.play")} — ${v.title ?? v.id}`}
|
||||
className="relative w-20 h-11.25 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</Pressable>
|
||||
<span className="min-w-0 line-clamp-2 text-sm">{v.title ?? v.id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "length",
|
||||
header: t("channels.watched.videos.colLength"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted tabular-nums">{formatDuration(v.duration_seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "watched",
|
||||
header: t("channels.watched.videos.colWatched"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted tabular-nums">{formatDate(v.takeout_watched_at, lang)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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.videos.loading")} />
|
||||
) : (
|
||||
<DataTable
|
||||
rows={items}
|
||||
columns={columns}
|
||||
rowKey={(v) => v.id}
|
||||
pager={false}
|
||||
emptyText={t("channels.watched.videos.empty")}
|
||||
keyboardNav
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("channels.watched.videos.title", { name })}
|
||||
initialFocus="close"
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-3xl"
|
||||
headerActions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Play className="fill-current" />}
|
||||
onClick={() => setPlaying(0)}
|
||||
disabled={!items.length}
|
||||
>
|
||||
{t("playlists.playAll")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted pb-2">
|
||||
{t("channels.watched.videos.summary", {
|
||||
count: channel.watched,
|
||||
when: formatDate(channel.last_watched_at, lang),
|
||||
})}
|
||||
{gone > 0 && ` · ${t("channels.watched.videos.gone", { count: gone })}`}
|
||||
</p>
|
||||
{body}
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={<EyeOff />}
|
||||
onClick={() => {
|
||||
actions.dismiss(channel);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("channels.watched.dismiss")}
|
||||
</Button>
|
||||
<Tooltip
|
||||
always={!canWrite}
|
||||
hint={
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
icon={<UserPlus />}
|
||||
aria-disabled={!canWrite}
|
||||
loading={actions.subscribing}
|
||||
onClick={() => {
|
||||
void actions.onSubscribe(channel).then((ok) => ok && onClose());
|
||||
}}
|
||||
>
|
||||
{t("channels.discovery.subscribe")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ModalFooter>
|
||||
{playing != null && items[playing] && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={items[playing]}
|
||||
queue={items}
|
||||
startIndex={playing}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { FilterMap } from "../../lib/tableColumns";
|
||||
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||
|
||||
// The manager's tabs.
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
export type ChannelsView = "subscribed" | "discovery" | "watched" | "hidden" | "blocked";
|
||||
|
||||
export type ChannelsState = {
|
||||
search: string;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { api, type WatchedChannel } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
|
||||
/** Follow and take-off-the-list for a watched channel: the tab's rows and its videos dialog share
|
||||
* them, so the confirm, the toasts and the cache refresh are written once. */
|
||||
export function useWatchedChannelActions(onOpenWizard: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const nameOf = (c: WatchedChannel) => c.title ?? c.id;
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: (c: WatchedChannel) => api.subscribeChannel(c.id),
|
||||
onSuccess: (_data, c) => {
|
||||
invalidateSubscription(qc, c.id);
|
||||
notify({
|
||||
level: "success",
|
||||
title: t("channels.discovery.subscribedTitle"),
|
||||
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }),
|
||||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const restore = useMutation({
|
||||
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
|
||||
meta: { failTitle: "errors.writeFailed.restoreWatchedChannel" },
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.watchedChannels() }),
|
||||
});
|
||||
// Taken off the list at once, with the toast's Undo as the way back (the same bargain as hiding a
|
||||
// channel): the next channel steps up into its place.
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (c: WatchedChannel) => api.dismissWatchedChannel(c.id),
|
||||
meta: { failTitle: "errors.writeFailed.dismissWatchedChannel" },
|
||||
onSuccess: (_data, c) => {
|
||||
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.watched.dismissed", { name: nameOf(c) }),
|
||||
action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Subscribing changes the user's real YouTube account and spends a little quota — confirmed, as
|
||||
// on the Discovery tab. Resolves whether it went ahead.
|
||||
const onSubscribe = async (c: WatchedChannel): Promise<boolean> => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.discovery.subscribe"),
|
||||
message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }),
|
||||
confirmLabel: t("channels.discovery.subscribe"),
|
||||
});
|
||||
if (ok) subscribe.mutate(c);
|
||||
return ok;
|
||||
};
|
||||
return {
|
||||
onSubscribe,
|
||||
subscribing: subscribe.isPending,
|
||||
dismiss: (c: WatchedChannel) => dismiss.mutate(c),
|
||||
};
|
||||
}
|
||||
|
||||
export type WatchedChannelActions = ReturnType<typeof useWatchedChannelActions>;
|
||||
Reference in New Issue
Block a user