fix(downloads): an upload's cancel, progress, failure and notice speak of an upload

- Cancelling an upload asks "Delete upload?" and says the uploaded file is
  removed from the server with it.
- The phone-safe conversion's real ffmpeg % shows as a bar ("Converting for
  phones"), not an indeterminate pulse.
- A failed upload is titled "The upload isn't ready", with its own reasons
  (convert_failed, retryable; upload_missing) instead of download copy.
- The inbox says "Upload ready to share" for a converted upload.
This commit is contained in:
2026-10-06 03:19:32 +02:00
parent dba4758c59
commit 6073a006b8
14 changed files with 157 additions and 21 deletions
@@ -124,4 +124,21 @@ describe("DownloadCenter confirm dialogs", () => {
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
});
it("cancelling an upload says the uploaded file goes with it", async () => {
// Nothing to fetch again: the cancel deletes the original from the server.
mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
await pick("Clip 3", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.confirmLabel]).toEqual([
"downloads.confirm.cancelUploadTitle",
"downloads.confirm.cancelUploadAction",
]);
});
});
@@ -364,3 +364,19 @@ describe("a download-lifecycle notice", () => {
expect(screen.queryByText("download")).toBeNull();
});
});
describe("NotificationsPanel · a finished job's title", () => {
it("names what finished: a download, a clip, an upload", () => {
h.server = [
server(1),
server(2, { data: { title: "Ready 2", kind: "edit" } }),
server(3, { data: { title: "Ready 3", kind: "upload" } }),
];
h.client = [];
mount();
const titleOf = (text: string) => rowOf(text).textContent ?? "";
expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title");
expect(titleOf("Ready 2")).toContain("inbox.editDone.title");
expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title");
});
});
@@ -446,6 +446,11 @@ const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
download_expired: "downloadExpired",
download_evicted: "downloadEvicted",
};
// A `download_done` notice's title by the producer's `kind`; any other kind reads as a download.
const DONE_TITLE: Record<string, string | undefined> = {
edit: "inbox.editDone.title",
upload: "inbox.uploadDone.title",
};
function serverKind(n: AppNotification): "scheduler" | "download" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
@@ -552,10 +557,11 @@ function NotificationRow({
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
// upload isn't called a download.
const d = n.data as { title?: string | null; kind?: string };
title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title");
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
body = d.title || n.body;
}
return (
+3 -3
View File
@@ -7,7 +7,7 @@ import Modal, { ModalFooter } from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { Button } from "./ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys } from "../lib/downloadFailure";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
@@ -112,7 +112,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
// retry control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
const RETRYABLE_ERRORS = ["postprocess", "network"];
const RETRYABLE_ERRORS = ["postprocess", "network", "convert_failed"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
@@ -130,7 +130,7 @@ export function ErrorModal({
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal initialFocus="close" title={t("downloads.errors.fail.title")} onClose={onClose}>
<Modal initialFocus="close" title={t(failTitleKey(job))} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
@@ -292,3 +292,27 @@ describe("DownloadRow · no picture", () => {
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
});
});
// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has;
// a yt-dlp post-step reports none and keeps the pulse.
describe("DownloadRow · a running conversion", () => {
const running = (phase: string): DownloadJob => ({
...baseJob,
status: "running",
progress: 40,
phase,
});
it("shows its phase and its %", () => {
const { container } = render(<DownloadRow job={running("converting")} />);
expect(screen.getByText("downloads.phase.converting")).toBeTruthy();
expect(screen.getByText("40%")).toBeTruthy();
expect(container.querySelector(".animate-pulse")).toBeNull();
});
it("a post-step with no % keeps the pulse", () => {
const { container } = render(<DownloadRow job={running("merging")} />);
expect(screen.queryByText("40%")).toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
});
+6 -4
View File
@@ -14,7 +14,7 @@ import {
relativeTime,
} from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
@@ -34,12 +34,14 @@ const STATUS_CLS: Record<string, string> = {
};
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
// "editing" (the phase-2 editor) and "converting" (the phone-safe conversion of a download or an
// upload) report a real % from ffmpeg's out_time, so they get a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing", "converting"];
const PHASE_KEYS = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
@@ -313,7 +315,7 @@ export function DownloadRow({
<Pressable
onClick={() => onShowError?.(job)}
className="text-danger inline-block leading-6 min-h-6 touch-target pointer-coarse:leading-(--touch-target)"
title={t("downloads.errors.fail.title")}
title={t(failTitleKey(job))}
>
· {t(failCopyKeys(job, "short"))}{" "}
<span className="text-muted underline">
+20 -7
View File
@@ -35,6 +35,18 @@ import { menuGroups } from "./ui/menuGroups";
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
title: "downloads.confirm.cancelTitle",
body: "downloads.confirm.cancelBody",
action: "downloads.confirm.cancelAction",
};
const CANCEL_UPLOAD = {
title: "downloads.confirm.cancelUploadTitle",
body: "downloads.confirm.cancelUploadBody",
action: "downloads.confirm.cancelUploadAction",
};
export function useDownloadActions({
onPlay,
onEdit,
@@ -147,13 +159,14 @@ export function useDownloadActions({
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
),
// Cancelling an upload deletes the file it uploaded (there is nothing to fetch again), so
// its confirmation says so — "stop the download" would read as safe to undo.
onSelect: () => {
const copy = job.job_kind === "upload" ? CANCEL_UPLOAD : CANCEL_DOWNLOAD;
void confirmThen(t(copy.title), t(copy.body), t(copy.action), () =>
act.mutate({ id: job.id, op: "cancel" }),
);
},
},
],
);