fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim

- Share: a link that expired is never copied (the dialog and one-click Share pick a live one,
  else make one), says it expired, and the copy-on-open claim clears on the next copy.
- A password-protected file link asks to open a file, not to watch a video.
- Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed
  refetch keeps the target; a folder being made says so.
- Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop;
  the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested.
- Auto-skip: the wait gives way on a marker too short for it, so the skip still fires.
- Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus.
- Continue watching's See all sorts by last watched when in progress was already on.
This commit is contained in:
2026-11-06 00:09:39 +01:00
parent c34f6f3297
commit 8383459f70
39 changed files with 760 additions and 82 deletions
+1
View File
@@ -231,6 +231,7 @@ export default function Modal({
size="sm"
variant="ghost"
onClick={onClose}
data-dismiss=""
>
<X className="w-4 h-4" />
</IconButton>
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -33,6 +33,7 @@
"added": "File uploaded",
"addedConverting": "File uploaded — converting it so it plays on any phone",
"targetLabel": "Upload to",
"makingFolder": "Making “{{name}}”…",
"targetFailed": "The folders could not be loaded",
"failed": "Couldn't upload the file"
},
@@ -113,6 +114,7 @@
"revokeConfirm": "This link will stop working immediately.",
"views_one": "{{count}} view",
"views_other": "{{count}} views",
"expiredOn": "expired {{date}}",
"expiresOn": "expires {{date}}",
"hasPassword": "password",
"downloadable": "downloadable",
@@ -33,6 +33,7 @@
"added": "Fájl feltöltve",
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
"targetLabel": "Feltöltés ide",
"makingFolder": "„{{name}}” létrehozása…",
"targetFailed": "A mappákat nem sikerült betölteni",
"failed": "Nem sikerült feltölteni a fájlt"
},
@@ -113,6 +114,7 @@
"revokeConfirm": "A link azonnal használhatatlanná válik.",
"views_one": "{{count}} megtekintés",
"views_other": "{{count}} megtekintés",
"expiredOn": "lejárt: {{date}}",
"expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó",
"downloadable": "letölthető",
+4
View File
@@ -7,7 +7,11 @@ export interface Tag {
color: string | null;
category: string;
system: boolean;
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
* the link) — what deleting the tag removes. */
channel_count: number;
/** The user's own links on channels they follow: the rows the Channels page's filter shows. */
subscribed_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
+16
View File
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
expect(createDownloadLink).not.toHaveBeenCalled();
});
// An expired link stays listed for its owner, but its recipient would land on "link gone".
it("passes over an expired link, and mints one when every link has expired", async () => {
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
downloadLinks.mockResolvedValue([
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
{ id: 3, url: "/watch/live", expires_at: nextWeek },
]);
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
expect(createDownloadLink).not.toHaveBeenCalled();
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
});
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
const hidden = { id: 4, url: null };
downloadLinks.mockResolvedValue([hidden]);
+12 -3
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { api, type NewShareLink, type ShareLink } from "./api";
import { copyText } from "./clipboard";
import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
}
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
* revoke it), but it is no link to hand out. */
export const linkExpired = (link: ShareLink, now = Date.now()) =>
link.expires_at !== null && Date.parse(link.expires_at) <= now;
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
* to copy. None: make one. */
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
// The newest link whose address can still be shown; one that cannot is no use to copy.
path = existing.find((l) => l.url)?.url;
path = liveLink(existing)?.url;
if (!path) {
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]);
@@ -0,0 +1,16 @@
// @vitest-environment jsdom
//
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
// shows. The bar it draws must read empty through the wait, not a negative width.
import { describe, expect, it } from "vitest";
import { skipCountdown } from "./plexPlayerHooks";
describe("skipCountdown", () => {
it("stays empty through the wait, runs over the delay, and stops at full", () => {
expect(skipCountdown(-3, 5)).toBe(0);
expect(skipCountdown(-0.1, 5)).toBe(0);
expect(skipCountdown(2.5, 5)).toBe(0.5);
expect(skipCountdown(7, 5)).toBe(1);
});
});
+18 -3
View File
@@ -434,8 +434,18 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel };
}
// How long a marker plays before an auto-skip's countdown starts (seconds).
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
const AUTO_SKIP_WAIT_S = 3;
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
// the media's, and the marker stops counting a second before its end.
const AUTO_SKIP_SLACK_S = 0.5;
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
* CSS, which the browser drops and keeps whatever it drew before. */
export const skipCountdown = (elapsed: number, delay: number) =>
Math.max(0, Math.min(1, elapsed / delay));
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
@@ -484,6 +494,7 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
const absRef = useLatest(abs);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
@@ -543,14 +554,17 @@ export function useAutoSkip(opts: {
if (autoSkipDelay <= 0) return doSkip(m); // immediate
// Below zero is the wait: time to see where the marker has landed and press the button, which
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
let elapsed = -AUTO_SKIP_WAIT_S;
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay));
const p = skipCountdown(elapsed, autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
@@ -573,6 +587,7 @@ export function useAutoSkip(opts: {
doSkip,
videoRef,
activeMarkerRef,
absRef,
activeAuto,
]);
+1
View File
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
color: null,
category: "topic",
channel_count: 1,
subscribed_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
@@ -11,7 +11,8 @@
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -114,4 +115,76 @@ describe("TagFilterChips counts", () => {
expect(name("alpha")?.textContent).toBe("alpha42");
expect(name("beta")?.textContent).toBe("beta0");
});
it("draws nothing after the name of a chip that has no count", () => {
fit.visible = 3;
render(<TagFilterChips chips={[{ ...chip("alpha"), count: 42 }, chip("gamma")]} />);
const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma"));
expect(gamma?.textContent).toBe("gamma");
expect(gamma?.querySelector("span")).toBeNull();
});
});
// The channel manager puts the picked chips first, so a toggle moves its chip: along the row, out
// of the "+N" fold, or into it. The keyboard's focus goes with the chip, not to <body>.
describe("TagFilterChips focus on a toggle that moves the chip", () => {
function Row({ start }: { start: string[] }) {
const [picked, setPicked] = useState(start);
const all = ["alpha", "beta", "gamma"].map((k) => ({
...chip(k),
active: picked.includes(k),
onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])),
}));
return (
<TagFilterChips chips={[...all.filter((c) => c.active), ...all.filter((c) => !c.active)]} />
);
}
const press = (el: HTMLElement) => {
el.focus();
fireEvent.click(el);
};
const named = (label: string) =>
screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!;
it("along the row: the chip keeps the focus where it now stands", () => {
fit.visible = 3;
render(<Row start={[]} />);
press(named("gamma"));
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
"gamma",
"alpha",
"beta",
]);
expect(document.activeElement).toBe(named("gamma"));
});
it("out of the fold: picked in the popover, it is focused on the row", () => {
fit.visible = 1;
render(<Row start={[]} />);
fireEvent.click(moreBtn()!);
const inFold = named("beta");
press(inFold);
const onRow = named("beta");
expect(onRow).not.toBe(inFold);
expect(document.activeElement).toBe(onRow);
});
it("into a closed fold: the focus goes to the +N that now holds it", () => {
fit.visible = 1;
render(<Row start={["gamma"]} />);
press(named("gamma"));
expect(named("alpha").closest('[role="menu"]')).toBeNull(); // alpha leads the row now
expect(document.activeElement).toBe(moreBtn());
});
it("a mouse press that leaves the focus elsewhere takes nothing", () => {
fit.visible = 3;
render(<Row start={[]} />);
const outside = document.body.appendChild(document.createElement("input"));
outside.focus();
fireEvent.click(named("gamma"));
act(() => {});
expect(document.activeElement).toBe(outside);
outside.remove();
});
});
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
@@ -39,9 +39,18 @@ function ChipText({ chip }: { chip: FilterChip }) {
);
}
function TagChip({ chip }: { chip: FilterChip }) {
function TagChip({ chip, onToggled }: { chip: FilterChip; onToggled: (key: string) => void }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<Chip
data-chip-key={chip.key}
onClick={(e) => {
// Before the toggle: a pressed chip may move (the caller's order can put picks first), and
// the focus must go where it went.
if (e.currentTarget === document.activeElement) onToggled(chip.key);
chip.onToggle();
}}
pressed={chip.active}
>
<ChipText chip={chip} />
</Chip>
);
@@ -104,6 +113,32 @@ export default function TagFilterChips({
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
// A toggle may move its chip — between the row and the "+N" fold (two parents: the button is
// remounted), or along the row (a keyed move that drops the focus to <body>). The focus follows
// it: onto the chip where it now stands, or onto "+N" when it went into a closed fold. Held for
// the frame, since the fit re-decides in layout effects after the toggle's render.
const follow = useRef<string | null>(null);
const foldRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const onToggled = (chipKey: string) => {
follow.current = chipKey;
requestAnimationFrame(() => (follow.current = null));
};
useLayoutEffect(() => {
const chipKey = follow.current;
if (!chipKey) return;
const find = (box: HTMLElement | null) =>
[...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? [])].find(
(b) => b.dataset.chipKey === chipKey,
);
const el = find(rowRef.current) ?? find(foldRef.current) ?? moreRef.current;
if (el && document.activeElement !== el) el.focus();
});
const setMore = (el: HTMLButtonElement | null) => {
moreRef.current = el;
setReference(el);
};
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
@@ -132,10 +167,10 @@ export default function TagFilterChips({
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
@@ -146,9 +181,11 @@ export default function TagFilterChips({
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
<div ref={foldRef} className="contents">
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
</div>
</PopoverSurface>
</div>
);
@@ -188,7 +188,9 @@ describe("buildChannelColumns — Added", () => {
});
describe("tagFilterChips", () => {
it("counts a tag's channels, and only a tag's", () => {
// An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the
// filter shows, not every link the tag has.
it("counts a tag's followed channels, and only a tag's", () => {
const tag = {
id: 3,
name: "Tech",
@@ -196,6 +198,7 @@ describe("tagFilterChips", () => {
category: "topic",
system: false,
channel_count: 17,
subscribed_count: 15,
};
const chips = tagFilterChips({
userTags: [tag],
@@ -206,7 +209,7 @@ describe("tagFilterChips", () => {
toggleNoTag: () => {},
t: ((k: string) => k) as never,
});
expect(chips.find((c) => c.label === "Tech")?.count).toBe(17);
expect(chips.find((c) => c.label === "Tech")?.count).toBe(15);
expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
expect(chips[chips.length - 1]?.count).toBeUndefined();
});
@@ -72,7 +72,7 @@ export function tagFilterChips(o: {
...o.userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
count: tg.channel_count,
count: tg.subscribed_count,
active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id),
})),
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -110,24 +110,58 @@ describe("ShareDialog · opened to share", () => {
</QueryClientProvider>,
);
};
const row = (id: number, url: string | null) => ({
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
id,
token: "t",
url,
allow_download: true,
has_password: false,
expires_at: null,
expires_at,
view_count: 0,
created_at: null,
});
const origin = window.location.origin;
const status = () => screen.getByRole("status").textContent;
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
it("copies the newest link it can show, and says so", async () => {
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect((await screen.findByRole("status")).textContent).toBe("downloads.share.linkCopied");
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
});
// The list keeps an expired link for its owner to see and revoke; the recipient of one lands on
// "link gone".
it("copies the newest link that still opens, and says an expired one has expired", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy();
expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy();
});
it("makes a link when every one it has expired, and copies that", async () => {
h.links = [row(1, "/file/old", lastWeek)];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
expect(h.copyText).toHaveBeenCalledTimes(1);
});
// The clipboard holds what was copied last: the link copied on opening stops saying so.
it("stops claiming the opening copy once another link is copied", async () => {
h.links = [row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region
fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!);
await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`));
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("copies the link it made when there was none", async () => {
@@ -143,7 +177,8 @@ describe("ShareDialog · opened to share", () => {
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
await screen.findByRole("button", { name: "downloads.share.copy" });
expect(screen.queryByRole("status")).toBeNull();
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("offers the link in a new tab, beside Copy", async () => {
+25 -11
View File
@@ -12,7 +12,9 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
import {
fetchLinks,
invalidateLinks,
linkExpired,
linksKey,
liveLink,
makeLink,
type LinkTarget,
} from "../../lib/downloads";
@@ -80,11 +82,14 @@ function UserShare({ job }: { job: DownloadJob }) {
function LinkRow({
link,
copiedOnOpen,
onCopied,
onChanged,
}: {
link: ShareLink;
/** The dialog put this link on the clipboard when it opened. */
/** The dialog put this link on the clipboard when it opened, and nothing was copied since. */
copiedOnOpen: boolean;
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
onCopied: () => void;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation();
@@ -95,7 +100,9 @@ function LinkRow({
const fullUrl = link.url && window.location.origin + link.url;
const copy = () => {
if (fullUrl) void copyValue(fullUrl);
if (!fullUrl) return;
onCopied();
void copyValue(fullUrl);
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -124,7 +131,9 @@ function LinkRow({
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
badges.push(
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
t(linkExpired(link) ? "downloads.share.expiredOn" : "downloads.share.expiresOn", {
date: formatDate(link.expires_at, i18n.language),
}),
);
return (
@@ -198,11 +207,9 @@ function LinkRow({
<Trash2 className="w-4 h-4 text-danger" />
</IconButton>
</div>
{copiedOnOpen && (
<p role="status" className="text-xs text-success">
{t("downloads.share.linkCopied")}
</p>
)}
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
filled may never be announced. */}
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
@@ -314,7 +321,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
// (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the
// 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.
const autoTried = useRef(false);
@@ -327,13 +334,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
useEffect(() => {
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0)
// The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it):
// with only expired ones, or none whose address can be shown, a new one is made as for none.
const live = liveLink(links.data);
if (!live)
createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) });
else void copiedLink(links.data.find((l) => l.url)).then(setCopiedOnOpen);
else void copiedLink(live).then(setCopiedOnOpen);
}, [links.data, createLink]);
return (
<div>
<p role="status" className="sr-only">
{copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""}
</p>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
@@ -360,6 +373,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
key={l.id}
link={l}
copiedOnOpen={copiedOnOpen === l.id}
onCopied={() => setCopiedOnOpen(null)}
onChanged={invalidate}
/>
))}
@@ -186,6 +186,74 @@ describe("UploadDialog · where the file goes", () => {
expect(await sentTo()).toBe(7);
});
// The name field saves on blur, and a press on Cancel or ✕ blurs it before the dialog closes.
it.each([
["Cancel", () => screen.getByText("downloads.actions.cancel")],
["the close button", () => screen.getByRole("button", { name: "common.close" })],
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
const onClose = vi.fn();
wrap(onClose);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
const control = dismiss();
fireEvent.pointerDown(control);
fireEvent.blur(field);
fireEvent.click(control);
expect(onClose).toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
expect(h.createFolder).not.toHaveBeenCalled();
});
it("leaving the name for anything else in the dialog makes the folder", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.pointerDown(screen.getByText("downloads.upload.targetLabel"));
fireEvent.blur(field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("says a new folder is being made while Upload waits for it", async () => {
h.createFolder.mockReturnValue(new Promise(() => {}));
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(await screen.findByText("downloads.upload.makingFolder")).toBeTruthy();
});
// A failed refetch keeps the list it had: the target the file goes to stays shown.
it("keeps the target when a later read of the folders fails", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{
setSearch: vi.fn(),
setView: vi.fn(),
openFile: h.openFile,
consumeFocus: vi.fn(),
}}
>
<UploadDialog folderId={5} initialFile={null} onClose={vi.fn()} />
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
expect((await target()).textContent).toContain("peat");
h.fileFolders.mockRejectedValue(new Error("down"));
await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {});
await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error"));
expect((await target()).textContent).toContain("peat");
expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull();
});
it("an empty name makes no folder and leaves the target", async () => {
wrap(vi.fn(), 5);
fireEvent.click(await target());
+34 -9
View File
@@ -88,6 +88,20 @@ export default function UploadDialog({
refocusTarget.current = false;
targetBox.current?.querySelector("button")?.focus();
}, [naming]);
// A press on Cancel, the ✕ or the backdrop blurs the name field before the dialog closes, and the
// field saves on blur: that press is "never mind", not "make it". The press is seen before the
// blur it causes, in every browser (which control takes the focus, if any, differs: Safari
// focuses no button).
const dismissing = useRef(false);
useEffect(() => {
const onPress = (e: PointerEvent) => {
const el = e.target instanceof Element ? e.target : null;
dismissing.current =
!!el && (!!el.closest("[data-dismiss]") || el.matches('[role="dialog"]'));
};
document.addEventListener("pointerdown", onPress, true);
return () => document.removeEventListener("pointerdown", onPress, true);
}, []);
const makeFolder = useMutation({
mutationFn: (folder: string) => api.createFolder(folder),
onSuccess: (made) => {
@@ -113,8 +127,10 @@ export default function UploadDialog({
const pickTarget = ([value]: string[]) => {
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
// there, and a name field that loses the focus is done, with no name.
if (value === NEW_FOLDER) window.setTimeout(() => setNaming(true));
else if (value === UPLOADS) setTarget(null);
if (value === NEW_FOLDER) {
dismissing.current = false; // a press before the naming began (Cancel aborting an upload) is not this one
window.setTimeout(() => setNaming(true));
} else if (value === UPLOADS) setTarget(null);
else setTarget(Number(value));
};
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
@@ -234,11 +250,11 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX}
/>
<p id="upload-target-label" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.targetLabel")}
</p>
{foldersQ.isError ? (
// The file still goes where the dialog was opened on: only the list could not be read.
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
{foldersQ.isError && !foldersQ.data ? (
// The file still goes where the dialog was opened on: only the list could not be read. A
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
// target the file goes to stays shown, as the rail does it.
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
{t("downloads.upload.targetFailed")}
</StateMessage>
@@ -249,12 +265,20 @@ export default function UploadDialog({
onDone={(folder, byKey) => {
refocusTarget.current = byKey;
setNaming(false);
if (!byKey && dismissing.current) return;
if (folder?.trim()) makeFolder.mutate(folder.trim());
}}
/>
) : (
<div ref={targetBox} aria-labelledby="upload-target-label">
{foldersQ.data && !busy && (
// The select's own height held while the list loads, so it does not push the form down.
<div ref={targetBox} className="min-h-8 flex items-center">
{makeFolder.isPending && (
// Upload waits for it (`targetSettling`): this says why.
<span className="text-sm text-muted">
{t("downloads.upload.makingFolder", { name: makeFolder.variables })}
</span>
)}
{foldersQ.data && !busy && !makeFolder.isPending && (
<FilterSelect
hideLabel
label={t("downloads.upload.targetLabel")}
@@ -300,6 +324,7 @@ export default function UploadDialog({
variant="ghost"
size="sm"
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
data-dismiss=""
>
{t("downloads.actions.cancel")}
</Button>
@@ -31,14 +31,18 @@ afterEach(() => {
vi.restoreAllMocks();
});
// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the
// stored one, and the two must not be mistaken for each other.
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor crop", () => {
it("sends the size the crop was measured on with it", async () => {
vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920);
vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080);
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
//
// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently
// added newest first, Continue watching in progress by last watched. The rails only call back; what
// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover /
// Browse toggle — so a sort picked earlier must not survive the press.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
// The rails themselves are PlexDiscover's own test: here only their two See all presses.
vi.mock("./PlexDiscover", () => ({
default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => (
<div>
<button onClick={p.onSeeAllRecent}>recent</button>
<button onClick={p.onSeeAllContinue}>continue</button>
</div>
),
}));
import PlexBrowse from "./PlexBrowse";
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import {
PlexActionsContext,
PlexStateContext,
type PlexActions,
type PlexState,
} from "./plexContext";
import type { SortState } from "../../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({}, "", "/"));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const TITLE: SortState = { key: "title", dir: "asc" };
const slot = document.body.appendChild(document.createElement("div"));
// The store's show and sort, real enough for PlexBrowse's own effect to act on them.
function mount(start: { show: string; sort: SortState }) {
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
const now = { state: null as PlexState | null, actions: null as PlexActions | null };
function Store() {
const [show, setShow] = useState(start.show);
const [sort, setSort] = useState<SortState>(start.sort);
const state: PlexState = {
scope: "both",
show,
sort,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
} as PlexState;
const actions = {
setShow,
setSort,
setScope: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
} as PlexActions;
now.state = state;
now.actions = actions;
return (
<PlexStateContext.Provider value={state}>
<PlexActionsContext.Provider value={actions}>
<PlexBrowse />
</PlexActionsContext.Provider>
</PlexStateContext.Provider>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Store />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
return now;
}
describe("PlexBrowse · Discover's See all", () => {
it("Recently added opens every title, newest first, over a sort picked before", async () => {
const store = mount({ show: "in_progress", sort: TITLE });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
// Entering in-progress keeps the sort from before it, to give back on the way out: leaving for
// Recently added must not hand it back over newest first.
it("Recently added from in progress does not give back the sort from before it", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
it("Continue watching opens in progress, last watched first", async () => {
const store = mount({ show: "all", sort: TITLE });
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
});
// Already in progress, with another sort picked there: no entering edge brings the sort.
it("Continue watching sorts by last watched when in progress was already on", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
act(() => store.actions!.setSort(TITLE));
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
// …and the sort from before in-progress still comes back on the way out.
act(() => store.actions!.setShow("all"));
expect(store.state!.sort).toEqual(TITLE);
});
});
+4 -1
View File
@@ -592,7 +592,10 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
sub.open({ kind: "grid" });
}}
onSeeAllContinue={() => {
setShow("in_progress");
// Already in progress (with a sort picked since), no edge brings the sort: set it here,
// keeping the sort from before in-progress for the way out.
if (show === "in_progress") setSort(LASTWATCHED_SORT);
else setShow("in_progress");
sub.open({ kind: "grid" });
}}
/>
@@ -1169,6 +1169,29 @@ describe("PlexPlayer — clip mode", () => {
enterMarker(true);
advance(10_000);
expect(left).not.toHaveBeenCalled();
video()._paused = false;
advance(3500); // the whole wait is still ahead, then the 1 s countdown
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
// An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the
// countdown stops there with nothing skipped.
it("shortens the wait on a marker too short for it, so the skip still fires", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 5 });
const short = {
...withCredits,
markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }],
} as unknown as PlexItemDetail;
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false); // at 2910: 6 s before the marker stops counting
advance(5000);
expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here
advance(700);
expect(left).toHaveBeenCalledWith("a", "watched");
});
});
+13
View File
@@ -281,6 +281,19 @@ describe("WatchPage honest failures", () => {
return page;
}
it.each([
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
[{}, "This video is password-protected", "Watch"],
])("a password link that opens %o asks in its words", async (kind, title, button) => {
window.history.replaceState({}, "", "/file/tok-lock");
mockMeta({ needs_password: true, ...kind });
const page = render(<WatchPage />);
await page.findByText(title);
expect(page.getByRole("button", { name: button })).toBeTruthy();
});
it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
+11 -6
View File
@@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
| { needs_password: true; kind?: "file" | "folder"; video?: boolean };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
@@ -97,8 +97,9 @@ export default function WatchPage() {
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
// it said `video` only had video links' words, so a file without it reads as one.
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
@@ -124,7 +125,7 @@ export default function WatchPage() {
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
setStatus("password");
} else {
setMeta(m);
@@ -246,7 +247,11 @@ export default function WatchPage() {
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
{locked === "folder"
? T.passwordTitleFolder
: locked === "file"
? T.passwordTitleFile
: T.passwordTitle}
</div>
<Input
className="w-full"
@@ -272,7 +277,7 @@ export default function WatchPage() {
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
{locked === "video" ? T.watch : T.open}
</Button>
</div>
</div>
+6
View File
@@ -27,6 +27,8 @@ const STR = {
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
passwordTitleFile: "This file is password-protected",
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
@@ -55,6 +57,8 @@ const STR = {
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
passwordTitleFile: "Ez a fájl jelszóval védett",
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
@@ -83,6 +87,8 @@ const STR = {
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
passwordTitleFile: "Diese Datei ist passwortgeschützt",
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),