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

- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one
  app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses
  the box on its history entry.
- The download tray, bottom right on every page: what waits, runs, is paused or failed (reason +
  Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until
  closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the
  work changes or something finishes. Back to top steps up over it, the page end keeps clear.
- A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts
  and notices may carry a second action).
- The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id.
- GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with
  each row's source key.
This commit is contained in:
2026-10-11 16:56:13 +02:00
parent d22c64f4dd
commit 1c4f0042d2
42 changed files with 1509 additions and 162 deletions
+6
View File
@@ -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();
+20 -1
View File
@@ -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"));
+6 -62
View File
@@ -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");
});
});
+393
View File
@@ -0,0 +1,393 @@
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
FolderOpen,
Link,
Loader2,
RotateCw,
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
import { quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
clearFinished,
fetchQueue,
takeUnannounced,
useFinished,
useUnannounced,
} from "../lib/downloadQueue";
import { navItemAttrs } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { usePref } from "../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { sourceLabel } from "./fileFolders";
import { useFilesActions } from "./filesContext";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { setCornerLift } from "./ui/backToTopState";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import { IconButton } from "./ui/IconButton";
import { menuGroups } from "./ui/menuGroups";
import { MoreButton } from "./ui/MoreButton";
import NavGrid from "./ui/NavGrid";
import { Pressable } from "./ui/Pressable";
import { CONTENT_ID } from "./ui/landmarks";
import { SURFACE } from "./ui/surfaces";
import { useContextMenu } from "./ui/useContextMenu";
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
// until it is closed. Folds to a pill (remembered per account). A row's right click and "…" are the
// queue's one menu; a finish is announced once with "Copy link" and "Open" (DESIGN.md "Download
// done"). It sits under every dialog and player (z-chrome), so it never covers a player's controls.
type Fold = "open" | "folded";
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
// Byte progress (a bar with a %) vs ffmpeg's post-steps (a pulse): the queue row's split.
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id;
function Progress({ job }: { job: QueueJob }) {
if (job.phase && !BYTE_PHASES.includes(job.phase))
return (
<span className="block h-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full w-full animate-pulse bg-accent/60" />
</span>
);
return (
<span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</span>
<span className="w-8 text-right tabular-nums">{job.progress}%</span>
</span>
);
}
export default function DownloadTray() {
const { t } = useTranslation();
const qc = useQueryClient();
const { openFile } = useFilesActions();
const actions = useDownloadActions();
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once
// (invalidateDownloads), and one started on another device shows within a minute.
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
intervalMs: (data) => (data?.some((j) => IN_FLIGHT.has(j.status)) ? 2000 : 60000),
});
const finished = useFinished();
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
const activeIds = new Set(active.map((j) => j.id));
const rows = [...active, ...finished.filter((j) => !activeIds.has(j.id))];
// Closed with work still on it, it stays away until that work changes: a new job, or one failing.
// Remembered (per account) past a reload: an old failure the reader has seen must not reopen it
// on every page they load.
const signature = active.map((j) => `${j.id}${j.status === "error" ? "!" : ""}`).join(",");
const [closedAt, setClosedAt] = usePref<string | null>("downloadTrayClosed", null, {
localKey: LS.downloadTrayClosed,
parse: parseClosed,
});
// A finish since it was closed is news of its own, whatever else is still on it.
const shown = rows.length > 0 && (signature !== closedAt || finished.length > 0);
const [fold, setFold] = usePref<Fold>("downloadTray", "open", {
localKey: LS.downloadTray,
parse: parseFold,
});
const [errorJob, setErrorJob] = useState<QueueJob | null>(null);
const menu = useContextMenu<QueueJob>({ keyOf });
// Folding swaps the control under the keyboard for its counterpart: the focus follows it there
// (the pill, or the fold button), never left on <body> where the pressed one was.
const pill = useRef<HTMLButtonElement>(null);
const foldButton = useRef<HTMLButtonElement>(null);
const focusNext = useRef<Fold | null>(null);
const toggleFold = (next: Fold) => {
focusNext.current = next;
setFold(next);
};
useEffect(() => {
const target = focusNext.current;
focusNext.current = null;
if (target === "folded") pill.current?.focus();
else if (target === "open") foldButton.current?.focus();
}, [fold]);
// --- the done notice ------------------------------------------------------------------------
const announce = useEffectEvent((job: QueueJob) => {
const title = jobTitle(job);
notify({
level: "success",
title: t("tray.done.title", { title }),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
},
});
});
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
if (fresh.length) takeUnannounced().forEach(announce);
}, [fresh]);
// --- Back to top steps up over the tray while it shows -----------------------------------------
const box = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = box.current;
if (!el) {
setCornerLift(0);
return;
}
const measure = () => setCornerLift(el.getBoundingClientRect().height);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
ro.disconnect();
setCornerLift(0);
};
}, [shown, fold]);
if (!shown)
return errorJob ? (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
) : null;
const counts = {
running: rows.filter((j) => j.status === "queued" || j.status === "running").length,
paused: rows.filter((j) => j.status === "paused").length,
error: rows.filter((j) => j.status === "error").length,
done: rows.filter((j) => j.status === "done").length,
};
const summary = (["running", "paused", "error", "done"] as const)
.filter((k) => counts[k] > 0)
.map((k) => t(`tray.count.${k}`, { count: counts[k] }))
.join(", ");
const running = rows.filter((j) => j.status === "running");
const meanProgress = running.length
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: null;
const StateIcon = counts.running ? Loader2 : counts.error ? AlertTriangle : CheckCircle2;
const stateIcon = (
<StateIcon
aria-hidden
className={clsx(
"size-4 shrink-0",
counts.running ? "animate-spin text-accent" : counts.error ? "text-danger" : "text-success",
)}
/>
);
const close = () => {
clearFinished();
setClosedAt(signature);
// The tray goes with the button pressed: the keyboard rests on the page (its <main>).
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
};
const doneItems = (job: QueueJob): ActionMenuItem[] =>
menuGroups([
{
id: "open",
label: t("tray.done.open"),
icon: <FolderOpen className="w-4 h-4" />,
onSelect: () => openFile(job.id, job.folder_id),
},
{
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, job, t),
},
]);
const menuItems = menu.target
? menu.target.status === "done"
? doneItems(menu.target)
: actions.items(menu.target)
: [];
return (
<div
ref={box}
role="region"
aria-label={t("tray.label")}
className="absolute right-4 bottom-4 z-chrome flex max-w-[calc(100%-2rem)] flex-col items-end"
>
{fold === "folded" ? (
<Pressable
ref={pill}
onClick={() => toggleFold("open")}
aria-expanded={false}
title={t("tray.unfold")}
className={clsx(
SURFACE.floating,
"inline-flex items-center gap-2 px-3 py-1.5 text-sm glass-hover",
)}
>
{stateIcon}
<span className="truncate">{summary}</span>
{meanProgress !== null && (
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent"
style={{ width: `${meanProgress}%` }}
/>
</span>
)}
</Pressable>
) : (
<div
className={clsx(
SURFACE.popover,
"@container/tray w-80 max-w-full overflow-hidden shadow-float",
)}
>
<div className="flex items-center gap-2 border-b border-border/60 py-1 pr-1 pl-3">
{stateIcon}
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
<span className="@max-[18rem]/tray:hidden">{t("tray.title")} · </span>
{summary}
</span>
<IconButton
ref={foldButton}
label={t("tray.fold")}
size="sm"
variant="ghost"
aria-expanded
onClick={() => toggleFold("folded")}
>
<ChevronDown />
</IconButton>
<IconButton label={t("tray.close")} size="sm" variant="ghost" onClick={close}>
<X />
</IconButton>
</div>
<NavGrid className="max-h-[50dvh] overflow-y-auto py-1">
{rows.map((job, i) => {
const title = jobTitle(job);
const mine = menu.open && menu.target?.id === job.id;
return (
<div
key={job.id}
{...navItemAttrs(i, title)}
{...menu.triggerProps(job)}
data-job-id={job.id}
className="group flex items-start gap-1 px-3 py-2 focus-visible:bg-fg/5"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<span className="shrink-0 text-xs text-muted">
{sourceLabel(job.source_key, t)}
</span>
</span>
<RowState
job={job}
onShowError={() => setErrorJob(job)}
onRetry={() => actions.run(job, "resume")}
onOpen={() => openFile(job.id, job.folder_id)}
/>
</div>
<MoreButton
label={t("tray.rowMenu")}
size="sm"
expanded={mine}
menuProps={menu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
</div>
);
})}
</NavGrid>
</div>
)}
<ContextMenu menu={menu} ariaLabel={t("tray.rowMenu")} items={menuItems} />
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
</div>
);
}
/** What a tray row says under its title: how far it is, that it waits, why it failed, or that it is
* done — with the one thing to do about it in reach. */
function RowState({
job,
onShowError,
onRetry,
onOpen,
}: {
job: QueueJob;
onShowError: () => void;
onRetry: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
if (job.status === "running") return <Progress job={job} />;
if (job.status === "error")
return (
<span className="flex items-center gap-2 text-xs">
<AlertTriangle aria-hidden className="size-3.5 shrink-0 text-danger" />
<Pressable
onClick={onShowError}
title={t(failTitleKey(job))}
className="min-w-0 flex-1 truncate text-left text-danger hover:underline"
>
{t(failCopyKeys(job, "short"))}
</Pressable>
<Button
variant="link"
size="none"
icon={<RotateCw className="size-3" />}
onClick={onRetry}
className="text-xs"
>
{t("downloads.actions.retry")}
</Button>
</span>
);
if (job.status === "done")
return (
<span className="flex items-center gap-2 text-xs">
<CheckCircle2 aria-hidden className="size-3.5 shrink-0 text-success" />
<span className="flex-1 text-success">{t("tray.done.label")}</span>
<Button variant="link" size="none" onClick={onOpen} data-nav-open="" className="text-xs">
{t("tray.done.open")}
</Button>
</span>
);
return <span className="text-xs text-muted">{t(`downloads.status.${job.status}`)}</span>;
}
@@ -259,7 +259,7 @@ describe("Files · my file's row", () => {
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
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));
});
+28 -20
View File
@@ -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 && (
{isGrid && (
<span className="ml-auto">
<ColumnSortControl
columns={columns}
sort={sort}
@@ -584,23 +584,31 @@ export default function Files() {
ascLabel={t("files.sort.asc")}
descLabel={t("files.sort.desc")}
/>
)}
<ViewSwitcher
value={layout}
options={layoutOptions}
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
label={t("files.layout.label")}
/>
{canUpload && (
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload({ file: null })}
>
{t("files.actions.upload")}
</Button>
)}
</span>
)}
{/* 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"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload({ file: null })}
>
{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>
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ClipboardPaste, Download } from "lucide-react";
import {
asDownloadLink,
requestDownload,
takeLinkBox,
useLinkBoxRequest,
} from "../lib/downloadIntake";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
/** The clipboard's text, or null where the browser refuses. Outside the component: the React
* Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */
async function clipboardText(): Promise<string | null> {
try {
return (await navigator.clipboard?.readText()) ?? null;
} catch {
return null; // permission denied or unsupported — the paste button still works on a click
}
}
// "Paste a link and I download it": the field, the clipboard button and Download. The link goes to
// the app's one download dialog (lib/downloadIntake), where the profile is picked.
export function LinkBox({ className }: { className?: string }) {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const field = useRef<HTMLInputElement>(null);
const submit = () => {
const link = url.trim();
if (!link) return;
requestDownload(link);
setUrl("");
};
// Pull a link off the clipboard. Reading it needs a secure context and a gesture or a permission
// (iOS Safari and Brave are strict), so it is best-effort: the button is a gesture and reliable, the
// field's focus tries silently. Only an http(s) link is taken, and never over what was typed.
const paste = async (force: boolean) => {
if (!force && url.trim()) return;
const link = asDownloadLink(await clipboardText());
if (link) setUrl(link);
};
// The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the
// take decides — it clears the token, so a later pass or another entry's visit does nothing.
const request = useLinkBoxRequest();
useEffect(() => {
if (request !== null && takeLinkBox()) field.current?.focus();
}, [request]);
return (
// Narrow, the field takes its own line and the two buttons go under it, right: beside them it
// was left a few letters wide on a phone.
<span className={`flex min-w-0 flex-wrap items-center justify-end gap-2 ${className ?? ""}`}>
<Input
ref={field}
size="sm"
className="min-w-0 flex-[1_1_14rem]"
value={url}
onChange={(e) => setUrl(e.target.value)}
onFocus={() => void paste(false)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder={t("files.link.placeholder")}
aria-label={t("files.link.label")}
/>
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => void paste(true)}
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
icon={<Download className="w-4 h-4" />}
onClick={submit}
className="shrink-0"
>
{t("files.link.download")}
</Button>
</span>
);
}
@@ -6,7 +6,7 @@
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
// 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 = [
+50 -34
View File
@@ -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,
onSelect: () => {
n.action!.onClick();
dismissClient(n.id);
},
: !n.dismissed
? liveActions(n).map((action, i) => ({
id: `action${i}`,
label: action.label,
onSelect: () => {
action.onClick();
dismissClient(n.id);
},
]
}))
: [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
}
@@ -391,7 +396,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenDownloads={openDownloads}
onOpenFile={() => openDone(n)}
/>
);
})}
@@ -438,6 +443,11 @@ export default function NotificationsPanel() {
);
}
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
}
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
// The download-lifecycle notification types → their `inbox.<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 && (
<Button
variant="link"
className="mt-1 text-sm"
data-nav-open=""
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
>
{n.action.label}
</Button>
!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="text-sm"
// The first is the row's own: Enter on the row runs it.
data-nav-open={i === 0 ? "" : undefined}
onClick={() => {
action.onClick();
dismissClient(n.id);
}}
>
{action.label}
</Button>
))}
</div>
)
)}
</div>
+4 -1
View File
@@ -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"
}`}
+11 -2
View File
@@ -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();
});
});
+19 -11
View File
@@ -171,17 +171,25 @@ export default function Toaster({
</span>
)}
</div>
{toast.action && (
<Button
variant="link"
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-sm"
>
{toast.action.label}
</Button>
{(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={() => {
action.onClick();
dismiss(toast.id);
}}
className="text-sm"
>
{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…"
+6 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -15,7 +15,8 @@
"actions": "Actions"
},
"actions": {
"surprise": "Surprise me"
"surprise": "Surprise me",
"downloadLink": "Download from a link…"
},
"open": "Open the command palette"
}
+26
View File
@@ -0,0 +1,26 @@
{
"label": "Downloads in progress",
"title": "Downloads",
"rowMenu": "Download actions",
"fold": "Fold the downloads",
"unfold": "Show the downloads",
"close": "Close",
"count": {
"running_one": "{{count}} running",
"running_other": "{{count}} running",
"paused_one": "{{count}} paused",
"paused_other": "{{count}} paused",
"error_one": "{{count}} failed",
"error_other": "{{count}} failed",
"done_one": "{{count}} done",
"done_other": "{{count}} done"
},
"done": {
"label": "Done",
"open": "Open",
"copyLink": "Copy link",
"title": "Downloaded: {{title}}",
"phone": "Plays on a phone too",
"file": "Saved in Files"
}
}
@@ -19,8 +19,6 @@
"manage": "Formátumok kezelése…"
},
"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…"
+6 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -15,7 +15,8 @@
"actions": "Műveletek"
},
"actions": {
"surprise": "Meglepetés"
"surprise": "Meglepetés",
"downloadLink": "Letöltés linkről…"
},
"open": "Parancspaletta megnyitása"
}
+26
View File
@@ -0,0 +1,26 @@
{
"label": "Folyamatban lévő letöltések",
"title": "Letöltések",
"rowMenu": "Letöltés műveletei",
"fold": "Letöltések összecsukása",
"unfold": "Letöltések megjelenítése",
"close": "Bezárás",
"count": {
"running_one": "{{count}} fut",
"running_other": "{{count}} fut",
"paused_one": "{{count}} szünetel",
"paused_other": "{{count}} szünetel",
"error_one": "{{count}} hibás",
"error_other": "{{count}} hibás",
"done_one": "{{count}} kész",
"done_other": "{{count}} kész"
},
"done": {
"label": "Kész",
"open": "Megnyitás",
"copyLink": "Link másolása",
"title": "Letöltve: {{title}}",
"phone": "Telefonon is lejátszható",
"file": "Bekerült a Fájlok közé"
}
}
+7 -1
View File
@@ -118,6 +118,12 @@ export interface DownloadJob {
position_seconds?: number;
}
// 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 }),
+51
View File
@@ -0,0 +1,51 @@
import { ensureNavId, peekNavId } from "./history";
import { createStore } from "./store";
// Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto
// any page, the command palette. They all hand the link to ONE app-wide download dialog
// (components/DownloadIntake), so none of them mounts a dialog of its own.
const pending = createStore<string | null>(null);
/** An http(s) link, alone — what a paste or the link box may download; anything else is not one. */
export function asDownloadLink(text: string | null | undefined): string | null {
const s = text?.trim() ?? "";
return /^https?:\/\/\S+$/i.test(s) ? s : null;
}
/** Whether a paste here is the page's to take: not typed into a field, and no dialog on top (the
* reader is busy with that, and a link pasted at it is not a request to download). */
export function pasteIsForThePage(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null;
if (el?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])"))
return false;
return !document.querySelector("[aria-modal='true']");
}
/** Ask to download `url`: the app's download dialog opens on it. */
export const requestDownload = (url: string): void => pending.set(url);
/** The link the dialog is open on (null = closed). DownloadIntake only. */
export const usePendingDownload = (): string | null => pending.useValue();
/** The dialog closed, downloading or not. */
export const clearPendingDownload = (): void => pending.set(null);
// The palette's "Download from link…" takes the reader to the Files page's link box. A token for the
// history entry the navigation made (as lib/homeIntent's): Files is a lazy module, often not mounted
// when it is asked, and a request that outlived its entry must not grab the focus on a later visit.
const linkBox = createStore<number | null>(null);
/** Ask the Files page to focus its link box on THIS entry — `setPage("files", requestLinkBox)`. */
export const requestLinkBox = (): void => linkBox.set(ensureNavId());
/** The pending request (null = none) — the render subscription that wakes the link box. */
export const useLinkBoxRequest = (): number | null => linkBox.useValue();
/** Take the request, clearing it; true only on the entry it was made for. */
export const takeLinkBox = (): boolean => {
const entry = linkBox.get();
if (entry === null) return false;
linkBox.set(null);
return entry === peekNavId();
};
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
//
// The queue as a tab watches it: a finish is a job that is done now and was not on the last look —
// seen once, kept for the tray until it is closed, announced once. The first look only learns.
import { beforeEach, describe, expect, it } from "vitest";
import type { QueueJob } from "./api";
import {
clearFinished,
noteQueue,
resetQueueWatch,
takeUnannounced,
useFinished,
} from "./downloadQueue";
import { renderHook } from "@testing-library/react";
const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob;
const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id);
beforeEach(resetQueueWatch);
describe("downloadQueue · a finish", () => {
it("the first look is old news: what is already done is not announced", () => {
noteQueue([job(1, "done"), job(2, "running")]);
expect(takeUnannounced()).toEqual([]);
expect(finishedIds()).toEqual([]);
});
it("a job that turns done is announced once and kept, newest first", () => {
noteQueue([job(1, "running"), job(2, "queued")]);
noteQueue([job(1, "done"), job(2, "running")]);
noteQueue([job(1, "done"), job(2, "done")]);
expect(takeUnannounced().map((j) => j.id)).toEqual([1, 2]);
expect(takeUnannounced()).toEqual([]);
expect(finishedIds()).toEqual([2, 1]);
});
it("a job first seen done after the first look finished meanwhile (a cache hit, another device)", () => {
noteQueue([]);
noteQueue([job(5, "done")]);
expect(takeUnannounced().map((j) => j.id)).toEqual([5]);
});
it("a failure is not a finish; closing the tray forgets what finished", () => {
noteQueue([job(1, "running"), job(2, "running")]);
noteQueue([job(1, "error"), job(2, "done")]);
expect(finishedIds()).toEqual([2]);
clearFinished();
expect(finishedIds()).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { api, type QueueJob } from "./api";
import { createStore } from "./store";
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
// ("Done · Open") until it is closed, and announces each once (the done notice).
//
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue (the
// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them
// polled, and nothing has to diff the previous render's data.
/** The statuses a job leaves on its own: it is waiting, or working. */
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]);
let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]);
const unannounced = createStore<QueueJob[]>([]);
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
* first look only learns what is there — a file finished before this tab opened is old news. */
export function noteQueue(jobs: QueueJob[]): void {
const before = lastSeen;
lastSeen = new Map(jobs.map((j) => [j.id, j.status]));
if (!before) return;
const done = jobs.filter((j) => j.status === "done" && before.get(j.id) !== "done");
if (done.length === 0) return;
const ids = new Set(done.map((j) => j.id));
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
unannounced.set((list) => [...list, ...done]);
}
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
export async function fetchQueue(): Promise<QueueJob[]> {
const jobs = await api.downloads();
noteQueue(jobs);
return jobs;
}
/** The jobs this tab saw finish, newest first, until the tray is closed. */
export const useFinished = (): QueueJob[] => finished.useValue();
/** The tray was closed: what finished is no longer shown. */
export const clearFinished = (): void => finished.set([]);
/** The finishes not yet announced — the render subscription that wakes the announcer. */
export const useUnannounced = (): QueueJob[] => unannounced.useValue();
/** Take the finishes to announce, clearing them: each is announced once. */
export function takeUnannounced(): QueueJob[] {
const list = unannounced.get();
if (list.length) unannounced.set([]);
return list;
}
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
export function resetQueueWatch(): void {
lastSeen = null;
finished.set([]);
unannounced.set([]);
}
+8 -1
View File
@@ -60,6 +60,8 @@ export interface Notification {
title?: string;
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,
+5
View File
@@ -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",