E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
|
import { api, type MyStatus } from "../lib/api";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { formatCompact } from "../lib/format";
|
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
|
// user, how many of *their* channels are still being fetched, and how many lack full
|
|
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
|
|
export default function SyncStatus({
|
|
isAdmin,
|
|
onGoToFullHistory,
|
|
}: {
|
|
isAdmin: boolean;
|
|
onGoToFullHistory: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const [open, setOpen] = useState(false);
|
|
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
|
|
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
|
|
// stacking and positioning.
|
|
const pop = useAnchoredPopover({
|
|
open,
|
|
onOpenChange: setOpen,
|
|
mode: "dialog",
|
|
role: "dialog",
|
|
placement: "bottom-end",
|
|
offset: 8,
|
|
});
|
|
const { setReference } = pop.refs;
|
|
const { data } = useQuery({
|
|
queryKey: qk.myStatus(),
|
|
queryFn: api.myStatus,
|
|
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
|
|
// refresh immediately on tab focus (the global default disables focus refetch), so the
|
|
// "N without full history" count keeps ticking down without a manual reload. Poll faster
|
|
// while a job is running or work is pending — so the live "syncing" state is actually
|
|
// caught — and ease back to a slow idle tick once everything's settled.
|
|
refetchInterval: (query) => {
|
|
const d = query.state.data as MyStatus | undefined;
|
|
const busy =
|
|
!!d && (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
|
|
return busy ? 8_000 : 30_000;
|
|
},
|
|
refetchIntervalInBackground: true,
|
|
refetchOnWindowFocus: true,
|
|
staleTime: 5_000,
|
|
});
|
|
|
|
const toggle = useMutation({
|
|
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
|
onError: (e) =>
|
|
notifyActionError(
|
|
e,
|
|
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
|
),
|
|
});
|
|
|
|
if (!data) return null;
|
|
|
|
const syncing = data.channels_recent_pending;
|
|
const notFull = data.channels_deep_pending; // your channels without full history yet
|
|
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
|
|
// static state (or, for deep history, just the "N without full history" link below).
|
|
const active = data.sync_active;
|
|
const showMain = data.paused || active || syncing > 0 || notFull === 0;
|
|
|
|
// Calm one-liner describing the current sync state.
|
|
const stateNode = data.paused ? (
|
|
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
|
) : active ? (
|
|
<span className="flex items-center gap-1">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
{syncing > 0
|
|
? t("header.sync.syncing", { count: syncing })
|
|
: notFull > 0
|
|
? t("header.sync.backfillingHistory")
|
|
: t("header.sync.working")}
|
|
</span>
|
|
) : syncing > 0 ? (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3.5 h-3.5" />
|
|
{t("header.sync.recentQueued", { count: syncing })}
|
|
</span>
|
|
) : (
|
|
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
|
|
<span className="flex items-center gap-1 text-success">
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
<span className="text-muted">{t("header.sync.allSynced")}</span>
|
|
</span>
|
|
);
|
|
|
|
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
|
<IconButton
|
|
onClick={() => toggle.mutate()}
|
|
loading={toggle.isPending}
|
|
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
|
>
|
|
{data.paused ? <Play /> : <Pause />}
|
|
</IconButton>
|
|
);
|
|
|
|
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
|
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
|
|
// history) so the collapsed chip still tells you when to look.
|
|
const needsAttention = data.paused || notFull > 0;
|
|
const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact(
|
|
data.total_videos,
|
|
lang,
|
|
)} ${t("header.sync.total")}`;
|
|
return (
|
|
<div className="pointer-events-auto shrink-0">
|
|
<Pressable
|
|
ref={setReference}
|
|
{...pop.getReferenceProps()}
|
|
// Short label only: the popover already explains the numbers, and the long one overflowed
|
|
// the viewport edge from here.
|
|
title={countsText}
|
|
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted touch-target`}
|
|
>
|
|
{active ? (
|
|
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
|
|
) : (
|
|
<Database className="w-3.5 h-3.5 shrink-0" />
|
|
)}
|
|
<span className="text-fg font-medium tabular-nums">
|
|
{formatCompact(data.my_videos, lang)}
|
|
</span>
|
|
{needsAttention && (
|
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
|
)}
|
|
</Pressable>
|
|
|
|
<PopoverSurface
|
|
popover={pop}
|
|
ariaLabel={countsText}
|
|
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
|
|
>
|
|
<div
|
|
title={t("header.sync.countTooltip")}
|
|
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
|
>
|
|
<Database className="w-3.5 h-3.5 shrink-0" />
|
|
<span>
|
|
<span className="text-fg font-medium">{formatCompact(data.my_videos, lang)}</span>{" "}
|
|
{t("header.sync.yours")}
|
|
<span className="opacity-40"> / </span>
|
|
{formatCompact(data.total_videos, lang)} {t("header.sync.total")}
|
|
</span>
|
|
</div>
|
|
{showMain && (
|
|
<div className="flex items-center justify-between gap-1.5 pt-2">
|
|
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
|
{pauseBtn}
|
|
</div>
|
|
)}
|
|
{notFull > 0 && (
|
|
<div className="flex items-center justify-between gap-1.5 pt-2">
|
|
<Button
|
|
variant="link-muted"
|
|
icon={<History className="size-3.5!" />}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onGoToFullHistory();
|
|
}}
|
|
title={t("header.sync.fullHistoryTooltip")}
|
|
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
|
|
>
|
|
{t("header.sync.withoutFullHistory", { count: notFull })}
|
|
</Button>
|
|
{!showMain && pauseBtn}
|
|
</div>
|
|
)}
|
|
</PopoverSurface>
|
|
</div>
|
|
);
|
|
}
|