feat(downloads): one-click Share + downloadable-by-default links (E7 S3)
D2-A: the library row's Share button becomes a single action — quickShareLink() ensures a public link (minting on demand), copies it to the clipboard, and toasts with an "Open in new tab" action. A copy failure still toasts (warning) with that action so the link is never a dead end. Advanced controls (password/expiry/revoke/ user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row button. D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted link is downloadable by default. The default lives in the API, not a migration — the download_links column default stays false, so no existing link flips. The ShareDialog create-default is flipped to match. Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity.
This commit is contained in:
@@ -823,7 +823,12 @@ def list_links(
|
|||||||
|
|
||||||
class CreateLinkIn(BaseModel):
|
class CreateLinkIn(BaseModel):
|
||||||
password: str = ""
|
password: str = ""
|
||||||
allow_download: bool = False
|
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
||||||
|
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||||
|
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||||
|
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||||
|
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
||||||
|
allow_download: bool = True
|
||||||
expires_days: int | None = None
|
expires_days: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ class TestPublicMetaAudioFlag:
|
|||||||
assert meta["audio"] is False
|
assert meta["audio"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateLinkDefault:
|
||||||
|
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
|
||||||
|
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
|
||||||
|
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||||
|
catalogue link silently flips to downloadable."""
|
||||||
|
|
||||||
|
def test_omitted_allow_download_defaults_downloadable(self):
|
||||||
|
from app.routes.downloads import CreateLinkIn
|
||||||
|
|
||||||
|
assert CreateLinkIn().allow_download is True
|
||||||
|
|
||||||
|
def test_explicit_false_is_respected(self):
|
||||||
|
# The owner can still uncheck it to mint a stream-only link.
|
||||||
|
from app.routes.downloads import CreateLinkIn
|
||||||
|
|
||||||
|
assert CreateLinkIn(allow_download=False).allow_download is False
|
||||||
|
|
||||||
|
|
||||||
class TestIsExpired:
|
class TestIsExpired:
|
||||||
def test_none_expiry_never_expires(self):
|
def test_none_expiry_never_expires(self):
|
||||||
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Download,
|
Download,
|
||||||
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -29,7 +30,7 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
@@ -101,6 +102,21 @@ export default function DownloadCenter() {
|
|||||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||||
|
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||||
|
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||||
|
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||||
|
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||||
|
const quickShare = async (job: DownloadJob) => {
|
||||||
|
if (sharingId !== null) return;
|
||||||
|
setSharingId(job.id);
|
||||||
|
try {
|
||||||
|
await quickShareLink(qc, job, t);
|
||||||
|
} finally {
|
||||||
|
setSharingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
// 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
|
// 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
|
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||||
@@ -261,9 +277,15 @@ export default function DownloadCenter() {
|
|||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||||
<Share2 className="w-4 h-4" />
|
<Share2 className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => setShareJob(job)}
|
||||||
|
title={t("downloads.actions.shareManage")}
|
||||||
|
>
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
confirmThen(
|
confirmThen(
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [allowDownload, setAllowDownload] = useState(false);
|
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
|
||||||
|
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||||
|
const [allowDownload, setAllowDownload] = useState(true);
|
||||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||||
@@ -276,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||||
const autoTried = useRef(false);
|
const autoTried = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"editDetails": "Edit details",
|
"editDetails": "Edit details",
|
||||||
"share": "Share",
|
"share": "Share (copy link)",
|
||||||
|
"shareManage": "Manage sharing",
|
||||||
"edit": "Edit / trim",
|
"edit": "Edit / trim",
|
||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
@@ -104,6 +105,9 @@
|
|||||||
"noUsers": "No other users yet.",
|
"noUsers": "No other users yet.",
|
||||||
"linkSection": "Share a link",
|
"linkSection": "Share a link",
|
||||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||||
|
"linkCopied": "Share link copied to clipboard",
|
||||||
|
"openNewTab": "Open in new tab",
|
||||||
|
"linkFailed": "Couldn’t create the share link",
|
||||||
"noLinks": "No links yet.",
|
"noLinks": "No links yet.",
|
||||||
"newLink": "Create a link",
|
"newLink": "Create a link",
|
||||||
"createLink": "Create link",
|
"createLink": "Create link",
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"editDetails": "Adatok szerkesztése",
|
"editDetails": "Adatok szerkesztése",
|
||||||
"share": "Megosztás",
|
"share": "Megosztás (link másolása)",
|
||||||
|
"shareManage": "Megosztás kezelése",
|
||||||
"edit": "Szerkesztés / vágás",
|
"edit": "Szerkesztés / vágás",
|
||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"removeShared": "Eltávolítás a listámból"
|
||||||
@@ -104,6 +105,9 @@
|
|||||||
"noUsers": "Nincs más felhasználó.",
|
"noUsers": "Nincs más felhasználó.",
|
||||||
"linkSection": "Megosztás linkkel",
|
"linkSection": "Megosztás linkkel",
|
||||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||||
|
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||||
|
"openNewTab": "Megnyitás új lapon",
|
||||||
|
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||||
"noLinks": "Még nincs link.",
|
"noLinks": "Még nincs link.",
|
||||||
"newLink": "Link létrehozása",
|
"newLink": "Link létrehozása",
|
||||||
"createLink": "Létrehozás",
|
"createLink": "Létrehozás",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
|
||||||
|
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
|
||||||
|
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
|
||||||
|
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
|
||||||
|
downloadLinks: vi.fn(),
|
||||||
|
createDownloadLink: vi.fn(),
|
||||||
|
copyText: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||||
|
vi.mock("./clipboard", () => ({ copyText }));
|
||||||
|
vi.mock("./notifications", () => ({ notify }));
|
||||||
|
|
||||||
|
import { quickShareLink } from "./downloads";
|
||||||
|
|
||||||
|
const t = (k: string) => k;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
|
type ToastArg = {
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
};
|
||||||
|
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||||
|
|
||||||
|
function qc() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
downloadLinks.mockReset();
|
||||||
|
createDownloadLink.mockReset();
|
||||||
|
copyText.mockReset().mockResolvedValue(true);
|
||||||
|
notify.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quickShareLink", () => {
|
||||||
|
it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([]); // none yet
|
||||||
|
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/tok7`);
|
||||||
|
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
|
||||||
|
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
|
||||||
|
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||||
|
const arg = firstToast();
|
||||||
|
expect(arg.level).toBe("success");
|
||||||
|
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||||
|
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||||
|
arg.action!.onClick();
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/old`);
|
||||||
|
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||||
|
expect(firstToast().level).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||||
|
copyText.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 2 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||||
|
const arg = firstToast();
|
||||||
|
expect(arg.level).toBe("warning");
|
||||||
|
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||||
|
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an error and returns null when link creation fails", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([]);
|
||||||
|
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||||
|
|
||||||
|
expect(url).toBeNull();
|
||||||
|
expect(copyText).not.toHaveBeenCalled();
|
||||||
|
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { copyText } from "./clipboard";
|
||||||
|
import { notify } from "./notifications";
|
||||||
|
|
||||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||||
@@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) {
|
|||||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
|
||||||
|
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
|
||||||
|
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
|
||||||
|
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
|
||||||
|
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
|
||||||
|
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||||
|
export async function quickShareLink(
|
||||||
|
qc: QueryClient,
|
||||||
|
job: { id: number },
|
||||||
|
t: (key: string) => string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
let link;
|
||||||
|
try {
|
||||||
|
const existing = await qc.fetchQuery({
|
||||||
|
queryKey: qk.downloadLinks(job.id),
|
||||||
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
|
});
|
||||||
|
link = existing[0];
|
||||||
|
if (!link) {
|
||||||
|
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
|
||||||
|
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify({ level: "error", message: t("downloads.share.linkFailed") });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = window.location.origin + link.url;
|
||||||
|
const action = {
|
||||||
|
label: t("downloads.share.openNewTab"),
|
||||||
|
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||||
|
};
|
||||||
|
notify(
|
||||||
|
(await copyText(url))
|
||||||
|
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||||
|
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||||
|
);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user