fix(files): an upload is announced once, as an upload, and a finish shows in Files at once

An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".

A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
This commit is contained in:
2026-10-14 23:54:19 +02:00
parent db56f25387
commit c31d7f9d65
8 changed files with 70 additions and 7 deletions
@@ -70,6 +70,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
@@ -237,6 +238,28 @@ describe("DownloadTray", () => {
);
});
it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => {
const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries");
poll([job(1, "running")]);
const view = mount();
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() });
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() });
});
it("an upload the worker converted first is announced as uploaded, not downloaded", async () => {
poll([job(1, "running", { source_key: "upload" })]);
const view = mount();
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
),
);
});
it("closed, it forgets what finished and stays away until the work changes", () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
+11 -4
View File
@@ -14,7 +14,7 @@ import {
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
import { quickShareLink } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
@@ -190,7 +190,10 @@ export default function DownloadTray() {
const title = jobTitle(job);
notify({
level: "success",
title: t("tray.done.title", { title }),
// 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, job, t) },
secondaryAction: {
@@ -202,8 +205,12 @@ export default function DownloadTray() {
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
if (fresh.length) takeUnannounced().forEach(announce);
}, [fresh]);
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(() => {
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
showInTray: vi.fn(),
noteAnnounced: vi.fn(),
openFile: vi.fn(),
}));
@@ -35,7 +36,10 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
vi.mock("../lib/downloadQueue", () => ({
showInTray: h.showInTray,
noteAnnounced: h.noteAnnounced,
}));
import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
@@ -112,6 +116,8 @@ describe("UploadDialog", () => {
expect(h.notify.mock.calls[0]![0]).toMatchObject({
message: "downloads.upload.addedConverting",
});
// Its finish is still to come, and the tray's to announce.
expect(h.noteAnnounced).not.toHaveBeenCalledWith(43);
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
@@ -125,6 +131,8 @@ describe("UploadDialog", () => {
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.showInTray).not.toHaveBeenCalled();
// Done and said so here: the queue's next look must not announce it again, as a download.
expect(h.noteAnnounced).toHaveBeenCalledWith(42);
cleanup();
h.notify.mockClear();
+4 -1
View File
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { showInTray } from "../lib/downloadQueue";
import { noteAnnounced, showInTray } from "../lib/downloadQueue";
import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format";
@@ -103,6 +103,9 @@ export default function UploadDialog({
onProgress: setPct,
signal: ac.signal,
});
// Said here, so the queue does not say it again; one the worker still converts is the tray's
// to announce when it is done.
if (job.status === "done") noteAnnounced(job.id);
invalidateDownloads(qc);
notify({
level: "success",