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 (
{/* One column for the step, so every row's bar starts on the same line. */}
{step && (
{step}
)}
{bytes ? (
) : (
)}
{bytes && {job.progress}%}
);
}
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("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("downloadTray", "open", {
localKey: LS.downloadTray,
parse: parseFold,
});
const [errorJob, setErrorJob] = useState(null);
const menu = useContextMenu({ 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 where the pressed one was.
const pill = useRef(null);
const foldButton = useRef(null);
const focusNext = useRef(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(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 ? (
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 = (
);
const close = () => {
clearFinished();
setClosedAt(signature);
// The tray goes with the button pressed: the keyboard rests on the page (its ).
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
};
const doneItems = (job: QueueJob): ActionMenuItem[] =>
menuGroups([
{
id: "open",
label: t("tray.done.open"),
icon: ,
onSelect: () => openFile(job.id, job.folder_id),
},
{
id: "copyLink",
label: t("tray.done.copyLink"),
icon: ,
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 (
{stateIcon}
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
{t("tray.title")} ·
{summary}
toggleFold("folded")}
>
);
}
/** 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 ;
if (job.status === "error")
return (
{t(failCopyKeys(job, "short"))}
}
onClick={onRetry}
className="text-xs"
>
{t("downloads.actions.retry")}
);
if (job.status === "done")
return (
{t("tray.done.label")}
);
return {t(`downloads.status.${job.status}`)};
}