2026-06-19 02:16:42 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-19 03:22:10 +02:00
|
|
|
import { UserPlus } from "lucide-react";
|
2026-06-19 02:16:42 +02:00
|
|
|
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
import { accountKey } from "../lib/storage";
|
2026-06-19 02:16:42 +02:00
|
|
|
import { formatViews } from "../lib/format";
|
|
|
|
|
import { notify } from "../lib/notifications";
|
|
|
|
|
import Tooltip from "./Tooltip";
|
2026-06-19 03:22:10 +02:00
|
|
|
import ChannelLink from "./ChannelLink";
|
2026-06-19 02:16:42 +02:00
|
|
|
import DataTable, { type Column } from "./DataTable";
|
2026-06-19 11:18:28 +02:00
|
|
|
import { useConfirm } from "./ConfirmProvider";
|
2026-06-19 02:16:42 +02:00
|
|
|
|
|
|
|
|
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
|
|
|
|
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
|
|
|
|
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
|
|
|
|
|
export default function ChannelDiscovery({
|
|
|
|
|
canWrite,
|
|
|
|
|
onOpenWizard,
|
|
|
|
|
}: {
|
|
|
|
|
canWrite: boolean;
|
|
|
|
|
onOpenWizard: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const qc = useQueryClient();
|
2026-06-19 11:18:28 +02:00
|
|
|
const confirm = useConfirm();
|
2026-06-19 02:16:42 +02:00
|
|
|
|
|
|
|
|
const query = useQuery({
|
|
|
|
|
queryKey: ["discovered-channels"],
|
|
|
|
|
queryFn: api.discoveredChannels,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const subscribe = useMutation({
|
|
|
|
|
mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id),
|
|
|
|
|
onSuccess: (_data, c) => {
|
|
|
|
|
// The channel moves from "discovery" to "subscribed", and its videos will start
|
|
|
|
|
// arriving — refresh both lists plus the per-user status.
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
|
|
|
const name = c.title ?? c.id;
|
|
|
|
|
// Name the channel and ship a typed payload so the inbox can offer "open in the
|
|
|
|
|
// Channel manager" / "open on YouTube" links — kept after reload, when live
|
|
|
|
|
// callbacks are gone (see ChannelSubscribedMeta).
|
|
|
|
|
notify({
|
|
|
|
|
level: "success",
|
|
|
|
|
title: t("channels.discovery.subscribedTitle"),
|
|
|
|
|
message: t("channels.discovery.subscribedBody", { name }),
|
|
|
|
|
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
onError: (err: unknown) => {
|
|
|
|
|
// A 403 means the user hasn't granted the write scope — offer to connect instead of
|
|
|
|
|
// a vague failure (mirrors the subscriptions tab).
|
|
|
|
|
if (err instanceof HttpError && err.status === 403) {
|
|
|
|
|
notify({
|
|
|
|
|
level: "error",
|
|
|
|
|
message: t("channels.notify.needYouTube"),
|
|
|
|
|
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 11:18:28 +02:00
|
|
|
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
|
|
|
|
|
// first (mirrors the unsubscribe guard in the Subscriptions tab).
|
|
|
|
|
const onSubscribe = async (c: DiscoveredChannel) => {
|
|
|
|
|
const ok = await confirm({
|
|
|
|
|
title: t("channels.discovery.subscribe"),
|
|
|
|
|
message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }),
|
|
|
|
|
confirmLabel: t("channels.discovery.subscribe"),
|
|
|
|
|
});
|
|
|
|
|
if (ok) subscribe.mutate(c);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-19 02:16:42 +02:00
|
|
|
const rows = query.data ?? [];
|
|
|
|
|
|
|
|
|
|
const columns: Column<DiscoveredChannel>[] = [
|
|
|
|
|
{
|
|
|
|
|
key: "channel",
|
|
|
|
|
header: t("channels.cols.channel"),
|
|
|
|
|
sortable: true,
|
|
|
|
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
|
|
|
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
|
|
|
|
cardPrimary: true,
|
2026-06-19 03:22:10 +02:00
|
|
|
render: (c) => (
|
|
|
|
|
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
|
|
|
|
|
),
|
2026-06-19 02:16:42 +02:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "subs",
|
|
|
|
|
header: t("channels.cols.subs"),
|
|
|
|
|
align: "right",
|
|
|
|
|
nowrap: true,
|
|
|
|
|
sortable: true,
|
|
|
|
|
sortValue: (c) => c.subscriber_count ?? -1,
|
|
|
|
|
render: (c) => (
|
|
|
|
|
<span className="text-muted tabular-nums">
|
|
|
|
|
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-06-19 11:18:28 +02:00
|
|
|
{
|
|
|
|
|
key: "videos",
|
|
|
|
|
header: t("channels.discovery.cols.totalVideos"),
|
|
|
|
|
align: "right",
|
|
|
|
|
nowrap: true,
|
|
|
|
|
sortable: true,
|
|
|
|
|
sortValue: (c) => c.video_count ?? -1,
|
|
|
|
|
render: (c) => (
|
|
|
|
|
<span className="text-muted tabular-nums">
|
|
|
|
|
{c.video_count != null ? formatViews(c.video_count) : "—"}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-06-19 02:16:42 +02:00
|
|
|
{
|
|
|
|
|
key: "inPlaylists",
|
|
|
|
|
header: t("channels.discovery.cols.inPlaylists"),
|
|
|
|
|
align: "right",
|
|
|
|
|
nowrap: true,
|
|
|
|
|
sortable: true,
|
|
|
|
|
sortValue: (c) => c.playlist_video_count,
|
|
|
|
|
render: (c) => (
|
|
|
|
|
<Tooltip
|
|
|
|
|
hint={t("channels.discovery.cols.inPlaylistsHint", {
|
|
|
|
|
videos: c.playlist_video_count,
|
|
|
|
|
playlists: c.playlist_count,
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-muted tabular-nums cursor-help">
|
|
|
|
|
{c.playlist_video_count} / {c.playlist_count}
|
|
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "actions",
|
|
|
|
|
header: t("channels.cols.actions"),
|
|
|
|
|
align: "right",
|
|
|
|
|
nowrap: true,
|
|
|
|
|
width: "120px",
|
|
|
|
|
cardLabel: false,
|
|
|
|
|
render: (c) => (
|
|
|
|
|
<Tooltip
|
|
|
|
|
hint={
|
|
|
|
|
canWrite
|
|
|
|
|
? t("channels.discovery.subscribeHint")
|
|
|
|
|
: t("channels.discovery.needWriteHint")
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<button
|
2026-06-19 11:18:28 +02:00
|
|
|
onClick={() => onSubscribe(c)}
|
2026-06-19 02:16:42 +02:00
|
|
|
disabled={!canWrite || subscribe.isPending}
|
|
|
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
|
|
|
|
>
|
|
|
|
|
<UserPlus className="w-3.5 h-3.5" />
|
|
|
|
|
{t("channels.discovery.subscribe")}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
|
|
|
|
|
<p className="text-xs text-muted mb-4 leading-relaxed">
|
|
|
|
|
{t("channels.discovery.intro")}
|
|
|
|
|
</p>
|
|
|
|
|
{query.isLoading ? (
|
|
|
|
|
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<DataTable
|
|
|
|
|
rows={rows}
|
|
|
|
|
columns={columns}
|
|
|
|
|
rowKey={(c) => c.id}
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
|
2026-06-19 02:16:42 +02:00
|
|
|
controlsPosition="top"
|
|
|
|
|
emptyText={t("channels.discovery.empty")}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|