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 (
{fold === "folded" ? ( 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} {summary} {meanProgress !== null && ( )} ) : (
{stateIcon} {/* A phone's narrow tray keeps the news and drops the name (the region names it). */} {t("tray.title")} · {summary} toggleFold("folded")} >
{rows.map((job, i) => { const title = jobTitle(job); const mine = menu.open && menu.target?.id === job.id; return (
{title} {sourceLabel(job.source_key, t)} setErrorJob(job)} onRetry={() => actions.run(job, "resume")} onOpen={() => openFile(job.id, job.folder_id)} />
); })}
)} {errorJob && ( actions.run(errorJob, "resume")} onClose={() => setErrorJob(null)} /> )}
); } /** 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"))} ); if (job.status === "done") return ( {t("tray.done.label")} ); return {t(`downloads.status.${job.status}`)}; }