fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2
- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its hover list count the same channels as the chip; /api/tags sends both counts under their names. - A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place. The fold is a dialog of toggle buttons, not an empty menu. - Upload: a folder name left by a press is made once the press is over, if the dialog is still open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place holds the focus and a live region says so. - Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached list, so a reopen makes no second one; the New link form survives the link made on open. - Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with their rail. - An unsized clip of a rotated file gets the upright size its crop cut.
This commit is contained in:
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: { action?: string; title?: string }) =>
|
||||
o?.title ? `${o.action}: ${o.title}` : k,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { Carousel } from "./Carousel";
|
||||
@@ -54,7 +60,10 @@ describe("Carousel", () => {
|
||||
);
|
||||
expect(screen.getByText("card-a")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
|
||||
// Named with its rail, the visible words first: a page of rails has a See all on each.
|
||||
const seeAll = screen.getByRole("button", { name: "common.seeAll: Recently added" });
|
||||
expect(seeAll.textContent).toBe("common.seeAll");
|
||||
fireEvent.click(seeAll);
|
||||
expect(onSeeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,21 @@ export function Carousel({
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={onSeeAll}
|
||||
// Named with its rail: a page of rails has a See all on each, and a screen reader's
|
||||
// list of buttons (or a voice command) must tell them apart. The visible words lead.
|
||||
aria-label={
|
||||
label
|
||||
? t("common.seeAllOf", {
|
||||
action: seeAllLabel ?? t("common.seeAll"),
|
||||
title: label,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className="ml-auto gap-0.5 text-xs"
|
||||
>
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -231,7 +231,6 @@ export default function Modal({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
data-dismiss=""
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A tag's row in the manager says how many channels it is on, beside a hover list of them, over the
|
||||
// Channels page whose chip names the same tag with a number: all three count the same channels —
|
||||
// the followed ones the Subscriptions tab shows, hidden ones left out. Every link (the number a
|
||||
// delete removes) is only the delete's confirm.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { count?: number }) => (o?.count != null ? `${key}:${o.count}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
import TagManager from "./TagManager";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const tag = {
|
||||
id: 5,
|
||||
name: "Music",
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 4,
|
||||
subscribed_count: 2,
|
||||
} as Tag;
|
||||
const ch = (id: string, hidden: boolean) =>
|
||||
({ id, title: id, hidden, tag_ids: [5] }) as unknown as ManagedChannel;
|
||||
|
||||
describe("TagManager · a tag's count", () => {
|
||||
it("counts the followed channels the Subscriptions tab shows, not every link", async () => {
|
||||
vi.spyOn(api, "tags").mockResolvedValue([tag]);
|
||||
vi.spyOn(api, "channels").mockResolvedValue([
|
||||
ch("UCa", false),
|
||||
ch("UCb", false),
|
||||
ch("UChid", true),
|
||||
]);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("tagManager.channels:2")).toBeTruthy();
|
||||
expect(screen.queryByText("tagManager.channels:4")).toBeNull();
|
||||
fireEvent.mouseEnter(screen.getByText("tagManager.channels:2"));
|
||||
expect(await screen.findByText("UCa")).toBeTruthy();
|
||||
expect(screen.queryByText("UChid")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function TagRow({
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
{t("tagManager.channels", { count: tag.subscribed_count })}
|
||||
</span>
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
@@ -119,7 +119,7 @@ export default function TagManager({
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
(channelsQuery.data ?? [])
|
||||
.filter((c) => c.tag_ids.includes(tagId))
|
||||
.filter((c) => !c.hidden && c.tag_ids.includes(tagId))
|
||||
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
|
||||
|
||||
const invalidate = () => {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"loadError": "Couldn't load this — check your connection and try again",
|
||||
"deletedTag": "Deleted tag",
|
||||
"seeAll": "See all",
|
||||
"seeAllOf": "{{action}}: {{title}}",
|
||||
"scrollLeft": "Scroll left",
|
||||
"scrollRight": "Scroll right",
|
||||
"search": "Search",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
|
||||
"deletedTag": "Törölt címke",
|
||||
"seeAll": "Összes",
|
||||
"seeAllOf": "{{action}}: {{title}}",
|
||||
"scrollLeft": "Görgetés balra",
|
||||
"scrollRight": "Görgetés jobbra",
|
||||
"search": "Keresés",
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface Tag {
|
||||
/** 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. */
|
||||
/** The user's own links on channels they follow and have not hidden: the rows the Channels page's
|
||||
* filter shows (a hidden channel has its own tab). */
|
||||
subscribed_count: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { api, HttpError } from "./api";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -44,6 +45,23 @@ describe("useChannelTagActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A tag counts the followed channels it is on: a toggle that lands changes that number.
|
||||
it("refreshes the tags once a toggle lands", async () => {
|
||||
vi.spyOn(api, "attachChannelTag").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries");
|
||||
const { result } = renderHook(
|
||||
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
act(() => result.current.toggleTag("UC1", 7, false));
|
||||
await waitFor(() => expect(spy.mock.calls.map(([f]) => f?.queryKey)).toContainEqual(qk.tags()));
|
||||
});
|
||||
|
||||
it("names a refused create-and-attach", async () => {
|
||||
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -21,9 +21,23 @@ describe("a subscription change", () => {
|
||||
qk.feedSummary(),
|
||||
qk.discoveredChannels(),
|
||||
qk.watchedChannels(),
|
||||
qk.tags(),
|
||||
]);
|
||||
});
|
||||
|
||||
// A tag counts the followed channels it is on, not hidden: a hide changes the number as an
|
||||
// unsubscribe does, and both places that hide a channel must refresh it.
|
||||
it("a hide refreshes the tags wherever it is made", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
.map((f) => f.split("\\").join("/"))
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/"))
|
||||
.map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const)
|
||||
.filter(([, src]) => /api\.updateChannel\([^)]*hidden/.test(src));
|
||||
expect(sites.length).toBeGreaterThanOrEqual(2);
|
||||
for (const [f, src] of sites) expect(src, f).toContain("queryKey: qk.tags()");
|
||||
});
|
||||
|
||||
it("is refreshed through that one function wherever it is made", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
|
||||
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
|
||||
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
|
||||
* counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a
|
||||
* subscribed channel leaves them), and the tags (a tag counts the followed channels it is on). ONE set for every site that makes the change: each used to keep
|
||||
* its own list, and each list missed something. */
|
||||
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
|
||||
for (const queryKey of [
|
||||
@@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
|
||||
qk.feedSummary(),
|
||||
qk.discoveredChannels(),
|
||||
qk.watchedChannels(),
|
||||
qk.tags(),
|
||||
])
|
||||
void qc.invalidateQueries({ queryKey });
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ export function useChannelTagActions(opts: {
|
||||
? api.detachChannelTag(channelId, tagId)
|
||||
: api.attachChannelTag(channelId, tagId);
|
||||
call
|
||||
.then(() => opts.onSuccess?.(channelId))
|
||||
.then(() => {
|
||||
// A tag counts the followed channels it is on: the toggle changed that number.
|
||||
void qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
opts.onSuccess?.(channelId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
notifyActionError(err, msg("channels.tags.updateFailed"));
|
||||
opts.onError(channelId);
|
||||
|
||||
@@ -38,7 +38,7 @@ const chips = [chip("alpha"), chip("beta"), chip("gamma")];
|
||||
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
|
||||
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
|
||||
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
|
||||
const menu = () => screen.queryByRole("menu", { hidden: true });
|
||||
const menu = () => screen.queryByRole("dialog", { hidden: true });
|
||||
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
|
||||
|
||||
afterEach(() => {
|
||||
@@ -112,8 +112,10 @@ describe("TagFilterChips counts", () => {
|
||||
);
|
||||
const name = (label: string) =>
|
||||
screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
|
||||
expect(name("alpha")?.textContent).toBe("alpha42");
|
||||
expect(name("beta")?.textContent).toBe("beta0");
|
||||
expect(name("alpha")?.textContent).toBe("alpha · 42");
|
||||
expect(name("beta")?.textContent).toBe("beta · 0");
|
||||
// In the chip's own colour: a dimmed count falls under the text contrast floor.
|
||||
expect(name("alpha")?.querySelector('[class*="opacity"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing after the name of a chip that has no count", () => {
|
||||
@@ -125,8 +127,8 @@ describe("TagFilterChips counts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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>.
|
||||
// The channel manager puts the picked chips first, so a toggle moves its chip: out of the "+N" fold,
|
||||
// into it, or out of the list. 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);
|
||||
@@ -146,17 +148,9 @@ describe("TagFilterChips focus on a toggle that moves the chip", () => {
|
||||
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"));
|
||||
});
|
||||
// Along the row needs no test of its own: a keyed move within one parent keeps its node, and
|
||||
// React gives the focus back to it after the commit. Only a remount (row ↔ fold) or a chip that
|
||||
// leaves the list loses it.
|
||||
|
||||
it("out of the fold: picked in the popover, it is focused on the row", () => {
|
||||
fit.visible = 1;
|
||||
@@ -167,13 +161,41 @@ describe("TagFilterChips focus on a toggle that moves the chip", () => {
|
||||
const onRow = named("beta");
|
||||
expect(onRow).not.toBe(inFold);
|
||||
expect(document.activeElement).toBe(onRow);
|
||||
// …and the fold is shut: the focus is not left outside a popover still open.
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
// A stored pick of a tag that no longer exists is an active chip that an un-pick removes for good.
|
||||
function WithGhost() {
|
||||
const [ghost, setGhost] = useState(true);
|
||||
const rest = ["alpha", "beta"].map(chip);
|
||||
return (
|
||||
<TagFilterChips
|
||||
chips={
|
||||
ghost
|
||||
? [{ ...chip("ghost"), active: true, onToggle: () => setGhost(false) }, ...rest]
|
||||
: rest
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["with room for all", 3],
|
||||
["with a fold", 1],
|
||||
])("a chip that leaves the list hands the focus to the one now in its place (%s)", (_, n) => {
|
||||
fit.visible = n;
|
||||
render(<WithGhost />);
|
||||
press(named("ghost"));
|
||||
expect(document.activeElement).toBe(named("alpha"));
|
||||
});
|
||||
|
||||
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(named("alpha").closest('[role="dialog"]')).toBeNull(); // alpha leads the row now
|
||||
expect(document.activeElement).toBe(moreBtn());
|
||||
});
|
||||
|
||||
|
||||
@@ -34,19 +34,32 @@ function ChipText({ chip }: { chip: FilterChip }) {
|
||||
return (
|
||||
<>
|
||||
{chip.label}
|
||||
{chip.count != null && <span className="ml-1 opacity-70 tabular-nums">{chip.count}</span>}
|
||||
{/* The count in the chip's own colour, set off by a dot rather than dimmed: a dimmed count
|
||||
falls under the text contrast floor, on a resting chip and on a pressed one. */}
|
||||
{chip.count != null && (
|
||||
<>
|
||||
{" "}
|
||||
<span aria-hidden="true">·</span> <span className="tabular-nums">{chip.count}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagChip({ chip, onToggled }: { chip: FilterChip; onToggled: (key: string) => void }) {
|
||||
function TagChip({
|
||||
chip,
|
||||
onToggled,
|
||||
}: {
|
||||
chip: FilterChip;
|
||||
onToggled: (key: string, from: HTMLElement) => void;
|
||||
}) {
|
||||
return (
|
||||
<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);
|
||||
if (e.currentTarget === document.activeElement) onToggled(chip.key, e.currentTarget);
|
||||
chip.onToggle();
|
||||
}}
|
||||
pressed={chip.active}
|
||||
@@ -107,31 +120,43 @@ export default function TagFilterChips({
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
// A fold of toggle buttons, not a menu of items: each chip is a pressed/unpressed button in the
|
||||
// Tab order, which a role=menu with no menu items would hide from a screen reader.
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
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);
|
||||
// A toggle may move its chip between the row and the "+N" fold (two parents: the button is
|
||||
// remounted and the focus falls to <body>), or take it out of the list altogether (a stored pick of
|
||||
// a tag that no longer exists). A move along the row keeps its node, and React gives it the focus
|
||||
// back. The focus follows the chip: onto it where it now stands, onto "+N" when it went into a
|
||||
// closed fold, or onto the chip now in its old place when it is gone. A toggle in the open fold
|
||||
// closes it, so the focus never sits outside a popover left open. Held for the frame, since the fit
|
||||
// re-decides in layout effects after the toggle's render.
|
||||
const follow = useRef<{ key: string; at: number } | null>(null);
|
||||
const foldRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const onToggled = (chipKey: string) => {
|
||||
follow.current = chipKey;
|
||||
const onToggled = (chipKey: string, from: HTMLElement) => {
|
||||
follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey) };
|
||||
requestAnimationFrame(() => (follow.current = null));
|
||||
if (foldRef.current?.contains(from)) setOpen(false);
|
||||
};
|
||||
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;
|
||||
const target = follow.current;
|
||||
if (!target) return;
|
||||
const inBox = (box: HTMLElement | null) => [
|
||||
...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? []),
|
||||
];
|
||||
const row = inBox(rowRef.current);
|
||||
const find = (box: HTMLElement[]) => box.find((b) => b.dataset.chipKey === target.key);
|
||||
const gone = !chips.some((c) => c.key === target.key);
|
||||
const el =
|
||||
find(row) ??
|
||||
find(inBox(foldRef.current)) ??
|
||||
(gone ? (row[Math.min(target.at, row.length - 1)] ?? moreRef.current) : moreRef.current);
|
||||
if (el && document.activeElement !== el) el.focus();
|
||||
});
|
||||
const setMore = (el: HTMLButtonElement | null) => {
|
||||
|
||||
@@ -50,8 +50,10 @@ export function useChannelActions({
|
||||
const nameOf = (c: ManagedChannel) =>
|
||||
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
|
||||
|
||||
// A hide also changes the tags' counts: a tag counts only the channels the Subscriptions tab shows.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
|
||||
@@ -194,6 +194,8 @@ export default function ChannelPage({
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
// A tag counts only the channels the Subscriptions tab shows, which a hide leaves out.
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,59 @@ describe("ShareDialog · opened to share", () => {
|
||||
expect(h.copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// An expired link opens nothing: it is no link to hand out, and reads as dead at a glance.
|
||||
it("offers no Copy on an expired link, and marks it with a warning", async () => {
|
||||
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
||||
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("downloads.share.expiredOn", { exact: false }).className).toContain(
|
||||
"text-warn",
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: "downloads.share.openNewTab" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Closed before the link it made came back, the dialog must not open next time on the list it
|
||||
// had and make another.
|
||||
it("makes one link, closed before it came back and opened again", async () => {
|
||||
h.links = [];
|
||||
let made: (l: unknown) => void = () => {};
|
||||
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const dialog = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const first = render(dialog());
|
||||
await waitFor(() => expect(h.createDownloadLink).toHaveBeenCalledTimes(1));
|
||||
first.unmount();
|
||||
made(row(4, "/file/new"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
h.links = [row(4, "/file/new")];
|
||||
render(dialog());
|
||||
await screen.findByDisplayValue(`${origin}/file/new`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(h.createDownloadLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a New link form the user opened while the first link was on its way", async () => {
|
||||
h.links = [];
|
||||
let made: (l: unknown) => void = () => {};
|
||||
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
|
||||
open();
|
||||
fireEvent.click(await screen.findByText("downloads.share.newLink"));
|
||||
fireEvent.change(screen.getByPlaceholderText("downloads.share.password"), {
|
||||
target: { value: "s3cret" },
|
||||
});
|
||||
h.links = [row(4, "/file/new")];
|
||||
made(row(4, "/file/new"));
|
||||
await screen.findByDisplayValue(`${origin}/file/new`);
|
||||
expect(screen.getByPlaceholderText<HTMLInputElement>("downloads.share.password").value).toBe(
|
||||
"s3cret",
|
||||
);
|
||||
});
|
||||
|
||||
// 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")];
|
||||
|
||||
@@ -25,6 +25,7 @@ import { copyText } from "../../lib/clipboard";
|
||||
import { Input, InnerInput } from "../../components/ui/Input";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import UserPicker from "./UserPicker";
|
||||
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
@@ -126,14 +127,15 @@ function LinkRow({
|
||||
},
|
||||
});
|
||||
|
||||
// An expired link opens nothing, so it is no link to hand out: no Copy, and it reads as dead at a
|
||||
// glance (a warn badge, its address struck through). Open stays, to check it; Revoke, to clear it.
|
||||
const expired = linkExpired(link);
|
||||
const badges: string[] = [];
|
||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||
if (link.expires_at)
|
||||
if (link.expires_at && !expired)
|
||||
badges.push(
|
||||
t(linkExpired(link) ? "downloads.share.expiredOn" : "downloads.share.expiresOn", {
|
||||
date: formatDate(link.expires_at, i18n.language),
|
||||
}),
|
||||
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -142,14 +144,25 @@ function LinkRow({
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
{fullUrl ? (
|
||||
<>
|
||||
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
||||
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
|
||||
{copied || copiedOnOpen ? (
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
<InnerInput
|
||||
className={`text-xs text-muted truncate ${expired ? "line-through" : ""}`}
|
||||
readOnly
|
||||
value={fullUrl}
|
||||
/>
|
||||
{!expired && (
|
||||
<IconButton
|
||||
label={t("downloads.share.copy")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied || copiedOnOpen ? (
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconLink
|
||||
label={t("downloads.share.openNewTab")}
|
||||
href={fullUrl}
|
||||
@@ -214,6 +227,11 @@ function LinkRow({
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
|
||||
</span>
|
||||
{expired && link.expires_at && (
|
||||
<Badge tone="warn">
|
||||
{t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })}
|
||||
</Badge>
|
||||
)}
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b}
|
||||
@@ -309,16 +327,26 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
expires_days: expiryDays,
|
||||
password: password.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// In the list at once, as the one-click Share puts it: closed before the refetch, the dialog
|
||||
// would open next time on the cached list without it and make another.
|
||||
onSuccess: (made) => {
|
||||
qc.setQueryData<ShareLink[]>(linksKey(target), (old) => [made, ...(old ?? [])]);
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
meta: { failTitle: "downloads.share.linkFailed" },
|
||||
});
|
||||
// The form's own success, not the mutation's: the link made on open goes through the same
|
||||
// mutation, and must not close and clear a form the user opened while it was on its way.
|
||||
const createFromForm = () =>
|
||||
create.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setCreating(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
|
||||
// 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 that opens — as if the
|
||||
@@ -419,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
onClick={createFromForm}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
{t("downloads.share.createLink")}
|
||||
|
||||
@@ -72,7 +72,7 @@ const FOLDERS = [
|
||||
];
|
||||
|
||||
const wrap = (
|
||||
onClose = vi.fn(),
|
||||
onClose: () => void = vi.fn(),
|
||||
folderId: number | null = null,
|
||||
initialFile: File | null = null,
|
||||
) => {
|
||||
@@ -186,34 +186,58 @@ 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);
|
||||
// The name field saves on blur, and a press blurs it before the press acts: one that closes the
|
||||
// dialog (Cancel, the ✕, the backdrop) makes no folder; one that leaves it open keeps the name.
|
||||
const naming = async (onClose: () => void) => {
|
||||
const r = 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);
|
||||
return { r, field };
|
||||
};
|
||||
const pressOn = (el: Element, field: HTMLElement, click = true) => {
|
||||
fireEvent.pointerDown(el);
|
||||
fireEvent.mouseDown(el);
|
||||
fireEvent.blur(field);
|
||||
fireEvent.click(control);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
await new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
|
||||
fireEvent.pointerUp(el);
|
||||
fireEvent.mouseUp(el);
|
||||
if (click) fireEvent.click(el);
|
||||
};
|
||||
const settled = () => new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
|
||||
|
||||
it.each([
|
||||
["Cancel", () => screen.getByText("downloads.actions.cancel")],
|
||||
["the close button", () => screen.getByRole("button", { name: "common.close" })],
|
||||
["the backdrop", () => screen.getByRole("dialog")],
|
||||
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
|
||||
let unmount = () => {};
|
||||
const { r, field } = await naming(() => unmount());
|
||||
unmount = r.unmount;
|
||||
pressOn(dismiss(), field);
|
||||
await settled();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(h.createFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A right-button press on the backdrop blurs the field but closes nothing: the name stands.
|
||||
it("a press that closes nothing makes the folder it left", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
const { field } = await naming(vi.fn());
|
||||
pressOn(screen.getByRole("dialog"), field, false);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
|
||||
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"));
|
||||
const { field } = await naming(vi.fn());
|
||||
pressOn(screen.getByText("downloads.upload.targetLabel"), field);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
|
||||
it("leaving the name by Tab makes the folder at once", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
const { field } = await naming(vi.fn());
|
||||
fireEvent.blur(field);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
@@ -227,7 +251,10 @@ describe("UploadDialog · where the file goes", () => {
|
||||
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();
|
||||
const making = await screen.findByText("downloads.upload.makingFolder");
|
||||
// Said in a live region, and the keyboard is held on its place, not dropped to <body>.
|
||||
expect(making.closest('[role="status"]')).toBeTruthy();
|
||||
await waitFor(() => expect(document.activeElement).toBe(making.parentElement));
|
||||
made({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
await waitFor(async () => expect(document.activeElement).toBe(await target()));
|
||||
});
|
||||
|
||||
@@ -82,19 +82,34 @@ export default function UploadDialog({
|
||||
const [naming, setNaming] = useState(false);
|
||||
const targetBox = useRef<HTMLDivElement>(null);
|
||||
const refocusTarget = useRef(false);
|
||||
// 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);
|
||||
// The name field saves on blur, and a press blurs it before the press does anything: a press on
|
||||
// Cancel, the ✕ or the backdrop that then closes the dialog is "never mind", not "make it". So a
|
||||
// name left by a press is made once the press is over, and only if the dialog is still here — its
|
||||
// close unmounts this and drops the wait. A name left without a press (Tab) is made at once.
|
||||
const pressing = useRef(false);
|
||||
const afterPress = useRef<(() => void) | null>(null);
|
||||
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"]'));
|
||||
let wait = 0;
|
||||
const down = () => {
|
||||
pressing.current = true;
|
||||
};
|
||||
const up = () => {
|
||||
pressing.current = false;
|
||||
const run = afterPress.current;
|
||||
afterPress.current = null;
|
||||
// After the click the press ends in, where a dismissal closes the dialog.
|
||||
if (run) wait = window.setTimeout(run);
|
||||
};
|
||||
document.addEventListener("pointerdown", down, true);
|
||||
document.addEventListener("pointerup", up, true);
|
||||
document.addEventListener("pointercancel", up, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", down, true);
|
||||
document.removeEventListener("pointerup", up, true);
|
||||
document.removeEventListener("pointercancel", up, true);
|
||||
window.clearTimeout(wait);
|
||||
afterPress.current = null;
|
||||
};
|
||||
document.addEventListener("pointerdown", onPress, true);
|
||||
return () => document.removeEventListener("pointerdown", onPress, true);
|
||||
}, []);
|
||||
const makeFolder = useMutation({
|
||||
mutationFn: (folder: string) => api.createFolder(folder),
|
||||
@@ -106,12 +121,13 @@ export default function UploadDialog({
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.folder.create" },
|
||||
});
|
||||
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list — once
|
||||
// it is back, as the folder is made (or fails): until then its place says it is being made.
|
||||
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list once it
|
||||
// is back, as the folder is made (or fails). Until then on its place, which says it is being made.
|
||||
useEffect(() => {
|
||||
if (naming || !refocusTarget.current) return;
|
||||
const list = targetBox.current?.querySelector("button");
|
||||
if (!list) return;
|
||||
const box = targetBox.current;
|
||||
const list = box?.querySelector("button");
|
||||
if (!list) return box?.focus();
|
||||
refocusTarget.current = false;
|
||||
list.focus();
|
||||
}, [naming, makeFolder.isPending]);
|
||||
@@ -131,7 +147,6 @@ export default function UploadDialog({
|
||||
// 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) {
|
||||
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));
|
||||
@@ -268,19 +283,22 @@ export default function UploadDialog({
|
||||
onDone={(folder, byKey) => {
|
||||
refocusTarget.current = byKey;
|
||||
setNaming(false);
|
||||
if (!byKey && dismissing.current) return;
|
||||
if (folder?.trim()) makeFolder.mutate(folder.trim());
|
||||
const name = folder?.trim();
|
||||
if (!name) return;
|
||||
if (!byKey && pressing.current) afterPress.current = () => makeFolder.mutate(name);
|
||||
else makeFolder.mutate(name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// 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>
|
||||
)}
|
||||
// It holds the keyboard's focus while a folder is made (tabIndex -1: never a Tab stop), and
|
||||
// says it in a live region, as the list it replaces is gone until then.
|
||||
<div ref={targetBox} tabIndex={-1} className="min-h-8 flex items-center outline-none">
|
||||
<span role="status" className="text-sm text-muted">
|
||||
{/* Upload waits for it (`targetSettling`): this says why. */}
|
||||
{makeFolder.isPending &&
|
||||
t("downloads.upload.makingFolder", { name: makeFolder.variables })}
|
||||
</span>
|
||||
{foldersQ.data && !busy && !makeFolder.isPending && (
|
||||
<FilterSelect
|
||||
hideLabel
|
||||
@@ -327,7 +345,6 @@ export default function UploadDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||
data-dismiss=""
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -204,7 +204,11 @@ describe("PlexDiscover", () => {
|
||||
onSeeAllContinue={onSeeAllContinue}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "common.seeAllOf:plex.discover.continueWatching",
|
||||
}),
|
||||
);
|
||||
expect(onSeeAllContinue).toHaveBeenCalledOnce();
|
||||
expect(onSeeAllRecent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -224,9 +228,10 @@ describe("PlexDiscover", () => {
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
// The last of them: Continue watching, above it, has its own.
|
||||
const seeAll = await screen.findAllByRole("button", { name: "common.seeAll" });
|
||||
fireEvent.click(seeAll[seeAll.length - 1]!);
|
||||
// Each See all is named with its rail: Continue watching, above it, has its own.
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "common.seeAllOf:plex.discover.recentlyAdded" }),
|
||||
);
|
||||
expect(onSeeAllRecent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -294,6 +294,22 @@ describe("WatchPage honest failures", () => {
|
||||
expect(page.getByRole("button", { name: button })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a repeated Enter sends one try while it is on its way", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-enter");
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(res(200, { needs_password: true }));
|
||||
fetchMock.mockImplementation(() => new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const page = render(<WatchPage />);
|
||||
const field = await page.findByPlaceholderText("Enter password");
|
||||
fireEvent.change(field, { target: { value: "guess" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -259,7 +259,9 @@ export default function WatchPage() {
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||
// Not again while one is on its way, as the button: a held or repeated Enter would
|
||||
// spend the link's tries (and its view count) once per press.
|
||||
onKeyDown={(e) => e.key === "Enter" && password && !unlocking && unlock()}
|
||||
placeholder={T.passwordPlaceholder}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user