From 1c4f0042d2ff092f9ffc91d984b25c31715b9389 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 16:56:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(files):=20download=20from=20anywhere=20?= =?UTF-8?q?=E2=80=94=20a=20link=20box,=20a=20pasted=20link,=20the=20palett?= =?UTF-8?q?e;=20the=20running=20downloads=20in=20a=20tray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key. --- backend/app/downloads/pipeline.py | 12 +- backend/app/downloads/status.py | 4 + backend/app/routes/downloads.py | 17 +- backend/app/routes/files.py | 8 +- backend/tests/test_file_folders.py | 19 + backend/tests/test_files_api.py | 32 ++ frontend/src/App.tsx | 6 + .../components/AppCommandPalette.dom.test.tsx | 13 + frontend/src/components/AppCommandPalette.tsx | 21 +- .../DownloadCenter.menu.dom.test.tsx | 16 +- frontend/src/components/DownloadCenter.tsx | 68 +-- .../components/DownloadIntake.dom.test.tsx | 137 ++++++ frontend/src/components/DownloadIntake.tsx | 42 ++ .../src/components/DownloadTray.dom.test.tsx | 268 ++++++++++++ frontend/src/components/DownloadTray.tsx | 393 ++++++++++++++++++ .../src/components/Files.menu.dom.test.tsx | 2 +- frontend/src/components/Files.tsx | 48 ++- frontend/src/components/LinkBox.tsx | 88 ++++ .../NotificationsPanel.menu.dom.test.tsx | 45 +- .../src/components/NotificationsPanel.tsx | 84 ++-- frontend/src/components/ui/BackToTop.tsx | 5 +- frontend/src/components/ui/PageScroller.tsx | 13 +- .../src/components/ui/Toaster.dom.test.tsx | 22 + frontend/src/components/ui/Toaster.tsx | 30 +- frontend/src/components/ui/backToTopState.ts | 12 + .../ui/pressableSelectable.contract.test.ts | 4 + frontend/src/i18n/locales/en/downloads.json | 2 - frontend/src/i18n/locales/en/files.json | 7 +- frontend/src/i18n/locales/en/inbox.json | 2 +- frontend/src/i18n/locales/en/palette.json | 3 +- frontend/src/i18n/locales/en/tray.json | 26 ++ frontend/src/i18n/locales/hu/downloads.json | 2 - frontend/src/i18n/locales/hu/files.json | 7 +- frontend/src/i18n/locales/hu/inbox.json | 2 +- frontend/src/i18n/locales/hu/palette.json | 3 +- frontend/src/i18n/locales/hu/tray.json | 26 ++ frontend/src/lib/api/downloads.ts | 8 +- frontend/src/lib/downloadIntake.ts | 51 +++ frontend/src/lib/downloadQueue.dom.test.ts | 50 +++ frontend/src/lib/downloadQueue.ts | 59 +++ frontend/src/lib/notifications.ts | 9 +- frontend/src/lib/storage.ts | 5 + 42 files changed, 1509 insertions(+), 162 deletions(-) create mode 100644 frontend/src/components/DownloadIntake.dom.test.tsx create mode 100644 frontend/src/components/DownloadIntake.tsx create mode 100644 frontend/src/components/DownloadTray.dom.test.tsx create mode 100644 frontend/src/components/DownloadTray.tsx create mode 100644 frontend/src/components/LinkBox.tsx create mode 100644 frontend/src/i18n/locales/en/tray.json create mode 100644 frontend/src/i18n/locales/hu/tray.json create mode 100644 frontend/src/lib/downloadIntake.ts create mode 100644 frontend/src/lib/downloadQueue.dom.test.ts create mode 100644 frontend/src/lib/downloadQueue.ts diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 3e792513..307d3886 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -95,16 +95,24 @@ def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, notification failure must never mask the completed job, so it is isolated + rolled back (mirrors scheduler._notify_done). `kind` ("download"/"edit"/"upload") lets the inbox pick accurate copy; the English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the - caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row.""" + caller BEFORE its commit; only the folder is read here, once, for the notice's "Open".""" if user_id is None: return try: + # The notice's "Open" lands on the file in its folder: both ride along (a folder the file + # has left since is the page's to notice — it then looks among every file). + job = db.get(DownloadJob, job_id) create_notification( db, user_id=user_id, type="download_done", title=f"Ready to watch: {title}" if title else "Ready to watch", - data={"title": title, "kind": kind}, + data={ + "title": title, + "kind": kind, + "job_id": job_id, + "folder_id": job.folder_id if job else None, + }, ) except Exception: db.rollback() diff --git a/backend/app/downloads/status.py b/backend/app/downloads/status.py index 7849930e..f8d6da0d 100644 --- a/backend/app/downloads/status.py +++ b/backend/app/downloads/status.py @@ -53,3 +53,7 @@ NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP}) # States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free # the asset, leave the route's status intact) when it observes one of these on a job it is running. STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED}) + + +# A job that will not move again on its own: the queue lists it only briefly after it got here. +FINISHED = frozenset({JobStatus.DONE, JobStatus.CANCELED}) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index f50e4162..053e83c1 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -30,7 +30,7 @@ from app.downloads import edit as editmod from app.downloads import links as linksmod from app.downloads import quota, service, storage from app.downloads import urlguard -from app.downloads.status import NOT_REFETCHABLE, JobStatus +from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus from app.models import ( DownloadJob, DownloadLink, @@ -58,6 +58,7 @@ from app.routes.files import ( quota_detail, serialize, serialize_job, + source_keys, ) from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses @@ -396,12 +397,22 @@ def enqueue_clip( return serialize_job(db, job) +# How long a finished job stays in the queue: the tray polls it from every page and must see a job +# turn done (its "Done · Open" row and the done notice) — the finished file itself is the library's. +QUEUE_RECENT = timedelta(minutes=10) + + @router.get("") def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: + """The queue: what is waiting, running, paused or failed, and what finished a moment ago.""" + recent = datetime.now(timezone.utc) - QUEUE_RECENT jobs = ( db.execute( select(DownloadJob) - .where(DownloadJob.user_id == user.id) + .where( + DownloadJob.user_id == user.id, + DownloadJob.status.not_in(FINISHED) | (DownloadJob.updated_at >= recent), + ) .order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc()) .limit(MAX_LIST) ) @@ -410,10 +421,12 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get ) assets = assets_for(db, jobs) positions = positions_for(db, user, jobs) + sources = source_keys(db, jobs) return [ { **serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), "position_seconds": positions.get(j.id, 0), + "source_key": sources[j.id], } for j in jobs ] diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 7c33a86e..0e3d8d1e 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -556,7 +556,7 @@ def list_files( assets = assets_for(db, jobs) positions = positions_for(db, user, jobs) shared_at = _shared_at(db, user, jobs) if shared else {} - sources = _source_keys(db, jobs) + sources = source_keys(db, jobs) return [ { **serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), @@ -569,9 +569,9 @@ def list_files( ] -def _source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]: - """Where each file came from (its source folder's key), for the list's Source column and the - "back to its source folder" move: a file in a source folder has its folder's key; one moved into +def source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]: + """Where each file came from (its source folder's key), for the list's Source column, the + "back to its source folder" move and the download tray's site: a file in a source folder has its folder's key; one moved into an own folder (or someone else's, shared) is traced back by `folders.source_key_for`.""" ids = {j.folder_id for j in jobs if j.folder_id is not None} by_id = ( diff --git a/backend/tests/test_file_folders.py b/backend/tests/test_file_folders.py index 7bd6681e..94e245c3 100644 --- a/backend/tests/test_file_folders.py +++ b/backend/tests/test_file_folders.py @@ -261,3 +261,22 @@ def test_the_longest_host_has_room_for_its_folder(db, make_user): uid = make_user().id job = _job(db, uid, _asset(db, "url", f"https://{host}/v"), kind="url") assert _folder(db, job).source_key == f"site:{host}" + + +def test_the_ready_notice_names_the_file_and_its_folder(db, make_user): + # The notice's "Open" goes to the file in its folder, so both ride along as data. + from app.downloads import pipeline + + user = make_user() + job = _job(db, user.id, _asset(db)) + pipeline._notify_download_done(db, user.id, "A title", job.id, kind="download") + db.commit() + note = db.scalars(select(Notification).where(Notification.user_id == user.id)).one() + assert note.type == "download_done" + assert note.data == { + "title": "A title", + "kind": "download", + "job_id": job.id, + "folder_id": job.folder_id, + } + assert job.folder_id is not None diff --git a/backend/tests/test_files_api.py b/backend/tests/test_files_api.py index b0de520a..a0819277 100644 --- a/backend/tests/test_files_api.py +++ b/backend/tests/test_files_api.py @@ -506,3 +506,35 @@ def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user): asset.sha256 = "ab" * 32 asset.rel_path = "converted.mp4" assert asset.sha256 is None + + +# --- the queue (the tray's poll) ------------------------------------------------------------------ + + +def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library( + db, make_user, as_user +): + # The tray polls this on every page: a finished file is the library's, so it is listed only + # while the tray may still be showing it done. + from sqlalchemy import update + + user = make_user() + old = _file(db, user.id, title="Old") + fresh = _file(db, user.id, title="Fresh") + waiting = _file(db, user.id, title="Waiting", status=JobStatus.QUEUED) + stuck = _file(db, user.id, title="Stuck", status=JobStatus.ERROR) + paused = _file(db, user.id, title="Paused", status=JobStatus.PAUSED) + stopped = _file(db, user.id, title="Stopped", status=JobStatus.ERROR) + dropped = _file(db, user.id, title="Dropped", status=JobStatus.CANCELED) + for job in (old, stopped, dropped): + db.execute( + update(DownloadJob) + .where(DownloadJob.id == job.id) + .values(updated_at=NOW - timedelta(hours=1)) + ) + db.commit() + rows = as_user(user).get("/api/downloads").json() + # An old failure still waits for the user; an old finished or cancelled job does not. + assert {row["id"] for row in rows} == {fresh.id, waiting.id, stuck.id, paused.id, stopped.id} + # The tray names where each came from, as the library does. + assert {row["source_key"] for row in rows} == {"youtube"} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3ac441e..d773ab27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,8 @@ import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; import KeyboardHelp from "./components/ui/KeyboardHelp"; import AppCommandPalette from "./components/AppCommandPalette"; +import DownloadIntake from "./components/DownloadIntake"; +import DownloadTray from "./components/DownloadTray"; import { useGlobalKeymap } from "./lib/keymap"; import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; @@ -461,12 +463,16 @@ export default function App() { Anchored inside the content column so they clear the sidebar automatically (collapsed or expanded) without tracking its width. */} + {/* The running downloads, bottom-right in the same column (the toasts keep the left). */} + {/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K). Mounted once, outside the page area, so every surface answers "what can I press here" and "take me to X" the same way. Their keys are bound by useGlobalKeymap above. */} + {/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */} + {/* The debug recorder: its start dialog, and its pill docked under the toasts. */} {me && } diff --git a/frontend/src/components/AppCommandPalette.dom.test.tsx b/frontend/src/components/AppCommandPalette.dom.test.tsx index 679d3b2b..0a88376e 100644 --- a/frontend/src/components/AppCommandPalette.dom.test.tsx +++ b/frontend/src/components/AppCommandPalette.dom.test.tsx @@ -62,6 +62,8 @@ vi.mock("./savedViewsContext", () => ({ })); const requestSurprise = vi.fn(); vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() })); +const requestLinkBox = vi.fn(); +vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() })); import AppCommandPalette from "./AppCommandPalette"; @@ -142,6 +144,17 @@ describe("AppCommandPalette", () => { expect(requestSurprise).toHaveBeenCalledTimes(1); }); + it("Download from a link takes the reader to the Files link box, asked on that entry", () => { + mount(); + const item = group("actions")!.items.find((i) => i.id === "action:downloadLink")!; + setPage.mockImplementationOnce(() => {}); + item.run(); + expect(setPage.mock.lastCall?.[0]).toBe("files"); + expect(requestLinkBox).not.toHaveBeenCalled(); + setPage.mock.lastCall?.[1]?.(); + expect(requestLinkBox).toHaveBeenCalledTimes(1); + }); + it("offers the debug recorder by its state", () => { const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:")); mount(); diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx index d45d6c82..0e98b081 100644 --- a/frontend/src/components/AppCommandPalette.tsx +++ b/frontend/src/components/AppCommandPalette.tsx @@ -1,7 +1,17 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { Bug, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, Tv } from "lucide-react"; +import { + Bug, + Download, + Flag, + Keyboard, + ListVideo, + Palette, + Sparkles, + Square, + Tv, +} from "lucide-react"; import CommandPalette, { type CommandGroup } from "./ui/CommandPalette"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; @@ -10,6 +20,7 @@ import { moduleDef, moduleOrder } from "../lib/modules"; import { SCHEMES, type Scheme } from "../lib/theme"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { requestSurprise } from "../lib/homeIntent"; +import { requestLinkBox } from "../lib/downloadIntake"; import { openRecorderStart } from "../lib/debugRecorder"; import { useDebugRecording } from "./useDebugRecording"; import { useNavigationActions } from "./navigationContext"; @@ -145,6 +156,14 @@ export default function AppCommandPalette() { // polcodról" under a command that promised a surprise (owner's call, 2026-09-19). run: () => setPage("home", requestSurprise), }, + // Downloading is an action you start anywhere; with nothing pasted yet, the place to paste is + // the Files page's link box (lib/downloadIntake), focused on arrival. + { + id: "action:downloadLink", + label: t("palette.actions.downloadLink"), + icon: , + run: () => setPage("files", requestLinkBox), + }, { id: "action:shortcuts", label: t("keys.title"), diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index a02fe0d5..c85cb948 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -79,15 +79,21 @@ afterEach(() => { }); function mount(tab?: string) { - render( - + const qc = new QueryClient(); + const tree = () => ( + - , + ); + const { rerender } = render(tree()); + mounted.rerender = () => rerender(tree()); if (tab) fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) })); } +// The last mount's re-render: what a poll's new data does to the page. +const mounted = { rerender: () => {} }; + const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="downloads.rowMenu"]')!; @@ -172,9 +178,7 @@ describe("DownloadCenter · the queue row", () => { await openMenu(() => fireEvent.click(moreOf("Clip 1"))); // The queue polls while a job runs: the next render lists a NEW object for the same job. mocks.jobs = [job(1, "running", { progress: 40 })]; - fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), { - target: { value: "x" }, - }); + mounted.rerender(); act(() => { fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Clip 1")); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 79777477..de372660 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,42 +1,31 @@ import { lazy, Suspense, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react"; +import { Pause, Play, RefreshCw, Settings2, UploadCloud } from "lucide-react"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { PageToolbar } from "./ui/PageShell"; import { LoadingState, StateMessage } from "./ui/QueryState"; // Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used. -const DownloadDialog = lazy(() => import("./DownloadDialog")); const UploadDialog = lazy(() => import("./UploadDialog")); const ProfileEditor = lazy(() => import("./ProfileEditor")); import { useLiveQuery } from "../lib/useLiveQuery"; import { useMe } from "../lib/useMe"; -import { api, type DownloadJob } from "../lib/api"; +import { type DownloadJob } from "../lib/api"; +import { fetchQueue } from "../lib/downloadQueue"; import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; import { DownloadRow, IconBtn } from "./downloadRow"; import { ErrorModal } from "./downloadModals"; import { AdminSystem } from "./downloadAdmin"; import NavGrid from "./ui/NavGrid"; -import { Input } from "./ui/Input"; import { useContextMenu } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { useDownloadActions } from "./useDownloadActions"; +import { LinkBox } from "./LinkBox"; const jobKey = (job: DownloadJob) => job.id; -/** The clipboard's text, or null where the browser refuses. Outside the component: the React - * Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */ -async function clipboardText(): Promise { - try { - return (await navigator.clipboard?.readText())?.trim() ?? null; - } catch { - return null; // permission denied or unsupported — the manual paste button still works on a click - } -} - // The download queue: what is downloading, waiting or failed, the link box and the upload, the // download profiles, and (admin) the instance's downloads. A finished download is a library file and // lives in Files. @@ -47,8 +36,6 @@ export default function DownloadCenter() { // any failures are, so it's the useful default every visit. const [tab, setTab] = useState("queue"); const panel = useTabPanel(); - const [addUrl, setAddUrl] = useState(""); - const [addSource, setAddSource] = useState(null); const [upload, setUpload] = useState(false); const [manage, setManage] = useState(false); const [errorJob, setErrorJob] = useState(null); @@ -56,7 +43,7 @@ export default function DownloadCenter() { // Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled // (only done/error/paused rows left, which don't advance on their own) so an idle Download Center // isn't hitting the API — and adding to the shared box's load — every 2s for nothing. - const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { + const jobsQ = useLiveQuery(qk.downloads(), fetchQueue, { intervalMs: (data) => data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000, }); @@ -86,16 +73,6 @@ export default function DownloadCenter() { }; }; - // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + - // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste - // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if - // the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text. - const pasteFromClipboard = async (force = false) => { - if (!force && addUrl.trim()) return; - const text = await clipboardText(); - if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text); - }; - const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), @@ -137,31 +114,7 @@ export default function DownloadCenter() { <> {/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
- setAddUrl(e.target.value)} - onFocus={() => pasteFromClipboard()} - onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} - placeholder={t("downloads.page.addUrl")} - /> - pasteFromClipboard(true)} - > - - - + {/* Local upload is a peer to "add a URL": a general file → the same library. */} +
+ ), +})); + +import DownloadIntake from "./DownloadIntake"; +import { LinkBox } from "./LinkBox"; +import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake"; +import { ensureNavId } from "../lib/history"; + +afterEach(() => { + cleanup(); + act(clearPendingDownload); +}); + +function paste(target: EventTarget, text: string) { + const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { + value: { getData: (type: string) => (type === "text/plain" ? text : "") }, + }); + act(() => { + target.dispatchEvent(event); + }); + return event; +} + +const dialog = () => screen.queryByRole("dialog", { name: "download" }); + +describe("DownloadIntake · a pasted link", () => { + it("a link pasted onto the page opens the download dialog on it", async () => { + render(); + const event = paste(document.body, " https://www.tiktok.com/@a/video/1 "); + expect(await screen.findByText("https://www.tiktok.com/@a/video/1")).toBeTruthy(); + // Taken: the browser does nothing else with it. + expect(event.defaultPrevented).toBe(true); + }); + + it("text that is not one link, or a paste into a field, is left alone", async () => { + render( + <> + + + , + ); + paste(document.body, "just some words"); + paste(document.body, "https://a.example/1 https://a.example/2"); + const field = screen.getByRole("textbox", { name: "search" }); + expect(paste(field, "https://a.example/1").defaultPrevented).toBe(false); + await act(async () => {}); + expect(dialog()).toBeNull(); + }); + + it("with a dialog on top, a paste is the dialog's, not a download", async () => { + render( + <> + +
+ , + ); + paste(document.body, "https://a.example/1"); + await act(async () => {}); + expect(dialog()).toBeNull(); + }); + + it("closing the dialog closes it", async () => { + render(); + paste(document.body, "https://a.example/1"); + fireEvent.click(await screen.findByText("close")); + expect(dialog()).toBeNull(); + }); +}); + +describe("LinkBox", () => { + it("Enter hands the typed link to the dialog and empties the box", async () => { + render( + <> + + + , + ); + const field = screen.getByRole("textbox", { name: "files.link.label" }); + fireEvent.change(field, { target: { value: " https://vimeo.com/123 " } }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(await screen.findByText("https://vimeo.com/123")).toBeTruthy(); + expect((field as HTMLInputElement).value).toBe(""); + }); + + it("an empty box downloads nothing", async () => { + render( + <> + + + , + ); + fireEvent.click(screen.getByText("files.link.download")); + await act(async () => {}); + expect(dialog()).toBeNull(); + }); + + it("the palette's request focuses the box on its entry, once", () => { + ensureNavId(); + act(requestLinkBox); + const { unmount } = render(); + expect(document.activeElement).toBe(screen.getByRole("textbox", { name: "files.link.label" })); + unmount(); + // Spent: the next mount on the same entry is not focused again. + render(); + expect(document.activeElement).toBe(document.body); + }); + + it("a request made for another entry (the navigation never got here) focuses nothing", () => { + ensureNavId(); + act(requestLinkBox); + // A later entry — say Back, then elsewhere — mounts the box. + window.history.pushState({}, ""); + ensureNavId(); + render(); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/frontend/src/components/DownloadIntake.tsx b/frontend/src/components/DownloadIntake.tsx new file mode 100644 index 00000000..4297c9ae --- /dev/null +++ b/frontend/src/components/DownloadIntake.tsx @@ -0,0 +1,42 @@ +import { lazy, Suspense, useEffect } from "react"; +import { + asDownloadLink, + clearPendingDownload, + pasteIsForThePage, + requestDownload, + usePendingDownload, +} from "../lib/downloadIntake"; + +const DownloadDialog = lazy(() => import("./DownloadDialog")); + +// The app's one download dialog (lib/downloadIntake), and the paste that opens it: a lone http(s) +// link pasted onto any page — not into a field — is downloaded, the profile picked in the dialog. +export default function DownloadIntake() { + const source = usePendingDownload(); + + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + if (e.defaultPrevented || !pasteIsForThePage(e.target)) return; + const link = asDownloadLink(e.clipboardData?.getData("text/plain")); + if (!link) return; + e.preventDefault(); + requestDownload(link); + }; + document.addEventListener("paste", onPaste); + return () => document.removeEventListener("paste", onPaste); + }, []); + + return ( + + {/* The link itself under the title: a paste can be an accident, and this says what it was. */} + {source && ( + + )} + + ); +} diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/components/DownloadTray.dom.test.tsx new file mode 100644 index 00000000..fe62ddb2 --- /dev/null +++ b/frontend/src/components/DownloadTray.dom.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment jsdom +// +// The download tray: up only while something waits, runs or failed — and what finished in this tab, +// until it is closed; a row's right click and "…" are the queue's menu; a failure says why and offers +// a retry; a finish is announced once with "Copy link" and "Open"; it folds to a pill, remembered. +// The queue is fed straight in (the poll is stubbed); a poll's look is `noteQueue`, as fetchQueue does. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { api, type QueueJob } from "../lib/api"; + +const h = vi.hoisted(() => ({ + jobs: [] as unknown[], + openFile: vi.fn(), + share: vi.fn(async () => "https://x/watch/t"), + notify: vi.fn(), + prefs: new Map(), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string | string[], o?: { count?: number; title?: string }) => + `${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`, + }), +})); +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: () => ({ data: h.jobs, isError: false, isLoading: false, refetch: vi.fn() }), +})); +// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How +// usePref stores them is its own business (usePref.dom.test). +vi.mock("../lib/usePref", () => ({ + usePref: (key: string, initial: T) => { + const [value, setValue] = useState(() => + h.prefs.has(key) ? (h.prefs.get(key) as T) : initial, + ); + return [ + value, + (next: T) => { + h.prefs.set(key, next); + setValue(next); + }, + ]; + }, +})); +vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) })); +vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("../lib/downloads", async (importOriginal) => ({ + ...(await importOriginal()), + quickShareLink: h.share, +})); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: h.notify, +})); + +import DownloadTray from "./DownloadTray"; +import { noteQueue, resetQueueWatch } from "../lib/downloadQueue"; + +beforeEach(() => { + resetQueueWatch(); + h.prefs.clear(); + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const f of [h.openFile, h.share, h.notify]) f.mockClear(); +}); + +const job = (id: number, status: string, extra: Partial = {}) => + ({ + id, + status, + progress: 40, + phase: "video", + error_code: null, + error_detail: null, + job_kind: "download", + display_name: `Clip ${id}`, + source_key: "site:tiktok.com", + folder_id: 9, + asset: { media_kind: "video" }, + ...extra, + }) as unknown as QueueJob; + +/** A poll: the queue the tray now sees. */ +function poll(jobs: QueueJob[], view?: { rerender: () => void }) { + h.jobs = jobs; + act(() => noteQueue(jobs)); + view?.rerender(); +} + +function mount() { + const qc = new QueryClient(); + const tree = () => ( + + + + ); + const { rerender } = render(tree()); + return { rerender: () => act(() => rerender(tree())) }; +} + +const tray = () => screen.queryByRole("region", { name: "tray.label" }); +const rowOf = (title: string) => screen.getByText(title).closest("[data-job-id]")!; + +describe("DownloadTray", () => { + it("is not there while nothing waits, runs or failed", () => { + poll([job(1, "done")]); + mount(); + expect(tray()).toBeNull(); + }); + + it("lists what waits, runs and failed, its sites, and sums them up", () => { + poll([job(1, "running"), job(2, "queued"), job(3, "error", { error_code: "login_required" })]); + mount(); + expect(tray()).not.toBeNull(); + expect(screen.getByText(/tray\.count\.running:2, tray\.count\.error:1/)).toBeTruthy(); + expect(within(rowOf("Clip 1")).getByText("tiktok.com")).toBeTruthy(); + expect(within(rowOf("Clip 1")).getByText("40%")).toBeTruthy(); + expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy(); + }); + + it("a failure says why, and its Retry resumes it", () => { + const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never); + poll([job(3, "error", { error_code: "login_required" })]); + mount(); + expect( + within(rowOf("Clip 3")).getByText("downloads.errors.fail.cat.login_required.short"), + ).toBeTruthy(); + fireEvent.click(within(rowOf("Clip 3")).getByText("downloads.actions.retry")); + return waitFor(() => expect(resume).toHaveBeenCalledWith(3)); + }); + + it("a row's right click opens the queue's menu", async () => { + poll([job(1, "running")]); + mount(); + act(() => { + fireEvent.contextMenu(rowOf("Clip 1")); + }); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toContain( + "downloads.actions.pause", + ), + ); + }); + + it("a finish is announced once — Copy link, Open — and stays as Done · Open", async () => { + poll([job(1, "running")]); + const view = mount(); + poll([job(1, "done")], view); + await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); + const notice = h.notify.mock.calls[0]![0] as { + title: string; + message: string; + action: { label: string; onClick: () => void }; + secondaryAction: { label: string; onClick: () => void }; + }; + expect(notice.title).toBe("tray.done.title:Clip 1"); + expect(notice.message).toBe("tray.done.phone"); + expect(notice.action.label).toBe("tray.done.copyLink"); + notice.action.onClick(); + expect(h.share).toHaveBeenCalled(); + notice.secondaryAction.onClick(); + expect(h.openFile).toHaveBeenCalledWith(1, 9); + // Still on the tray, done; its Open opens it. + fireEvent.click(within(rowOf("Clip 1")).getByText("tray.done.open")); + expect(h.openFile).toHaveBeenCalledTimes(2); + view.rerender(); + expect(h.notify).toHaveBeenCalledTimes(1); + }); + + it("each finish is announced once: a later one does not repeat an earlier", async () => { + poll([job(1, "running"), job(2, "running")]); + const view = mount(); + poll([job(1, "done"), job(2, "running")], view); + await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); + poll([job(1, "done"), job(2, "done")], view); + await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); + expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ + "tray.done.title:Clip 1", + "tray.done.title:Clip 2", + ]); + }); + + it("a plain file's notice does not promise a phone", async () => { + poll([job(1, "running", { asset: { media_kind: "file" } as never })]); + const view = mount(); + poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); + await waitFor(() => + expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })), + ); + }); + + it("closed, it forgets what finished and stays away until the work changes", () => { + poll([job(1, "running"), job(2, "running")]); + const view = mount(); + poll([job(1, "running"), job(2, "done")], view); + fireEvent.click(screen.getByRole("button", { name: "tray.close" })); + expect(tray()).toBeNull(); + // The same work moving on is not news… + poll([job(1, "running", { progress: 80 })], view); + expect(tray()).toBeNull(); + // …a new job is. + poll([job(1, "running"), job(4, "queued")], view); + expect(tray()).not.toBeNull(); + expect(screen.queryByText("Clip 2")).toBeNull(); + }); + + it("closed on an old failure, it stays closed after a reload", () => { + poll([job(3, "error")]); + const first = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "tray.close" })); + first.unmount(); + resetQueueWatch(); + poll([job(3, "error")]); + mount(); + expect(tray()).toBeNull(); + }); + + it("closed on an old failure, a new download's finish still shows", () => { + poll([job(3, "error")]); + const view = mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.close" })); + expect(tray()).toBeNull(); + poll([job(4, "running"), job(3, "error")], view); + expect(tray()).not.toBeNull(); + // Done, the queue is the old failure alone again — the very queue it was closed on. + poll([job(4, "done"), job(3, "error")], view); + expect(tray()).not.toBeNull(); + expect(within(rowOf("Clip 4")).getByText("tray.done.label")).toBeTruthy(); + }); + + it("folds to a pill that sums it up, and opens again — the keyboard following", () => { + poll([job(1, "running")]); + mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); + expect(screen.queryByText("Clip 1")).toBeNull(); + const pill = screen.getByRole("button", { name: /tray\.count\.running:1/ }); + expect(document.activeElement).toBe(pill); + fireEvent.click(pill); + expect(screen.getByText("Clip 1")).toBeTruthy(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "tray.fold" })); + }); + + it("closed, it leaves the keyboard on the page", () => { + poll([job(1, "running")]); + render( + +
+ + , + ); + fireEvent.click(screen.getByRole("button", { name: "tray.close" })); + expect(document.activeElement?.id).toBe("content"); + }); +}); diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx new file mode 100644 index 00000000..2aa2bc94 --- /dev/null +++ b/frontend/src/components/DownloadTray.tsx @@ -0,0 +1,393 @@ +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + FolderOpen, + Link, + Loader2, + RotateCw, + X, +} from "lucide-react"; +import type { QueueJob } from "../lib/api"; +import { quickShareLink } from "../lib/downloads"; +import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; +import { + IN_FLIGHT, + clearFinished, + fetchQueue, + takeUnannounced, + useFinished, + useUnannounced, +} from "../lib/downloadQueue"; +import { navItemAttrs } from "../lib/listNav"; +import { notify } from "../lib/notifications"; +import { qk } from "../lib/queryKeys"; +import { LS } from "../lib/storage"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { usePref } from "../lib/usePref"; +import { ErrorModal } from "./downloadModals"; +import { jobTitle } from "./downloadTitle"; +import { sourceLabel } from "./fileFolders"; +import { useFilesActions } from "./filesContext"; +import { useDownloadActions } from "./useDownloadActions"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { setCornerLift } from "./ui/backToTopState"; +import { Button } from "./ui/Button"; +import { ContextMenu } from "./ui/ContextMenu"; +import { IconButton } from "./ui/IconButton"; +import { menuGroups } from "./ui/menuGroups"; +import { MoreButton } from "./ui/MoreButton"; +import NavGrid from "./ui/NavGrid"; +import { Pressable } from "./ui/Pressable"; +import { CONTENT_ID } from "./ui/landmarks"; +import { SURFACE } from "./ui/surfaces"; +import { useContextMenu } from "./ui/useContextMenu"; + +// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner, +// up only while something waits, runs or failed — and what finished in this tab, "Done · Open", +// until it is closed. Folds to a pill (remembered per account). A row's right click and "…" are the +// queue's one menu; a finish is announced once with "Copy link" and "Open" (DESIGN.md "Download +// done"). It sits under every dialog and player (z-chrome), so it never covers a player's controls. + +type Fold = "open" | "folded"; +const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open"); +const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null); + +// Byte progress (a bar with a %) vs ffmpeg's post-steps (a pulse): the queue row's split. +const BYTE_PHASES = ["video", "audio", "editing", "converting"]; + +const keyOf = (job: QueueJob) => job.id; + +function Progress({ job }: { job: QueueJob }) { + if (job.phase && !BYTE_PHASES.includes(job.phase)) + return ( + + + + ); + return ( + + + + + {job.progress}% + + ); +} + +export default function DownloadTray() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { openFile } = useFilesActions(); + const actions = useDownloadActions(); + + // Every page polls it now, so the idle cadence is slow: a job started here refetches at once + // (invalidateDownloads), and one started on another device shows within a minute. + const queueQ = useLiveQuery(qk.downloads(), fetchQueue, { + intervalMs: (data) => (data?.some((j) => IN_FLIGHT.has(j.status)) ? 2000 : 60000), + }); + const finished = useFinished(); + const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error"); + const activeIds = new Set(active.map((j) => j.id)); + const rows = [...active, ...finished.filter((j) => !activeIds.has(j.id))]; + + // Closed with work still on it, it stays away until that work changes: a new job, or one failing. + // Remembered (per account) past a reload: an old failure the reader has seen must not reopen it + // on every page they load. + const signature = active.map((j) => `${j.id}${j.status === "error" ? "!" : ""}`).join(","); + const [closedAt, setClosedAt] = usePref("downloadTrayClosed", null, { + localKey: LS.downloadTrayClosed, + parse: parseClosed, + }); + // A finish since it was closed is news of its own, whatever else is still on it. + const shown = rows.length > 0 && (signature !== closedAt || finished.length > 0); + const [fold, setFold] = usePref("downloadTray", "open", { + localKey: LS.downloadTray, + parse: parseFold, + }); + + const [errorJob, setErrorJob] = useState(null); + const menu = useContextMenu({ keyOf }); + + // Folding swaps the control under the keyboard for its counterpart: the focus follows it there + // (the pill, or the fold button), never left on where the pressed one was. + const pill = useRef(null); + const foldButton = useRef(null); + const focusNext = useRef(null); + const toggleFold = (next: Fold) => { + focusNext.current = next; + setFold(next); + }; + useEffect(() => { + const target = focusNext.current; + focusNext.current = null; + if (target === "folded") pill.current?.focus(); + else if (target === "open") foldButton.current?.focus(); + }, [fold]); + + // --- the done notice ------------------------------------------------------------------------ + const announce = useEffectEvent((job: QueueJob) => { + const title = jobTitle(job); + notify({ + level: "success", + title: t("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: { + label: t("tray.done.open"), + onClick: () => openFile(job.id, job.folder_id), + }, + }); + }); + 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]); + + // --- Back to top steps up over the tray while it shows ----------------------------------------- + const box = useRef(null); + useLayoutEffect(() => { + const el = box.current; + if (!el) { + setCornerLift(0); + return; + } + const measure = () => setCornerLift(el.getBoundingClientRect().height); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => { + ro.disconnect(); + setCornerLift(0); + }; + }, [shown, fold]); + + if (!shown) + return errorJob ? ( + actions.run(errorJob, "resume")} + onClose={() => setErrorJob(null)} + /> + ) : null; + + const counts = { + running: rows.filter((j) => j.status === "queued" || j.status === "running").length, + paused: rows.filter((j) => j.status === "paused").length, + error: rows.filter((j) => j.status === "error").length, + done: rows.filter((j) => j.status === "done").length, + }; + const summary = (["running", "paused", "error", "done"] as const) + .filter((k) => counts[k] > 0) + .map((k) => t(`tray.count.${k}`, { count: counts[k] })) + .join(", "); + const running = rows.filter((j) => j.status === "running"); + const meanProgress = running.length + ? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length) + : null; + const StateIcon = counts.running ? Loader2 : counts.error ? AlertTriangle : CheckCircle2; + const stateIcon = ( + + ); + + const close = () => { + clearFinished(); + setClosedAt(signature); + // The tray goes with the button pressed: the keyboard rests on the page (its
). + document.getElementById(CONTENT_ID)?.focus({ preventScroll: true }); + }; + + const doneItems = (job: QueueJob): ActionMenuItem[] => + menuGroups([ + { + id: "open", + label: t("tray.done.open"), + icon: , + onSelect: () => openFile(job.id, job.folder_id), + }, + { + id: "copyLink", + label: t("tray.done.copyLink"), + icon: , + onSelect: () => void quickShareLink(qc, job, t), + }, + ]); + const menuItems = menu.target + ? menu.target.status === "done" + ? doneItems(menu.target) + : actions.items(menu.target) + : []; + + return ( +
+ {fold === "folded" ? ( + toggleFold("open")} + aria-expanded={false} + title={t("tray.unfold")} + className={clsx( + SURFACE.floating, + "inline-flex items-center gap-2 px-3 py-1.5 text-sm glass-hover", + )} + > + {stateIcon} + {summary} + {meanProgress !== null && ( + + + + )} + + ) : ( +
+
+ {stateIcon} + + {/* A phone's narrow tray keeps the news and drops the name (the region names it). */} + {t("tray.title")} · + {summary} + + toggleFold("folded")} + > + + + + + +
+ + {rows.map((job, i) => { + const title = jobTitle(job); + const mine = menu.open && menu.target?.id === job.id; + return ( +
+
+ + + {title} + + + {sourceLabel(job.source_key, t)} + + + setErrorJob(job)} + onRetry={() => actions.run(job, "resume")} + onOpen={() => openFile(job.id, job.folder_id)} + /> +
+ +
+ ); + })} +
+
+ )} + + {errorJob && ( + actions.run(errorJob, "resume")} + onClose={() => setErrorJob(null)} + /> + )} +
+ ); +} + +/** What a tray row says under its title: how far it is, that it waits, why it failed, or that it is + * done — with the one thing to do about it in reach. */ +function RowState({ + job, + onShowError, + onRetry, + onOpen, +}: { + job: QueueJob; + onShowError: () => void; + onRetry: () => void; + onOpen: () => void; +}) { + const { t } = useTranslation(); + if (job.status === "running") return ; + if (job.status === "error") + return ( + + + + {t(failCopyKeys(job, "short"))} + + + + ); + if (job.status === "done") + return ( + + + {t("tray.done.label")} + + + ); + return {t(`downloads.status.${job.status}`)}; +} diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx index 3de77046..4aae85be 100644 --- a/frontend/src/components/Files.menu.dom.test.tsx +++ b/frontend/src/components/Files.menu.dom.test.tsx @@ -259,7 +259,7 @@ describe("Files · my file's row", () => { const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); fireEvent.change(field, { target: { value: "Más" } }); fireEvent.keyDown(field, { key: "Escape" }); - expect(screen.queryByRole("textbox")).toBeNull(); + expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(); expect(update).not.toHaveBeenCalled(); await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true)); }); diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx index a794aa79..199adf0d 100644 --- a/frontend/src/components/Files.tsx +++ b/frontend/src/components/Files.tsx @@ -34,6 +34,7 @@ import { MoreButton } from "./ui/MoreButton"; import NavGrid from "./ui/NavGrid"; import Overlay from "./ui/Overlay"; import { PageToolbar } from "./ui/PageShell"; +import { LinkBox } from "./LinkBox"; import Pager from "./ui/Pager"; import { Pressable } from "./ui/Pressable"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -572,9 +573,8 @@ export default function Files() { {t("files.count", { count: rows.length })} )} - {/* Wraps on a phone: sort, view and Upload together are wider than the column. */} - - {isGrid && ( + {isGrid && ( + - )} - setLayouts({ ...layouts, [viewKey]: next })} - label={t("files.layout.label")} - /> - {canUpload && ( - - )} + + )} + {/* The intake row (DESIGN.md "Files page"): paste a link, or upload; then the paint. On a + phone the link box takes the line and the rest wraps under it. A link is downloaded + into its site's folder whatever is open, so the box stays even in Shared with me. */} + + + + {canUpload && ( + + )} + setLayouts({ ...layouts, [viewKey]: next })} + label={t("files.layout.label")} + /> +
diff --git a/frontend/src/components/LinkBox.tsx b/frontend/src/components/LinkBox.tsx new file mode 100644 index 00000000..0911cb01 --- /dev/null +++ b/frontend/src/components/LinkBox.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ClipboardPaste, Download } from "lucide-react"; +import { + asDownloadLink, + requestDownload, + takeLinkBox, + useLinkBoxRequest, +} from "../lib/downloadIntake"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Input } from "./ui/Input"; + +/** The clipboard's text, or null where the browser refuses. Outside the component: the React + * Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */ +async function clipboardText(): Promise { + try { + return (await navigator.clipboard?.readText()) ?? null; + } catch { + return null; // permission denied or unsupported — the paste button still works on a click + } +} + +// "Paste a link and I download it": the field, the clipboard button and Download. The link goes to +// the app's one download dialog (lib/downloadIntake), where the profile is picked. +export function LinkBox({ className }: { className?: string }) { + const { t } = useTranslation(); + const [url, setUrl] = useState(""); + const field = useRef(null); + + const submit = () => { + const link = url.trim(); + if (!link) return; + requestDownload(link); + setUrl(""); + }; + + // Pull a link off the clipboard. Reading it needs a secure context and a gesture or a permission + // (iOS Safari and Brave are strict), so it is best-effort: the button is a gesture and reliable, the + // field's focus tries silently. Only an http(s) link is taken, and never over what was typed. + const paste = async (force: boolean) => { + if (!force && url.trim()) return; + const link = asDownloadLink(await clipboardText()); + if (link) setUrl(link); + }; + + // The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the + // take decides — it clears the token, so a later pass or another entry's visit does nothing. + const request = useLinkBoxRequest(); + useEffect(() => { + if (request !== null && takeLinkBox()) field.current?.focus(); + }, [request]); + + return ( + // Narrow, the field takes its own line and the two buttons go under it, right: beside them it + // was left a few letters wide on a phone. + + setUrl(e.target.value)} + onFocus={() => void paste(false)} + onKeyDown={(e) => e.key === "Enter" && submit()} + placeholder={t("files.link.placeholder")} + aria-label={t("files.link.label")} + /> + void paste(true)} + > + + + + + ); +} diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx index 5cb4ef94..9f7f099a 100644 --- a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx +++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx @@ -6,7 +6,7 @@ // The band keeps Mark all read; Clear everything moved into the header's "…" and asks first. // i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type AppNotification } from "../lib/api"; import type { Notification as ClientNotif } from "../lib/notifications"; @@ -15,6 +15,7 @@ const h = vi.hoisted(() => ({ server: [] as unknown[], client: [] as unknown[], setPage: vi.fn(), + openFile: vi.fn(), setFilters: vi.fn(), remove: vi.fn(), clearAll: vi.fn(), @@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ clearAll: () => h.clearAll(), })); vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) })); +vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) })); vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) })); vi.mock("./feedFiltersContext", () => ({ // Every narrowing a feed can carry (the year, the custom range, the length, the @@ -81,7 +83,8 @@ import PageShell from "./ui/PageShell"; afterEach(() => { cleanup(); vi.restoreAllMocks(); - for (const f of [h.setPage, h.setFilters, h.remove, h.clearAll, h.confirm]) f.mockClear(); + for (const f of [h.setPage, h.openFile, h.setFilters, h.remove, h.clearAll, h.confirm]) + f.mockClear(); }); const server = (id: number, extra: Partial = {}) => @@ -144,7 +147,7 @@ describe("NotificationsPanel · a server row", () => { expect(row.classList.contains("group")).toBe(true); const buttons = [...row.querySelectorAll("button")]; expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([ - "inbox.openDownloads", + "inbox.openFile", "inbox.markRead", "inbox.dismiss", "inbox.rowMenu", @@ -154,13 +157,27 @@ describe("NotificationsPanel · a server row", () => { expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true); }); + it("a finished download opens on its file, in its folder; an older notice on Files", async () => { + h.server = [ + server(1, { data: { title: "Ready 1", kind: "download", job_id: 41, folder_id: 3 } }), + server(2), + ]; + h.client = []; + mount(); + fireEvent.click(within(rowOf("Ready 1")).getByText("inbox.openFile")); + expect(h.openFile).toHaveBeenCalledWith(41, 3); + fireEvent.click(within(rowOf("Ready 2")).getByText("inbox.openFile")); + expect(h.setPage).toHaveBeenCalledWith("files"); + expect(h.openFile).toHaveBeenCalledTimes(1); + }); + it("holds the link, mark read and dismiss", async () => { h.server = [server(1)]; h.client = []; const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.click(moreOf("Ready 1"))); - expect(menuRows()).toEqual(["inbox.openDownloads", "inbox.markRead", "inbox.dismiss"]); + expect(menuRows()).toEqual(["inbox.openFile", "inbox.markRead", "inbox.dismiss"]); choose("inbox.dismiss"); await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything())); }); @@ -235,6 +252,26 @@ describe("NotificationsPanel · an activity row", () => { await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new")); }); + it("a live notice with two actions shows both, in the row and in its menu", async () => { + h.server = []; + const copy = vi.fn(); + const open = vi.fn(); + h.client = [ + client(5, { + dismissed: false, + action: { label: "tray.done.copyLink", onClick: copy }, + secondaryAction: { label: "tray.done.open", onClick: open }, + }), + ]; + mount(); + fireEvent.click(within(rowOf("Activity 5")).getByText("tray.done.open")); + expect(open).toHaveBeenCalled(); + await openMenu(() => fireEvent.contextMenu(rowOf("Activity 5"))); + expect(menuRows()).toEqual(["tray.done.copyLink", "tray.done.open", "inbox.dismiss"]); + choose("tray.done.copyLink"); + await waitFor(() => expect(copy).toHaveBeenCalled()); + }); + it("finds a watched notice's video with nothing narrowing the feed past it", async () => { h.server = []; h.client = [ diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 134a5cfb..0720694c 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -8,9 +8,9 @@ import { Bell, Check, CheckCheck, - Download, ExternalLink, Eye, + FolderOpen, RotateCcw, Search, Trash2, @@ -45,6 +45,7 @@ import { PageToolbar } from "./ui/PageShell"; import { PageMenu } from "./ui/PageMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useNavigationActions } from "./navigationContext"; +import { useFilesActions } from "./filesContext"; import { useChannelsActions } from "./channelsContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { SURFACE } from "./ui/surfaces"; @@ -71,6 +72,7 @@ const byId = (n: { id: number }) => n.id; export default function NotificationsPanel() { const { t } = useTranslation(); const { setPage } = useNavigationActions(); + const { openFile } = useFilesActions(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from a new-subscription notification) lives in @@ -184,7 +186,12 @@ export default function NotificationsPanel() { const serverMenu = useContextMenu({ keyOf: byId }); const clientMenu = useContextMenu({ keyOf: byId }); const openScheduler = () => setPage("scheduler"); - const openDownloads = () => setPage("downloads"); + // A finished download opens on its file, in its folder; a notice from before it named one, on Files. + const openDone = (n: AppNotification) => { + const { job_id: id, folder_id: folder } = n.data ?? {}; + if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null); + else setPage("files"); + }; const reviewAccess = () => { focusAccessRequestsTab(); setPage("users"); @@ -213,9 +220,9 @@ export default function NotificationsPanel() { ? [ { id: "open", - label: t("inbox.openDownloads"), - icon: icon(Download), - onSelect: openDownloads, + label: t("inbox.openFile"), + icon: icon(FolderOpen), + onSelect: () => openDone(n), }, ] : []), @@ -280,17 +287,15 @@ export default function NotificationsPanel() { onSelect: reviewAccess, }, ] - : n.action && !n.dismissed - ? [ - { - id: "action", - label: n.action.label, - onSelect: () => { - n.action!.onClick(); - dismissClient(n.id); - }, + : !n.dismissed + ? liveActions(n).map((action, i) => ({ + id: `action${i}`, + label: action.label, + onSelect: () => { + action.onClick(); + dismissClient(n.id); }, - ] + })) : []; return menuGroups(own, [dismissRow(() => removeClient(n.id))]); } @@ -391,7 +396,7 @@ export default function NotificationsPanel() { onRead={() => readMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)} onOpenScheduler={openScheduler} - onOpenDownloads={openDownloads} + onOpenFile={() => openDone(n)} /> ); })} @@ -438,6 +443,11 @@ export default function NotificationsPanel() { ); } +/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */ +function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] { + return [n.action, n.secondaryAction].filter((a) => a !== undefined); +} + /** A server notification's link, from its type and typed payload (the row's button and its menu). */ // The download-lifecycle notification types → their `inbox.` copy. const DOWNLOAD_LIFECYCLE: Record = { @@ -500,7 +510,7 @@ function NotificationRow({ onRead, onDismiss, onOpenScheduler, - onOpenDownloads, + onOpenFile, }: { n: AppNotification; t: TFunction; @@ -510,7 +520,7 @@ function NotificationRow({ onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; - onOpenDownloads: () => void; + onOpenFile: () => void; }) { const lang = useTranslation().i18n.language; // The maintenance producer ships the affected videos in `data.videos`; list a few titles. @@ -609,10 +619,10 @@ function NotificationRow({ variant="link" className="text-sm mt-1.5" data-nav-open="" - onClick={onOpenDownloads} - icon={} + onClick={onOpenFile} + icon={} > - {t("inbox.openDownloads")} + {t("inbox.openFile")} )} @@ -706,19 +716,25 @@ function ClientActivityRow({ {t("common.accessRequestsReview")} ) : ( - n.action && - !n.dismissed && ( - + !n.dismissed && + liveActions(n).length > 0 && ( +
+ {liveActions(n).map((action, i) => ( + + ))} +
) )} diff --git a/frontend/src/components/ui/BackToTop.tsx b/frontend/src/components/ui/BackToTop.tsx index 5564da1b..e981487a 100644 --- a/frontend/src/components/ui/BackToTop.tsx +++ b/frontend/src/components/ui/BackToTop.tsx @@ -4,7 +4,7 @@ import { ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; -import { backToTopShown, useBackToTopShown } from "./backToTopState"; +import { backToTopShown, useBackToTopShown, useCornerLift } from "./backToTopState"; // A floating "back to top" button that fades in once the current page's scroll container is // scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a @@ -15,6 +15,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr const { t } = useTranslation(); // Shared, not local: PageScroller keeps the page's end clear of the button while it is up. const show = useBackToTopShown(); + // Over the download tray when it is up: the tray's 1rem inset, its height, then a gap. + const lift = useCornerLift(); const scrollerRef = useRef(null); // Listen on the document in the CAPTURE phase and read the scroller off the event, rather than @@ -63,6 +65,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr // Hidden, it only goes transparent (so it can fade): out of the Tab order as well. tabIndex={show ? undefined : -1} aria-hidden={show ? undefined : true} + style={lift ? { bottom: `calc(${lift}px + 1.5rem)` } : undefined} className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${ show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none" }`} diff --git a/frontend/src/components/ui/PageScroller.tsx b/frontend/src/components/ui/PageScroller.tsx index 8a8e0b5c..66eb92c0 100644 --- a/frontend/src/components/ui/PageScroller.tsx +++ b/frontend/src/components/ui/PageScroller.tsx @@ -9,7 +9,7 @@ import { import { useScrollFade } from "../../lib/useScrollFade"; import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper"; import { StickyTopProvider } from "./stickyTop"; -import { useBackToTopShown } from "./backToTopState"; +import { useBackToTopShown, useCornerLift } from "./backToTopState"; import { CONTENT_ID } from "./landmarks"; import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; @@ -139,6 +139,7 @@ export default function PageScroller({ }, []); const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0); const fabUp = useBackToTopShown(); + const cornerLift = useCornerLift(); const elRef = useRef(null); // Stable identity: an inline ref callback changes every render, which makes React detach and // re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it @@ -217,7 +218,15 @@ export default function PageScroller({ {/* While Back to top floats over the corner, the page ends a button's height (3rem + its 1.25rem inset) later, so its last row or pager can scroll out from under it. Only then: a spacer always there would give a full-height layout a scrollbar it does not need. */} - {fabUp &&