A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
469 lines
17 KiB
TypeScript
469 lines
17 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 { sourceLabel } from "./fileFolders";
|
|
import { useFilesActions } from "./filesContext";
|
|
import { useDownloadActions } from "./useDownloadActions";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { setCornerLift } from "./ui/backToTopState";
|
|
import { Button } from "./ui/Button";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { MoreButton } from "./ui/MoreButton";
|
|
import NavGrid from "./ui/NavGrid";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { CONTENT_ID } from "./ui/landmarks";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { useContextMenu } from "./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 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>;
|
|
}
|