From 2efa7d5a75aa0cb3c58043cfdc05c8efa972be4d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 04:58:17 +0200 Subject: [PATCH] fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The queue holds an upload it sees land done while this tab's upload still waits for its answer, so the dialog's notice is the only one whichever comes first; another device's upload follows after. - One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token dialog's two Copy buttons are named by what they copy. - The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no New link beside a list it has not seen. - The settings texts say that signing out elsewhere or a password change revokes the API tokens. - DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the Links, Tokens and Files rounds. --- docs/DESIGN.md | 243 ++++++++--------- frontend/src/components/AccountPanel.tsx | 22 +- .../src/components/ApiTokens.dom.test.tsx | 5 + frontend/src/components/ApiTokens.tsx | 18 +- .../src/components/ShareDialog.dom.test.tsx | 21 ++ frontend/src/components/ShareDialog.tsx | 44 +-- .../src/components/UploadDialog.dom.test.tsx | 11 +- frontend/src/components/UploadDialog.tsx | 8 +- .../components/useCopyFeedback.dom.test.ts | 42 +++ frontend/src/components/useCopyFeedback.ts | 30 +++ frontend/src/design-lab/DesignLab.tsx | 25 +- frontend/src/design-lab/filesRound.tsx | 250 +++++------------- frontend/src/design-lab/labKit.tsx | 80 +----- frontend/src/design-lab/linksRound.tsx | 179 +++---------- frontend/src/design-lab/tokensRound.tsx | 205 +++----------- frontend/src/i18n/locales/en/downloads.json | 1 + frontend/src/i18n/locales/en/settings.json | 9 +- frontend/src/i18n/locales/en/users.json | 1 - frontend/src/i18n/locales/hu/downloads.json | 1 + frontend/src/i18n/locales/hu/settings.json | 9 +- frontend/src/i18n/locales/hu/users.json | 1 - frontend/src/lib/downloadQueue.dom.test.ts | 36 ++- frontend/src/lib/downloadQueue.ts | 37 ++- 23 files changed, 505 insertions(+), 773 deletions(-) create mode 100644 frontend/src/components/useCopyFeedback.dom.test.ts create mode 100644 frontend/src/components/useCopyFeedback.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 524d7f88..1d7f250a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -661,124 +661,125 @@ of a full-width panel and nothing on the right. ## Decision log -| Date | Decision | Chosen | Over | Why | -| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized | -| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | -| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | -| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | -| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | -| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | -| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | -| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | -| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | -| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | -| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | -| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | -| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | -| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | -| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | -| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | -| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | -| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | -| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | -| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | -| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function | -| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid | -| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | -| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | -| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | -| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) | -| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 | -| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw ``" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere | -| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role | -| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing | -| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill | -| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names | -| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down | -| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light | -| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call | -| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing | -| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint | -| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page | -| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge | -| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar | -| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons | -| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line | -| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width | -| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs | -| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video | -| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback | -| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift | -| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has | -| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else | -| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) | -| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet | -| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px | -| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete | -| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title | -| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing | -| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something | -| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say | -| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row | -| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box | -| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap | -| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere | -| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row | -| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip | -| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start | -| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw ` diff --git a/frontend/src/components/ShareDialog.dom.test.tsx b/frontend/src/components/ShareDialog.dom.test.tsx index 48d82111..793a9e7c 100644 --- a/frontend/src/components/ShareDialog.dom.test.tsx +++ b/frontend/src/components/ShareDialog.dom.test.tsx @@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => { expect((allow() as HTMLInputElement).checked).toBe(true); }); + it("a list that did not load says so with Retry, and offers no New link beside it", async () => { + h.folderLinks + .mockReset() + .mockRejectedValueOnce(new Error("503")) + .mockResolvedValue([link(4)]); + h.createFolderLink.mockReset(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} /> + , + ); + expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy(); + expect(screen.queryByText("downloads.share.noLinks")).toBeNull(); + expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull(); + expect(h.createFolderLink).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy(); + expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy(); + }); + it("makes a link on opening with none, but never right after the last one was revoked", async () => { h.folderLinks.mockReset().mockResolvedValue([]); h.createFolderLink.mockReset().mockResolvedValue(link(6)); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 3b0fd461..5b4cf0f7 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -7,7 +7,6 @@ import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; -import { copyText } from "../lib/clipboard"; import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads"; @@ -17,6 +16,8 @@ import { Input, InnerInput } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; import { SURFACE } from "./ui/surfaces"; import UserPicker from "./UserPicker"; +import { useCopyFeedback } from "./useCopyFeedback"; +import { LoadingState, StateMessage } from "./ui/QueryState"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- @@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) { function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { const { t, i18n } = useTranslation(); const confirm = useConfirm(); - const [copied, setCopied] = useState(false); + const { copied, copy: copyValue } = useCopyFeedback(); const [editPass, setEditPass] = useState(false); const [newPass, setNewPass] = useState(""); const fullUrl = link.url && window.location.origin + link.url; - const copy = async () => { - if (fullUrl && (await copyText(fullUrl))) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } else { - notify({ level: "error", message: t("downloads.share.copyFailed") }); - } + const copy = () => { + if (fullUrl) void copyValue(fullUrl); }; const toggle = useMutation({ mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), @@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) { )}

