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:
@@ -95,16 +95,24 @@ def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *,
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit"/"upload") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
caller BEFORE its commit; only the folder is read here, once, for the notice's "Open"."""
|
||||
if user_id is None:
|
||||
return
|
||||
try:
|
||||
# The notice's "Open" lands on the file in its folder: both ride along (a folder the file
|
||||
# has left since is the page's to notice — it then looks among every file).
|
||||
job = db.get(DownloadJob, job_id)
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||
data={"title": title, "kind": kind},
|
||||
data={
|
||||
"title": title,
|
||||
"kind": kind,
|
||||
"job_id": job_id,
|
||||
"folder_id": job.folder_id if job else None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -53,3 +53,7 @@ NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP})
|
||||
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
||||
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
||||
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||
|
||||
|
||||
# A job that will not move again on its own: the queue lists it only briefly after it got here.
|
||||
FINISHED = frozenset({JobStatus.DONE, JobStatus.CANCELED})
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.status import NOT_REFETCHABLE, JobStatus
|
||||
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -58,6 +58,7 @@ from app.routes.files import (
|
||||
quota_detail,
|
||||
serialize,
|
||||
serialize_job,
|
||||
source_keys,
|
||||
)
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
@@ -396,12 +397,22 @@ def enqueue_clip(
|
||||
return serialize_job(db, job)
|
||||
|
||||
|
||||
# How long a finished job stays in the queue: the tray polls it from every page and must see a job
|
||||
# turn done (its "Done · Open" row and the done notice) — the finished file itself is the library's.
|
||||
QUEUE_RECENT = timedelta(minutes=10)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
"""The queue: what is waiting, running, paused or failed, and what finished a moment ago."""
|
||||
recent = datetime.now(timezone.utc) - QUEUE_RECENT
|
||||
jobs = (
|
||||
db.execute(
|
||||
select(DownloadJob)
|
||||
.where(DownloadJob.user_id == user.id)
|
||||
.where(
|
||||
DownloadJob.user_id == user.id,
|
||||
DownloadJob.status.not_in(FINISHED) | (DownloadJob.updated_at >= recent),
|
||||
)
|
||||
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||
.limit(MAX_LIST)
|
||||
)
|
||||
@@ -410,10 +421,12 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
|
||||
)
|
||||
assets = assets_for(db, jobs)
|
||||
positions = positions_for(db, user, jobs)
|
||||
sources = source_keys(db, jobs)
|
||||
return [
|
||||
{
|
||||
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||
"position_seconds": positions.get(j.id, 0),
|
||||
"source_key": sources[j.id],
|
||||
}
|
||||
for j in jobs
|
||||
]
|
||||
|
||||
@@ -556,7 +556,7 @@ def list_files(
|
||||
assets = assets_for(db, jobs)
|
||||
positions = positions_for(db, user, jobs)
|
||||
shared_at = _shared_at(db, user, jobs) if shared else {}
|
||||
sources = _source_keys(db, jobs)
|
||||
sources = source_keys(db, jobs)
|
||||
return [
|
||||
{
|
||||
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||
@@ -569,9 +569,9 @@ def list_files(
|
||||
]
|
||||
|
||||
|
||||
def _source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]:
|
||||
"""Where each file came from (its source folder's key), for the list's Source column and the
|
||||
"back to its source folder" move: a file in a source folder has its folder's key; one moved into
|
||||
def source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]:
|
||||
"""Where each file came from (its source folder's key), for the list's Source column, the
|
||||
"back to its source folder" move and the download tray's site: a file in a source folder has its folder's key; one moved into
|
||||
an own folder (or someone else's, shared) is traced back by `folders.source_key_for`."""
|
||||
ids = {j.folder_id for j in jobs if j.folder_id is not None}
|
||||
by_id = (
|
||||
|
||||
@@ -261,3 +261,22 @@ def test_the_longest_host_has_room_for_its_folder(db, make_user):
|
||||
uid = make_user().id
|
||||
job = _job(db, uid, _asset(db, "url", f"https://{host}/v"), kind="url")
|
||||
assert _folder(db, job).source_key == f"site:{host}"
|
||||
|
||||
|
||||
def test_the_ready_notice_names_the_file_and_its_folder(db, make_user):
|
||||
# The notice's "Open" goes to the file in its folder, so both ride along as data.
|
||||
from app.downloads import pipeline
|
||||
|
||||
user = make_user()
|
||||
job = _job(db, user.id, _asset(db))
|
||||
pipeline._notify_download_done(db, user.id, "A title", job.id, kind="download")
|
||||
db.commit()
|
||||
note = db.scalars(select(Notification).where(Notification.user_id == user.id)).one()
|
||||
assert note.type == "download_done"
|
||||
assert note.data == {
|
||||
"title": "A title",
|
||||
"kind": "download",
|
||||
"job_id": job.id,
|
||||
"folder_id": job.folder_id,
|
||||
}
|
||||
assert job.folder_id is not None
|
||||
|
||||
@@ -506,3 +506,35 @@ def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user):
|
||||
asset.sha256 = "ab" * 32
|
||||
asset.rel_path = "converted.mp4"
|
||||
assert asset.sha256 is None
|
||||
|
||||
|
||||
# --- the queue (the tray's poll) ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||
db, make_user, as_user
|
||||
):
|
||||
# The tray polls this on every page: a finished file is the library's, so it is listed only
|
||||
# while the tray may still be showing it done.
|
||||
from sqlalchemy import update
|
||||
|
||||
user = make_user()
|
||||
old = _file(db, user.id, title="Old")
|
||||
fresh = _file(db, user.id, title="Fresh")
|
||||
waiting = _file(db, user.id, title="Waiting", status=JobStatus.QUEUED)
|
||||
stuck = _file(db, user.id, title="Stuck", status=JobStatus.ERROR)
|
||||
paused = _file(db, user.id, title="Paused", status=JobStatus.PAUSED)
|
||||
stopped = _file(db, user.id, title="Stopped", status=JobStatus.ERROR)
|
||||
dropped = _file(db, user.id, title="Dropped", status=JobStatus.CANCELED)
|
||||
for job in (old, stopped, dropped):
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job.id)
|
||||
.values(updated_at=NOW - timedelta(hours=1))
|
||||
)
|
||||
db.commit()
|
||||
rows = as_user(user).get("/api/downloads").json()
|
||||
# An old failure still waits for the user; an old finished or cancelled job does not.
|
||||
assert {row["id"] for row in rows} == {fresh.id, waiting.id, stuck.id, paused.id, stopped.id}
|
||||
# The tray names where each came from, as the library does.
|
||||
assert {row["source_key"] for row in rows} == {"youtube"}
|
||||
|
||||
@@ -39,6 +39,8 @@ import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import DownloadIntake from "./components/DownloadIntake";
|
||||
import DownloadTray from "./components/DownloadTray";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
@@ -461,12 +463,16 @@ export default function App() {
|
||||
Anchored inside the content column so they clear the sidebar automatically
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
|
||||
<DownloadTray />
|
||||
</div>
|
||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<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. */}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -62,6 +62,8 @@ vi.mock("./savedViewsContext", () => ({
|
||||
}));
|
||||
const requestSurprise = vi.fn();
|
||||
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
|
||||
const requestLinkBox = vi.fn();
|
||||
vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() }));
|
||||
|
||||
import AppCommandPalette from "./AppCommandPalette";
|
||||
|
||||
@@ -142,6 +144,17 @@ describe("AppCommandPalette", () => {
|
||||
expect(requestSurprise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Download from a link takes the reader to the Files link box, asked on that entry", () => {
|
||||
mount();
|
||||
const item = group("actions")!.items.find((i) => i.id === "action:downloadLink")!;
|
||||
setPage.mockImplementationOnce(() => {});
|
||||
item.run();
|
||||
expect(setPage.mock.lastCall?.[0]).toBe("files");
|
||||
expect(requestLinkBox).not.toHaveBeenCalled();
|
||||
setPage.mock.lastCall?.[1]?.();
|
||||
expect(requestLinkBox).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers the debug recorder by its state", () => {
|
||||
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
|
||||
mount();
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bug, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, Tv } from "lucide-react";
|
||||
import {
|
||||
Bug,
|
||||
Download,
|
||||
Flag,
|
||||
Keyboard,
|
||||
ListVideo,
|
||||
Palette,
|
||||
Sparkles,
|
||||
Square,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -10,6 +20,7 @@ import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { requestSurprise } from "../lib/homeIntent";
|
||||
import { requestLinkBox } from "../lib/downloadIntake";
|
||||
import { openRecorderStart } from "../lib/debugRecorder";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
@@ -145,6 +156,14 @@ export default function AppCommandPalette() {
|
||||
// polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
|
||||
run: () => setPage("home", requestSurprise),
|
||||
},
|
||||
// Downloading is an action you start anywhere; with nothing pasted yet, the place to paste is
|
||||
// the Files page's link box (lib/downloadIntake), focused on arrival.
|
||||
{
|
||||
id: "action:downloadLink",
|
||||
label: t("palette.actions.downloadLink"),
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
run: () => setPage("files", requestLinkBox),
|
||||
},
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
label: t("keys.title"),
|
||||
|
||||
@@ -79,15 +79,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function mount(tab?: string) {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
const qc = new QueryClient();
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<DownloadCenter />
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(tree());
|
||||
mounted.rerender = () => rerender(tree());
|
||||
if (tab)
|
||||
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
|
||||
}
|
||||
|
||||
// The last mount's re-render: what a poll's new data does to the page.
|
||||
const mounted = { rerender: () => {} };
|
||||
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const moreOf = (text: string) =>
|
||||
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")));
|
||||
// The queue polls while a job runs: the next render lists a NEW object for the same job.
|
||||
mocks.jobs = [job(1, "running", { progress: 40 })];
|
||||
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
mounted.rerender();
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Clip 1"));
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react";
|
||||
import { Pause, Play, RefreshCw, Settings2, UploadCloud } from "lucide-react";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used.
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { type DownloadJob } from "../lib/api";
|
||||
import { fetchQueue } from "../lib/downloadQueue";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||
import { ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Input } from "./ui/Input";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useDownloadActions } from "./useDownloadActions";
|
||||
import { LinkBox } from "./LinkBox";
|
||||
|
||||
const jobKey = (job: DownloadJob) => job.id;
|
||||
|
||||
/** The clipboard's text, or null where the browser refuses. Outside the component: the React
|
||||
* Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */
|
||||
async function clipboardText(): Promise<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
|
||||
// download profiles, and (admin) the instance's downloads. A finished download is a library file and
|
||||
// lives in Files.
|
||||
@@ -47,8 +36,6 @@ export default function DownloadCenter() {
|
||||
// any failures are, so it's the useful default every visit.
|
||||
const [tab, setTab] = useState("queue");
|
||||
const panel = useTabPanel();
|
||||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
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
|
||||
// (only done/error/paused rows left, which don't advance on their own) so an idle Download Center
|
||||
// isn't hitting the API — and adding to the shared box's load — every 2s for nothing.
|
||||
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, {
|
||||
const jobsQ = useLiveQuery(qk.downloads(), fetchQueue, {
|
||||
intervalMs: (data) =>
|
||||
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
|
||||
});
|
||||
@@ -86,16 +73,6 @@ export default function DownloadCenter() {
|
||||
};
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
||||
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||
// the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text.
|
||||
const pasteFromClipboard = async (force = false) => {
|
||||
if (!force && addUrl.trim()) return;
|
||||
const text = await clipboardText();
|
||||
if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
|
||||
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
|
||||
@@ -137,31 +114,7 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Input
|
||||
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>
|
||||
<LinkBox className="flex-[1_1_16rem]" />
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -230,15 +183,6 @@ export default function DownloadCenter() {
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{addSource && (
|
||||
<DownloadDialog
|
||||
source={addSource}
|
||||
onClose={() => {
|
||||
setAddSource(null);
|
||||
setAddUrl("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
fireEvent.change(field, { target: { value: "Más" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import { MoreButton } from "./ui/MoreButton";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LinkBox } from "./LinkBox";
|
||||
import Pager from "./ui/Pager";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
@@ -572,9 +573,8 @@ export default function Files() {
|
||||
{t("files.count", { count: rows.length })}
|
||||
</span>
|
||||
)}
|
||||
{/* Wraps on a phone: sort, view and Upload together are wider than the column. */}
|
||||
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
{isGrid && (
|
||||
<span className="ml-auto">
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
@@ -584,13 +584,14 @@ export default function Files() {
|
||||
ascLabel={t("files.sort.asc")}
|
||||
descLabel={t("files.sort.desc")}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
||||
label={t("files.layout.label")}
|
||||
/>
|
||||
{/* The intake row (DESIGN.md "Files page"): paste a link, or upload; then the paint. On a
|
||||
phone the link box takes the line and the rest wraps under it. A link is downloaded
|
||||
into its site's folder whatever is open, so the box stays even in Shared with me. */}
|
||||
<span className="flex w-full flex-wrap items-center gap-2">
|
||||
<LinkBox className="flex-[1_1_18rem]" />
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -601,6 +602,13 @@ export default function Files() {
|
||||
{t("files.actions.upload")}
|
||||
</Button>
|
||||
)}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
||||
label={t("files.layout.label")}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
@@ -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.
|
||||
// i18n is key-passthrough, so labels read as keys.
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type AppNotification } from "../lib/api";
|
||||
import type { Notification as ClientNotif } from "../lib/notifications";
|
||||
@@ -15,6 +15,7 @@ const h = vi.hoisted(() => ({
|
||||
server: [] as unknown[],
|
||||
client: [] as unknown[],
|
||||
setPage: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
clearAll: () => h.clearAll(),
|
||||
}));
|
||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
|
||||
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
||||
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
// Every narrowing a feed can carry (the year, the custom range, the length, the
|
||||
@@ -81,7 +83,8 @@ import PageShell from "./ui/PageShell";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
for (const f of [h.setPage, h.setFilters, h.remove, h.clearAll, h.confirm]) f.mockClear();
|
||||
for (const f of [h.setPage, h.openFile, h.setFilters, h.remove, h.clearAll, h.confirm])
|
||||
f.mockClear();
|
||||
});
|
||||
|
||||
const server = (id: number, extra: Partial<AppNotification> = {}) =>
|
||||
@@ -144,7 +147,7 @@ describe("NotificationsPanel · a server row", () => {
|
||||
expect(row.classList.contains("group")).toBe(true);
|
||||
const buttons = [...row.querySelectorAll("button")];
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([
|
||||
"inbox.openDownloads",
|
||||
"inbox.openFile",
|
||||
"inbox.markRead",
|
||||
"inbox.dismiss",
|
||||
"inbox.rowMenu",
|
||||
@@ -154,13 +157,27 @@ describe("NotificationsPanel · a server row", () => {
|
||||
expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true);
|
||||
});
|
||||
|
||||
it("a finished download opens on its file, in its folder; an older notice on Files", async () => {
|
||||
h.server = [
|
||||
server(1, { data: { title: "Ready 1", kind: "download", job_id: 41, folder_id: 3 } }),
|
||||
server(2),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
fireEvent.click(within(rowOf("Ready 1")).getByText("inbox.openFile"));
|
||||
expect(h.openFile).toHaveBeenCalledWith(41, 3);
|
||||
fireEvent.click(within(rowOf("Ready 2")).getByText("inbox.openFile"));
|
||||
expect(h.setPage).toHaveBeenCalledWith("files");
|
||||
expect(h.openFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("holds the link, mark read and dismiss", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [];
|
||||
const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
|
||||
expect(menuRows()).toEqual(["inbox.openDownloads", "inbox.markRead", "inbox.dismiss"]);
|
||||
expect(menuRows()).toEqual(["inbox.openFile", "inbox.markRead", "inbox.dismiss"]);
|
||||
choose("inbox.dismiss");
|
||||
await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything()));
|
||||
});
|
||||
@@ -235,6 +252,26 @@ describe("NotificationsPanel · an activity row", () => {
|
||||
await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new"));
|
||||
});
|
||||
|
||||
it("a live notice with two actions shows both, in the row and in its menu", async () => {
|
||||
h.server = [];
|
||||
const copy = vi.fn();
|
||||
const open = vi.fn();
|
||||
h.client = [
|
||||
client(5, {
|
||||
dismissed: false,
|
||||
action: { label: "tray.done.copyLink", onClick: copy },
|
||||
secondaryAction: { label: "tray.done.open", onClick: open },
|
||||
}),
|
||||
];
|
||||
mount();
|
||||
fireEvent.click(within(rowOf("Activity 5")).getByText("tray.done.open"));
|
||||
expect(open).toHaveBeenCalled();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 5")));
|
||||
expect(menuRows()).toEqual(["tray.done.copyLink", "tray.done.open", "inbox.dismiss"]);
|
||||
choose("tray.done.copyLink");
|
||||
await waitFor(() => expect(copy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("finds a watched notice's video with nothing narrowing the feed past it", async () => {
|
||||
h.server = [];
|
||||
h.client = [
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -45,6 +45,7 @@ import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
@@ -71,6 +72,7 @@ const byId = (n: { id: number }) => n.id;
|
||||
export default function NotificationsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const { openFile } = useFilesActions();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from a new-subscription notification) lives in
|
||||
@@ -184,7 +186,12 @@ export default function NotificationsPanel() {
|
||||
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
||||
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
||||
const openScheduler = () => setPage("scheduler");
|
||||
const openDownloads = () => setPage("downloads");
|
||||
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||
const openDone = (n: AppNotification) => {
|
||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||
if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null);
|
||||
else setPage("files");
|
||||
};
|
||||
const reviewAccess = () => {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
@@ -213,9 +220,9 @@ export default function NotificationsPanel() {
|
||||
? [
|
||||
{
|
||||
id: "open",
|
||||
label: t("inbox.openDownloads"),
|
||||
icon: icon(Download),
|
||||
onSelect: openDownloads,
|
||||
label: t("inbox.openFile"),
|
||||
icon: icon(FolderOpen),
|
||||
onSelect: () => openDone(n),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -280,17 +287,15 @@ export default function NotificationsPanel() {
|
||||
onSelect: reviewAccess,
|
||||
},
|
||||
]
|
||||
: n.action && !n.dismissed
|
||||
? [
|
||||
{
|
||||
id: "action",
|
||||
label: n.action.label,
|
||||
: !n.dismissed
|
||||
? liveActions(n).map((action, i) => ({
|
||||
id: `action${i}`,
|
||||
label: action.label,
|
||||
onSelect: () => {
|
||||
n.action!.onClick();
|
||||
action.onClick();
|
||||
dismissClient(n.id);
|
||||
},
|
||||
},
|
||||
]
|
||||
}))
|
||||
: [];
|
||||
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
|
||||
}
|
||||
@@ -391,7 +396,7 @@ export default function NotificationsPanel() {
|
||||
onRead={() => readMut.mutate(n.id)}
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={openScheduler}
|
||||
onOpenDownloads={openDownloads}
|
||||
onOpenFile={() => openDone(n)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -438,6 +443,11 @@ export default function NotificationsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
|
||||
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
|
||||
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
|
||||
}
|
||||
|
||||
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
|
||||
// The download-lifecycle notification types → their `inbox.<key>` copy.
|
||||
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
|
||||
@@ -500,7 +510,7 @@ function NotificationRow({
|
||||
onRead,
|
||||
onDismiss,
|
||||
onOpenScheduler,
|
||||
onOpenDownloads,
|
||||
onOpenFile,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: TFunction;
|
||||
@@ -510,7 +520,7 @@ function NotificationRow({
|
||||
onRead: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
onOpenDownloads: () => void;
|
||||
onOpenFile: () => void;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||
@@ -609,10 +619,10 @@ function NotificationRow({
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenDownloads}
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
onClick={onOpenFile}
|
||||
icon={<FolderOpen className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("inbox.openDownloads")}
|
||||
{t("inbox.openFile")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -706,19 +716,25 @@ function ClientActivityRow({
|
||||
{t("common.accessRequestsReview")}
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
!n.dismissed &&
|
||||
liveActions(n).length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||
{liveActions(n).map((action, i) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
data-nav-open=""
|
||||
className="text-sm"
|
||||
// The first is the row's own: Enter on the row runs it.
|
||||
data-nav-open={i === 0 ? "" : undefined}
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
action.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
>
|
||||
{n.action.label}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import { backToTopShown, useBackToTopShown } from "./backToTopState";
|
||||
import { backToTopShown, useBackToTopShown, useCornerLift } from "./backToTopState";
|
||||
|
||||
// A floating "back to top" button that fades in once the current page's scroll container is
|
||||
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
|
||||
@@ -15,6 +15,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
const { t } = useTranslation();
|
||||
// Shared, not local: PageScroller keeps the page's end clear of the button while it is up.
|
||||
const show = useBackToTopShown();
|
||||
// Over the download tray when it is up: the tray's 1rem inset, its height, then a gap.
|
||||
const lift = useCornerLift();
|
||||
const scrollerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than
|
||||
@@ -63,6 +65,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
// Hidden, it only goes transparent (so it can fade): out of the Tab order as well.
|
||||
tabIndex={show ? undefined : -1}
|
||||
aria-hidden={show ? undefined : true}
|
||||
style={lift ? { bottom: `calc(${lift}px + 1.5rem)` } : undefined}
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||
}`}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper";
|
||||
import { StickyTopProvider } from "./stickyTop";
|
||||
import { useBackToTopShown } from "./backToTopState";
|
||||
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
||||
import { CONTENT_ID } from "./landmarks";
|
||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
||||
|
||||
@@ -139,6 +139,7 @@ export default function PageScroller({
|
||||
}, []);
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
|
||||
const fabUp = useBackToTopShown();
|
||||
const cornerLift = useCornerLift();
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
// Stable identity: an inline ref callback changes every render, which makes React detach and
|
||||
// re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it
|
||||
@@ -217,7 +218,15 @@ export default function PageScroller({
|
||||
{/* While Back to top floats over the corner, the page ends a button's height (3rem + its 1.25rem
|
||||
inset) later, so its last row or pager can scroll out from under it. Only then: a spacer
|
||||
always there would give a full-height layout a scrollbar it does not need. */}
|
||||
{fabUp && <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,17 +171,25 @@ export default function Toaster({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
{(toast.action || toast.secondaryAction) && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||
{[toast.action, toast.secondaryAction].map(
|
||||
(action) =>
|
||||
action && (
|
||||
<Button
|
||||
key={action.label}
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
action.onClick();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="mt-1 text-sm"
|
||||
className="text-sm"
|
||||
>
|
||||
{toast.action.label}
|
||||
{action.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
|
||||
@@ -8,3 +8,15 @@ export const backToTopShown = createStore(false);
|
||||
export function useBackToTopShown(): boolean {
|
||||
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]. */
|
||||
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)"],
|
||||
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
||||
"Home.tsx": [
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"manage": "Manage formats…"
|
||||
},
|
||||
"page": {
|
||||
"addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"upload": "Upload",
|
||||
"manage": "Manage formats…"
|
||||
|
||||
@@ -86,5 +86,10 @@
|
||||
"here": "Drop to upload into {{folder}}",
|
||||
"oneAtATime": "Upload one file at a time — the first one is ready to go."
|
||||
},
|
||||
"perPage": "Files per page"
|
||||
"perPage": "Files per page",
|
||||
"link": {
|
||||
"label": "A link to download",
|
||||
"placeholder": "Paste a link and I'll download it (YouTube, TikTok, Facebook…)",
|
||||
"download": "Download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"andMore_one": "and {{count}} more",
|
||||
"andMore_other": "and {{count}} more",
|
||||
"openScheduler": "Open Scheduler",
|
||||
"openDownloads": "Open Downloads",
|
||||
"openFile": "Open the file",
|
||||
"revertFailed": "Couldn't undo that — please try again",
|
||||
"downloadDone": {
|
||||
"title": "Download ready"
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"surprise": "Surprise me"
|
||||
"surprise": "Surprise me",
|
||||
"downloadLink": "Download from a link…"
|
||||
},
|
||||
"open": "Open the command palette"
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
"page": {
|
||||
"addUrl": "Illessz be egy videólinket (YouTube, Facebook, …) vagy YouTube-videóazonosítót…",
|
||||
"add": "Hozzáad",
|
||||
"paste": "Link beillesztése a vágólapról",
|
||||
"upload": "Feltöltés",
|
||||
"manage": "Formátumok kezelése…"
|
||||
|
||||
@@ -86,5 +86,10 @@
|
||||
"here": "Engedd el, és feltöltöm ide: {{folder}}",
|
||||
"oneAtATime": "Egyszerre egy fájlt tölthetsz fel — az első készen áll."
|
||||
},
|
||||
"perPage": "Fájl oldalanként"
|
||||
"perPage": "Fájl oldalanként",
|
||||
"link": {
|
||||
"label": "Letöltendő link",
|
||||
"placeholder": "Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)",
|
||||
"download": "Letöltés"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"andMore_one": "és még {{count}}",
|
||||
"andMore_other": "és még {{count}}",
|
||||
"openScheduler": "Ütemező megnyitása",
|
||||
"openDownloads": "Letöltések megnyitása",
|
||||
"openFile": "Fájl megnyitása",
|
||||
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
|
||||
"downloadDone": {
|
||||
"title": "Letöltés kész"
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"actions": "Műveletek"
|
||||
},
|
||||
"actions": {
|
||||
"surprise": "Meglepetés"
|
||||
"surprise": "Meglepetés",
|
||||
"downloadLink": "Letöltés linkről…"
|
||||
},
|
||||
"open": "Parancspaletta megnyitása"
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,12 @@ export interface DownloadJob {
|
||||
position_seconds?: number;
|
||||
}
|
||||
|
||||
// A row of the queue (the tray's poll): what is in flight or failed, and what finished a moment ago,
|
||||
// with where it came from as the library names it.
|
||||
export interface QueueJob extends DownloadJob {
|
||||
source_key: string;
|
||||
}
|
||||
|
||||
export interface ShareRecipient {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -211,7 +217,7 @@ export const downloadsApi = {
|
||||
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
|
||||
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||
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"),
|
||||
pauseDownload: (id: number): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export interface Notification {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: NotifAction;
|
||||
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
|
||||
secondaryAction?: NotifAction;
|
||||
meta?: NotifMeta;
|
||||
requiresInteraction: boolean;
|
||||
duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
|
||||
@@ -82,6 +84,7 @@ export interface NotifyInput {
|
||||
level?: NotifLevel;
|
||||
title?: string;
|
||||
action?: NotifAction;
|
||||
secondaryAction?: NotifAction;
|
||||
meta?: NotifMeta;
|
||||
requiresInteraction?: boolean;
|
||||
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
|
||||
@@ -185,6 +188,7 @@ function load(): Notification[] {
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
action: undefined,
|
||||
secondaryAction: undefined,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
}) as Notification,
|
||||
@@ -198,7 +202,9 @@ function persist() {
|
||||
try {
|
||||
// Transient status notices are live-session only — never write them to history,
|
||||
// so a reload can't leave one stranded with no producer left to clear it.
|
||||
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||
const slim = items
|
||||
.filter((n) => !n.transient)
|
||||
.map(({ action: _action, secondaryAction: _second, ...n }) => n);
|
||||
writeAccount(HISTORY_KEY, slim);
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
@@ -317,6 +323,7 @@ export function notify(input: NotifyInput): number {
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
action: input.action,
|
||||
secondaryAction: input.secondaryAction,
|
||||
meta: input.meta,
|
||||
requiresInteraction,
|
||||
duration,
|
||||
|
||||
@@ -21,6 +21,11 @@ export const LS = {
|
||||
filesLayouts: "siftlode.filesLayouts",
|
||||
filesSort: "siftlode.filesSort",
|
||||
filesCardSize: "siftlode.filesCardSize",
|
||||
// The download tray open or folded to its pill — a per-account mirror of the server pref.
|
||||
downloadTray: "siftlode.downloadTray",
|
||||
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
||||
// changes. Per-account mirror of the server pref.
|
||||
downloadTrayClosed: "siftlode.downloadTrayClosed",
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
|
||||
Reference in New Issue
Block a user