feat(files): share a folder by link — its page lists what is in it, plays a video above the list

A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id,
exactly one set; a folder's links go with it). The page shows what is in the folder when it is
opened, newest first: a video or a song plays in the same player a file link has, above the list,
each keeping its own resume point; a document only downloads, never opens as a page. A file moved
out stops opening through the link at its next request. Password, expiry and stream-only work as on
a file link; the unfurl names the folder and counts its files, never their names. Source folders
and the Hub cannot be shared.

The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail
marks a folder a live link opens.

Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once,
and the new-link form goes back to downloadable after making a link.
This commit is contained in:
2026-10-15 02:23:24 +02:00
parent 90567718b2
commit 86712b6b0e
33 changed files with 1880 additions and 321 deletions
@@ -5,7 +5,7 @@
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -15,12 +15,30 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
vi.mock("./ui/Modal", () => ({
default: ({ title, children }: { title: string; children: ReactNode }) => (
<div>
<h2>{title}</h2>
{children}
</div>
),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
const h = vi.hoisted(() => ({
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
folderLinks: vi.fn(),
createFolderLink: vi.fn(),
createDownloadLink: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
shareRecipients: async () => [],
folderLinks: h.folderLinks,
createFolderLink: h.createFolderLink,
createDownloadLink: h.createDownloadLink,
revokeDownloadLink: h.revokeDownloadLink,
downloadLinks: async () =>
h.links ?? [
{
@@ -53,6 +71,7 @@ import type { DownloadJob } from "../lib/api";
afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -106,3 +125,86 @@ describe("ShareDialog · a link whose address cannot be shown again", () => {
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
});
});
describe("ShareDialog · a folder", () => {
const link = (id: number) => ({
id,
token: `f${id}`,
url: `/watch/f${id}`,
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
h.createFolderLink.mockReset().mockResolvedValue(link(5));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(h.folderLinks).toHaveBeenCalledWith(7);
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
// No sharing with a user: "Shared with me" lists files, not folders.
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
// A stream-only link, then the form is back at its default for the next one: downloadable.
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
// The form's own checkbox, under the link rows' (each row has its own).
const allow = () => {
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
return boxes[boxes.length - 1]!;
};
fireEvent.click(allow());
expect((allow() as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
await waitFor(() =>
expect(h.createFolderLink).toHaveBeenCalledWith(
7,
expect.objectContaining({
allow_download: false,
}),
),
);
expect(h.createDownloadLink).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
expect((allow() as HTMLInputElement).checked).toBe(true);
});
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));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
view.unmount();
h.confirm = true;
h.folderLinks
.mockReset()
.mockResolvedValueOnce([link(4)])
.mockResolvedValue([]);
h.createFolderLink.mockReset();
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc2}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
await screen.findByText("downloads.share.noLinks");
expect(h.createFolderLink).not.toHaveBeenCalled();
});
});