fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so

- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
  the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
  for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
  dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
  New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
  Links, Tokens and Files rounds.
This commit is contained in:
2026-10-15 04:58:17 +02:00
parent 827df7494c
commit 2efa7d5a75
23 changed files with 505 additions and 773 deletions
+7 -15
View File
@@ -9,13 +9,13 @@ import { displayName } from "../lib/api/account";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { useCopyFeedback } from "./useCopyFeedback";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives.
@@ -59,7 +59,7 @@ export default function AccountPanel({
const lang = i18n.language;
const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false);
const { copied, copy: copyValue } = useCopyFeedback();
const q = useQuery({
queryKey: qk.adminUserAccess(user.id),
@@ -88,19 +88,11 @@ export default function AccountPanel({
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const copy = async () => {
if (!link) return;
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// minimal self-host this no-email link was built for.
if (await copyText(link.url)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
// action failing. Say so rather than leaving the button looking inert.
notify({ level: "warning", message: t("users.account.copyFailed") });
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// minimal self-host this no-email link was built for.
const copy = () => {
if (link) void copyValue(link.url);
};
const a: AdminUserAccess | undefined = q.data;
@@ -187,6 +187,11 @@ describe("API tokens · making one", () => {
);
// The keyboard lands on copying it, not on the closed form's button.
expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
// Two Copy buttons in one dialog: each is named by the value it copies.
expect(
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
).toBe(document.activeElement);
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
expect(screen.queryByRole("dialog")).toBeNull();
});
+5 -13
View File
@@ -14,11 +14,9 @@ import {
type TokenRight,
} from "../lib/api";
import { qk } from "../lib/queryKeys";
import { copyText } from "../lib/clipboard";
import { compareText } from "../lib/collate";
import { formatDate } from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { useConfirm } from "./confirmContext";
import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -37,6 +35,7 @@ import { RelativeTime } from "./ui/RelativeTime";
import SegmentedToggle from "./ui/SegmentedToggle";
import { SURFACE } from "./ui/surfaces";
import { useContextMenu } from "./ui/useContextMenu";
import { useCopyFeedback } from "./useCopyFeedback";
// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
@@ -477,16 +476,7 @@ function CopyField({
autoFocus?: boolean;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async () => {
if (await copyText(value)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The value is on screen and selectable: a failed copy is a convenience that failed.
notify({ level: "warning", message: t("common.copy.failed") });
};
const { copied, copy } = useCopyFeedback();
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
@@ -499,7 +489,9 @@ function CopyField({
size="sm"
autoFocus={autoFocus}
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={() => void copy()}
// Two of these sit in one dialog: each is named by the value it copies.
aria-label={t("settings.tokens.copyField", { field: label })}
onClick={() => void copy(value)}
>
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
</Button>
@@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => {
expect((allow() as HTMLInputElement).checked).toBe(true);
});
it("a list that did not load says so with Retry, and offers no New link beside it", async () => {
h.folderLinks
.mockReset()
.mockRejectedValueOnce(new Error("503"))
.mockResolvedValue([link(4)]);
h.createFolderLink.mockReset();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy();
expect(screen.queryByText("downloads.share.noLinks")).toBeNull();
expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull();
expect(h.createFolderLink).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy();
});
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
h.folderLinks.mockReset().mockResolvedValue([]);
h.createFolderLink.mockReset().mockResolvedValue(link(6));
+26 -18
View File
@@ -7,7 +7,6 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
@@ -17,6 +16,8 @@ import { Input, InnerInput } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker";
import { useCopyFeedback } from "./useCopyFeedback";
import { LoadingState, StateMessage } from "./ui/QueryState";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) {
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
const [copied, setCopied] = useState(false);
const { copied, copy: copyValue } = useCopyFeedback();
const [editPass, setEditPass] = useState(false);
const [newPass, setNewPass] = useState("");
const fullUrl = link.url && window.location.origin + link.url;
const copy = async () => {
if (fullUrl && (await copyText(fullUrl))) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
notify({ level: "error", message: t("downloads.share.copyFailed") });
}
const copy = () => {
if (fullUrl) void copyValue(fullUrl);
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) {
)}
</p>
<div className="space-y-2">
{(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data && links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
{/* A list that did not load is said so, with Retry — never shown as "no links", and with no
"New link" beside it, which would add a second public link next to ones not seen. */}
{!links.data ? (
links.isError ? (
<StateMessage tone="error" compact onRetry={() => void links.refetch()}>
{t("downloads.share.linksFailed")}
</StateMessage>
) : (
<LoadingState label={t("common.loading")} className="py-4" />
)
) : (
<div className="space-y-2">
{links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
)}
{creating ? (
{!links.data ? null : creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
{t("downloads.share.allowDownload")}
@@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
showInTray: vi.fn(),
noteAnnounced: vi.fn(),
uploadStarted: vi.fn(),
uploadEnded: vi.fn(),
openFile: vi.fn(),
}));
@@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
}));
vi.mock("../lib/downloadQueue", () => ({
showInTray: h.showInTray,
noteAnnounced: h.noteAnnounced,
uploadStarted: h.uploadStarted,
uploadEnded: h.uploadEnded,
}));
import UploadDialog from "./UploadDialog";
@@ -117,7 +119,7 @@ describe("UploadDialog", () => {
message: "downloads.upload.addedConverting",
});
// Its finish is still to come, and the tray's to announce.
expect(h.noteAnnounced).not.toHaveBeenCalledWith(43);
expect(h.uploadEnded).toHaveBeenCalledWith(null);
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
@@ -132,7 +134,8 @@ describe("UploadDialog", () => {
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.showInTray).not.toHaveBeenCalled();
// Done and said so here: the queue's next look must not announce it again, as a download.
expect(h.noteAnnounced).toHaveBeenCalledWith(42);
expect(h.uploadStarted).toHaveBeenCalled();
expect(h.uploadEnded).toHaveBeenCalledWith(42);
cleanup();
h.notify.mockClear();
+6 -2
View File
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { noteAnnounced, showInTray } from "../lib/downloadQueue";
import { showInTray, uploadEnded, uploadStarted } from "../lib/downloadQueue";
import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format";
@@ -28,6 +28,8 @@ import { Input } from "./ui/Input";
// skip the whole dialog.
const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// The job the upload's own notice announces (a done one), for the queue not to say it again.
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
export default function UploadDialog({
folderId = null,
initialFile = null,
@@ -96,6 +98,7 @@ export default function UploadDialog({
abortRef.current = ac;
setPct(0);
const displayName = name.trim() || undefined;
uploadStarted();
try {
const job = await api.uploadFile(file, {
displayName,
@@ -105,7 +108,7 @@ export default function UploadDialog({
});
// Said here, so the queue does not say it again; one the worker still converts is the tray's
// to announce when it is done.
if (job.status === "done") noteAnnounced(job.id);
uploadEnded(announces(job));
invalidateDownloads(qc);
notify({
level: "success",
@@ -123,6 +126,7 @@ export default function UploadDialog({
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
// named and no job in the list. The comment here claimed the global error modal had it; it
// had not since the shared `reportFailure` landed.
uploadEnded(null);
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again.
setPct(null);
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
//
// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a
// warning when the clipboard refused — the same at every such button.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => {
vi.useRealTimers();
h.notify.mockClear();
});
describe("useCopyFeedback", () => {
it("a copy that worked reads Copied for a moment and is said out loud", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
h.copyText.mockResolvedValueOnce(false);
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
});
});
@@ -0,0 +1,30 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a
* set-password link), the same at every such button: it reads "Copied" for a moment, and a notice
* says it too — the notices are a live region, so a screen reader hears what a swapped label would
* not tell it. A copy that failed is a warning, not an error: the value is on screen and
* selectable, so it is a convenience that failed, not the action.
*
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
notify({ level: "success", message: t("common.copy.done") });
window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
};
return { copied, copy };
}