From b5f9cf14dbaa9156ff967ff71f78629873d75e8e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 24 Oct 2026 00:00:50 +0200 Subject: [PATCH] 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. --- .../ui/pressableSelectable.contract.test.ts | 1 + frontend/src/i18n/locales/en/channels.json | 34 +++ frontend/src/i18n/locales/en/errors.json | 4 +- frontend/src/i18n/locales/hu/channels.json | 34 +++ frontend/src/i18n/locales/hu/errors.json | 4 +- frontend/src/lib/api/feed.ts | 34 +++ frontend/src/lib/queryKeys.ts | 2 + frontend/src/lib/subscriptionCache.test.ts | 1 + frontend/src/lib/subscriptionCache.ts | 5 +- frontend/src/modules/channels/Channels.tsx | 16 +- .../src/modules/channels/ChannelsProvider.tsx | 4 +- .../channels/WatchedChannels.dom.test.tsx | 195 +++++++++++++++ .../src/modules/channels/WatchedChannels.tsx | 227 ++++++++++++++++++ .../modules/channels/WatchedVideosDialog.tsx | 205 ++++++++++++++++ .../src/modules/channels/channelsContext.ts | 2 +- .../channels/useWatchedChannelActions.ts | 70 ++++++ frontend/src/modules/feed/ChannelPage.tsx | 2 + 17 files changed, 831 insertions(+), 9 deletions(-) create mode 100644 frontend/src/modules/channels/WatchedChannels.dom.test.tsx create mode 100644 frontend/src/modules/channels/WatchedChannels.tsx create mode 100644 frontend/src/modules/channels/WatchedVideosDialog.tsx create mode 100644 frontend/src/modules/channels/useWatchedChannelActions.ts diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index b0ac57fc..5425ae26 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -56,6 +56,7 @@ const PLAIN: Record = { "modules/files/VideoEditor.tsx": [2, "timeline glyphs"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"], "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/plexViews.tsx": [1, "the rating filter (a number)"], "components/ui/ActionMenu.tsx": [1, "a menu row"], diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 3d9b956b..4cc834fa 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Subscriptions", "discovery": "Discover from playlists", + "watched": "Discover from your history", "hidden": "Hidden", "blocked": "Blocked" }, @@ -154,5 +155,38 @@ "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.", "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" + } } } diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index c5d7d4dd..0b1ae16c 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Couldn't create 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", "offline": { diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index f73a6dd6..5ff9c159 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Feliratkozások", "discovery": "Felfedezés a lejátszási listákból", + "watched": "Felfedezés az előzményekből", "hidden": "Elrejtett", "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.", "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" + }, + "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" + } } } diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 39b735b4..b902ced6 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Nem sikerült létrehozni 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", "offline": { diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 41a89376..0e53ed30 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -195,6 +195,33 @@ export interface TakeoutStatus { 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 = { feed: (f: FeedFilters, cursor: string | null, limit: number): Promise => 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. takeoutUpload: (file: File): Promise => uploadReq("/api/takeout", file), takeoutStatus: (): Promise => req("/api/takeout"), + watchedChannels: (): Promise => req("/api/takeout/channels"), + watchedChannelVideos: (id: string): Promise => + 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" }), }; diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 22c2c030..3f9b868a 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -37,6 +37,8 @@ export const qk = { channels: () => ["channels"] as const, channel: (id: string) => ["channel", id] 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, // --- playlists --- diff --git a/frontend/src/lib/subscriptionCache.test.ts b/frontend/src/lib/subscriptionCache.test.ts index ff9f6109..bc5efd25 100644 --- a/frontend/src/lib/subscriptionCache.test.ts +++ b/frontend/src/lib/subscriptionCache.test.ts @@ -20,6 +20,7 @@ describe("a subscription change", () => { qk.feed(), qk.feedSummary(), qk.discoveredChannels(), + qk.watchedChannels(), ]); }); diff --git a/frontend/src/lib/subscriptionCache.ts b/frontend/src/lib/subscriptionCache.ts index 20721000..dff6d221 100644 --- a/frontend/src/lib/subscriptionCache.ts +++ b/frontend/src/lib/subscriptionCache.ts @@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; /** 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 - * channel leaves it). ONE set for every site that makes the change: each used to keep + * counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a + * 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. */ export function invalidateSubscription(qc: QueryClient, channelId: string): void { for (const queryKey of [ @@ -14,6 +14,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void qk.feed(), qk.feedSummary(), qk.discoveredChannels(), + qk.watchedChannels(), ]) void qc.invalidateQueries({ queryKey }); } diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index a1ee78a0..9358d4aa 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -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() { ); - // 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[] = 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" ? ( + ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 0bfc05d1..21581803 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -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 diff --git a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx new file mode 100644 index 00000000..bcaef4a0 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx @@ -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(); + return { + ...actual, + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: Record) => + o && "count" in o ? `${key}:${String(o.count)}` : key, + }), + }; +}); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + 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
; + }, +})); + +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 => ({ + 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( + + {}} onViewChannel={() => {}} /> + , + ); + 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(); + }); +}); diff --git a/frontend/src/modules/channels/WatchedChannels.tsx b/frontend/src/modules/channels/WatchedChannels.tsx new file mode 100644 index 00000000..f8591648 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.tsx @@ -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(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[] = [ + { + key: "actions", + header: t("channels.cols.actions"), + align: "left", + nowrap: true, + cardLabel: false, + render: (c) => ( +
+ + + + + + +
+ ), + }, + { + key: "channel", + header: t("channels.cols.channel"), + cardPrimary: true, + render: (c) => ( +
+ onViewChannel(c.id, c.title ?? c.id)} + /> +
+ ), + }, + { + key: "about", + header: t("channels.cols.about"), + hideInCard: true, + render: (c) => , + }, + { ...subsColumn(t, lang), sortable: false }, + { + key: "watched", + header: t("channels.watched.cols.watched"), + align: "right", + nowrap: true, + render: (c) => ( + + + + ), + }, + { + key: "last", + header: t("channels.watched.cols.last"), + align: "right", + nowrap: true, + render: (c) => ( + {formatDate(c.last_watched_at, lang)} + ), + }, + ]; + + const pending = query.data?.pending ?? 0; + const more = Math.min(WATCHED_PAGE, rows.length - visible.length); + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : !query.data?.imported ? ( + // The message and the way to the upload, one line: StateMessage lays its content in a row. + + {t("channels.watched.notImported")} + + + ) : layout === "table" ? ( + c.id} + pager={false} + emptyText={t("channels.watched.empty")} + keyboardNav + narrowedBy={q} + /> + ) : ( + c.id} + aboutKey="about" + actionsKey="actions" + emptyText={t("channels.watched.empty")} + /> + ); + + return ( +
+

{t("channels.watched.intro")}

+ {pending > 0 && ( +

+ {t("channels.watched.pending", { count: pending })} +

+ )} + {body} + {rows.length > 0 && ( +
+ + {t("channels.watched.shown", { shown: visible.length, total: rows.length })} + + {more > 0 && ( + + )} +
+ )} + {open && ( + + setOpen(null)} + /> + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/WatchedVideosDialog.tsx b/frontend/src/modules/channels/WatchedVideosDialog.tsx new file mode 100644 index 00000000..4f9c435a --- /dev/null +++ b/frontend/src/modules/channels/WatchedVideosDialog.tsx @@ -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(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[] = [ + { + key: "title", + header: t("channels.watched.videos.colTitle"), + width: "100%", + cardPrimary: true, + render: (v) => ( +
+ 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 && ( + + )} + + + + + {v.title ?? v.id} +
+ ), + }, + { + key: "length", + header: t("channels.watched.videos.colLength"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDuration(v.duration_seconds)} + ), + }, + { + key: "watched", + header: t("channels.watched.videos.colWatched"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDate(v.takeout_watched_at, lang)} + ), + }, + ]; + + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : ( + v.id} + pager={false} + emptyText={t("channels.watched.videos.empty")} + keyboardNav + /> + ); + + return ( + } + onClick={() => setPlaying(0)} + disabled={!items.length} + > + {t("playlists.playAll")} + + } + > +

+ {t("channels.watched.videos.summary", { + count: channel.watched, + when: formatDate(channel.last_watched_at, lang), + })} + {gone > 0 && ` · ${t("channels.watched.videos.gone", { count: gone })}`} +

+ {body} + + + + + + + {playing != null && items[playing] && ( + + + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/channelsContext.ts b/frontend/src/modules/channels/channelsContext.ts index 47828f36..aa729c60 100644 --- a/frontend/src/modules/channels/channelsContext.ts +++ b/frontend/src/modules/channels/channelsContext.ts @@ -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; diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts new file mode 100644 index 00000000..8b30bd7e --- /dev/null +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -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 => { + 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; diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index 0551865d..18147aa9 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -157,6 +157,8 @@ export default function ChannelPage({ qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); 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 // reporter names the failure the error dialog does not cover (a 404 for a channel gone).