Files
siftlode/frontend/src/features/downloads/DownloadTray.tsx
T
peter b443ac0c84 fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic
Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
2026-10-23 13:45:40 +02:00

469 lines
18 KiB
TypeScript

import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
FolderOpen,
Link,
Loader2,
PauseCircle,
RotateCw,
X,
} from "lucide-react";
import type { QueueJob } from "../../lib/api";
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import {
IN_FLIGHT,
DOWNLOAD_PHASES,
MOVING,
clearFinished,
doneRevealing,
fetchQueue,
takeUnannounced,
useFinished,
useRevealing,
useUnannounced,
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { useFilesActions } from "../../modules/files";
import { sourceLabel } from "../folders/fileFolders";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState";
import { Button } from "../../components/ui/Button";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { IconButton } from "../../components/ui/IconButton";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import NavGrid from "../../components/ui/NavGrid";
import { Pressable } from "../../components/ui/Pressable";
import { CONTENT_ID } from "../../components/ui/landmarks";
import { SURFACE } from "../../components/ui/surfaces";
import { useContextMenu } from "../../components/ui/useContextMenu";
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
// until it is closed. Folds to a pill (remembered per account). A row's right click and "…" are the
// queue's one menu; a finish is announced once with "Copy link" and "Open" (DESIGN.md "Download
// done"). It sits under every dialog and player (z-chrome), so it never covers a player's controls.
type Fold = "open" | "folded";
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
// Byte progress (a bar with a %) vs a post-step that reports none (a pulse): "editing" and
// "converting" report a real % from ffmpeg's out_time.
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id;
// The step a running job is on is named beside its bar: a download fetches its video and its audio
// as two streams, so the bar fills twice — without the name, the second start reads as a restart.
function Progress({ job }: { job: QueueJob }) {
const { t } = useTranslation();
const bytes = !job.phase || BYTE_PHASES.includes(job.phase);
const step =
job.phase && DOWNLOAD_PHASES.includes(job.phase) ? t(`downloads.phase.${job.phase}`) : null;
return (
<span className="flex min-w-0 items-center gap-2 text-xs text-muted">
{/* One column for the step, so every row's bar starts on the same line. */}
{step && (
<span className="w-36 shrink-0 truncate" title={step}>
{step}
</span>
)}
<span
role="progressbar"
aria-label={step ?? t("downloads.status.running")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={bytes ? job.progress : undefined}
className="h-1 min-w-12 flex-1 overflow-hidden rounded-full bg-fg/10"
>
{bytes ? (
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
) : (
<span className="block h-full w-full motion-safe:animate-pulse bg-accent/60" />
)}
</span>
{bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
</span>
);
}
export default function DownloadTray() {
const { t } = useTranslation();
const qc = useQueryClient();
const { openFile } = useFilesActions();
const actions = useDownloadActions();
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once
// (invalidateDownloads), and one started on another device shows within a minute.
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
intervalMs: (data) => (data?.some((j) => MOVING.has(j.status)) ? 2000 : 60000),
});
const finished = useFinished();
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
const activeIds = new Set(active.map((j) => j.id));
const rows = [...active, ...finished.filter((j) => !activeIds.has(j.id))];
// Closed with work still on it, it stays away until that work changes: a new job, or one failing.
// Remembered (per account) past a reload: an old failure the reader has seen must not reopen it
// on every page they load.
const signature = active.map((j) => `${j.id}${j.status === "error" ? "!" : ""}`).join(",");
const [closedAt, setClosedAt] = usePref<string | null>("downloadTrayClosed", null, {
localKey: LS.downloadTrayClosed,
parse: parseClosed,
});
// A finish since it was closed is news of its own, whatever else is still on it.
const shown = rows.length > 0 && (signature !== closedAt || finished.length > 0);
const [fold, setFold] = usePref<Fold>("downloadTray", "open", {
localKey: LS.downloadTray,
parse: parseFold,
});
const [errorJob, setErrorJob] = useState<QueueJob | null>(null);
const menu = useContextMenu<QueueJob>({ keyOf });
// Folding swaps the control under the keyboard for its counterpart: the focus follows it there
// (the pill, or the fold button), never left on <body> where the pressed one was.
const pill = useRef<HTMLButtonElement>(null);
const foldButton = useRef<HTMLButtonElement>(null);
const focusNext = useRef<Fold | null>(null);
const toggleFold = (next: Fold) => {
focusNext.current = next;
setFold(next);
};
useEffect(() => {
const target = focusNext.current;
focusNext.current = null;
if (target === "folded") pill.current?.focus();
else if (target === "open") foldButton.current?.focus();
}, [fold]);
// --- "View" on a job just started: the tray opens on its row ----------------------------------
// Answered over as many renders as it takes: a closed or folded tray is brought back first, and the
// row is focused once it is on the page. A job the tray does not list is done already (it opens as
// a file), or — once a poll made after the ask has landed without it — not in the queue at all.
// Asked from inside a player (a clip's notice, the player's own download), the tray opens under it
// but leaves the keyboard there: the row is behind the player, out of the reader's sight.
const box = useRef<HTMLDivElement>(null);
const revealing = useRevealing();
const answerReveal = useEffectEvent(() => {
if (!revealing) return;
const index = rows.findIndex((j) => j.id === revealing.id);
if (index >= 0) {
if (!shown) setClosedAt(null);
else if (fold === "folded") setFold("open");
else {
if (box.current && !anyBlockingLayer()) focusNavItem(box.current, index);
doneRevealing();
}
return;
}
const job = queueQ.data?.find((j) => j.id === revealing.id);
if (job?.status === "done") {
doneRevealing();
openFile(job.id, job.folder_id);
} else if (queueQ.dataUpdatedAt > revealing.at) doneRevealing();
else void qc.invalidateQueries({ queryKey: qk.downloads() });
});
useEffect(() => answerReveal(), [revealing, queueQ.data, shown, fold]);
// --- the done notice ------------------------------------------------------------------------
const announce = useEffectEvent((job: QueueJob) => {
const title = jobTitle(job);
notify({
level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
},
});
});
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
if (!fresh.length) return;
// A notice that offers "Open" while Files still lists the folder without the file (its own poll
// is slower) sends the reader to something not there yet.
invalidateDownloads(qc);
takeUnannounced().forEach(announce);
}, [fresh, qc]);
// --- Back to top steps up over the tray while it shows -----------------------------------------
useLayoutEffect(() => {
const el = box.current;
if (!el) {
setCornerLift(0);
return;
}
const measure = () => setCornerLift(el.getBoundingClientRect().height);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
ro.disconnect();
setCornerLift(0);
};
}, [shown, fold]);
if (!shown)
return errorJob ? (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
) : null;
const counts = {
running: rows.filter((j) => j.status === "queued" || j.status === "running").length,
paused: rows.filter((j) => j.status === "paused").length,
error: rows.filter((j) => j.status === "error").length,
done: rows.filter((j) => j.status === "done").length,
};
const summary = (["running", "paused", "error", "done"] as const)
.filter((k) => counts[k] > 0)
.map((k) => t(`tray.count.${k}`, { count: counts[k] }))
.join(", ");
const running = rows.filter((j) => j.status === "running");
const meanProgress = running.length
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: null;
// The tray's state, worst news first; a paused job is neither working nor done.
const StateIcon = counts.running
? Loader2
: counts.error
? AlertTriangle
: counts.paused
? PauseCircle
: CheckCircle2;
const stateIcon = (
<StateIcon
aria-hidden
className={clsx(
"size-4 shrink-0",
counts.running
? "animate-spin text-accent"
: counts.error
? "text-danger"
: counts.paused
? "text-warn"
: "text-success",
)}
/>
);
const close = () => {
clearFinished();
setClosedAt(signature);
// The tray goes with the button pressed: the keyboard rests on the page (its <main>).
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
};
const doneItems = (job: QueueJob): ActionMenuItem[] =>
menuGroups([
{
id: "open",
label: t("tray.done.open"),
icon: <FolderOpen className="w-4 h-4" />,
onSelect: () => openFile(job.id, job.folder_id),
},
{
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
]);
const menuItems = menu.target
? menu.target.status === "done"
? doneItems(menu.target)
: actions.items(menu.target)
: [];
return (
<div
ref={box}
role="region"
aria-label={t("tray.label")}
className="absolute right-4 bottom-4 z-chrome flex max-w-[calc(100%-2rem)] flex-col items-end"
>
{fold === "folded" ? (
<Pressable
ref={pill}
onClick={() => toggleFold("open")}
aria-expanded={false}
title={t("tray.unfold")}
className={clsx(
SURFACE.floating,
"inline-flex items-center gap-2 px-3 py-1.5 text-sm glass-hover",
)}
>
{stateIcon}
<span className="truncate">{summary}</span>
{meanProgress !== null && (
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent"
style={{ width: `${meanProgress}%` }}
/>
</span>
)}
</Pressable>
) : (
<div
className={clsx(
SURFACE.popover,
"@container/tray w-80 max-w-full overflow-hidden shadow-float",
)}
>
<div className="flex items-center gap-2 border-b border-border/60 py-1 pr-1 pl-3">
{stateIcon}
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
<span className="@max-[18rem]/tray:hidden">{t("tray.title")} · </span>
{summary}
</span>
<IconButton
ref={foldButton}
label={t("tray.fold")}
size="sm"
variant="ghost"
aria-expanded
onClick={() => toggleFold("folded")}
>
<ChevronDown />
</IconButton>
<IconButton label={t("tray.close")} size="sm" variant="ghost" onClick={close}>
<X />
</IconButton>
</div>
<NavGrid className="max-h-[50dvh] overflow-y-auto py-1">
{rows.map((job, i) => {
const title = jobTitle(job);
const mine = menu.open && menu.target?.id === job.id;
return (
<div
key={job.id}
{...navItemAttrs(i, title)}
{...menu.triggerProps(job)}
data-job-id={job.id}
className="group flex items-start gap-1 px-3 py-2 focus-visible:bg-fg/5"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<span className="shrink-0 text-xs text-muted">
{sourceLabel(job.source_key, t)}
</span>
</span>
<RowState
job={job}
onShowError={() => setErrorJob(job)}
onRetry={() => actions.run(job, "resume")}
onOpen={() => openFile(job.id, job.folder_id)}
/>
</div>
<MoreButton
label={t("tray.rowMenu")}
size="sm"
expanded={mine}
menuProps={menu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
</div>
);
})}
</NavGrid>
</div>
)}
<ContextMenu menu={menu} ariaLabel={t("tray.rowMenu")} items={menuItems} />
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
</div>
);
}
/** What a tray row says under its title: how far it is, that it waits, why it failed, or that it is
* done — with the one thing to do about it in reach. */
function RowState({
job,
onShowError,
onRetry,
onOpen,
}: {
job: QueueJob;
onShowError: () => void;
onRetry: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
if (job.status === "running") return <Progress job={job} />;
if (job.status === "error")
return (
<span className="flex items-center gap-2 text-xs">
<AlertTriangle aria-hidden className="size-3.5 shrink-0 text-danger" />
<Pressable
onClick={onShowError}
title={t(failTitleKey(job))}
className="min-w-0 flex-1 truncate text-left text-danger hover:underline"
>
{t(failCopyKeys(job, "short"))}
</Pressable>
<Button
variant="link"
size="none"
icon={<RotateCw className="size-3" />}
onClick={onRetry}
className="text-xs"
>
{t("downloads.actions.retry")}
</Button>
</span>
);
if (job.status === "done")
return (
<span className="flex items-center gap-2 text-xs">
<CheckCircle2 aria-hidden className="size-3.5 shrink-0 text-success" />
<span className="flex-1 text-success">{t("tray.done.label")}</span>
<Button variant="link" size="none" onClick={onOpen} data-nav-open="" className="text-xs">
{t("tray.done.open")}
</Button>
</span>
);
return <span className="text-xs text-muted">{t(`downloads.status.${job.status}`)}</span>;
}