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
@@ -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>;