feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray

- 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.
This commit is contained in:
2026-10-11 16:56:13 +02:00
parent d22c64f4dd
commit 1c4f0042d2
42 changed files with 1509 additions and 162 deletions
+10 -2
View File
@@ -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 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 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 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: if user_id is None:
return return
try: 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( create_notification(
db, db,
user_id=user_id, user_id=user_id,
type="download_done", type="download_done",
title=f"Ready to watch: {title}" if title else "Ready to watch", 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: except Exception:
db.rollback() db.rollback()
+4
View File
@@ -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 # 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. # 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}) 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})
+15 -2
View File
@@ -30,7 +30,7 @@ from app.downloads import edit as editmod
from app.downloads import links as linksmod from app.downloads import links as linksmod
from app.downloads import quota, service, storage from app.downloads import quota, service, storage
from app.downloads import urlguard 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 ( from app.models import (
DownloadJob, DownloadJob,
DownloadLink, DownloadLink,
@@ -58,6 +58,7 @@ from app.routes.files import (
quota_detail, quota_detail,
serialize, serialize,
serialize_job, serialize_job,
source_keys,
) )
from app.security import hash_password from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
@@ -396,12 +397,22 @@ def enqueue_clip(
return serialize_job(db, job) 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("") @router.get("")
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: 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 = ( jobs = (
db.execute( db.execute(
select(DownloadJob) 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()) .order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
.limit(MAX_LIST) .limit(MAX_LIST)
) )
@@ -410,10 +421,12 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
) )
assets = assets_for(db, jobs) assets = assets_for(db, jobs)
positions = positions_for(db, user, jobs) positions = positions_for(db, user, jobs)
sources = source_keys(db, jobs)
return [ return [
{ {
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), **serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0), "position_seconds": positions.get(j.id, 0),
"source_key": sources[j.id],
} }
for j in jobs for j in jobs
] ]
+4 -4
View File
@@ -556,7 +556,7 @@ def list_files(
assets = assets_for(db, jobs) assets = assets_for(db, jobs)
positions = positions_for(db, user, jobs) positions = positions_for(db, user, jobs)
shared_at = _shared_at(db, user, jobs) if shared else {} shared_at = _shared_at(db, user, jobs) if shared else {}
sources = _source_keys(db, jobs) sources = source_keys(db, jobs)
return [ return [
{ {
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), **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]: 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 """Where each file came from (its source folder's key), for the list's Source column, the
"back to its source folder" move: a file in a source folder has its folder's key; one moved into "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`.""" 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} ids = {j.folder_id for j in jobs if j.folder_id is not None}
by_id = ( by_id = (
+19
View File
@@ -261,3 +261,22 @@ def test_the_longest_host_has_room_for_its_folder(db, make_user):
uid = make_user().id uid = make_user().id
job = _job(db, uid, _asset(db, "url", f"https://{host}/v"), kind="url") job = _job(db, uid, _asset(db, "url", f"https://{host}/v"), kind="url")
assert _folder(db, job).source_key == f"site:{host}" 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
+32
View File
@@ -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.sha256 = "ab" * 32
asset.rel_path = "converted.mp4" asset.rel_path = "converted.mp4"
assert asset.sha256 is None 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"}
+6
View File
@@ -39,6 +39,8 @@ import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster"; import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp"; import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette"; import AppCommandPalette from "./components/AppCommandPalette";
import DownloadIntake from "./components/DownloadIntake";
import DownloadTray from "./components/DownloadTray";
import { useGlobalKeymap } from "./lib/keymap"; import { useGlobalKeymap } from "./lib/keymap";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
@@ -461,12 +463,16 @@ export default function App() {
Anchored inside the content column so they clear the sidebar automatically Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */} (collapsed or expanded) without tracking its width. */}
<Toaster /> <Toaster />
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
<DownloadTray />
</div> </div>
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K). {/* 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 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. */} "take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp /> <KeyboardHelp />
<AppCommandPalette /> <AppCommandPalette />
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
<DownloadIntake />
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */} {/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
{me && <DebugRecorder />} {me && <DebugRecorder />}
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -62,6 +62,8 @@ vi.mock("./savedViewsContext", () => ({
})); }));
const requestSurprise = vi.fn(); const requestSurprise = vi.fn();
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() })); vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
const requestLinkBox = vi.fn();
vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() }));
import AppCommandPalette from "./AppCommandPalette"; import AppCommandPalette from "./AppCommandPalette";
@@ -142,6 +144,17 @@ describe("AppCommandPalette", () => {
expect(requestSurprise).toHaveBeenCalledTimes(1); 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", () => { it("offers the debug recorder by its state", () => {
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:")); const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
mount(); mount();
+20 -1
View File
@@ -1,7 +1,17 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; 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 CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -10,6 +20,7 @@ import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme"; import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent"; import { requestSurprise } from "../lib/homeIntent";
import { requestLinkBox } from "../lib/downloadIntake";
import { openRecorderStart } from "../lib/debugRecorder"; import { openRecorderStart } from "../lib/debugRecorder";
import { useDebugRecording } from "./useDebugRecording"; import { useDebugRecording } from "./useDebugRecording";
import { useNavigationActions } from "./navigationContext"; 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). // polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
run: () => setPage("home", requestSurprise), 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: <Download className="w-4 h-4" />,
run: () => setPage("files", requestLinkBox),
},
{ {
id: "action:shortcuts", id: "action:shortcuts",
label: t("keys.title"), label: t("keys.title"),
@@ -79,15 +79,21 @@ afterEach(() => {
}); });
function mount(tab?: string) { function mount(tab?: string) {
render( const qc = new QueryClient();
<QueryClientProvider client={new QueryClient()}> const tree = () => (
<QueryClientProvider client={qc}>
<DownloadCenter /> <DownloadCenter />
</QueryClientProvider>, </QueryClientProvider>
); );
const { rerender } = render(tree());
mounted.rerender = () => rerender(tree());
if (tab) if (tab)
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${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<HTMLElement>("[data-nav-index]")!; const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) => const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!; rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
@@ -172,9 +178,7 @@ describe("DownloadCenter · the queue row", () => {
await openMenu(() => fireEvent.click(moreOf("Clip 1"))); 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. // 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 })]; mocks.jobs = [job(1, "running", { progress: 40 })];
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), { mounted.rerender();
target: { value: "x" },
});
act(() => { act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1")); fireEvent.mouseDown(moreOf("Clip 1"));
+6 -62
View File
@@ -1,42 +1,31 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; 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 Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel"; import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used. // 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 UploadDialog = lazy(() => import("./UploadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor")); const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe"; 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 { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn } from "./downloadRow"; import { DownloadRow, IconBtn } from "./downloadRow";
import { ErrorModal } from "./downloadModals"; import { ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin"; import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid"; import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu"; import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton"; import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions } from "./useDownloadActions"; import { useDownloadActions } from "./useDownloadActions";
import { LinkBox } from "./LinkBox";
const jobKey = (job: DownloadJob) => job.id; 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<string | null> {
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 // 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 // download profiles, and (admin) the instance's downloads. A finished download is a library file and
// lives in Files. // lives in Files.
@@ -47,8 +36,6 @@ export default function DownloadCenter() {
// any failures are, so it's the useful default every visit. // any failures are, so it's the useful default every visit.
const [tab, setTab] = useState("queue"); const [tab, setTab] = useState("queue");
const panel = useTabPanel(); const panel = useTabPanel();
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false); const [upload, setUpload] = useState(false);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null); const [errorJob, setErrorJob] = useState<DownloadJob | null>(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 // 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 // (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. // 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) => intervalMs: (data) =>
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000, 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 = [ const tabs = [
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ...(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). */} {/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
<Input <LinkBox className="flex-[1_1_16rem]" />
className="min-w-0 flex-[1_1_16rem]"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
/>
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => pasteFromClipboard(true)}
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="shrink-0"
>
{t("downloads.page.add")}
</Button>
{/* Local upload is a peer to "add a URL": a general file → the same library. */} {/* Local upload is a peer to "add a URL": a general file → the same library. */}
<Button <Button
variant="outline" variant="outline"
@@ -230,15 +183,6 @@ export default function DownloadCenter() {
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} /> <ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
<Suspense fallback={null}> <Suspense fallback={null}>
{addSource && (
<DownloadDialog
source={addSource}
onClose={() => {
setAddSource(null);
setAddUrl("");
}}
/>
)}
{upload && <UploadDialog onClose={() => setUpload(false)} />} {upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{errorJob && ( {errorJob && (
@@ -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<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("./DownloadDialog", () => ({
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
<div role="dialog" aria-label="download">
{source}
<button onClick={onClose}>close</button>
</div>
),
}));
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(<DownloadIntake />);
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(
<>
<DownloadIntake />
<input aria-label="search" />
</>,
);
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(
<>
<DownloadIntake />
<div aria-modal="true" />
</>,
);
paste(document.body, "https://a.example/1");
await act(async () => {});
expect(dialog()).toBeNull();
});
it("closing the dialog closes it", async () => {
render(<DownloadIntake />);
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(
<>
<DownloadIntake />
<LinkBox />
</>,
);
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(
<>
<DownloadIntake />
<LinkBox />
</>,
);
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(<LinkBox />);
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(<LinkBox />);
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(<LinkBox />);
expect(document.activeElement).toBe(document.body);
});
});
@@ -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 (
<Suspense fallback={null}>
{/* The link itself under the title: a paste can be an accident, and this says what it was. */}
{source && (
<DownloadDialog
key={source}
source={source}
title={source}
onClose={clearPendingDownload}
/>
)}
</Suspense>
);
}
@@ -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<string, unknown>(),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
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: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
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<typeof import("../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
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<QueueJob> = {}) =>
({
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 = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
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<HTMLElement>("[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(
<QueryClientProvider client={new QueryClient()}>
<DownloadTray />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<main id="content" tabIndex={-1} />
<DownloadTray />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(document.activeElement?.id).toBe("content");
});
});
+393
View File
@@ -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 (
<span className="block h-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full w-full animate-pulse bg-accent/60" />
</span>
);
return (
<span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</span>
<span className="w-8 text-right tabular-nums">{job.progress}%</span>
</span>
);
}
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<string | null>("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<Fold>("downloadTray", "open", {
localKey: LS.downloadTray,
parse: parseFold,
});
const [errorJob, setErrorJob] = useState<QueueJob | null>(null);
const menu = useContextMenu<QueueJob>({ 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 <body> where the pressed one was.
const pill = useRef<HTMLButtonElement>(null);
const foldButton = useRef<HTMLButtonElement>(null);
const focusNext = useRef<Fold | null>(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<HTMLDivElement>(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 ? (
<ErrorModal
job={errorJob}
onRetry={() => 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 = (
<StateIcon
aria-hidden
className={clsx(
"size-4 shrink-0",
counts.running ? "animate-spin text-accent" : counts.error ? "text-danger" : "text-success",
)}
/>
);
const close = () => {
clearFinished();
setClosedAt(signature);
// The tray goes with the button pressed: the keyboard rests on the page (its <main>).
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
};
const doneItems = (job: QueueJob): ActionMenuItem[] =>
menuGroups([
{
id: "open",
label: t("tray.done.open"),
icon: <FolderOpen className="w-4 h-4" />,
onSelect: () => openFile(job.id, job.folder_id),
},
{
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, job, t),
},
]);
const menuItems = menu.target
? menu.target.status === "done"
? doneItems(menu.target)
: actions.items(menu.target)
: [];
return (
<div
ref={box}
role="region"
aria-label={t("tray.label")}
className="absolute right-4 bottom-4 z-chrome flex max-w-[calc(100%-2rem)] flex-col items-end"
>
{fold === "folded" ? (
<Pressable
ref={pill}
onClick={() => 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}
<span className="truncate">{summary}</span>
{meanProgress !== null && (
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent"
style={{ width: `${meanProgress}%` }}
/>
</span>
)}
</Pressable>
) : (
<div
className={clsx(
SURFACE.popover,
"@container/tray w-80 max-w-full overflow-hidden shadow-float",
)}
>
<div className="flex items-center gap-2 border-b border-border/60 py-1 pr-1 pl-3">
{stateIcon}
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
<span className="@max-[18rem]/tray:hidden">{t("tray.title")} · </span>
{summary}
</span>
<IconButton
ref={foldButton}
label={t("tray.fold")}
size="sm"
variant="ghost"
aria-expanded
onClick={() => toggleFold("folded")}
>
<ChevronDown />
</IconButton>
<IconButton label={t("tray.close")} size="sm" variant="ghost" onClick={close}>
<X />
</IconButton>
</div>
<NavGrid className="max-h-[50dvh] overflow-y-auto py-1">
{rows.map((job, i) => {
const title = jobTitle(job);
const mine = menu.open && menu.target?.id === job.id;
return (
<div
key={job.id}
{...navItemAttrs(i, title)}
{...menu.triggerProps(job)}
data-job-id={job.id}
className="group flex items-start gap-1 px-3 py-2 focus-visible:bg-fg/5"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<span className="shrink-0 text-xs text-muted">
{sourceLabel(job.source_key, t)}
</span>
</span>
<RowState
job={job}
onShowError={() => setErrorJob(job)}
onRetry={() => actions.run(job, "resume")}
onOpen={() => openFile(job.id, job.folder_id)}
/>
</div>
<MoreButton
label={t("tray.rowMenu")}
size="sm"
expanded={mine}
menuProps={menu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
</div>
);
})}
</NavGrid>
</div>
)}
<ContextMenu menu={menu} ariaLabel={t("tray.rowMenu")} items={menuItems} />
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
</div>
);
}
/** 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 <Progress job={job} />;
if (job.status === "error")
return (
<span className="flex items-center gap-2 text-xs">
<AlertTriangle aria-hidden className="size-3.5 shrink-0 text-danger" />
<Pressable
onClick={onShowError}
title={t(failTitleKey(job))}
className="min-w-0 flex-1 truncate text-left text-danger hover:underline"
>
{t(failCopyKeys(job, "short"))}
</Pressable>
<Button
variant="link"
size="none"
icon={<RotateCw className="size-3" />}
onClick={onRetry}
className="text-xs"
>
{t("downloads.actions.retry")}
</Button>
</span>
);
if (job.status === "done")
return (
<span className="flex items-center gap-2 text-xs">
<CheckCircle2 aria-hidden className="size-3.5 shrink-0 text-success" />
<span className="flex-1 text-success">{t("tray.done.label")}</span>
<Button variant="link" size="none" onClick={onOpen} data-nav-open="" className="text-xs">
{t("tray.done.open")}
</Button>
</span>
);
return <span className="text-xs text-muted">{t(`downloads.status.${job.status}`)}</span>;
}
@@ -259,7 +259,7 @@ describe("Files · my file's row", () => {
const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: "Más" } }); fireEvent.change(field, { target: { value: "Más" } });
fireEvent.keyDown(field, { key: "Escape" }); fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull(); expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
expect(update).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled();
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true)); await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
}); });
+28 -20
View File
@@ -34,6 +34,7 @@ import { MoreButton } from "./ui/MoreButton";
import NavGrid from "./ui/NavGrid"; import NavGrid from "./ui/NavGrid";
import Overlay from "./ui/Overlay"; import Overlay from "./ui/Overlay";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { LinkBox } from "./LinkBox";
import Pager from "./ui/Pager"; import Pager from "./ui/Pager";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -572,9 +573,8 @@ export default function Files() {
{t("files.count", { count: rows.length })} {t("files.count", { count: rows.length })}
</span> </span>
)} )}
{/* Wraps on a phone: sort, view and Upload together are wider than the column. */} {isGrid && (
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2"> <span className="ml-auto">
{isGrid && (
<ColumnSortControl <ColumnSortControl
columns={columns} columns={columns}
sort={sort} sort={sort}
@@ -584,23 +584,31 @@ export default function Files() {
ascLabel={t("files.sort.asc")} ascLabel={t("files.sort.asc")}
descLabel={t("files.sort.desc")} descLabel={t("files.sort.desc")}
/> />
)} </span>
<ViewSwitcher )}
value={layout} {/* The intake row (DESIGN.md "Files page"): paste a link, or upload; then the paint. On a
options={layoutOptions} phone the link box takes the line and the rest wraps under it. A link is downloaded
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })} into its site's folder whatever is open, so the box stays even in Shared with me. */}
label={t("files.layout.label")} <span className="flex w-full flex-wrap items-center gap-2">
/> <LinkBox className="flex-[1_1_18rem]" />
{canUpload && ( <span className="ml-auto flex items-center gap-2">
<Button {canUpload && (
variant="outline" <Button
size="sm" variant="outline"
icon={<UploadCloud className="w-4 h-4" />} size="sm"
onClick={() => setUpload({ file: null })} icon={<UploadCloud className="w-4 h-4" />}
> onClick={() => setUpload({ file: null })}
{t("files.actions.upload")} >
</Button> {t("files.actions.upload")}
)} </Button>
)}
<ViewSwitcher
value={layout}
options={layoutOptions}
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
label={t("files.layout.label")}
/>
</span>
</span> </span>
</div> </div>
</PageToolbar> </PageToolbar>
+88
View File
@@ -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<string | null> {
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<HTMLInputElement>(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.
<span className={`flex min-w-0 flex-wrap items-center justify-end gap-2 ${className ?? ""}`}>
<Input
ref={field}
size="sm"
className="min-w-0 flex-[1_1_14rem]"
value={url}
onChange={(e) => 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")}
/>
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => void paste(true)}
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
icon={<Download className="w-4 h-4" />}
onClick={submit}
className="shrink-0"
>
{t("files.link.download")}
</Button>
</span>
);
}
@@ -6,7 +6,7 @@
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first. // 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. // i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../lib/api"; import { api, type AppNotification } from "../lib/api";
import type { Notification as ClientNotif } from "../lib/notifications"; import type { Notification as ClientNotif } from "../lib/notifications";
@@ -15,6 +15,7 @@ const h = vi.hoisted(() => ({
server: [] as unknown[], server: [] as unknown[],
client: [] as unknown[], client: [] as unknown[],
setPage: vi.fn(), setPage: vi.fn(),
openFile: vi.fn(),
setFilters: vi.fn(), setFilters: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
clearAll: vi.fn(), clearAll: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
clearAll: () => h.clearAll(), clearAll: () => h.clearAll(),
})); }));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) })); vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) })); vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
vi.mock("./feedFiltersContext", () => ({ vi.mock("./feedFiltersContext", () => ({
// Every narrowing a feed can carry (the year, the custom range, the length, the // Every narrowing a feed can carry (the year, the custom range, the length, the
@@ -81,7 +83,8 @@ import PageShell from "./ui/PageShell";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); 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<AppNotification> = {}) => const server = (id: number, extra: Partial<AppNotification> = {}) =>
@@ -144,7 +147,7 @@ describe("NotificationsPanel · a server row", () => {
expect(row.classList.contains("group")).toBe(true); expect(row.classList.contains("group")).toBe(true);
const buttons = [...row.querySelectorAll("button")]; const buttons = [...row.querySelectorAll("button")];
expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([ expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([
"inbox.openDownloads", "inbox.openFile",
"inbox.markRead", "inbox.markRead",
"inbox.dismiss", "inbox.dismiss",
"inbox.rowMenu", "inbox.rowMenu",
@@ -154,13 +157,27 @@ describe("NotificationsPanel · a server row", () => {
expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true); 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 () => { it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)]; h.server = [server(1)];
h.client = []; h.client = [];
const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never); const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never);
mount(); mount();
await openMenu(() => fireEvent.click(moreOf("Ready 1"))); 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"); choose("inbox.dismiss");
await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything())); await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything()));
}); });
@@ -235,6 +252,26 @@ describe("NotificationsPanel · an activity row", () => {
await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new")); 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 () => { it("finds a watched notice's video with nothing narrowing the feed past it", async () => {
h.server = []; h.server = [];
h.client = [ h.client = [
+50 -34
View File
@@ -8,9 +8,9 @@ import {
Bell, Bell,
Check, Check,
CheckCheck, CheckCheck,
Download,
ExternalLink, ExternalLink,
Eye, Eye,
FolderOpen,
RotateCcw, RotateCcw,
Search, Search,
Trash2, Trash2,
@@ -45,6 +45,7 @@ import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu"; import { PageMenu } from "./ui/PageMenu";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./navigationContext"; import { useNavigationActions } from "./navigationContext";
import { useFilesActions } from "./filesContext";
import { useChannelsActions } from "./channelsContext"; import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
@@ -71,6 +72,7 @@ const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() { export default function NotificationsPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const { openFile } = useFilesActions();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in // "Focus this channel in the manager" (from a new-subscription notification) lives in
@@ -184,7 +186,12 @@ export default function NotificationsPanel() {
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId }); const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId }); const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler"); 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 = () => { const reviewAccess = () => {
focusAccessRequestsTab(); focusAccessRequestsTab();
setPage("users"); setPage("users");
@@ -213,9 +220,9 @@ export default function NotificationsPanel() {
? [ ? [
{ {
id: "open", id: "open",
label: t("inbox.openDownloads"), label: t("inbox.openFile"),
icon: icon(Download), icon: icon(FolderOpen),
onSelect: openDownloads, onSelect: () => openDone(n),
}, },
] ]
: []), : []),
@@ -280,17 +287,15 @@ export default function NotificationsPanel() {
onSelect: reviewAccess, onSelect: reviewAccess,
}, },
] ]
: n.action && !n.dismissed : !n.dismissed
? [ ? liveActions(n).map((action, i) => ({
{ id: `action${i}`,
id: "action", label: action.label,
label: n.action.label, onSelect: () => {
onSelect: () => { action.onClick();
n.action!.onClick(); dismissClient(n.id);
dismissClient(n.id);
},
}, },
] }))
: []; : [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]); return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
} }
@@ -391,7 +396,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)} onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler} 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). */ /** 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.<key>` copy. // The download-lifecycle notification types → their `inbox.<key>` copy.
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = { const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
@@ -500,7 +510,7 @@ function NotificationRow({
onRead, onRead,
onDismiss, onDismiss,
onOpenScheduler, onOpenScheduler,
onOpenDownloads, onOpenFile,
}: { }: {
n: AppNotification; n: AppNotification;
t: TFunction; t: TFunction;
@@ -510,7 +520,7 @@ function NotificationRow({
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void; onOpenScheduler: () => void;
onOpenDownloads: () => void; onOpenFile: () => void;
}) { }) {
const lang = useTranslation().i18n.language; const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles. // The maintenance producer ships the affected videos in `data.videos`; list a few titles.
@@ -609,10 +619,10 @@ function NotificationRow({
variant="link" variant="link"
className="text-sm mt-1.5" className="text-sm mt-1.5"
data-nav-open="" data-nav-open=""
onClick={onOpenDownloads} onClick={onOpenFile}
icon={<Download className="w-3.5 h-3.5" />} icon={<FolderOpen className="w-3.5 h-3.5" />}
> >
{t("inbox.openDownloads")} {t("inbox.openFile")}
</Button> </Button>
)} )}
</div> </div>
@@ -706,19 +716,25 @@ function ClientActivityRow({
{t("common.accessRequestsReview")} {t("common.accessRequestsReview")}
</Button> </Button>
) : ( ) : (
n.action && !n.dismissed &&
!n.dismissed && ( liveActions(n).length > 0 && (
<Button <div className="mt-1 flex flex-wrap gap-x-4">
variant="link" {liveActions(n).map((action, i) => (
className="mt-1 text-sm" <Button
data-nav-open="" key={action.label}
onClick={() => { variant="link"
n.action!.onClick(); className="text-sm"
dismissClient(n.id); // The first is the row's own: Enter on the row runs it.
}} data-nav-open={i === 0 ? "" : undefined}
> onClick={() => {
{n.action.label} action.onClick();
</Button> dismissClient(n.id);
}}
>
{action.label}
</Button>
))}
</div>
) )
)} )}
</div> </div>
+4 -1
View File
@@ -4,7 +4,7 @@ import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable"; import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces"; 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 // 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 // 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(); const { t } = useTranslation();
// Shared, not local: PageScroller keeps the page's end clear of the button while it is up. // Shared, not local: PageScroller keeps the page's end clear of the button while it is up.
const show = useBackToTopShown(); 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<HTMLElement | null>(null); const scrollerRef = useRef<HTMLElement | null>(null);
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than // 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. // Hidden, it only goes transparent (so it can fade): out of the Tab order as well.
tabIndex={show ? undefined : -1} tabIndex={show ? undefined : -1}
aria-hidden={show ? undefined : true} 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 ${ 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" show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`} }`}
+11 -2
View File
@@ -9,7 +9,7 @@ import {
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper"; import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper";
import { StickyTopProvider } from "./stickyTop"; import { StickyTopProvider } from "./stickyTop";
import { useBackToTopShown } from "./backToTopState"; import { useBackToTopShown, useCornerLift } from "./backToTopState";
import { CONTENT_ID } from "./landmarks"; import { CONTENT_ID } from "./landmarks";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; 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 { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
const fabUp = useBackToTopShown(); const fabUp = useBackToTopShown();
const cornerLift = useCornerLift();
const elRef = useRef<HTMLElement | null>(null); const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and // 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 // 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 {/* 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 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. */} always there would give a full-height layout a scrollbar it does not need. */}
{fabUp && <div aria-hidden="true" className="h-18" />} {cornerLift > 0 ? (
// The download tray sits in the same corner (and the button, when up, over it).
<div
aria-hidden="true"
style={{ height: `calc(${cornerLift}px + ${fabUp ? "5.25rem" : "1.5rem"})` }}
/>
) : (
fabUp && <div aria-hidden="true" className="h-18" />
)}
</main> </main>
); );
} }
@@ -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(<Toaster />);
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();
});
});
+19 -11
View File
@@ -171,17 +171,25 @@ export default function Toaster({
</span> </span>
)} )}
</div> </div>
{toast.action && ( {(toast.action || toast.secondaryAction) && (
<Button <div className="mt-1 flex flex-wrap gap-x-4">
variant="link" {[toast.action, toast.secondaryAction].map(
onClick={() => { (action) =>
toast.action!.onClick(); action && (
dismiss(toast.id); <Button
}} key={action.label}
className="mt-1 text-sm" variant="link"
> onClick={() => {
{toast.action.label} action.onClick();
</Button> dismiss(toast.id);
}}
className="text-sm"
>
{action.label}
</Button>
),
)}
</div>
)} )}
</div> </div>
<IconButton <IconButton
@@ -8,3 +8,15 @@ export const backToTopShown = createStore(false);
export function useBackToTopShown(): boolean { export function useBackToTopShown(): boolean {
return backToTopShown.useValue(); return backToTopShown.useValue();
} }
// How tall the thing docked in the same corner under the button is (px; 0 = nothing) — the download
// tray. The button steps up over it, and PageScroller keeps the page's end clear of both.
const cornerLift = createStore(0);
export function setCornerLift(px: number): void {
if (cornerLift.get() !== px) cornerLift.set(px);
}
export function useCornerLift(): number {
return cornerLift.useValue();
}
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */ /** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = { const PLAIN: Record<string, [number, string]> = {
"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)"], "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"], "FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"Home.tsx": [ "Home.tsx": [
@@ -19,8 +19,6 @@
"manage": "Manage formats…" "manage": "Manage formats…"
}, },
"page": { "page": {
"addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…",
"add": "Add",
"paste": "Paste link from clipboard", "paste": "Paste link from clipboard",
"upload": "Upload", "upload": "Upload",
"manage": "Manage formats…" "manage": "Manage formats…"
+6 -1
View File
@@ -86,5 +86,10 @@
"here": "Drop to upload into {{folder}}", "here": "Drop to upload into {{folder}}",
"oneAtATime": "Upload one file at a time — the first one is ready to go." "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"
}
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"andMore_one": "and {{count}} more", "andMore_one": "and {{count}} more",
"andMore_other": "and {{count}} more", "andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler", "openScheduler": "Open Scheduler",
"openDownloads": "Open Downloads", "openFile": "Open the file",
"revertFailed": "Couldn't undo that — please try again", "revertFailed": "Couldn't undo that — please try again",
"downloadDone": { "downloadDone": {
"title": "Download ready" "title": "Download ready"
+2 -1
View File
@@ -15,7 +15,8 @@
"actions": "Actions" "actions": "Actions"
}, },
"actions": { "actions": {
"surprise": "Surprise me" "surprise": "Surprise me",
"downloadLink": "Download from a link…"
}, },
"open": "Open the command palette" "open": "Open the command palette"
} }
+26
View File
@@ -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"
}
}
@@ -19,8 +19,6 @@
"manage": "Formátumok kezelése…" "manage": "Formátumok kezelése…"
}, },
"page": { "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", "paste": "Link beillesztése a vágólapról",
"upload": "Feltöltés", "upload": "Feltöltés",
"manage": "Formátumok kezelése…" "manage": "Formátumok kezelése…"
+6 -1
View File
@@ -86,5 +86,10 @@
"here": "Engedd el, és feltöltöm ide: {{folder}}", "here": "Engedd el, és feltöltöm ide: {{folder}}",
"oneAtATime": "Egyszerre egy fájlt tölthetsz fel — az első készen áll." "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"
}
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"andMore_one": "és még {{count}}", "andMore_one": "és még {{count}}",
"andMore_other": "és még {{count}}", "andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása", "openScheduler": "Ütemező megnyitása",
"openDownloads": "Letöltések megnyitása", "openFile": "Fájl megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra", "revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": { "downloadDone": {
"title": "Letöltés kész" "title": "Letöltés kész"
+2 -1
View File
@@ -15,7 +15,8 @@
"actions": "Műveletek" "actions": "Műveletek"
}, },
"actions": { "actions": {
"surprise": "Meglepetés" "surprise": "Meglepetés",
"downloadLink": "Letöltés linkről…"
}, },
"open": "Parancspaletta megnyitása" "open": "Parancspaletta megnyitása"
} }
+26
View File
@@ -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é"
}
}
+7 -1
View File
@@ -118,6 +118,12 @@ export interface DownloadJob {
position_seconds?: number; 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 { export interface ShareRecipient {
id: number; id: number;
email: string; email: string;
@@ -211,7 +217,7 @@ export const downloadsApi = {
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }), req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
downloadStoryboard: (id: number): Promise<StoryboardMeta> => downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`), req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"), downloads: (): Promise<QueueJob[]> => req("/api/downloads"),
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"), downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise<DownloadJob> => pauseDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
+51
View File
@@ -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<string | null>(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<number | null>(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();
};
@@ -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([]);
});
});
+59
View File
@@ -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<string> = new Set(["queued", "running", "paused"]);
let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]);
const unannounced = createStore<QueueJob[]>([]);
/** 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<QueueJob[]> {
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([]);
}
+8 -1
View File
@@ -60,6 +60,8 @@ export interface Notification {
title?: string; title?: string;
message: string; message: string;
action?: NotifAction; action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
requiresInteraction: boolean; requiresInteraction: boolean;
duration?: number; // auto-dismiss ms (undefined when it stays until acted on) duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
@@ -82,6 +84,7 @@ export interface NotifyInput {
level?: NotifLevel; level?: NotifLevel;
title?: string; title?: string;
action?: NotifAction; action?: NotifAction;
secondaryAction?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
requiresInteraction?: boolean; requiresInteraction?: boolean;
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
@@ -185,6 +188,7 @@ function load(): Notification[] {
repeat: 1, repeat: 1,
...(n as object), ...(n as object),
action: undefined, action: undefined,
secondaryAction: undefined,
dismissed: true, dismissed: true,
transient: false, transient: false,
}) as Notification, }) as Notification,
@@ -198,7 +202,9 @@ function persist() {
try { try {
// Transient status notices are live-session only — never write them to history, // 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. // 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); writeAccount(HISTORY_KEY, slim);
} catch { } catch {
/* ignore quota / serialization errors */ /* ignore quota / serialization errors */
@@ -317,6 +323,7 @@ export function notify(input: NotifyInput): number {
title: input.title, title: input.title,
message: input.message, message: input.message,
action: input.action, action: input.action,
secondaryAction: input.secondaryAction,
meta: input.meta, meta: input.meta,
requiresInteraction, requiresInteraction,
duration, duration,
+5
View File
@@ -21,6 +21,11 @@ export const LS = {
filesLayouts: "siftlode.filesLayouts", filesLayouts: "siftlode.filesLayouts",
filesSort: "siftlode.filesSort", filesSort: "siftlode.filesSort",
filesCardSize: "siftlode.filesCardSize", 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", hints: "siftlode.hints",
lang: "siftlode.lang", lang: "siftlode.lang",
filters: "siftlode.filters", filters: "siftlode.filters",