feat(design): a library row keeps Share (the link copy) in sight, ahead of save (E28 S3e3)

The owner's call: sharing a download's link is the library's main errand, more
important than saving the file, so it does not hide in the row's "…". Manage
sharing stays in the menu. The "…" tests press as a mouse, so a stray long-press
timer no longer fires after teardown.
This commit is contained in:
2026-09-26 07:21:21 +02:00
parent 2df60fbf07
commit 9c91eb5d22
7 changed files with 54 additions and 23 deletions
@@ -139,7 +139,7 @@ describe("DownloadCenter · the queue row", () => {
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"));
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
@@ -159,7 +159,7 @@ describe("DownloadCenter · the queue row", () => {
target: { value: "x" },
});
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"));
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
@@ -191,23 +191,24 @@ describe("DownloadCenter · the queue row", () => {
});
describe("DownloadCenter · the library row", () => {
it("shows play, save and its '…'; the editors, sharing and delete are in the menu", async () => {
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.share",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
@@ -215,6 +216,21 @@ describe("DownloadCenter · the library row", () => {
]);
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount("done");
await screen.findByText("Clip 2");
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
@@ -243,8 +259,8 @@ describe("DownloadCenter · the library row", () => {
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.download",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
@@ -10,6 +10,7 @@ import {
PlayCircle,
Plus,
Settings2,
Share2,
UploadCloud,
} from "lucide-react";
import Tabs from "./ui/Tabs";
@@ -259,6 +260,10 @@ export default function DownloadCenter() {
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
</DownloadRow>
))}
@@ -150,7 +150,7 @@ describe("NotificationsPanel · a server row", () => {
for (const name of ["Ready 1", "Activity 8"]) {
await openMenu(() => fireEvent.click(moreOf(name)));
act(() => {
fireEvent.pointerDown(moreOf(name));
fireEvent.pointerDown(moreOf(name), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf(name));
fireEvent.click(moreOf(name), { detail: 1 });
});
@@ -168,7 +168,7 @@ describe("NotificationsPanel · a server row", () => {
h.server = [server(1)]; // the next poll: a NEW object for the same notification
rerender();
act(() => {
fireEvent.pointerDown(moreOf("Ready 1"));
fireEvent.pointerDown(moreOf("Ready 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Ready 1"));
fireEvent.click(moreOf("Ready 1"), { detail: 1 });
});
@@ -136,7 +136,7 @@ describe("PlexPlaylistView · a row's menu", () => {
await screen.findByText("Movie 1");
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
act(() => {
fireEvent.pointerDown(moreOf("Movie 1"));
fireEvent.pointerDown(moreOf("Movie 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Movie 1"));
fireEvent.click(moreOf("Movie 1"), { detail: 1 });
});
@@ -190,7 +190,7 @@ describe("a video's menu", () => {
expect(more().getAttribute("aria-expanded")).toBe("true");
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
fireEvent.pointerDown(more());
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.mouseDown(more());
fireEvent.click(more(), { detail: 1 });
expect(labels()).toEqual([]);
@@ -200,7 +200,7 @@ describe("a video's menu", () => {
setup(video({}));
fireEvent.click(more(), { detail: 1 });
// A press on the open row's "…" that never became a click (released elsewhere)…
fireEvent.pointerDown(more());
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.keyDown(document, { key: "Escape" });
// …then Enter on the button: a click with no pointer (detail 0).
fireEvent.click(more(), { detail: 0 });
+19 -10
View File
@@ -28,7 +28,8 @@ import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list (E28 C1): the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// the rest — the editors, sharing, re-download, the copies and the destructive rows — lives here.
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
// managing the shares, re-download, the copies and the destructive rows — lives here.
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
@@ -165,7 +166,19 @@ export function useDownloadActions({
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), save(job)],
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? [play(job)] : []),
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
save(job),
],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
@@ -196,13 +209,6 @@ export function useDownloadActions({
: []),
],
[
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
@@ -261,5 +267,8 @@ export function useDownloadActions({
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
return { items, run };
/** The library row's visible Share: the same one-click link copy as its menu row. */
const share = (job: DownloadJob) => void quickShare(job);
return { items, run, share };
}