-
- {(links.data ?? []).map((l) => ( - - ))} - {links.data && links.data.length === 0 && !creating && !create.isPending && ( -

{t("downloads.share.noLinks")}

- )} -
+ {/* A list that did not load is said so, with Retry — never shown as "no links", and with no + "New link" beside it, which would add a second public link next to ones not seen. */} + {!links.data ? ( + links.isError ? ( + void links.refetch()}> + {t("downloads.share.linksFailed")} + + ) : ( + + ) + ) : ( +
+ {links.data.map((l) => ( + + ))} + {links.data.length === 0 && !creating && !create.isPending && ( +

{t("downloads.share.noLinks")}

+ )} +
+ )} - {creating ? ( + {!links.data ? null : creating ? (
{t("downloads.share.allowDownload")} diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index 6878f5b6..61b94203 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({ filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })), notify: vi.fn(), showInTray: vi.fn(), - noteAnnounced: vi.fn(), + uploadStarted: vi.fn(), + uploadEnded: vi.fn(), openFile: vi.fn(), })); @@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ })); vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray, - noteAnnounced: h.noteAnnounced, + uploadStarted: h.uploadStarted, + uploadEnded: h.uploadEnded, })); import UploadDialog from "./UploadDialog"; @@ -117,7 +119,7 @@ describe("UploadDialog", () => { message: "downloads.upload.addedConverting", }); // Its finish is still to come, and the tray's to announce. - expect(h.noteAnnounced).not.toHaveBeenCalledWith(43); + expect(h.uploadEnded).toHaveBeenCalledWith(null); }); it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => { @@ -132,7 +134,8 @@ describe("UploadDialog", () => { 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); + expect(h.uploadStarted).toHaveBeenCalled(); + expect(h.uploadEnded).toHaveBeenCalledWith(42); cleanup(); h.notify.mockClear(); diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 7eab023d..a9ce514d 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 { noteAnnounced, showInTray } from "../lib/downloadQueue"; +import { showInTray, uploadEnded, uploadStarted } from "../lib/downloadQueue"; import type { DownloadJob } from "../lib/api"; import { useFilesActions } from "./filesContext"; import { formatBytes } from "../lib/format"; @@ -28,6 +28,8 @@ import { Input } from "./ui/Input"; // skip the whole dialog. const uploadedKey = (status: string) => status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; +// The job the upload's own notice announces (a done one), for the queue not to say it again. +const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null); export default function UploadDialog({ folderId = null, initialFile = null, @@ -96,6 +98,7 @@ export default function UploadDialog({ abortRef.current = ac; setPct(0); const displayName = name.trim() || undefined; + uploadStarted(); try { const job = await api.uploadFile(file, { displayName, @@ -105,7 +108,7 @@ export default function UploadDialog({ }); // 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); + uploadEnded(announces(job)); invalidateDownloads(qc); notify({ level: "success", @@ -123,6 +126,7 @@ export default function UploadDialog({ // a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing // named and no job in the list. The comment here claimed the global error modal had it; it // had not since the shared `reportFailure` landed. + uploadEnded(null); if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed")); // Re-arm the form so the user can retry or pick again. setPct(null); diff --git a/frontend/src/components/useCopyFeedback.dom.test.ts b/frontend/src/components/useCopyFeedback.dom.test.ts new file mode 100644 index 00000000..62bc475b --- /dev/null +++ b/frontend/src/components/useCopyFeedback.dom.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +// +// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a +// warning when the clipboard refused — the same at every such button. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() })); +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); +vi.mock("../lib/clipboard", () => ({ copyText: h.copyText })); +vi.mock("../lib/notifications", () => ({ notify: h.notify })); + +import { useCopyFeedback } from "./useCopyFeedback"; + +afterEach(() => { + vi.useRealTimers(); + h.notify.mockClear(); +}); + +describe("useCopyFeedback", () => { + it("a copy that worked reads Copied for a moment and is said out loud", async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useCopyFeedback()); + await act(() => result.current.copy("sfl_x")); + expect(h.copyText).toHaveBeenCalledWith("sfl_x"); + expect(result.current.copied).toBe(true); + expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" }); + act(() => void vi.advanceTimersByTime(2000)); + expect(result.current.copied).toBe(false); + }); + + it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => { + h.copyText.mockResolvedValueOnce(false); + const { result } = renderHook(() => useCopyFeedback()); + await act(() => result.current.copy("sfl_x")); + expect(result.current.copied).toBe(false); + expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" }); + }); +}); diff --git a/frontend/src/components/useCopyFeedback.ts b/frontend/src/components/useCopyFeedback.ts new file mode 100644 index 00000000..161ca6ce --- /dev/null +++ b/frontend/src/components/useCopyFeedback.ts @@ -0,0 +1,30 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { copyText } from "../lib/clipboard"; +import { notify } from "../lib/notifications"; + +// How long a Copy button reads "Copied" after it worked. +const COPIED_MS = 2000; + +/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a + * set-password link), the same at every such button: it reads "Copied" for a moment, and a notice + * says it too — the notices are a live region, so a screen reader hears what a swapped label would + * not tell it. A copy that failed is a warning, not an error: the value is on screen and + * selectable, so it is a convenience that failed, not the action. + * + * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */ +export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copy = async (value: string) => { + if (await copyText(value)) { + setCopied(true); + notify({ level: "success", message: t("common.copy.done") }); + window.setTimeout(() => setCopied(false), COPIED_MS); + return; + } + notify({ level: "warning", message: t("common.copy.failed") }); + }; + return { copied, copy }; +} diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 3b106b62..25e1abc7 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -77,11 +77,14 @@ export default function DesignLab() { />
); } -// ── F1: the list and the grid ──────────────────────────────────────────────────────────────────── +// ── F1: the list ───────────────────────────────────────────────────────────────────────────────── // One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the -// badges, the sizes and the action icons line up down the page. An action that does not apply keeps -// its slot empty (a file has no "copy the play link"). +// badges, the sizes and the dates line up down the page. const LIST_COLUMNS = - "grid grid-cols-[4rem_minmax(0,1fr)_7rem_6.5rem_7.5rem_4.5rem_4.5rem_auto] items-center gap-x-3"; -const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5"; + "grid grid-cols-[4rem_minmax(0,1fr)_8rem_6.5rem_6.75rem_5.5rem_6.5rem_3rem] items-center gap-x-3"; +const ROW = "group col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5"; function ListHead() { const th = "text-2xs uppercase tracking-wider text-muted"; @@ -447,7 +413,7 @@ function ListRows() { {item.size} {item.when} - + ))} @@ -496,32 +462,6 @@ function RowMenu() { ); } -function GridCards() { - return ( -
- {ITEMS.map((item, i) => ( -
- - {item.name} - - - {item.by && {item.by}} - - - - -
- ))} -
- ); -} - // ── F2: the download tray ──────────────────────────────────────────────────────────────────────── function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) { @@ -536,13 +476,15 @@ function TrayRow({ title, site, children }: { title: string; site: string; child ); } -function Progress({ pct }: { pct: number }) { +function Progress({ step, pct }: { step: string; pct: number }) { return ( - {pct}% + + {step} · {pct}% + ); } @@ -561,7 +503,7 @@ function TrayPanel() { - + Sorban áll… @@ -603,20 +545,6 @@ function TrayPill() { ); } -function RailBell() { - return ( -
- - - - 3 - - - -
- ); -} - // ── F3: the done notification ──────────────────────────────────────────────────────────────────── function DoneToast() { @@ -647,84 +575,44 @@ function DoneToast() { export function FilesRound() { return (
- - - - - - - - - - - - - - - + + + + + + + - -
- - - - - - - - - - - +
+ + + + + +
- + - - - - - + +
); } diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx index 1bad5c48..8a2ce46e 100644 --- a/frontend/src/design-lab/labKit.tsx +++ b/frontend/src/design-lab/labKit.tsx @@ -1,12 +1,12 @@ import { useSyncExternalStore, type ReactNode } from "react"; import clsx from "clsx"; -import { Badge } from "../components/ui/Badge"; import { SURFACE } from "../components/ui/surfaces"; -// Building blocks of the design lab. A taken decision is a : the chosen rendering with the -// rule it became. An open design round shows its questions as s with their s side -// by side, until the owner picks. Everything -// is static markup on purpose: a rendering that only exists here must not need app state. +// Building blocks of the design lab. Every decision in it is taken, so the lab is the reference half +// only: each is the chosen rendering with the rule it became. A new design round brings +// back a side-by-side / pair (see git history) for its open questions, and deletes +// it again once the owner picks. Everything is static markup on purpose: a rendering that only exists +// here must not need app state. /** A decision already taken: the chosen rendering only, with the rule it became. The lab's * reference half — the living style guide. */ @@ -37,76 +37,6 @@ export function Reference({ ); } -/** An open question of a design round: what the app does today, then the variants side by side. */ -export function Decision({ - id, - code, - title, - question, - now, - children, -}: { - id: string; - code: string; - title: string; - question: string; - /** What the app does today. */ - now: ReactNode; - children: ReactNode; -}) { - return ( -
-
-
- {code} -

