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:
@@ -324,3 +324,106 @@ describe("WatchPage resume", () => {
|
||||
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
|
||||
// above the list; a document only downloads; Download follows the link's own setting.
|
||||
describe("WatchPage folder", () => {
|
||||
const file = (id: number, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
title: `Clip ${id}`,
|
||||
audio: false,
|
||||
media_kind: "video",
|
||||
size_bytes: 1000,
|
||||
duration_s: 71,
|
||||
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
|
||||
poster_url: null,
|
||||
...extra,
|
||||
});
|
||||
const folder = (extra: Record<string, unknown> = {}) => ({
|
||||
needs_password: false,
|
||||
kind: "folder",
|
||||
name: "Friends",
|
||||
allow_download: true,
|
||||
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
|
||||
...extra,
|
||||
});
|
||||
const scrolled = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrolled;
|
||||
|
||||
it("lists the folder's files and plays the one picked above the list", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
|
||||
expect(page.getByText(/^3 files/)).toBeTruthy();
|
||||
expect(page.container.querySelector("video")).toBeNull();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
|
||||
expect(video.autoplay).toBe(true);
|
||||
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
|
||||
"true",
|
||||
);
|
||||
// The file's title sits under the folder's, one level down.
|
||||
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
|
||||
expect(scrolled).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
|
||||
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
|
||||
"/api/public/watch/tok-folder/files/1/file",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a document for download only, and Download only where the link allows it", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText("Plan.pdf");
|
||||
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
|
||||
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
|
||||
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
|
||||
cleanup();
|
||||
|
||||
mockMeta(folder({ allow_download: false }));
|
||||
const streamOnly = render(<WatchPage />);
|
||||
await streamOnly.findByText("Plan.pdf");
|
||||
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
|
||||
});
|
||||
|
||||
it("each file keeps its own place in a folder", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-resume");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 600, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 250;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
const points = JSON.stringify(localStorage);
|
||||
expect(points).toContain("tok-folder-resume/1");
|
||||
expect(points).not.toContain('"tok-folder-resume"');
|
||||
});
|
||||
|
||||
it("says when the folder is empty", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder({ files: [] }));
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is empty.")).toBeTruthy();
|
||||
expect(page.getByText("0 files")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a password folder link asks for the folder's password", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-locked");
|
||||
mockMeta({ needs_password: true, kind: "folder" });
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user