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); }