{title}

-
-

{question}

-

- Today: - {now} -

-
-
{children}
-
- ); -} - -/** One answer to a Decision; the recommended one carries the accent edge. */ -export function Variant({ - letter, - name, - recommended = false, - why, - children, -}: { - letter: string; - name: string; - recommended?: boolean; - why: string; - children: ReactNode; -}) { - return ( -
-
- - {letter} - -

{name}

- {recommended && Recommended} -
-
{children}
-

{why}

-
- ); -} - /** A small caption above a sample inside a variant. */ export function Sample({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/frontend/src/design-lab/linksRound.tsx b/frontend/src/design-lab/linksRound.tsx index 50f05704..ad27182e 100644 --- a/frontend/src/design-lab/linksRound.tsx +++ b/frontend/src/design-lab/linksRound.tsx @@ -16,13 +16,13 @@ import { Users, X, } from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; +import { Reference, Sample } from "./labKit"; import { SURFACE } from "../components/ui/surfaces"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { MEDIA_BADGE } from "../components/ui/mediaCardStyles"; -// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on -// purpose (the lab's rule); the folder and its files are the owner's own kind of content. +// Folder links, DECIDED (owner A/A/A): a /watch link that opens a whole folder, not one file. Static +// markup on purpose (the lab's rule); the folder and its files are the owner's own kind of content. const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); @@ -91,15 +91,13 @@ function Rail({ marked }: { marked: boolean }) { ); } -function FolderMenu({ quickCopy }: { quickCopy: boolean }) { +function FolderMenu() { const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm"; const groups: [typeof Folder, string, boolean?][][] = [ - quickCopy - ? [ - [Link2, "Link másolása"], - [Users, "Megosztás linkkel…"], - ] - : [[Users, "Megosztás linkkel…"]], + [ + [Link2, "Link másolása"], + [Users, "Megosztás linkkel…"], + ], [ [Clock, "Megőrzés"], [Pencil, "Átnevezés"], @@ -266,155 +264,54 @@ function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) { ); } -function GridPage() { - return ( - -
-
- {FILES.map((f) => ( -
- - {f.name} - - {f.size} - - -
- ))} -
- - ); -} - -function DownloadOnlyPage() { - return ( - -
-
- {FILES.map((f) => ( -
- - {f.name} - {f.size} - - Letöltés - -
- ))} -
- - ); -} - // ── The round ─────────────────────────────────────────────────────────────────────────────────── export function LinksRound() { return ( -
- + -
- - -
- - -
-
-
- - - - - - +
+ +
+ + +
+
+ - +
- + - -
- - - - - - - - - - - - - - +
+ + + + + +
- + - -
- - - - - - -
-
+ +
); } diff --git a/frontend/src/design-lab/tokensRound.tsx b/frontend/src/design-lab/tokensRound.tsx index b879b845..f1a5d252 100644 --- a/frontend/src/design-lab/tokensRound.tsx +++ b/frontend/src/design-lab/tokensRound.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import type { ReactNode } from "react"; import { AlertTriangle, - Check, Copy, Folder, KeyRound, @@ -12,15 +11,16 @@ import { SlidersHorizontal, X, } from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; +import { Reference, Sample } from "./labKit"; import { Badge } from "../components/ui/Badge"; import { Chip } from "../components/ui/Chip"; import { SURFACE } from "../components/ui/surfaces"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { fieldClass } from "../components/ui/fieldStyles"; -// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on -// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script). +// API tokens, DECIDED (owner A/A/A): how a machine (the hub) gets a key to the Files library. Static +// markup on purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone +// script). const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); const noop = () => {}; @@ -143,49 +143,6 @@ function ListRows() { ); } -function ListTable() { - return ( -
- - - - - - - - - - - {TOKENS.map((tk) => ( - - - - - - - - ))} - -
NévMappákHasználvaLejár -
-
{tk.name}
- {tk.prefix}… -
{tk.grants.map((g) => g.folder).join(", ")}{tk.used} - {tk.expires} - - - - -
-
- ); -} - // ── T2 · the new-token dialog ─────────────────────────────────────────────────────────────────── function Dialog({ @@ -220,22 +177,16 @@ function Field({ label, children }: { label: string; children: ReactNode }) { ); } -function Expiry({ never = false }: { never?: boolean }) { +function Expiry() { return ( {["30 nap", "90 nap", "1 év", "Soha"].map((l) => ( - + {l} ))} - {never && ( - - - Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod. - - )} ); } @@ -325,30 +276,6 @@ function PresetFirst({ custom }: { custom: boolean }) { ); } -function OneForm() { - return ( - - Mégse - Létrehozás - - } - > - - Hub csere - - - Mappák és jogok - Hub csere kitöltése - - - - - ); -} - // ── T3 · the token, shown once ────────────────────────────────────────────────────────────────── const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe"; @@ -396,120 +323,48 @@ function ShownInDialog() { ); } -function ShownAsBanner() { - return ( - -
- - - „Hub csere” elkészült — másold ki most, később nem látod. - - {RAW} - - curl példa másolása - -
- -
- ); -} - // ── The round ─────────────────────────────────────────────────────────────────────────────────── export function TokensRound() { return ( -
- + -
- - - - - - - - - - -
-
+ + + + - -
- +
+ - - + + - - - - +
- + - -
- - - - - - - - -
-
+ + + +
); } diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index b1c92551..66d00946 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -94,6 +94,7 @@ "linkCopied": "Share link copied to clipboard", "openNewTab": "Open in new tab", "linkFailed": "Couldn’t create the share link", + "linksFailed": "Couldn’t load the links", "noLinks": "No links yet.", "newLink": "Create a link", "createLink": "Create link", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index f20bcedf..8aee5fda 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -99,16 +99,16 @@ "new": "New password (min. 10 characters)", "save": "Save password", "saving": "Saving…", - "saved": "Password updated for {{email}}", + "saved": "Password updated for {{email}} — other sessions were signed out and your API tokens revoked", "failed": "Couldn't update the password" }, "sessions": { "title": "Active sessions", - "hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.", + "hint": "Sign this account out everywhere else, and revoke its API tokens — handy if you left it signed in on a shared or lost device. You stay signed in here.", "action": "Log out other sessions", "confirmTitle": "Log out other sessions?", - "confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.", - "done": "Signed out of all other sessions", + "confirmBody": "This signs you out of every other browser and device, and revokes every API token of yours (make new ones for your machines). You'll stay signed in here.", + "done": "Signed out of all other sessions; API tokens revoked", "failed": "Couldn't sign out other sessions. Please try again." } }, @@ -284,6 +284,7 @@ "tryIt": "Try it (curl)", "copy": "Copy", "copied": "Copied", + "copyField": "Copy: {{field}}", "done": "Done" } } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 78e8edd2..f50b5ddf 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -71,7 +71,6 @@ "approvalWaitingOnYou": "waiting on you", "copied": "Copied", "copy": "Copy", - "copyFailed": "Couldn't copy — select the link and copy it by hand", "email": "Email", "emailConfirmed": "confirmed", "emailUnconfirmed": "not confirmed", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 72c308d0..3dcccbe0 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -94,6 +94,7 @@ "linkCopied": "Megosztási link a vágólapra másolva", "openNewTab": "Megnyitás új lapon", "linkFailed": "Nem sikerült a megosztási link létrehozása", + "linksFailed": "Nem sikerült betölteni a linkeket", "noLinks": "Még nincs link.", "newLink": "Link létrehozása", "createLink": "Létrehozás", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 57b2073e..d9623852 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -99,16 +99,16 @@ "new": "Új jelszó (min. 10 karakter)", "save": "Jelszó mentése", "saving": "Mentés…", - "saved": "Jelszó frissítve ehhez: {{email}}", + "saved": "Jelszó frissítve ehhez: {{email}} — a többi munkamenet kijelentkezett, az API-tokenjeid visszavonva", "failed": "Nem sikerült frissíteni a jelszót" }, "sessions": { "title": "Aktív munkamenetek", - "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", + "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen, és vond vissza az API-tokenjeit — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", "action": "Kijelentkezés máshonnan", "confirmTitle": "Kijelentkezés a többi munkamenetből?", - "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.", - "done": "Kijelentkeztetve minden más munkamenetből", + "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről, és visszavonja minden API-tokenedet (a gépeidnek újakat kell készítened). Itt bejelentkezve maradsz.", + "done": "Kijelentkeztetve minden más munkamenetből; API-tokenek visszavonva", "failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra." } }, @@ -284,6 +284,7 @@ "tryIt": "Kipróbálás (curl)", "copy": "Másolás", "copied": "Másolva", + "copyField": "Másolás: {{field}}", "done": "Kész" } } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 57b6666e..3211fb31 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -71,7 +71,6 @@ "approvalWaitingOnYou": "rád vár", "copied": "Másolva", "copy": "Másolás", - "copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel", "email": "E-mail", "emailConfirmed": "megerősítve", "emailUnconfirmed": "nincs megerősítve", diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts index fc3f4d3d..00d8db38 100644 --- a/frontend/src/lib/downloadQueue.dom.test.ts +++ b/frontend/src/lib/downloadQueue.dom.test.ts @@ -7,15 +7,18 @@ import type { QueueJob } from "./api"; import { clearFinished, forgetFinished, - noteAnnounced, noteQueue, resetQueueWatch, takeUnannounced, + uploadEnded, + uploadStarted, useFinished, } from "./downloadQueue"; import { renderHook } from "@testing-library/react"; const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob; +const upload = (id: number) => + ({ id, status: "done", source_kind: "upload" }) as unknown as QueueJob; const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id); beforeEach(resetQueueWatch); @@ -44,12 +47,39 @@ describe("downloadQueue · a finish", () => { 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")]); + uploadStarted(); + uploadEnded(7); // an upload, done in the answer that made it + noteQueue([upload(7), job(8, "done")]); expect(takeUnannounced().map((j) => j.id)).toEqual([8]); expect(finishedIds()).toEqual([7, 8]); }); + it("an upload the queue sees done before its own answer arrives is still said once, by it", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(7)]); // the server committed it; the answer is still on its way + expect(takeUnannounced()).toEqual([]); + uploadEnded(7); + expect(takeUnannounced()).toEqual([]); + expect(finishedIds()).toEqual([7]); + }); + + it("another device's upload landing meanwhile is announced once this tab's upload answers", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(7), upload(9)]); + uploadEnded(7); + expect(takeUnannounced().map((j) => j.id)).toEqual([9]); + }); + + it("an upload that failed announced nothing: what landed meanwhile is announced", () => { + noteQueue([]); + uploadStarted(); + noteQueue([upload(9)]); + uploadEnded(null); + expect(takeUnannounced().map((j) => j.id)).toEqual([9]); + }); + 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 31d57008..7e7a61d6 100644 --- a/frontend/src/lib/downloadQueue.ts +++ b/frontend/src/lib/downloadQueue.ts @@ -35,6 +35,11 @@ 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(); +// This tab's uploads still waiting for their answer, and the uploads the queue saw land done +// meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is, +// only the answer can tell (`uploadEnded`), so until then none of them is announced. +let uploading = 0; +let held: QueueJob[] = []; /** A job to show in the tray, asked for at `at` (ms). */ export interface Reveal { @@ -54,15 +59,35 @@ export function noteQueue(jobs: QueueJob[]): void { finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]); // `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]); + const landed = (j: QueueJob) => + uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined; + held = [...held, ...news.filter(landed)]; + const now = news.filter((j) => !landed(j)); + if (now.length) unannounced.set((list) => [...list, ...now]); } -/** 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); +/** An upload from this tab is on its way: what the queue sees land done may be its own job. */ +export const uploadStarted = (): void => { + uploading += 1; }; +/** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray + * still keeps it, but its done notice would say it a second time, and as a download), null when it + * announced none (still converting, failed, cancelled). In whichever order the queue saw it. The + * rest held meanwhile (another device's uploads) are announced once no upload is waiting. */ +export function uploadEnded(said: number | null): void { + uploading = Math.max(0, uploading - 1); + if (said !== null) { + const before = held.length; + held = held.filter((j) => j.id !== said); + if (held.length === before) announcedHere.add(said); + } + if (uploading > 0 || held.length === 0) return; + const release = held; + held = []; + unannounced.set((list) => [...list, ...release]); +} + /** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */ export async function fetchQueue(): Promise { const jobs = await api.downloads(); @@ -107,5 +132,7 @@ export function resetQueueWatch(): void { finished.set([]); unannounced.set([]); announcedHere.clear(); + uploading = 0; + held = []; revealing.set(null); }