From c31d7f9d65c322e60cd1f2345a44cc0c063cbf5c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 14 Oct 2026 23:54:19 +0200 Subject: [PATCH] fix(files): an upload is announced once, as an upload, and a finish shows in Files at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/DownloadTray.dom.test.tsx | 23 +++++++++++++++++++ frontend/src/components/DownloadTray.tsx | 15 ++++++++---- .../src/components/UploadDialog.dom.test.tsx | 10 +++++++- frontend/src/components/UploadDialog.tsx | 5 +++- frontend/src/i18n/locales/en/tray.json | 1 + frontend/src/i18n/locales/hu/tray.json | 1 + frontend/src/lib/downloadQueue.dom.test.ts | 9 ++++++++ frontend/src/lib/downloadQueue.ts | 13 ++++++++++- 8 files changed, 70 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/components/DownloadTray.dom.test.tsx index 162ffc54..b9775866 100644 --- a/frontend/src/components/DownloadTray.dom.test.tsx +++ b/frontend/src/components/DownloadTray.dom.test.tsx @@ -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(); diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx index 83e46e6b..da1a15e8 100644 --- a/frontend/src/components/DownloadTray.tsx +++ b/frontend/src/components/DownloadTray.tsx @@ -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(() => { diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index b0836ea8..6878f5b6 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -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()), 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(); diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 348b98cb..7eab023d 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -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", diff --git a/frontend/src/i18n/locales/en/tray.json b/frontend/src/i18n/locales/en/tray.json index 13663b54..f6f10fb1 100644 --- a/frontend/src/i18n/locales/en/tray.json +++ b/frontend/src/i18n/locales/en/tray.json @@ -20,6 +20,7 @@ "open": "Open", "copyLink": "Copy link", "title": "Downloaded: {{title}}", + "uploadedTitle": "Uploaded: {{title}}", "phone": "Plays on a phone too", "file": "Saved in Files" } diff --git a/frontend/src/i18n/locales/hu/tray.json b/frontend/src/i18n/locales/hu/tray.json index 5caf6158..fb0d374d 100644 --- a/frontend/src/i18n/locales/hu/tray.json +++ b/frontend/src/i18n/locales/hu/tray.json @@ -20,6 +20,7 @@ "open": "Megnyitás", "copyLink": "Link másolása", "title": "Letöltve: {{title}}", + "uploadedTitle": "Feltöltve: {{title}}", "phone": "Telefonon is lejátszható", "file": "Bekerült a Fájlok közé" } diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts index 67a4c69a..fc3f4d3d 100644 --- a/frontend/src/lib/downloadQueue.dom.test.ts +++ b/frontend/src/lib/downloadQueue.dom.test.ts @@ -7,6 +7,7 @@ import type { QueueJob } from "./api"; import { clearFinished, forgetFinished, + noteAnnounced, noteQueue, resetQueueWatch, takeUnannounced, @@ -41,6 +42,14 @@ describe("downloadQueue · a finish", () => { expect(takeUnannounced().map((j) => j.id)).toEqual([5]); }); + it("a finish its own action announced is kept for the tray but not announced again", () => { + noteQueue([]); + noteAnnounced(7); // an upload, done in the answer that made it + noteQueue([job(7, "done"), job(8, "done")]); + expect(takeUnannounced().map((j) => j.id)).toEqual([8]); + expect(finishedIds()).toEqual([7, 8]); + }); + it("a failure is not a finish; closing the tray forgets what finished", () => { noteQueue([job(1, "running"), job(2, "running")]); noteQueue([job(1, "error"), job(2, "done")]); diff --git a/frontend/src/lib/downloadQueue.ts b/frontend/src/lib/downloadQueue.ts index 235d1a56..31d57008 100644 --- a/frontend/src/lib/downloadQueue.ts +++ b/frontend/src/lib/downloadQueue.ts @@ -33,6 +33,8 @@ let lastSeen: Map | null = null; const finished = createStore([]); const unannounced = createStore([]); const revealing = createStore(null); +// Finishes this tab already said out loud where they happened: an upload that answered done. +const announcedHere = new Set(); /** A job to show in the tray, asked for at `at` (ms). */ export interface Reveal { @@ -50,9 +52,17 @@ export function noteQueue(jobs: QueueJob[]): void { if (done.length === 0) return; const ids = new Set(done.map((j) => j.id)); finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]); - unannounced.set((list) => [...list, ...done]); + // `delete` answers whether it was there: each id is spent on the one look that finds it done. + const news = done.filter((j) => !announcedHere.delete(j.id)); + if (news.length) unannounced.set((list) => [...list, ...news]); } +/** A finish its own action announced already (an upload done in the answer that made it): the tray + * still keeps it, but the done notice would say it a second time — and as a download. */ +export const noteAnnounced = (id: number): void => { + announcedHere.add(id); +}; + /** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */ export async function fetchQueue(): Promise { const jobs = await api.downloads(); @@ -96,5 +106,6 @@ export function resetQueueWatch(): void { lastSeen = null; finished.set([]); unannounced.set([]); + announcedHere.clear(); revealing.set(null); }