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)}
- >
-
-
- }
- onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
- className="shrink-0"
- >
- {t("downloads.page.add")}
-
+
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
- {addSource && (
- {
- setAddSource(null);
- setAddUrl("");
- }}
- />
- )}
{upload && setUpload(false)} />}
{manage && setManage(false)} />}
{errorJob && (
diff --git a/frontend/src/components/DownloadIntake.dom.test.tsx b/frontend/src/components/DownloadIntake.dom.test.tsx
new file mode 100644
index 00000000..3d46e595
--- /dev/null
+++ b/frontend/src/components/DownloadIntake.dom.test.tsx
@@ -0,0 +1,137 @@
+// @vitest-environment jsdom
+//
+// Downloading is an action you start anywhere: a lone http(s) link pasted onto a page (not into a
+// field, not at a dialog) opens the app's one download dialog; the Files link box hands its link to
+// the same dialog; the palette's "Download from link…" focuses that box on the entry it navigated to.
+// The dialog itself is stubbed (it names the link it was opened on).
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
+}));
+vi.mock("./DownloadDialog", () => ({
+ default: ({ source, onClose }: { source: string; onClose: () => void }) => (
+
+ {source}
+
+
+ ),
+}));
+
+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 (
+
+ );
+}
+
+/** 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"))}
+
+ }
+ onClick={onRetry}
+ className="text-xs"
+ >
+ {t("downloads.actions.retry")}
+
+
+ );
+ 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 && (
- }
- onClick={() => setUpload({ file: null })}
- >
- {t("files.actions.upload")}
-
- )}
+
+ )}
+ {/* 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 && (
+ }
+ onClick={() => setUpload({ file: null })}
+ >
+ {t("files.actions.upload")}
+
+ )}
+ 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)}
+ >
+
+
+ }
+ onClick={submit}
+ className="shrink-0"
+ >
+ {t("files.link.download")}
+
+
+ );
+}
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 && }
+ {cornerLift > 0 ? (
+ // The download tray sits in the same corner (and the button, when up, over it).
+
+ ) : (
+ fabUp &&
+ )}
);
}
diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx
index 76a6664c..69b50ab6 100644
--- a/frontend/src/components/ui/Toaster.dom.test.tsx
+++ b/frontend/src/components/ui/Toaster.dom.test.tsx
@@ -208,3 +208,25 @@ describe("Toaster — handing the toasts to another host", () => {
);
});
});
+
+describe("Toaster — a toast's actions", () => {
+ it("shows a second action beside the first; either runs and closes the toast", () => {
+ const copy = vi.fn();
+ const open = vi.fn();
+ render();
+ act(
+ () =>
+ void notify({
+ level: "success",
+ message: "Downloaded",
+ action: { label: "Copy link", onClick: copy },
+ secondaryAction: { label: "Open", onClick: open },
+ }),
+ );
+ expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Open" }));
+ expect(open).toHaveBeenCalled();
+ expect(copy).not.toHaveBeenCalled();
+ expect(screen.queryByText("Downloaded")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
index 271e5466..0c63810f 100644
--- a/frontend/src/components/ui/Toaster.tsx
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -171,17 +171,25 @@ export default function Toaster({
)}
- {toast.action && (
-
+ {(toast.action || toast.secondaryAction) && (
+
)}
= {
+ "DownloadTray.tsx": [
+ 2,
+ "the folded pill (its summary is its label); a failure's reason, which opens its details",
+ ],
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"Home.tsx": [
diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json
index c4a19dd4..16bdb39d 100644
--- a/frontend/src/i18n/locales/en/downloads.json
+++ b/frontend/src/i18n/locales/en/downloads.json
@@ -19,8 +19,6 @@
"manage": "Manage formats…"
},
"page": {
- "addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…",
- "add": "Add",
"paste": "Paste link from clipboard",
"upload": "Upload",
"manage": "Manage formats…"
diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json
index fbbca5d0..5ca1c087 100644
--- a/frontend/src/i18n/locales/en/files.json
+++ b/frontend/src/i18n/locales/en/files.json
@@ -86,5 +86,10 @@
"here": "Drop to upload into {{folder}}",
"oneAtATime": "Upload one file at a time — the first one is ready to go."
},
- "perPage": "Files per page"
+ "perPage": "Files per page",
+ "link": {
+ "label": "A link to download",
+ "placeholder": "Paste a link and I'll download it (YouTube, TikTok, Facebook…)",
+ "download": "Download"
+ }
}
diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json
index c615c6ba..66fee6b2 100644
--- a/frontend/src/i18n/locales/en/inbox.json
+++ b/frontend/src/i18n/locales/en/inbox.json
@@ -14,7 +14,7 @@
"andMore_one": "and {{count}} more",
"andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler",
- "openDownloads": "Open Downloads",
+ "openFile": "Open the file",
"revertFailed": "Couldn't undo that — please try again",
"downloadDone": {
"title": "Download ready"
diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json
index 0c4403fb..56d4d74f 100644
--- a/frontend/src/i18n/locales/en/palette.json
+++ b/frontend/src/i18n/locales/en/palette.json
@@ -15,7 +15,8 @@
"actions": "Actions"
},
"actions": {
- "surprise": "Surprise me"
+ "surprise": "Surprise me",
+ "downloadLink": "Download from a link…"
},
"open": "Open the command palette"
}
diff --git a/frontend/src/i18n/locales/en/tray.json b/frontend/src/i18n/locales/en/tray.json
new file mode 100644
index 00000000..13663b54
--- /dev/null
+++ b/frontend/src/i18n/locales/en/tray.json
@@ -0,0 +1,26 @@
+{
+ "label": "Downloads in progress",
+ "title": "Downloads",
+ "rowMenu": "Download actions",
+ "fold": "Fold the downloads",
+ "unfold": "Show the downloads",
+ "close": "Close",
+ "count": {
+ "running_one": "{{count}} running",
+ "running_other": "{{count}} running",
+ "paused_one": "{{count}} paused",
+ "paused_other": "{{count}} paused",
+ "error_one": "{{count}} failed",
+ "error_other": "{{count}} failed",
+ "done_one": "{{count}} done",
+ "done_other": "{{count}} done"
+ },
+ "done": {
+ "label": "Done",
+ "open": "Open",
+ "copyLink": "Copy link",
+ "title": "Downloaded: {{title}}",
+ "phone": "Plays on a phone too",
+ "file": "Saved in Files"
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json
index 8f64d196..39e12648 100644
--- a/frontend/src/i18n/locales/hu/downloads.json
+++ b/frontend/src/i18n/locales/hu/downloads.json
@@ -19,8 +19,6 @@
"manage": "Formátumok kezelése…"
},
"page": {
- "addUrl": "Illessz be egy videólinket (YouTube, Facebook, …) vagy YouTube-videóazonosítót…",
- "add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"upload": "Feltöltés",
"manage": "Formátumok kezelése…"
diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json
index f3e52ea6..95b5aa27 100644
--- a/frontend/src/i18n/locales/hu/files.json
+++ b/frontend/src/i18n/locales/hu/files.json
@@ -86,5 +86,10 @@
"here": "Engedd el, és feltöltöm ide: {{folder}}",
"oneAtATime": "Egyszerre egy fájlt tölthetsz fel — az első készen áll."
},
- "perPage": "Fájl oldalanként"
+ "perPage": "Fájl oldalanként",
+ "link": {
+ "label": "Letöltendő link",
+ "placeholder": "Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)",
+ "download": "Letöltés"
+ }
}
diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json
index 204501d7..3b0dc05e 100644
--- a/frontend/src/i18n/locales/hu/inbox.json
+++ b/frontend/src/i18n/locales/hu/inbox.json
@@ -14,7 +14,7 @@
"andMore_one": "és még {{count}}",
"andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
- "openDownloads": "Letöltések megnyitása",
+ "openFile": "Fájl megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": {
"title": "Letöltés kész"
diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json
index 551ca07b..48adf727 100644
--- a/frontend/src/i18n/locales/hu/palette.json
+++ b/frontend/src/i18n/locales/hu/palette.json
@@ -15,7 +15,8 @@
"actions": "Műveletek"
},
"actions": {
- "surprise": "Meglepetés"
+ "surprise": "Meglepetés",
+ "downloadLink": "Letöltés linkről…"
},
"open": "Parancspaletta megnyitása"
}
diff --git a/frontend/src/i18n/locales/hu/tray.json b/frontend/src/i18n/locales/hu/tray.json
new file mode 100644
index 00000000..5caf6158
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/tray.json
@@ -0,0 +1,26 @@
+{
+ "label": "Folyamatban lévő letöltések",
+ "title": "Letöltések",
+ "rowMenu": "Letöltés műveletei",
+ "fold": "Letöltések összecsukása",
+ "unfold": "Letöltések megjelenítése",
+ "close": "Bezárás",
+ "count": {
+ "running_one": "{{count}} fut",
+ "running_other": "{{count}} fut",
+ "paused_one": "{{count}} szünetel",
+ "paused_other": "{{count}} szünetel",
+ "error_one": "{{count}} hibás",
+ "error_other": "{{count}} hibás",
+ "done_one": "{{count}} kész",
+ "done_other": "{{count}} kész"
+ },
+ "done": {
+ "label": "Kész",
+ "open": "Megnyitás",
+ "copyLink": "Link másolása",
+ "title": "Letöltve: {{title}}",
+ "phone": "Telefonon is lejátszható",
+ "file": "Bekerült a Fájlok közé"
+ }
+}
diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts
index 3f9149ff..a12d4586 100644
--- a/frontend/src/lib/api/downloads.ts
+++ b/frontend/src/lib/api/downloads.ts
@@ -118,6 +118,12 @@ export interface DownloadJob {
position_seconds?: number;
}
+// A row of the queue (the tray's poll): what is in flight or failed, and what finished a moment ago,
+// with where it came from as the library names it.
+export interface QueueJob extends DownloadJob {
+ source_key: string;
+}
+
export interface ShareRecipient {
id: number;
email: string;
@@ -211,7 +217,7 @@ export const downloadsApi = {
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
downloadStoryboard: (id: number): Promise =>
req(`/api/downloads/${id}/storyboard`),
- downloads: (): Promise => req("/api/downloads"),
+ downloads: (): Promise => req("/api/downloads"),
downloadIndex: (): Promise> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
diff --git a/frontend/src/lib/downloadIntake.ts b/frontend/src/lib/downloadIntake.ts
new file mode 100644
index 00000000..70e54a14
--- /dev/null
+++ b/frontend/src/lib/downloadIntake.ts
@@ -0,0 +1,51 @@
+import { ensureNavId, peekNavId } from "./history";
+import { createStore } from "./store";
+
+// Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto
+// any page, the command palette. They all hand the link to ONE app-wide download dialog
+// (components/DownloadIntake), so none of them mounts a dialog of its own.
+
+const pending = createStore(null);
+
+/** An http(s) link, alone — what a paste or the link box may download; anything else is not one. */
+export function asDownloadLink(text: string | null | undefined): string | null {
+ const s = text?.trim() ?? "";
+ return /^https?:\/\/\S+$/i.test(s) ? s : null;
+}
+
+/** Whether a paste here is the page's to take: not typed into a field, and no dialog on top (the
+ * reader is busy with that, and a link pasted at it is not a request to download). */
+export function pasteIsForThePage(target: EventTarget | null): boolean {
+ const el = target instanceof Element ? target : null;
+ if (el?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])"))
+ return false;
+ return !document.querySelector("[aria-modal='true']");
+}
+
+/** Ask to download `url`: the app's download dialog opens on it. */
+export const requestDownload = (url: string): void => pending.set(url);
+
+/** The link the dialog is open on (null = closed). DownloadIntake only. */
+export const usePendingDownload = (): string | null => pending.useValue();
+
+/** The dialog closed, downloading or not. */
+export const clearPendingDownload = (): void => pending.set(null);
+
+// The palette's "Download from link…" takes the reader to the Files page's link box. A token for the
+// history entry the navigation made (as lib/homeIntent's): Files is a lazy module, often not mounted
+// when it is asked, and a request that outlived its entry must not grab the focus on a later visit.
+const linkBox = createStore(null);
+
+/** Ask the Files page to focus its link box on THIS entry — `setPage("files", requestLinkBox)`. */
+export const requestLinkBox = (): void => linkBox.set(ensureNavId());
+
+/** The pending request (null = none) — the render subscription that wakes the link box. */
+export const useLinkBoxRequest = (): number | null => linkBox.useValue();
+
+/** Take the request, clearing it; true only on the entry it was made for. */
+export const takeLinkBox = (): boolean => {
+ const entry = linkBox.get();
+ if (entry === null) return false;
+ linkBox.set(null);
+ return entry === peekNavId();
+};
diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts
new file mode 100644
index 00000000..050f082d
--- /dev/null
+++ b/frontend/src/lib/downloadQueue.dom.test.ts
@@ -0,0 +1,50 @@
+// @vitest-environment jsdom
+//
+// The queue as a tab watches it: a finish is a job that is done now and was not on the last look —
+// seen once, kept for the tray until it is closed, announced once. The first look only learns.
+import { beforeEach, describe, expect, it } from "vitest";
+import type { QueueJob } from "./api";
+import {
+ clearFinished,
+ noteQueue,
+ resetQueueWatch,
+ takeUnannounced,
+ useFinished,
+} from "./downloadQueue";
+import { renderHook } from "@testing-library/react";
+
+const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob;
+const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id);
+
+beforeEach(resetQueueWatch);
+
+describe("downloadQueue · a finish", () => {
+ it("the first look is old news: what is already done is not announced", () => {
+ noteQueue([job(1, "done"), job(2, "running")]);
+ expect(takeUnannounced()).toEqual([]);
+ expect(finishedIds()).toEqual([]);
+ });
+
+ it("a job that turns done is announced once and kept, newest first", () => {
+ noteQueue([job(1, "running"), job(2, "queued")]);
+ noteQueue([job(1, "done"), job(2, "running")]);
+ noteQueue([job(1, "done"), job(2, "done")]);
+ expect(takeUnannounced().map((j) => j.id)).toEqual([1, 2]);
+ expect(takeUnannounced()).toEqual([]);
+ expect(finishedIds()).toEqual([2, 1]);
+ });
+
+ it("a job first seen done after the first look finished meanwhile (a cache hit, another device)", () => {
+ noteQueue([]);
+ noteQueue([job(5, "done")]);
+ expect(takeUnannounced().map((j) => j.id)).toEqual([5]);
+ });
+
+ 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")]);
+ expect(finishedIds()).toEqual([2]);
+ clearFinished();
+ expect(finishedIds()).toEqual([]);
+ });
+});
diff --git a/frontend/src/lib/downloadQueue.ts b/frontend/src/lib/downloadQueue.ts
new file mode 100644
index 00000000..f59a605d
--- /dev/null
+++ b/frontend/src/lib/downloadQueue.ts
@@ -0,0 +1,59 @@
+import { api, type QueueJob } from "./api";
+import { createStore } from "./store";
+
+// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
+// ("Done · Open") until it is closed, and announces each once (the done notice).
+//
+// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue (the
+// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them
+// polled, and nothing has to diff the previous render's data.
+
+/** The statuses a job leaves on its own: it is waiting, or working. */
+export const IN_FLIGHT: ReadonlySet = new Set(["queued", "running", "paused"]);
+
+let lastSeen: Map | null = null;
+const finished = createStore([]);
+const unannounced = createStore([]);
+
+/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
+ * first look only learns what is there — a file finished before this tab opened is old news. */
+export function noteQueue(jobs: QueueJob[]): void {
+ const before = lastSeen;
+ lastSeen = new Map(jobs.map((j) => [j.id, j.status]));
+ if (!before) return;
+ const done = jobs.filter((j) => j.status === "done" && before.get(j.id) !== "done");
+ if (done.length === 0) return;
+ const ids = new Set(done.map((j) => j.id));
+ finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
+ unannounced.set((list) => [...list, ...done]);
+}
+
+/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
+export async function fetchQueue(): Promise {
+ const jobs = await api.downloads();
+ noteQueue(jobs);
+ return jobs;
+}
+
+/** The jobs this tab saw finish, newest first, until the tray is closed. */
+export const useFinished = (): QueueJob[] => finished.useValue();
+
+/** The tray was closed: what finished is no longer shown. */
+export const clearFinished = (): void => finished.set([]);
+
+/** The finishes not yet announced — the render subscription that wakes the announcer. */
+export const useUnannounced = (): QueueJob[] => unannounced.useValue();
+
+/** Take the finishes to announce, clearing them: each is announced once. */
+export function takeUnannounced(): QueueJob[] {
+ const list = unannounced.get();
+ if (list.length) unannounced.set([]);
+ return list;
+}
+
+/** Forget what was watched (a test starts from a tab that has looked at nothing). */
+export function resetQueueWatch(): void {
+ lastSeen = null;
+ finished.set([]);
+ unannounced.set([]);
+}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index cc535420..4a6bb2b1 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -60,6 +60,8 @@ export interface Notification {
title?: string;
message: string;
action?: NotifAction;
+ // A second, lesser action beside it (a done download's "Open" after its "Copy link").
+ secondaryAction?: NotifAction;
meta?: NotifMeta;
requiresInteraction: boolean;
duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
@@ -82,6 +84,7 @@ export interface NotifyInput {
level?: NotifLevel;
title?: string;
action?: NotifAction;
+ secondaryAction?: NotifAction;
meta?: NotifMeta;
requiresInteraction?: boolean;
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
@@ -185,6 +188,7 @@ function load(): Notification[] {
repeat: 1,
...(n as object),
action: undefined,
+ secondaryAction: undefined,
dismissed: true,
transient: false,
}) as Notification,
@@ -198,7 +202,9 @@ function persist() {
try {
// Transient status notices are live-session only — never write them to history,
// so a reload can't leave one stranded with no producer left to clear it.
- const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
+ const slim = items
+ .filter((n) => !n.transient)
+ .map(({ action: _action, secondaryAction: _second, ...n }) => n);
writeAccount(HISTORY_KEY, slim);
} catch {
/* ignore quota / serialization errors */
@@ -317,6 +323,7 @@ export function notify(input: NotifyInput): number {
title: input.title,
message: input.message,
action: input.action,
+ secondaryAction: input.secondaryAction,
meta: input.meta,
requiresInteraction,
duration,
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 6aaf9cae..360e846e 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -21,6 +21,11 @@ export const LS = {
filesLayouts: "siftlode.filesLayouts",
filesSort: "siftlode.filesSort",
filesCardSize: "siftlode.filesCardSize",
+ // The download tray open or folded to its pill — a per-account mirror of the server pref.
+ downloadTray: "siftlode.downloadTray",
+ // The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
+ // changes. Per-account mirror of the server pref.
+ downloadTrayClosed: "siftlode.downloadTrayClosed",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",