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:
2026-10-24 00:00:50 +02:00
parent bd2340c204
commit b5f9cf14db
17 changed files with 831 additions and 9 deletions
@@ -56,6 +56,7 @@ const PLAIN: Record<string, [number, string]> = {
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"], "modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"], "modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
"modules/channels/WatchedVideosDialog.tsx": [1, "the thumbnail (its title sits beside it)"],
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"], "modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"], "modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
"components/ui/ActionMenu.tsx": [1, "a menu row"], "components/ui/ActionMenu.tsx": [1, "a menu row"],
@@ -7,6 +7,7 @@
"tabs": { "tabs": {
"subscribed": "Subscriptions", "subscribed": "Subscriptions",
"discovery": "Discover from playlists", "discovery": "Discover from playlists",
"watched": "Discover from your history",
"hidden": "Hidden", "hidden": "Hidden",
"blocked": "Blocked" "blocked": "Blocked"
}, },
@@ -154,5 +155,38 @@
"hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.", "hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.",
"empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal Feed without unsubscribing.", "empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal Feed without unsubscribing.",
"unhide": "Unhide" "unhide": "Unhide"
},
"watched": {
"intro": "Channels you often watched on YouTube but don't follow, from the watch history you uploaded. Only videos of five minutes or more count, so Shorts stay out. Follow a channel to get its new uploads in your Feed, or take it off the list.",
"loading": "Finding channels…",
"empty": "No suggestions — you follow every channel you watched five or more longer videos from.",
"notImported": "Upload your YouTube watch history to see the channels you watch but don't follow.",
"openTakeout": "Open YouTube history",
"pending_one": "{{count}} more channel is still being looked at — the list may grow.",
"pending_other": "{{count}} more channels are still being looked at — the list may grow.",
"more_one": "Show {{count}} more",
"more_other": "Show {{count}} more",
"shown": "{{shown}} of {{total}}",
"dismiss": "Not interested",
"dismissHint": "Take this channel off the list. You can undo it right after.",
"dismissed": "{{name}} is off the list",
"cols": {
"watched": "Watched",
"watchedHint_one": "Show the {{count}} video of five minutes or more you watched from this channel",
"watchedHint_other": "Show the {{count}} videos of five minutes or more you watched from this channel",
"last": "Last watched"
},
"videos": {
"title": "Videos you watched from {{name}}",
"summary_one": "{{count}} video of five minutes or more · last watched {{when}}",
"summary_other": "{{count}} videos of five minutes or more · last watched {{when}}",
"loading": "Getting the videos ready…",
"gone_one": "{{count}} video is no longer on YouTube",
"gone_other": "{{count}} videos are no longer on YouTube",
"empty": "None of these videos is on YouTube any more.",
"colTitle": "Video",
"colLength": "Length",
"colWatched": "Watched"
}
} }
} }
+3 -1
View File
@@ -115,7 +115,9 @@
"apiToken": { "apiToken": {
"create": "Couldn't create the token", "create": "Couldn't create the token",
"revoke": "Couldn't revoke the token" "revoke": "Couldn't revoke the token"
} },
"dismissWatchedChannel": "Couldn't take the channel off the list",
"restoreWatchedChannel": "Couldn't put the channel back on the list"
}, },
"ok": "OK", "ok": "OK",
"offline": { "offline": {
@@ -7,6 +7,7 @@
"tabs": { "tabs": {
"subscribed": "Feliratkozások", "subscribed": "Feliratkozások",
"discovery": "Felfedezés a lejátszási listákból", "discovery": "Felfedezés a lejátszási listákból",
"watched": "Felfedezés az előzményekből",
"hidden": "Elrejtett", "hidden": "Elrejtett",
"blocked": "Letiltott" "blocked": "Letiltott"
}, },
@@ -154,5 +155,38 @@
"hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Könyvtárban továbbra is megjelennek.", "hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Könyvtárban továbbra is megjelennek.",
"empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.", "empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.",
"unhide": "Felfedés" "unhide": "Felfedés"
},
"watched": {
"intro": "Csatornák, amelyeket YouTube-on gyakran néztél, de nem követsz — a feltöltött nézési előzményeid alapján. Csak az öt percnél hosszabb videók számítanak, így a Shorts kimarad. Kövesd a csatornát, és az új videói megjelennek a hírfolyamodban, vagy vedd le a listáról.",
"loading": "Csatornák keresése…",
"empty": "Nincs javaslat — minden csatornát követsz, amelyről legalább öt hosszabb videót néztél.",
"notImported": "Töltsd fel a YouTube nézési előzményeidet, és itt látod a csatornákat, amelyeket nézel, de nem követsz.",
"openTakeout": "YouTube-előzmények megnyitása",
"pending_one": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.",
"pending_other": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.",
"more_one": "További {{count}}",
"more_other": "További {{count}}",
"shown": "{{shown}} / {{total}}",
"dismiss": "Nem érdekel",
"dismissHint": "A csatorna lekerül a listáról. Utána rögtön visszavonhatod.",
"dismissed": "{{name}} lekerült a listáról",
"cols": {
"watched": "Nézett",
"watchedHint_one": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása",
"watchedHint_other": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása",
"last": "Utoljára nézve"
},
"videos": {
"title": "{{name}}: a videók, amelyeket láttál",
"summary_one": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}",
"summary_other": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}",
"loading": "A videók előkészítése…",
"gone_one": "{{count}} videó már nincs fent a YouTube-on",
"gone_other": "{{count}} videó már nincs fent a YouTube-on",
"empty": "Ezek közül egyik videó sincs már fent a YouTube-on.",
"colTitle": "Videó",
"colLength": "Hossz",
"colWatched": "Nézve"
}
} }
} }
+3 -1
View File
@@ -115,7 +115,9 @@
"apiToken": { "apiToken": {
"create": "Nem sikerült létrehozni a tokent", "create": "Nem sikerült létrehozni a tokent",
"revoke": "Nem sikerült visszavonni a tokent" "revoke": "Nem sikerült visszavonni a tokent"
} },
"dismissWatchedChannel": "Nem sikerült levenni a csatornát a listáról",
"restoreWatchedChannel": "Nem sikerült visszatenni a csatornát a listára"
}, },
"ok": "OK", "ok": "OK",
"offline": { "offline": {
+34
View File
@@ -195,6 +195,33 @@ export interface TakeoutStatus {
watch_later?: TakeoutCounts; watch_later?: TakeoutCounts;
} }
// A channel the user's Takeout history shows them watching often but not following
// (`GET /api/takeout/channels`): `watched` counts its videos of five minutes or more they watched.
export interface WatchedChannel {
id: string;
title: string | null;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
description: string | null;
watched: number;
last_watched_at: string | null;
}
export interface WatchedChannels {
/** Whether a watch history was uploaded at all ("nothing to suggest" vs "nothing to go on"). */
imported: boolean;
/** How many more channels may join once the import has asked YouTube for their videos' lengths. */
pending: number;
channels: WatchedChannel[];
}
/** A channel's counted videos, ready to play, last watched first; `gone`: those YouTube lost. */
export interface WatchedChannelVideos {
ok: true;
items: (Video & { takeout_watched_at: string | null })[];
gone: number;
}
export const feedApi = { export const feedApi = {
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> => feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, cursor, limit)}`), req(`/api/feed?${feedQuery(f, cursor, limit)}`),
@@ -260,4 +287,11 @@ export const feedApi = {
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart. // One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file), takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"), takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
watchedChannels: (): Promise<WatchedChannels> => req("/api/takeout/channels"),
watchedChannelVideos: (id: string): Promise<WatchedChannelVideos> =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/videos`, { method: "POST" }),
dismissWatchedChannel: (id: string) =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "PUT" }),
restoreWatchedChannel: (id: string) =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "DELETE" }),
}; };
+2
View File
@@ -37,6 +37,8 @@ export const qk = {
channels: () => ["channels"] as const, channels: () => ["channels"] as const,
channel: (id: string) => ["channel", id] as const, channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const, discoveredChannels: () => ["discovered-channels"] as const,
watchedChannels: () => ["watched-channels"] as const,
watchedChannelVideos: (id: string) => ["watched-channels", "videos", id] as const,
blockedChannels: () => ["blocked-channels"] as const, blockedChannels: () => ["blocked-channels"] as const,
// --- playlists --- // --- playlists ---
@@ -20,6 +20,7 @@ describe("a subscription change", () => {
qk.feed(), qk.feed(),
qk.feedSummary(), qk.feedSummary(),
qk.discoveredChannels(), qk.discoveredChannels(),
qk.watchedChannels(),
]); ]);
}); });
+3 -2
View File
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its /** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
* counts, the feed and its facets (the channel's videos come or go), and discovery (a subscribed * counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
* channel leaves it). ONE set for every site that makes the change: each used to keep * subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
* its own list, and each list missed something. */ * its own list, and each list missed something. */
export function invalidateSubscription(qc: QueryClient, channelId: string): void { export function invalidateSubscription(qc: QueryClient, channelId: string): void {
for (const queryKey of [ for (const queryKey of [
@@ -14,6 +14,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
qk.feed(), qk.feed(),
qk.feedSummary(), qk.feedSummary(),
qk.discoveredChannels(), qk.discoveredChannels(),
qk.watchedChannels(),
]) ])
void qc.invalidateQueries({ queryKey }); void qc.invalidateQueries({ queryKey });
} }
+13 -3
View File
@@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions"; import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
import WatchedChannels from "./WatchedChannels";
import TagManager from "../../features/channel/TagManager"; import TagManager from "../../features/channel/TagManager";
import { import {
useChannelLayout, useChannelLayout,
@@ -183,6 +184,8 @@ export default function Channels() {
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() }); 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} /> <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. // layout to switch). Rides the same generic ViewSwitcher the feed uses.
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({ const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
id, id,
@@ -546,7 +549,7 @@ export default function Channels() {
labelClassName="hidden lg:inline" 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 // 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. // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = ( const sortControl = (
@@ -612,7 +615,7 @@ export default function Channels() {
const viewTabs: TabDef[] = useMemo( const viewTabs: TabDef[] = useMemo(
() => () =>
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ (["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
id: v, id: v,
label: t(`channels.tabs.${v}`), label: t(`channels.tabs.${v}`),
})), })),
@@ -778,6 +781,13 @@ export default function Channels() {
onOpenWizard={onOpenWizard} onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel} onViewChannel={onViewChannel}
/> />
) : view === "watched" ? (
<WatchedChannels
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? ( ) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ /* 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"> <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. // The contexts, their value types and the hooks live in channelsContext.ts.
function clampView(v: string | null): ChannelsView { 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 // 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. // components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
// The manager's tabs. // The manager's tabs.
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; export type ChannelsView = "subscribed" | "discovery" | "watched" | "hidden" | "blocked";
export type ChannelsState = { export type ChannelsState = {
search: string; 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>;
@@ -157,6 +157,8 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
// A blocked channel is off the watched-channels list; unblocked, it may be back on it.
qc.invalidateQueries({ queryKey: qk.watchedChannels() });
}, },
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide // No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone). // reporter names the failure the error dialog does not cover (a 404 for a channel gone).