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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user