import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, Ban, Check, Eye, EyeOff, History, LayoutGrid, Pencil, Plus, RefreshCw, Table, UserMinus, X, } from "lucide-react"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import Overlay from "./Overlay"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./PageShell"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider"; import { useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useWizard } from "./WizardProvider"; import { useMe } from "../lib/useMe"; export type ChannelsView = "subscribed" | "discovery" | "blocked"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, { id: "hidden", labelKey: "channels.filters.hidden" }, ]; const LAYOUT_ICON: Record = { table: Table, cards: LayoutGrid, }; export default function Channels() { const me = useMe(); const canWrite = me.can_write; const isAdmin = me.role === "admin"; // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). const { openWizard: onOpenWizard } = useWizard(); // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live // in ChannelsProvider (shared with the header search box + the cross-module "focus this channel" // callers); this page is their primary editor. const { search, statusFilter, view, sort, filtersResetToken, focusName: focusChannelName, focusToken: focusChannelToken, } = useChannels(); const layout = useChannelLayout(); const { setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel: onFocusChannel, } = useChannelsActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App // callback) now that Channels reads the feed filters + navigation straight from their providers. const onFilterByTag = (tagId: number, name: string) => { setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); setPage("feed"); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); }; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels }); const [tagManagerOpen, setTagManagerOpen] = useState(false); const unblock = useMutation({ mutationFn: (id: string) => api.unblockChannel(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: ["feed"] }); }, }); const invalidate = () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); // FB2: syncing subscriptions can change the feed immediately — newly-followed channels may // already have videos in the shared catalog, so refresh the feed too (not just the manager). qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); }; const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); const patch = useMutation({ mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean; deep_requested?: boolean }; }) => api.updateChannel(v.id, v.body), // Requesting full history may have just pulled in a channel's recent uploads, so // refresh the per-user status and feed too — not only the channel list. onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); }, }); // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // doesn't jump (the server list is priority-sorted; refetching would reorder rows and // lose your scroll position). The new order takes effect next time the page loads. const bumpPriority = (id: string, current: number, delta: number) => { const next = current + delta; qc.setQueryData(["channels"], (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), ); api .updateChannel(id, { priority: next }) .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; // Tagging is updated optimistically in place (no refetch of the whole channel list, which // made the toggle feel ~2s slow); only revert by refetching if the server call fails. const toggleTag = (id: string, tagId: number, hasTag: boolean) => { qc.setQueryData(["channels"], (old) => (old ?? []).map((ch) => ch.id === id ? { ...ch, tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId], } : ch, ), ); const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { invalidate(); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }), }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), }); const unsubscribe = useMutation({ mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), onSuccess: (_d, v) => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), }); const resetBackfill = useMutation({ mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), onSuccess: (_d, v) => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), }); // Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied // client-side over the status-filtered list. const [tagFilter, setTagFilter] = useState([]); // A focus-channel intent (header "without full history" / tag manager) seeds the search box. useEffect(() => { if (focusChannelName) setSearch(focusChannelName); // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusChannelToken]); // The header's reset intent clears the search + tag chips (skip the initial mount). const resetRef = useRef(filtersResetToken); useEffect(() => { if (filtersResetToken === resetRef.current) return; resetRef.current = filtersResetToken; setSearch(""); setTagFilter([]); }, [filtersResetToken]); // The Sync-status filter stays a top-level chip set (not a column filter) because the // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination // run over whatever the status chip leaves. const channels = (channelsQuery.data ?? []).filter((c) => statusFilter === "needs_full" ? !c.backfill_done : statusFilter === "fully_synced" ? c.backfill_done : statusFilter === "hidden" ? c.hidden : true, ); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter((c) => { if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false; if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false; return true; }); const s = statusQuery.data; const onView = (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); const onUnsub = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.unsubscribeOnYoutube"), message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), confirmLabel: t("channels.row.unsubscribeOnYoutube"), danger: true, }); if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id }); }; const onReset = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.backfillThis"), message: t("channels.confirmReset", { name: c.title ?? c.id }), confirmLabel: t("channels.row.backfillThis"), }); if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id }); }; // Distinct priority levels present in the catalog → the priority column's filter options. const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))] .sort((a, b) => b - a) .map((p) => ({ value: String(p), label: String(p) })); const columns: Column[] = [ { key: "priority", header: t("channels.cols.priority"), align: "center", width: "56px", sortable: true, hideInCard: true, sortValue: (c) => c.priority, filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, render: (c) => bumpPriority(c.id, c.priority, d)} />, }, { key: "actions", header: t("channels.cols.actions"), align: "left", nowrap: true, width: "104px", cardLabel: false, render: (c) => ( patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })} onReset={() => onReset(c)} onUnsubscribe={() => onUnsub(c)} /> ), }, { 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, // Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a // cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses). // Without this a single long name blows the column ~289→429px and overflows the centered table. render: (c) => (
onView(c)} />
), }, { key: "about", header: t("channels.cols.about"), hideInCard: true, render: (c) => , }, { key: "stored", header: t("channels.cols.stored"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.stored_videos, render: (c) => ( {c.stored_videos.toLocaleString()} ), }, subsColumn(t), { key: "lastUpload", header: t("channels.cols.lastUpload"), nowrap: true, sortable: true, sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => {relativeTime(c.last_video_at) || "—"}, }, { key: "length", header: t("channels.cols.length"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, render: (c) => ( {formatTotalHours(c.total_duration_seconds)} ), }, { key: "types", header: t("channels.cols.types"), nowrap: true, render: (c) => ( {c.count_normal} / {c.count_short} / {c.count_live} ), }, { key: "sync", header: t("channels.cols.sync"), nowrap: true, cardLabel: false, filter: { kind: "select", options: [ { value: "fully", label: t("channels.filters.fullySynced") }, { value: "needsFull", label: t("channels.filters.needsFull") }, ], test: (c, v) => v === "fully" ? c.recent_synced && c.backfill_done : v === "needsFull" ? !c.backfill_done : true, }, render: (c) => , }, { key: "tags", header: t("channels.cols.tags"), cardLabel: false, filter: { kind: "multi", options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), }, render: (c) => ( toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} onTagClick={onFilterByTag} /> ), }, ]; // Status chips sit in the table's controls row (next to paging) — compact and close to // where they act. const statusChips = (
{STATUS_FILTERS.map((f) => ( ))}
); // The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for // future tabs on the left. const statsBlock = (
{s && ( <> {s.deep_pending_count > 0 && ( )} )}
); // Sync + backfill — now share the tags row (right side). const actionButtons = (
); // Table / cards density — shared by the subscribed + 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, label: t("channels.layout." + id), icon: LAYOUT_ICON[id], })); const layoutSwitcher = ( ); const hasLayout = view === "subscribed" || view === "discovery"; // 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 = ( ); // The card layout paginates like the table (a page of cards + a pager), rather than one long // infinite scroll. Slice the sorted rows; the Pager reports page/size changes. // Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here // too would copy+sort the whole ~300-row array on every render for a result nothing reads. const isCards = layout === "cards"; const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels; const { paged: pagedCards, pagerProps, resetPage, } = useCardPager(sortedChannels, LS.channelCardSize, isCards); // Changing what you're looking at starts over at the first page, the way DataTable resets its own // page on a filter/sort change. Deliberately NOT keyed on the row count: the ["channels"] query // refetches on window focus and after every sync, so a catalogue that merely gained a channel would // otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is // handled by the hook's clamp instead. useEffect(() => { resetPage(); }, [statusFilter, search, tagFilter, sort, resetPage]); const cardPager = ; const tabs = (
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => ( ))}
{view === "subscribed" && statsBlock} {view === "subscribed" && layout === "cards" && sortControl} {hasLayout && layoutSwitcher}
); return ( <> {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */} {tabs} {view === "subscribed" && (
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name search lives in the shared top SearchBar, tag create/delete in the manager. */}
{t("channels.tags.yourTags")} {userTags.map((tg) => { const active = tagFilter.includes(tg.id); return ( ); })} {tagFilter.length > 0 && ( )}
{actionButtons}
)} {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card layout has no such row, so surface the same status chips (left) and its pager (right) here. */} {view === "subscribed" && layout === "cards" && (
{statusChips} {cardPager}
)}
{tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} {view === "discovery" ? ( ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
{(blockedQuery.data?.length ?? 0) === 0 ? (

{t("channels.blocked.empty")}

) : ( <>
{t("channels.blocked.title")}
{blockedQuery.data!.map((c) => ( {c.title ?? c.id} ))}

{t("channels.blocked.hint")}

)}
) : ( /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when `position: sticky` engages on the first scroll (the band's controls row above gives the visual gap). */
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row (status chips + pager) rides in the fixed band right above it (controlsInBand). The card layout swaps the table for a virtualized grid over the same rows (its pager and status chips ride in the fixed band above instead). */} {channelsQuery.isLoading ? (
{t("channels.loading")}
) : layout === "table" ? ( c.id} persistKey={accountKey(LS.channelsTable) ?? undefined} controlsPosition="both" controlsLeading={statusChips} controlsInBand controlsBandClassName="px-4 max-w-[96rem] mx-auto" sort={sort} onSortChange={setSort} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> ) : ( c.id} leadKey="priority" aboutKey="about" actionsKey="actions" rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> )}
)} ); } function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) { return ( {value} {label} ); } function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { return ( {ok && } {label} ); } function PriorityCell({ c, onPriority, }: { c: ManagedChannel; onPriority: (delta: number) => void; }) { const { t } = useTranslation(); return (
{c.priority}
); } // Status only — the backfill *action* lives in the Actions column. When both recent and // full history are in, collapse to a single "fully synced" chip; otherwise show what's // present plus the missing/queued state. function SyncCell({ c }: { c: ManagedChannel }) { const { t } = useTranslation(); if (c.recent_synced && c.backfill_done) { return ( ); } return (
{c.backfill_done ? ( ) : c.deep_requested ? ( ) : c.deep_in_queue ? ( ) : ( )}
); } function TagsCell({ c, userTags, onToggleTag, onTagClick, }: { c: ManagedChannel; userTags: Tag[]; onToggleTag: (tagId: number) => void; onTagClick: (tagId: number, name: string) => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); // Anchor for the portalled menu. It renders through (i.e. at ) rather than // `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a // `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew // UNDER the next card. Same hazard, same blessed fix as the players in E3. const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>( null, ); const fade = useScrollFade(); const ref = useRef(null); const popRef = useRef(null); const btnRef = useRef(null); // The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both. useDismiss(open, () => setOpen(false), [ref, popRef]); // Keep the scroll-fade hook's callback ref working alongside our own. const setPopRef = useCallback( (node: HTMLDivElement | null) => { popRef.current = node; fade.ref(node); }, [fade.ref], ); // ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't // drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal // clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess // reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below // re-runs this once it is. const coordsFrom = useCallback((r: DOMRect, flip: boolean) => { const w = popRef.current?.offsetWidth ?? 0; const left = w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left; return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 }; }, []); const place = useCallback(() => { const r = btnRef.current?.getBoundingClientRect(); if (!r) return false; setCoords(coordsFrom(r, false)); return true; }, [coordsFrom]); const toggle = () => { if (open) setOpen(false); else if (place()) setOpen(true); }; // Correct the placement against the MEASURED box: flip above when it would run off the bottom (and // there is room above), and apply the width clamp now that the menu can actually be measured. useLayoutEffect(() => { const pop = popRef.current; const r = btnRef.current?.getBoundingClientRect(); if (!open || !pop || !coords || !r) return; const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8; const fitsAbove = r.top - 4 - h >= 8; // Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling), // but leave it as soon as that side stops fitting and the other one does — a one-way flip would // keep an above-anchored menu above a row scrolling toward the top and push it off the screen. const above = coords.bottom !== undefined; const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove; const next = coordsFrom(r, flip); if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) { setCoords(next); } }, [open, coords, coordsFrom]); // A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing: // in the table the menu used to scroll with the row, and closing on every wheel tick would make // attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER // (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind // the toolbar is still "on screen" by window coords and the menu would float over the chrome). // Scrolling INSIDE the menu must not move it. useEffect(() => { if (!open) return; let raf = 0; const reposition = () => { raf = 0; const r = btnRef.current?.getBoundingClientRect(); const scroller = document.querySelector("main")?.getBoundingClientRect(); const topEdge = scroller ? scroller.top : 0; const bottomEdge = scroller ? scroller.bottom : window.innerHeight; if (!r || r.bottom < topEdge || r.top > bottomEdge) { setOpen(false); return; } // Keep whichever side it is anchored to, so the layout effect has nothing left to correct — // resetting to "below" every frame would make it flip back and forth, two renders per frame. setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur)); }; const onScroll = (e: Event) => { if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return; if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move }; const onResize = () => setOpen(false); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); return () => { if (raf) cancelAnimationFrame(raf); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }; }, [open, coordsFrom]); if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag // on every row). Tag create/delete still lives in the top "YOUR TAGS" row. const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id)); return (
{attached.map((tg) => ( ))} {open && coords && (
{userTags.map((tg) => { const on = c.tag_ids.includes(tg.id); return ( ); })}
)}
); } function ActionsCell({ c, isAdmin, canWrite, onHide, onDeep, onReset, onUnsubscribe, }: { c: ManagedChannel; isAdmin: boolean; canWrite: boolean; onHide: () => void; onDeep: () => void; onReset: () => void; onUnsubscribe: () => void; }) { const { t } = useTranslation(); // Admins get a "reset" trigger that re-fetches the channel from scratch regardless of // state — always actionable. Regular users get the per-user "request full history" opt-in, // which only applies until the back-catalog is in (or already coming for everyone). const done = c.backfill_done; const requested = !done && c.deep_requested; const optInActionable = !done && !c.deep_in_queue; const backfillHint = isAdmin ? t("channels.row.resetHint") : c.deep_in_queue ? t("channels.row.queuedByOtherHint") : requested ? t("channels.row.queuedRequestedHint") : done ? t("channels.row.fullHint") : t("channels.row.backfillThisHint"); const enabled = isAdmin || optInActionable; return (
{canWrite && ( )}
); }