Compare commits

...
19 Commits
Author SHA1 Message Date
peter 5a93a9a8e7 release: 0.117.0 (improvement/e57-files-and-small-ideas) 2026-11-06 03:09:58 +01:00
peter 911fea3243 release: 0.117.0 2026-11-06 02:57:39 +01:00
peter 4aece8aff7 docs(design): log the Hungarian unlock button's noun form 2026-11-06 02:46:27 +01:00
peter adb5e94511 fix(watch): the Hungarian unlock button says a noun for a video too, as for a file
"Megnézem" (a first-person verb) and "Megnyitás" (a noun) labelled the same button by link kind;
the app's Hungarian buttons are nouns, so a video's reads "Megtekintés".
2026-11-06 02:46:18 +01:00
peter 673decf4d4 fix(channels): a pick in the +N fold keeps the focus on its chip after the fold closes
The closing popover hands the focus back to "+N" in a microtask it queues as it unmounts, after the
chip was focused (seen in Chrome; jsdom does not show it). A second focus, queued after it, wins.
2026-11-06 02:32:49 +01:00
peter 4d1cf66d96 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.
2026-11-06 02:20:52 +01:00
peter 2baad2ebae fix(files): a folder made from the upload dialog gives the keyboard back the target once it is made 2026-11-06 00:23:09 +01:00
peter 8383459f70 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.
2026-11-06 00:09:39 +01:00
peter c34f6f3297 fix(channels): picked tag chips lead the row, so the +N fold never hides an active filter 2026-11-05 22:34:12 +01:00
peter e4b6d918e4 fix(plex,share): Recently added's See all opens newest first, and a share link says open
See all on Recently added opened Browse in whatever sort was last used, by title on the account it
was seen on. The share dialog said anyone can watch a link that may hold a zip.
2026-11-04 19:43:02 +01:00
peter bf5cf011ce feat: tag chips count their channels, Plex shows a file's size, a skip waits, Continue watching opens whole
The channel manager's tag chips carry a muted count. A Plex item's Info page names the file's size.
An auto-skip's countdown starts 3 s into the marker, with its button there from the start and a delay of
0 still immediate. Continue watching has a See all, opening Browse on in progress, last watched first;
Recently added's sets the watch state back so it opens what it says.
2026-11-04 19:27:46 +01:00
peter 6e6e47652a feat(files): the upload dialog says where the file goes, and sharing copies on open
The upload dialog shows its target folder and can change it, with New folder in the same list; a
file never goes while a folder is being named or made. The share dialog puts the link on the clipboard
when it opens, says so only if the browser let it, and has Open in new tab beside Copy. A file's
format joins the line under its name.
2026-11-04 19:16:20 +01:00
peter 4c09a22add feat(share): a link's address says what it opens
A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token
is the whole key, so every path opens every link and the links already sent keep working. The new
paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a
file a file.
2026-11-04 19:10:09 +01:00
peter 2bdf1ec76f fix(editor): a crop of a video with wide pixels keeps what its frame showed
The browser reports such a video by its shown size and ffmpeg's crop counts stored pixels, so a
frame over the middle half of a 320 stored, 640 shown video kept the whole width. A crop now carries
the size it was measured on and is scaled to the file's own pixels. A spec from before it did is cut
as it always was. Also drops a stale note in downloads/quota.py: edit jobs are held to the cap.
2026-11-04 19:01:49 +01:00
peter f7e036abef merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry) 2026-11-04 00:53:23 +01:00
peter f2de8f73aa test(e2e): a page load the machine had no buffer for is tried again
net::ERR_NO_BUFFER_SPACE stopped a release at its first page with nothing else running, and the
unchanged rerun passed. Every page load of the suite now goes through one helper, held by a test.
2026-11-04 00:26:50 +01:00
peter 643f305814 release: 0.116.3 (bug/lost-stylesheet) 2026-11-04 00:19:42 +01:00
peter cbe1b3bd63 chore(release): 0.116.3 2026-11-04 00:05:30 +01:00
peter ae775b383c fix(boot): a stylesheet lost on the way is asked for again
A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by
testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered
controls. Losing the main stylesheet on purpose gives those same eight.
2026-11-03 23:32:40 +01:00
87 changed files with 2720 additions and 297 deletions
+19
View File
@@ -7,6 +7,25 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.117.0 — 2026-11-06
### New
- A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.
- The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.
- The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size.
### Fixed
- An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.
- A Plex auto-skip waits 3 seconds into the marker before its countdown starts.
## 0.116.3 — 2026-11-04
### Fixed
- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload.
## 0.116.2 — 2026-11-03 ## 0.116.2 — 2026-11-03
### Fixed ### Fixed
+1 -1
View File
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions. [`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette, Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests, app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in `features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
+1 -1
View File
@@ -1 +1 @@
0.116.2 0.117.0
+10
View File
@@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None:
return n / d if n > 0 and d > 0 else None return n / d if n > 0 and d > 0 else None
def quarter_turned(probe: dict) -> bool | None:
"""Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter
(ffmpeg stands it upright before any filter, so its filters see width and height swapped). None
when there is no video stream."""
for s in probe.get("streams") or []:
if s.get("codec_type") == "video":
return _quarter_turned(s)
return None
def _quarter_turned(stream: dict) -> bool: def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate") rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []: for sd in stream.get("side_data_list") or []:
+48 -5
View File
@@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of
(the `-ss` seeks first), so a short clip is cheap even from a long source (the `-ss` seeks first), so a short clip is cheap even from a long source
* crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate * crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the `edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A
crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size
(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split` per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
is a frontend concept: the is a frontend concept: the
UI fans a split out into N trim jobs, so there is one output file per job here. UI fans a split out into N trim jobs, so there is one output file per job here.
@@ -60,6 +62,46 @@ class EditAborted(Exception):
_MAX_SEGMENTS = 100 _MAX_SEGMENTS = 100
def _crop_frame(crop: dict) -> dict | None:
"""The picture size a crop was measured on, or None when it has none it could be scaled by (a
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
try:
w, h = int(crop["frame_w"]), int(crop["frame_h"])
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
except (KeyError, TypeError, ValueError, OverflowError):
return None
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None
def crop_filter(crop: dict) -> str:
"""The ffmpeg `crop` of an edit. Its numbers count the picture as the browser reports it, which
is its SHOWN size: a video with wide pixels (320 stored, 640 shown) would be cut off to one side
in ffmpeg's stored ones, so they are scaled to `iw` / `ih` by the size they were measured on. A
rotated file needs nothing more: both the browser and ffmpeg's filters see it upright."""
frame_w, frame_h = crop.get("frame_w"), crop.get("frame_h")
if not frame_w or not frame_h:
return f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"
return (
f"crop=iw*{crop['w']}/{frame_w}:ih*{crop['h']}/{frame_h}"
f":iw*{crop['x']}/{frame_w}:ih*{crop['y']}/{frame_h}"
)
def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None:
"""The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it —
the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated
file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown
picture is a fraction of that frame, not its own pixel count; None when the size is unknown."""
if crop.get("frame_w") and crop.get("frame_h"):
if not src_w or not src_h:
return None
w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"]
else:
w, h = crop["w"], crop["h"]
return w // 2 * 2, h // 2 * 2
def normalize_edit_spec(spec: dict | None) -> dict: def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties. """Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -72,7 +114,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop: if crop:
try: try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError) as exc: except (KeyError, TypeError, ValueError, OverflowError) as exc:
# The clip still renders — uncropped. The user asked for a crop, got a whole frame, # The clip still renders — uncropped. The user asked for a crop, got a whole frame,
# and nothing anywhere called it a failure; this line is the only trace there is. # and nothing anywhere called it a failure; this line is the only trace there is.
log.warning( log.warning(
@@ -81,7 +123,8 @@ def normalize_edit_spec(spec: dict | None) -> dict:
) )
c = None c = None
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
out["crop"] = c frame = _crop_frame(crop)
out["crop"] = {**c, **frame} if frame else c
# Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`. # Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`.
segs = spec.get("segments") segs = spec.get("segments")
@@ -268,7 +311,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
if reencode: if reencode:
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to # Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
# the exact cut). A crop adds the filter; an accurate trim only evens the frame. # the exact cut). A crop adds the filter; an accurate trim only evens the frame.
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else "" cropf = f"{crop_filter(crop)}," if crop else ""
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS] cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
else: else:
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"] cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
@@ -313,7 +356,7 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
] ]
if needs_reencode(spec): if needs_reencode(spec):
cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else "" cropf = f",{crop_filter(crop)}" if crop else ""
parts, labels = [], [] parts, labels = [], []
for i, s in enumerate(segs): for i, s in enumerate(segs):
st, en = s["start_s"], s["end_s"] st, en = s["start_s"], s["end_s"]
+23 -2
View File
@@ -127,14 +127,35 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization ------------------------------------------------------------------------- # --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict: def opens(db: Session, link: DownloadLink) -> str:
"""What a link opens, in the words its address and its preview use: "folder", "video" or
"file". A file still being made reads as the video it mostly is."""
from app.downloads import mediakind
if link.folder_id is not None:
return "folder"
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
kind = asset.media_kind if asset else None
return "file" if kind and kind != mediakind.VIDEO else "video"
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens (`opens`). Only a word for the
reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so
the links already sent on /watch/ work for good."""
prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)]
return f"/{prefix}/{token}"
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
"""What the file owner sees when managing their links. `token` is the one just made (it is in """What the file owner sees when managing their links. `token` is the one just made (it is in
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be.""" hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
token = token or recover(link) token = token or recover(link)
return { return {
"id": link.id, "id": link.id,
"token": token, "token": token,
"url": f"{base_url}/watch/{token}" if token else None, "url": share_path(db, link, token) if token else None,
"allow_download": link.allow_download, "allow_download": link.allow_download,
"has_password": link.password_hash is not None, "has_password": link.password_hash is not None,
"expires_at": link.expires_at.isoformat() if link.expires_at else None, "expires_at": link.expires_at.isoformat() if link.expires_at else None,
+3 -1
View File
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column( source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL") ForeignKey("media_assets.id", ondelete="SET NULL")
) )
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip # For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its
# crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`),
# else in the file's stored ones. For clip
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop # jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
# in fractions of the box it was drawn in (`downloads.clip.normalize`). # in fractions of the box it was drawn in (`downloads.clip.normalize`).
edit_spec: Mapped[dict | None] = mapped_column(JSON) edit_spec: Mapped[dict | None] = mapped_column(JSON)
+12 -7
View File
@@ -1,4 +1,4 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page. """Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card. fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
link = linksmod.find(db, token) link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link): if link is None or linksmod.is_expired(link):
return None return None
url = f"{settings.app_base}/watch/{token}" url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
folder = link.folder_id is not None # What the card calls it, as its address does.
kind = linksmod.opens(db, link)
folder, video = kind == "folder", kind == "video"
if link.password_hash: if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl. # Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join( return "\n ".join(
[ [
"<title>Siftlode</title>", "<title>Siftlode</title>",
_tag("og:title", "Password-protected " + ("folder" if folder else "video")), _tag(
"og:title",
"Password-protected " + ("folder" if folder else "video" if video else "file"),
),
_tag("og:description", "Shared via Siftlode"), _tag("og:description", "Shared via Siftlode"),
_tag("og:type", "website" if folder else "video.other"), _tag("og:type", "video.other" if video else "website"),
_tag("og:url", url), _tag("og:url", url),
_tag("og:site_name", "Siftlode"), _tag("og:site_name", "Siftlode"),
] ]
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready": if job is None or asset is None or asset.status != "ready":
return None return None
title = (job.display_name or asset.title or "Video").strip() title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
channel = (job.display_uploader or asset.uploader or "").strip() channel = (job.display_uploader or asset.uploader or "").strip()
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the # Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's # Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
f"<title>{html.escape(title)}</title>", f"<title>{html.escape(title)}</title>",
_tag("og:title", title), _tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"), _tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"), _tag("og:type", "video.other" if video else "website"),
_tag("og:url", url), _tag("og:url", url),
_tag("og:site_name", "Siftlode"), _tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"), _tag("twitter:title", title, attr="name"),
+21 -4
View File
@@ -837,6 +837,18 @@ def _fail_edit(
) )
def _filter_frame(
path: Path, width: int | None, height: int | None
) -> tuple[int | None, int | None]:
"""A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored
turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read."""
data = probe.probe_json(path)
turned = clipmod.quarter_turned(data) if data else None
if turned is None:
return None, None
return (height, width) if turned else (width, height)
def _process_edit(job_id: int, asset_id: int) -> None: def _process_edit(job_id: int, asset_id: int) -> None:
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg.""" """Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
with SessionLocal() as db: with SessionLocal() as db:
@@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
size = final.stat().st_size if final.exists() else None size = final.stat().st_size if final.exists() else None
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`). # The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
_, _, out_w, out_h, _ = probe.probe_media(final) _, _, out_w, out_h, _ = probe.probe_media(final)
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
cut = (
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
if crop and not (out_w and out_h)
else None
)
with SessionLocal() as db: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) asset = db.get(MediaAsset, asset_id)
@@ -916,11 +934,10 @@ def _process_edit(job_id: int, asset_id: int) -> None:
asset.duration_s = total_s or asset.duration_s asset.duration_s = total_s or asset.duration_s
if out_w and out_h: if out_w and out_h:
asset.width, asset.height = out_w, out_h asset.width, asset.height = out_w, out_h
elif crop: elif cut:
asset.width = edit_spec["crop"]["w"] asset.width, asset.height = cut
asset.height = edit_spec["crop"]["h"]
else: else:
asset.width, asset.height = src_w, src_h asset.width, asset.height = (None, None) if crop else (src_w, src_h)
asset.error = None asset.error = None
asset.last_access_at = datetime.now(timezone.utc) asset.last_access_at = datetime.now(timezone.utc)
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
+2 -1
View File
@@ -16,7 +16,8 @@ That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19). their output's real size before it is placed. Edit jobs consult it with their output's real size
before it is placed (`test_edit_cap.py`).
""" """
from dataclasses import dataclass from dataclasses import dataclass
+10 -4
View File
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html). # A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
WATCH_HTML = STATIC_DIR / "watch.html" WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"} INDEX_HEADERS = {"Cache-Control": "no-cache"}
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
# (watchEntry.contract.test.ts).
SHARE_PREFIXES = ("watch/", "file/", "folder/")
app.mount( app.mount(
"/assets", "/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
@app.get("/watch/{token}") @app.get("/watch/{token}")
async def watch_page(token: str): @app.get("/file/{token}")
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the @app.get("/folder/{token}")
async def share_page(token: str):
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
generic card.""" generic card."""
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit. # stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"}) return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
# A /watch/ path the route above does not match (a trailing segment, no token) is still the # A share path the route above does not match (a trailing segment, no token) is still the
# player's: WatchPage reads the token off the path and says when a link is gone. # player's: WatchPage reads the token off the path and says when a link is gone.
if full_path.startswith("watch/"): if full_path.startswith(SHARE_PREFIXES):
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS) return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+18 -4
View File
@@ -41,11 +41,25 @@ def video(db: Session, video_id: str) -> Video | None:
# --- tags ------------------------------------------------------------------------------------ # --- tags ------------------------------------------------------------------------------------
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]: def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]:
"""The tags this user sees (the shared system ones and their own), each with how many channel """The tags this user sees (the shared system ones and their own), by category then name, each
links it has, by category then name.""" with how many channel links it has and how many of the user's own links sit on a channel they
subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden
channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ."""
subscribed = select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.hidden.is_(False)
)
on_subscribed = dict(
db.execute(
select(ChannelTag.tag_id, func.count(ChannelTag.id))
.where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed))
.group_by(ChannelTag.tag_id)
)
.tuples()
.all()
)
return [ return [
(tag, count) (tag, count, on_subscribed.get(tag.id, 0))
for tag, count in db.execute( for tag, count in db.execute(
select(Tag, func.count(ChannelTag.id)) select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id) .outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
+5 -5
View File
@@ -611,7 +611,7 @@ def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]: ) -> list[dict]:
own_job(db, user, job_id) own_job(db, user, job_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)] return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
class CreateLinkIn(BaseModel): class CreateLinkIn(BaseModel):
@@ -653,7 +653,7 @@ def create_link(
raise refused(409, "share_unfinished") raise refused(409, "share_unfinished")
link, token = _new_link(db, user, body, job_id=job_id) link, token = _new_link(db, user, body, job_id=job_id)
db.commit() db.commit()
return linksmod.owner_view(link, token=token) return linksmod.owner_view(db, link, token=token)
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob": def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
@@ -684,7 +684,7 @@ def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]: ) -> list[dict]:
own_folder(db, user, folder_id) own_folder(db, user, folder_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)] return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
@router.post("/folders/{folder_id}/links") @router.post("/folders/{folder_id}/links")
@@ -697,7 +697,7 @@ def create_folder_link(
_shareable_folder(db, user, folder_id) _shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id) link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit() db.commit()
return linksmod.owner_view(link, token=token) return linksmod.owner_view(db, link, token=token)
class UpdateLinkIn(BaseModel): class UpdateLinkIn(BaseModel):
@@ -724,7 +724,7 @@ def update_link(
sent=frozenset(body.model_fields_set), sent=frozenset(body.model_fields_set),
) )
db.commit() db.commit()
return linksmod.owner_view(link) return linksmod.owner_view(db, link)
@router.delete("/links/{link_id}") @router.delete("/links/{link_id}")
+1
View File
@@ -432,6 +432,7 @@ def item_detail(
"summary": (it.summary_hu or it.summary) if hu else it.summary, "summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year, "year": it.year,
"duration_seconds": it.duration_s, "duration_seconds": it.duration_s,
"size_bytes": it.size_bytes,
"playable": it.playable, "playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows. # Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw, "transcode_hw": it.transcode_hw,
+9 -2
View File
@@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak).""" returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token) link = _link_or_404(db, token)
if link.password_hash: if link.password_hash:
# Which kind it opens, for the password prompt's title — never what is in it. # Which kind it opens, for the password prompt's words — never what is in it. `video` tells
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"} # a video (watched) from any other file (opened); the address is only a hint, every
# prefix opens every link.
opens = linksmod.opens(db, link)
return {
"needs_password": True,
"kind": "folder" if opens == "folder" else "file",
"video": opens == "video",
}
meta = _opened(db, link, token) meta = _opened(db, link, token)
db.commit() db.commit()
return meta return meta
+2 -1
View File
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
"category": tag.category, "category": tag.category,
"system": tag.user_id is None, "system": tag.user_id is None,
"channel_count": count, "channel_count": count,
"subscribed_count": subscribed,
} }
for tag, count in queries.tags_with_counts(db, user) for tag, count, subscribed in queries.tags_with_counts(db, user)
] ]
+209
View File
@@ -0,0 +1,209 @@
"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's
pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown),
and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept
half the frame's red surroundings. So a crop carries the size it was measured on.
Real ffmpeg: the pixels of the output are the oracle.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import edit, pipeline, service, storage
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
"""Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown
turned by that many degrees counter-clockwise, as a phone records a portrait video."""
tag = ["-display_rotation:v:0", str(rotate)] if rotate else []
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10",
"-f", "lavfi", "-i", "sine=duration=2",
"-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path),
],
check=True,
) # fmt: skip
if tag:
tagged = path.with_name("tagged-" + path.name)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)],
check=True,
) # fmt: skip
tagged.replace(path)
def _red_and_green(path: Path) -> tuple[int, int]:
raw = subprocess.run(
["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
check=True,
capture_output=True,
).stdout # fmt: skip
red = green = 0
for i in range(0, len(raw) - 2, 3):
if raw[i] > 180 and raw[i + 1] < 80:
red += 1
elif raw[i + 1] > 180 and raw[i] < 80:
green += 1
return red, green
def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset:
user = make_user()
source = uploadmod.ingest_upload(
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
assert source.status == "done"
then()
job = service.enqueue_edit(db, user.id, source, spec)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, job.error, asset.status) == ("done", None, "ready")
return asset
def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path:
asset = _cut_asset(db, make_user, src, {"crop": crop, **spec})
return storage.abs_path(str(tmp_path), asset.rel_path)
def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
# What the editor sends for the middle half of this video: the browser shows it at 640 x 240.
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
) # fmt: skip
red, green = _red_and_green(final)
assert (red, green) == (0, 160 * 120)
def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1)
final = _cut(
db, make_user, tmp_path, src,
{"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 320)
def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4")
assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,")
@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}])
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch):
"""A cut-list joins through `filter_complex`, a crop path of its own."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True,
) # fmt: skip
assert _red_and_green(final) == (0, 160 * 120)
def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
"""The browser shows a rotated file upright, so the frame was drawn on the upright picture."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
# Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320.
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
# Turned counter-clockwise, the stored left half is the shown bottom half.
final = _cut(
db, make_user, tmp_path, src,
{"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 160)
def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}},
then=probe_fails,
) # fmt: skip
# Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted.
assert (asset.width, asset.height) == (160, 120)
def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
"""The frame was drawn on the upright picture, which is the stored one turned: so is its cut."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}},
then=probe_fails,
) # fmt: skip
# What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120.
assert (asset.width, asset.height) == (240, 160)
@pytest.mark.parametrize("key", ["frame_w", "x"])
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}})
if key == "frame_w":
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
else:
assert "crop" not in spec
+2 -1
View File
@@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
assert client.get(f"/api/public/watch/{token}").json() == { assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True, "needs_password": True,
"kind": "folder", "kind": "folder",
"video": False,
} }
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file" bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403 assert client.get(bare).status_code == 403
@@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client):
.json() .json()
) )
meta = client.get(f"/api/public/watch/{made['token']}").json() meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"} assert meta == {"needs_password": True, "kind": "file", "video": True}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db): def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
+2
View File
@@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
library_id=lib.id, library_id=lib.id,
kind="movie", kind="movie",
title="The Godfather", title="The Godfather",
size_bytes=15_000_000_000,
genres=["Crime", "Drama"], genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"], directors=["Francis Ford Coppola"],
studio="Paramount", studio="Paramount",
@@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["size_bytes"] == 15_000_000_000
assert body["imdb_rating"] == 8.7 assert body["imdb_rating"] == 8.7
assert body["imdb_id"] == "tt0068646" assert body["imdb_id"] == "tt0068646"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/" assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
+34 -2
View File
@@ -14,7 +14,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.discover import pins from app.discover import pins
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Subscription, Video
from app.reader import queries, service from app.reader import queries, service
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
@@ -60,12 +60,44 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
db.commit() db.commit()
assert queries.own_tag(db, user, system.id) is None assert queries.own_tag(db, user, system.id) is None
assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id] assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
# --- tags ------------------------------------------------------------------------------------ # --- tags ------------------------------------------------------------------------------------
def _music_on_four(db, make_user):
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
user, other = make_user(), make_user()
tag = service.create_tag(db, user, "Music", "other", None)
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(Channel(id=cid, title=cid))
db.flush()
for cid in ("UCa", "UCb"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
db.commit()
return user, tag
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
the followed channels not hidden, so its count must not hold the others."""
user, tag = _music_on_four(db, make_user)
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
assert (count, subscribed) == (4, 2)
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
user, tag = _music_on_four(db, make_user)
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user): def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
user = make_user() user = make_user()
tag = service.create_tag(db, user, "Later", "nonsense", "#fff") tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
+148
View File
@@ -0,0 +1,148 @@
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
Only a word for the reader: the token is the key, so every path opens every link, and the links
already sent out on /watch/ keep working for good. DB-lane.
"""
import pytest
from cryptography.fernet import Fernet
from app import main, security
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
_AUDIO = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def pages(tmp_path, monkeypatch):
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
monkeypatch.setattr(main, "INDEX_HTML", index)
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def key(monkeypatch):
"""A link's address is read back from its encrypted token."""
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload",
content=b"some bytes",
headers=upload_headers(name, display_name=name),
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return made.json()
share.owner = owner # type: ignore[attr-defined]
return share
@pytest.mark.parametrize(
("name", "data", "prefix"),
[
("clip.mp4", _VIDEO, "/watch/"),
("song.m4a", _AUDIO, "/file/"),
("backup.tar.gz", None, "/file/"),
],
)
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
made = shared(name, data)
assert made["url"] == f"{prefix}{made['token']}"
def test_a_folder_link_is_given_out_on_folder(shared, key):
owner = shared.owner
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
assert made["url"] == f"/folder/{made['token']}"
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
def test_the_list_gives_the_same_address_as_the_making(shared, key):
made = shared("backup.tar.gz", None)
owner = shared.owner
files = owner.get("/api/files").json()
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
@pytest.mark.parametrize(
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
)
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
"""The links already sent on /watch/ open whatever they open, for good."""
token = shared(name, data)["token"]
r = client.get(f"/{path}/{token}")
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
assert f'content="{name}"' in r.text
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
def test_a_card_names_a_file_as_a_file(shared, db):
made = shared("backup.tar.gz", None)
tags = og._watch_tags(made["token"], db)
assert f'{made["url"]}"' in tags
assert "video.other" not in tags
locked = shared("notes.tar.gz", None, password="pw")
hidden = og._watch_tags(locked["token"], db)
assert "Password-protected file" in hidden and "notes" not in hidden
assert "video.other" not in hidden
def test_a_card_of_a_video_stays_a_video(shared, db):
made = shared("clip.mp4", _VIDEO)
tags = og._watch_tags(made["token"], db)
assert f"/watch/{made['token']}" in tags and "video.other" in tags
locked = shared("other.mp4", _VIDEO, password="pw")
assert "Password-protected video" in og._watch_tags(locked["token"], db)
@pytest.mark.parametrize(
("name", "data", "video"),
[("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)],
)
def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video):
"""The lock screen asks to watch a video and to open anything else, whichever path it is on."""
token = shared(name, data, password="pw")["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta == {"needs_password": True, "kind": "file", "video": video}
+10 -2
View File
@@ -301,7 +301,8 @@ Also in force:
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a `fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned scroll, so no row has clipped ends, and a filter row puts its picked chips first so the fold never
holds the only sign that a filter is on; the carousels fade theirs. The dialog edges come with C4's pinned
footer (S3f). footer (S3f).
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's - **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested). toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
@@ -718,7 +719,7 @@ of a full-width panel and nothing on the right.
## Decision log ## Decision log
| Date | Decision | Chosen | Over | Why | | Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized | | 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | | 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | | 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
@@ -862,3 +863,10 @@ of a full-width panel and nothing on the right.
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found | | 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go | | 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in | | 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` |
| 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on |
| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost |
| 2026-11-06 | Tag chip count after a dot (owner a) | The count keeps the chip's own colour, set off from the name by a "·" | The number dimmed (it fell under the 4.5:1 text floor, resting and pressed); the number in a tinted inner badge | Contrast holds in every scheme without a new token, and the dot still reads the number as a detail of the name |
| 2026-11-06 | A pick in the open fold closes it (owner a) | Toggling a chip inside the "+N" popover closes the popover; the focus goes with the chip | Keep the popover open and focus the chip now in the picked one's place | The focus never sits outside a popover left open, and a picked chip leads the row anyway |
| 2026-11-06 | An expired share link offers no Copy (owner a) | An expired row in the share dialog has a warn badge "expired <date>" and its address struck through; Open and Revoke stay, Copy goes | The warn badge alone, Copy kept | An expired link opens nothing, so it is no link to hand out; Open still lets the owner check it |
| 2026-11-06 | The shared page's unlock button is a noun in Hungarian (owner a) | "Megtekintés" for a video, "Megnyitás" for a file or folder | Both verbs, "Megnézem" / "Megnyitom" | The app's Hungarian buttons are nouns; one button should not change grammatical form by link kind |
+4
View File
@@ -24,6 +24,10 @@ outlives an `expect` timeout); Node does not, so a request from the test side fa
that one error again and passes every other failure straight on. Don't run two e2e suites on the that one error again and passes every other failure straight on. Don't run two e2e suites on the
same machine at once. same machine at once.
**A page load the machine had no buffer for** — on the same host a page load can fail at once with
`net::ERR_NO_BUFFER_SPACE`. Every page load goes through `gotoRetrying` (same file), which tries that
one error again; a test that calls `page.goto` itself fails `src/lib/e2eRetryConnect.test.ts`.
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop **Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and
`devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per `devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE, SEED_FILE } from "./helpers"; import { MEMBER_STORAGE, SEED_FILE } from "./helpers";
import { retryConnect } from "./retryConnect"; import { gotoRetrying, retryConnect } from "./retryConnect";
/** /**
* One-time setup for the characterization net (the `setup` project the tests depend on): * One-time setup for the characterization net (the `setup` project the tests depend on):
@@ -52,7 +52,7 @@ async function signIn(page: Page, email: string, path: string): Promise<void> {
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one // and player flows this net locks all open "/" expecting the feed; the opt-out is the one
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the // Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData). // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
await page.goto("/"); await gotoRetrying(page, "/");
await page.evaluate((id: number) => { await page.evaluate((id: number) => {
localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.owner", String(id));
localStorage.setItem("siftlode.lang", "en"); localStorage.setItem("siftlode.lang", "en");
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { gotoRetrying } from "./retryConnect";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and /** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */ * `devices.spec.ts` run from it. */
@@ -16,7 +17,7 @@ export function seeded(key: string): string[] {
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */ /** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> { export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path); await gotoRetrying(page, path);
await expect(page.getByTestId("nav-feed")).toBeVisible(); await expect(page.getByTestId("nav-feed")).toBeVisible();
} }
+26 -11
View File
@@ -11,6 +11,19 @@ const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "fetch
const NO_CONNECTION = /\bconnect ETIMEDOUT\b/; const NO_CONNECTION = /\bconnect ETIMEDOUT\b/;
const WAITS_MS = [500, 1000, 2000, 4000, 8000]; const WAITS_MS = [500, 1000, 2000, 4000, 8000];
/** `attempt` again after each wait while it fails with `transient`; any other failure goes on. */
async function tryAgain<R>(attempt: () => Promise<R>, transient: RegExp): Promise<R> {
for (let i = 0; ; i++) {
try {
return await attempt();
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !transient.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
}
/** `ctx` with its request methods trying a connect that timed out again (see above). */ /** `ctx` with its request methods trying a connect that timed out again (see above). */
export function retryConnect<T extends object>(ctx: T): T { export function retryConnect<T extends object>(ctx: T): T {
return new Proxy(ctx, { return new Proxy(ctx, {
@@ -18,17 +31,19 @@ export function retryConnect<T extends object>(ctx: T): T {
const v: unknown = Reflect.get(target, key, target); const v: unknown = Reflect.get(target, key, target);
if (typeof v !== "function") return v; if (typeof v !== "function") return v;
if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target); if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target);
return async (...args: unknown[]) => { return (...args: unknown[]) =>
for (let i = 0; ; i++) { tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION);
try {
return await v.apply(target, args);
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !NO_CONNECTION.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
};
}, },
}); });
} }
// A page load has a failure of its own on the same machine: `net::ERR_NO_BUFFER_SPACE`, the socket
// call refused for the moment. It stopped a release's e2e at its first page (0.116.3, with nothing
// else running; 0.107.3 aborted on it too), and the unchanged rerun passed. A browser does not try
// a main document again by itself, and the request never left, so this does. The suite's every page
// load goes through here (`e2eRetryConnect.test.ts`).
const NO_BUFFER = /\bERR_NO_BUFFER_SPACE\b/;
export function gotoRetrying<R>(page: { goto(url: string): Promise<R> }, url: string): Promise<R> {
return tryAgain(() => page.goto(url), NO_BUFFER);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { test, type Page } from "@playwright/test"; import { test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { retryConnect } from "./retryConnect"; import { gotoRetrying, retryConnect } from "./retryConnect";
/** /**
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which * The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
@@ -78,7 +78,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext(); const ctx = await browser.newContext();
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto("/"); await gotoRetrying(page, "/");
// Timed inside the page, every frame: an `expect` would read the clock on its own retry // Timed inside the page, every frame: an `expect` would read the clock on its own retry
// schedule (100 ms … 1 s steps), not when the card appeared. // schedule (100 ms … 1 s steps), not when the card appeared.
const at = await page.waitForFunction( const at = await page.waitForFunction(
@@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(`/watch/${token}`); await gotoRetrying(page, `/watch/${token}`);
await page.waitForFunction(() => document.querySelector("video, audio"), null, { await page.waitForFunction(() => document.querySelector("video, audio"), null, {
polling: "raf", polling: "raf",
timeout: 30_000, timeout: 30_000,
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, type Page } from "@playwright/test";
import { openApp } from "./helpers";
import { gotoRetrying } from "./retryConnect";
// A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the
// whole page unstyled, and every test that finds its elements by testid still passes on it: only an
// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves.
/** Lose the first request for every stylesheet of the page; the ones after it go through. */
async function loseEachStylesheetOnce(page: Page): Promise<string[]> {
const lost: string[] = [];
await page.route(/\/assets\/[^/]+\.css/, async (route) => {
const url = route.request().url();
if (lost.includes(url)) return route.continue();
lost.push(url);
return route.abort("failed");
});
return lost;
}
/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw
* on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while
* `--font-scale` would prove nothing, the app also sets it from script. */
async function expectStyled(page: Page): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const hasRules = (l: HTMLLinkElement) => {
try {
return (l.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
};
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const root = getComputedStyle(document.documentElement);
return {
links: links.length > 0,
rules: links.every(hasRules),
tokens: root.getPropertyValue("--bg").trim() !== "",
};
}),
)
.toEqual({ links: true, rules: true, tokens: true });
}
test("the app gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
await openApp(page);
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
// No such link: the page says so, and it is the same entry with the same stylesheet.
await gotoRetrying(page, "/watch/no-such-token");
await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
+4 -2
View File
@@ -1,14 +1,16 @@
# Siftlode is mostly a private, login-gated app; only the public landing and legal # Siftlode is mostly a private, login-gated app; only the public landing and legal
# pages are worth indexing. Allow crawling but keep bots out of the API surface. # pages are worth indexing. Allow crawling but keep bots out of the API surface.
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes, # Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
# but link-preview crawlers are allowed below so shared links unfurl in chat apps. # but link-preview crawlers are allowed below so shared links unfurl in chat apps.
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /api/ Disallow: /api/
Disallow: /auth/ Disallow: /auth/
Disallow: /watch/ Disallow: /watch/
Disallow: /file/
Disallow: /folder/
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich # Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
# card (title/channel/thumbnail). They don't build a search index, so this doesn't # card (title/channel/thumbnail). They don't build a search index, so this doesn't
# expose share links to search engines. # expose share links to search engines.
User-agent: facebookexternalhit User-agent: facebookexternalhit
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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"; import { Carousel } from "./Carousel";
@@ -54,7 +60,10 @@ describe("Carousel", () => {
); );
expect(screen.getByText("card-a")).toBeTruthy(); expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).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(); expect(onSeeAll).toHaveBeenCalledOnce();
}); });
+15 -1
View File
@@ -109,7 +109,21 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2"> <div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2> <h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && ( {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")} {seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" /> <ChevronRight className="h-3.5 w-3.5" />
</Button> </Button>
@@ -1,4 +1,25 @@
[ [
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.",
"The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.",
"The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size."
],
"fixed": [
"An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.",
"A Plex auto-skip waits 3 seconds into the marker before its countdown starts."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload."
]
},
{ {
"version": "0.116.2", "version": "0.116.2",
"date": "2026-11-03", "date": "2026-11-03",
@@ -1,4 +1,25 @@
[ [
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A megosztási link címe most megmondja, mit nyit meg: /file/ egy fájlt, /folder/ egy mappát, /watch/ egy videót. A már elküldött linkek továbbra is működnek.",
"A feltöltési ablak mutatja, melyik mappába kerül a fájl, és ott választhatsz másikat vagy új mappát hozhatsz létre. A Megosztás megnyitáskor rögtön kimásol egy linket.",
"A Csatornák címkéi mutatják, hány követett csatorna tartozik hozzájuk. A Plex Folytatás sávjának is van Összes gombja, és az elem adatlapja mutatja a fájl méretét."
],
"fixed": [
"A lejárt megosztási link lejártként látszik, és sosem kerül a vágólapra. A széles képpontú videó vágása pontosan azt tartja meg, amit a keret mutatott.",
"A Plex automatikus átugrása a marker után 3 másodpercet vár, mielőtt a visszaszámlálás indul."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig."
]
},
{ {
"version": "0.116.2", "version": "0.116.2",
"date": "2026-11-03", "date": "2026-11-03",
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other", category: "other",
system: false, system: false,
channel_count: 0, channel_count: 0,
subscribed_count: 0,
}); });
const docu = tag(1, "docu"); const docu = tag(1, "docu");
const history = tag(2, "history"); const history = tag(2, "history");
@@ -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();
});
});
+2 -2
View File
@@ -71,7 +71,7 @@ function TagRow({
: "" : ""
}`} }`}
> >
{t("tagManager.channels", { count: tag.channel_count })} {t("tagManager.channels", { count: tag.subscribed_count })}
</span> </span>
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}> <IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 /> <Trash2 />
@@ -119,7 +119,7 @@ export default function TagManager({
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) => const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? []) (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 })); .map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => { const invalidate = () => {
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Couldn't load this — check your connection and try again", "loadError": "Couldn't load this — check your connection and try again",
"deletedTag": "Deleted tag", "deletedTag": "Deleted tag",
"seeAll": "See all", "seeAll": "See all",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Scroll left", "scrollLeft": "Scroll left",
"scrollRight": "Scroll right", "scrollRight": "Scroll right",
"search": "Search", "search": "Search",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Upload progress", "uploadProgress": "Upload progress",
"added": "File uploaded", "added": "File uploaded",
"addedConverting": "File uploaded — converting it so it plays on any phone", "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" "failed": "Couldn't upload the file"
}, },
"status": { "status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Search a user…", "pickPlaceholder": "Search a user…",
"noUsers": "No other users yet.", "noUsers": "No other users yet.",
"linkSection": "Share a link", "linkSection": "Share a link",
"linkHint": "Anyone with the link can watch — no account needed.", "linkHint": "Anyone with the link can open it — no account needed.",
"linkCopied": "Share link copied to clipboard", "linkCopied": "Share link copied to clipboard",
"openNewTab": "Open in new tab", "openNewTab": "Open in new tab",
"linkFailed": "Couldn’t create the share link", "linkFailed": "Couldn’t create the share link",
@@ -111,6 +114,7 @@
"revokeConfirm": "This link will stop working immediately.", "revokeConfirm": "This link will stop working immediately.",
"views_one": "{{count}} view", "views_one": "{{count}} view",
"views_other": "{{count}} views", "views_other": "{{count}} views",
"expiredOn": "expired {{date}}",
"expiresOn": "expires {{date}}", "expiresOn": "expires {{date}}",
"hasPassword": "password", "hasPassword": "password",
"downloadable": "downloadable", "downloadable": "downloadable",
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra", "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
"deletedTag": "Törölt címke", "deletedTag": "Törölt címke",
"seeAll": "Összes", "seeAll": "Összes",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Görgetés balra", "scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra", "scrollRight": "Görgetés jobbra",
"search": "Keresés", "search": "Keresés",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Feltöltés folyamata", "uploadProgress": "Feltöltés folyamata",
"added": "Fájl feltöltve", "added": "Fájl feltöltve",
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen", "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" "failed": "Nem sikerült feltölteni a fájlt"
}, },
"status": { "status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Keress egy felhasználót…", "pickPlaceholder": "Keress egy felhasználót…",
"noUsers": "Nincs más felhasználó.", "noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel", "linkSection": "Megosztás linkkel",
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.", "linkHint": "A link birtokában bárki megnyithatja — fiók nélkül is.",
"linkCopied": "Megosztási link a vágólapra másolva", "linkCopied": "Megosztási link a vágólapra másolva",
"openNewTab": "Megnyitás új lapon", "openNewTab": "Megnyitás új lapon",
"linkFailed": "Nem sikerült a megosztási link létrehozása", "linkFailed": "Nem sikerült a megosztási link létrehozása",
@@ -111,6 +114,7 @@
"revokeConfirm": "A link azonnal használhatatlanná válik.", "revokeConfirm": "A link azonnal használhatatlanná válik.",
"views_one": "{{count}} megtekintés", "views_one": "{{count}} megtekintés",
"views_other": "{{count}} megtekintés", "views_other": "{{count}} megtekintés",
"expiredOn": "lejárt: {{date}}",
"expiresOn": "lejár: {{date}}", "expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó", "hasPassword": "jelszó",
"downloadable": "letölthető", "downloadable": "letölthető",
+4 -2
View File
@@ -70,7 +70,9 @@ export interface ClipRequest {
export interface EditSpec { export interface EditSpec {
trim?: { start_s: number; end_s?: number }; trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[]; segments?: { start_s: number; end_s: number }[];
crop?: { x: number; y: number; w: number; h: number }; // In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean; accurate?: boolean;
} }
@@ -143,7 +145,7 @@ export interface ShareLink {
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the // Null when the server cannot show this link's address again (no encrypted copy its key reads): the
// link still opens for whoever has it; to share it anew, make a new link. // link still opens for whoever has it; to share it anew, make a new link.
token: string | null; token: string | null;
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
allow_download: boolean; allow_download: boolean;
has_password: boolean; has_password: boolean;
expires_at: string | null; expires_at: string | null;
+1
View File
@@ -154,6 +154,7 @@ export interface PlexItemDetail {
summary?: string | null; summary?: string | null;
year?: number | null; year?: number | null;
duration_seconds: number | null; duration_seconds: number | null;
size_bytes?: number | null; // the file's, null while unknown
playable: string; // direct | remux | transcode playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle) codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
+5
View File
@@ -7,7 +7,12 @@ export interface Tag {
color: string | null; color: string | null;
category: string; category: string;
system: boolean; 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; channel_count: number;
/** 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;
} }
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern. // One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
@@ -18,6 +18,7 @@ import { api, HttpError } from "./api";
import { msg, notify } from "./notifications"; import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions"; import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority"; import { useChannelPriority } from "./useChannelPriority";
import { qk } from "./queryKeys";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear()); 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 () => { it("names a refused create-and-attach", async () => {
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow")); vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
const { result } = renderHook( const { result } = renderHook(
+16
View File
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
expect(createDownloadLink).not.toHaveBeenCalled(); 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 () => { it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
const hidden = { id: 4, url: null }; const hidden = { id: 4, url: null };
downloadLinks.mockResolvedValue([hidden]); downloadLinks.mockResolvedValue([hidden]);
+12 -3
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api"; import { api, type NewShareLink, type ShareLink } from "./api";
import { copyText } from "./clipboard"; import { copyText } from "./clipboard";
import { msg, notify } from "./notifications"; import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors"; 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() }); 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. // 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 // 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 // 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), queryKey: linksKey(target),
queryFn: () => fetchLinks(target), queryFn: () => fetchLinks(target),
}); });
// The newest link whose address can still be shown; one that cannot is no use to copy. path = liveLink(existing)?.url;
path = existing.find((l) => l.url)?.url;
if (!path) { if (!path) {
const made = await makeLink(target, {}); // omit allow_download → backend default (true) const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]); qc.setQueryData(linksKey(target), [made, ...existing]);
+42 -1
View File
@@ -1,7 +1,8 @@
// The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried // The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried
// again, as every TCP stack would; any other failure, or an answer, goes straight to the test. // again, as every TCP stack would; any other failure, or an answer, goes straight to the test.
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { retryConnect } from "../../e2e/retryConnect"; import { gotoRetrying, retryConnect } from "../../e2e/retryConnect";
const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080"); const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080");
@@ -39,3 +40,43 @@ describe("retryConnect", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
// The text Playwright gave when this stopped a release's e2e at its first page load (2026-11-04).
const noBuffer = () => new Error("page.goto: net::ERR_NO_BUFFER_SPACE at http://127.0.0.1:8080/");
describe("gotoRetrying", () => {
it("tries a page load the machine had no buffer for again", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValueOnce(noBuffer()).mockResolvedValue("response");
const answer = gotoRetrying({ goto }, "/feed");
await vi.runAllTimersAsync();
await expect(answer).resolves.toBe("response");
expect(goto).toHaveBeenCalledTimes(2);
expect(goto).toHaveBeenLastCalledWith("/feed");
vi.useRealTimers();
});
it("passes any other failure straight on, untried", async () => {
const goto = vi.fn().mockRejectedValue(new Error("page.goto: net::ERR_CONNECTION_REFUSED"));
await expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_CONNECTION_REFUSED");
expect(goto).toHaveBeenCalledTimes(1);
});
it("gives up after its last wait, with the error", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValue(noBuffer());
const settled = expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_NO_BUFFER_SPACE");
await vi.runAllTimersAsync();
await settled;
expect(goto).toHaveBeenCalledTimes(6);
vi.useRealTimers();
});
it("is the only way the suite loads a page", () => {
const dir = new URL("../../e2e/", import.meta.url);
const direct = readdirSync(dir)
.filter((f) => f.endsWith(".ts") && f !== "retryConnect.ts")
.filter((f) => /\.(goto|reload)\(/.test(readFileSync(new URL(f, dir), "utf-8")));
expect(direct).toEqual([]);
});
});
@@ -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);
});
});
+22 -2
View File
@@ -434,6 +434,19 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel }; return { setVol, nudgeVolume, toggleMute, onWheel };
} }
// 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 // 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 // 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the // for the credits that end the item to the next item (binge). Which credits those are, and which the
@@ -481,6 +494,7 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1, (m) => abs >= m.start_s && abs < m.end_s - 1,
); );
const activeMarkerRef = useLatest(activeMarker); 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 // 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. // 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; const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
@@ -538,14 +552,19 @@ export function useAutoSkip(opts: {
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto; const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null); if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate if (autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0; // 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.
// 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); setSkipProgress(0);
const iv = window.setInterval(() => { const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a // 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. // deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return; if (videoRef.current?.paused) return;
elapsed += 0.1; elapsed += 0.1;
const p = Math.min(1, elapsed / autoSkipDelay); const p = skipCountdown(elapsed, autoSkipDelay);
setSkipProgress(p); setSkipProgress(p);
if (p >= 1) { if (p >= 1) {
window.clearInterval(iv); window.clearInterval(iv);
@@ -568,6 +587,7 @@ export function useAutoSkip(opts: {
doSkip, doSkip,
videoRef, videoRef,
activeMarkerRef, activeMarkerRef,
absRef,
activeAuto, activeAuto,
]); ]);
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clear, events } from "./log";
import { retryLostStylesheets } from "./stylesheetRetry";
const LOADED = { cssRules: [{}] };
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
const LOST = {
get cssRules(): never {
throw new DOMException("Cannot access rules", "SecurityError");
},
};
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
el.setAttribute("crossorigin", "");
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
document.head.append(el);
return el;
}
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const logged = () => events().map((e) => e.event);
describe("retryLostStylesheets", () => {
beforeEach(() => {
vi.useFakeTimers();
clear();
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = "";
});
it("leaves a loaded stylesheet alone", () => {
const fine = link("/a.css", true);
retryLostStylesheets();
vi.runAllTimers();
expect(links()).toEqual([fine]);
expect(logged()).toEqual([]);
});
it("asks again for a lost one, in its place, keeping its attributes", () => {
const first = link("/a.css", true);
const lost = link("/b.css", false);
const last = link("/c.css", true);
retryLostStylesheets();
vi.advanceTimersByTime(0);
const [a, b, c] = links();
expect(a).toBe(first);
expect(b).not.toBe(lost);
expect(b!.getAttribute("href")).toBe("/b.css");
expect(b!.hasAttribute("crossorigin")).toBe(true);
expect(c).toBe(last);
expect(logged()).toEqual(["app.stylesheet_lost"]);
});
it.each([
["no sheet at all", null],
["an empty sheet", { cssRules: [] }],
])("takes a link with %s for lost too", (_, sheet) => {
const lost = link("/a.css", false, sheet);
retryLostStylesheets();
vi.advanceTimersByTime(0);
expect(links()[0]).not.toBe(lost);
});
it("says so once the new request has loaded", () => {
link("/a.css", false);
retryLostStylesheets();
vi.advanceTimersByTime(0);
links()[0]!.dispatchEvent(new Event("load"));
vi.runAllTimers();
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
});
it("waits longer each time and then gives up, reporting it", () => {
link("/a.css", false);
retryLostStylesheets();
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
vi.advanceTimersByTime(0);
const second = links()[0];
fail();
vi.advanceTimersByTime(999);
expect(links()[0]).toBe(second);
vi.advanceTimersByTime(1);
const third = links()[0];
expect(third).not.toBe(second);
fail();
vi.advanceTimersByTime(2999);
expect(links()[0]).toBe(third);
vi.advanceTimersByTime(1);
const fourth = links()[0];
expect(fourth).not.toBe(third);
fail();
vi.runAllTimers();
expect(links()[0]).toBe(fourth);
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
});
});
+50
View File
@@ -0,0 +1,50 @@
// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again
// for one by itself: the page then stays unstyled until a reload, every control a bare browser button.
// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module
// too), so by the time this runs each link has either loaded or failed, and no event is left to wait
// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built.
//
// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw
// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here
// could loop, and the unstyled page still works.
import { log } from "./log";
const RETRY_AFTER_MS = [0, 1000, 3000];
function holdsRules(link: HTMLLinkElement): boolean {
try {
return (link.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
}
function askAgain(link: HTMLLinkElement, attempt: number): void {
const wait = RETRY_AFTER_MS[attempt];
if (wait === undefined) {
log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href });
return;
}
window.setTimeout(() => {
const next = link.cloneNode(false) as HTMLLinkElement;
next.addEventListener("load", () =>
log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", {
href: next.href,
attempt: attempt + 1,
}),
);
next.addEventListener("error", () => askAgain(next, attempt + 1));
link.replaceWith(next);
}, wait);
}
/** Run once, from a page's entry module. */
export function retryLostStylesheets(): void {
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
if (holdsRules(link)) continue;
log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", {
href: link.href,
});
askAgain(link, 0);
}
}
@@ -21,9 +21,23 @@ describe("a subscription change", () => {
qk.feedSummary(), qk.feedSummary(),
qk.discoveredChannels(), qk.discoveredChannels(),
qk.watchedChannels(), 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", () => { it("is refreshed through that one function wherever it is made", () => {
const root = new URL("../", import.meta.url); const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" }) const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
+3 -2
View File
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its /** 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 * counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep * 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. */ * its own list, and each list missed something. */
export function invalidateSubscription(qc: QueryClient, channelId: string): void { export function invalidateSubscription(qc: QueryClient, channelId: string): void {
for (const queryKey of [ for (const queryKey of [
@@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
qk.feedSummary(), qk.feedSummary(),
qk.discoveredChannels(), qk.discoveredChannels(),
qk.watchedChannels(), qk.watchedChannels(),
qk.tags(),
]) ])
void qc.invalidateQueries({ queryKey }); void qc.invalidateQueries({ queryKey });
} }
+5 -1
View File
@@ -28,7 +28,11 @@ export function useChannelTagActions(opts: {
? api.detachChannelTag(channelId, tagId) ? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId); : api.attachChannelTag(channelId, tagId);
call 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) => { .catch((err: unknown) => {
notifyActionError(err, msg("channels.tags.updateFailed")); notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId); opts.onError(channelId);
+1
View File
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
color: null, color: null,
category: "topic", category: "topic",
channel_count: 1, channel_count: 1,
subscribed_count: 1,
}); });
function wrapper({ children }: { children: ReactNode }) { function wrapper({ children }: { children: ReactNode }) {
+3
View File
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload"; import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks"; import { installClientLog } from "./lib/logHooks";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
import { appCopyOf } from "./i18n/appCopy"; import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too. // First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf, typedUrlKeys()); installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
// Split by top-level route so each entry point is its own chunk: a legal page never downloads the // Split by top-level route so each entry point is its own chunk: a legal page never downloads the
// authenticated app bundle, and the app never carries it either. The legal pages live outside the // authenticated app bundle, and the app never carries it either. The legal pages live outside the
@@ -11,7 +11,8 @@
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real // 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. // measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -37,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 // 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 // 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. // `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/ }); const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => { afterEach(() => {
@@ -100,3 +101,114 @@ describe("TagFilterChips — the overflow menu", () => {
expect(menu()).toBeNull(); expect(menu()).toBeNull();
}); });
}); });
describe("TagFilterChips counts", () => {
it("draws a chip's count after its name, and none when it has none", () => {
fit.visible = 2;
render(
<TagFilterChips
chips={[{ ...chip("alpha"), count: 42 }, { ...chip("beta"), count: 0 }, chip("gamma")]}
/>,
);
const name = (label: string) =>
screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
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", () => {
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: 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);
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)!;
// 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", async () => {
fit.visible = 1;
render(<Row start={[]} />);
fireEvent.click(moreBtn()!);
const inFold = named("beta");
press(inFold);
// The closing popover hands the focus back to "+N" a microtask later: past that too.
await act(async () => {});
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="dialog"]')).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 { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow"; import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit"; import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
@@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton";
export interface FilterChip { export interface FilterChip {
key: string; key: string;
label: string; label: string;
/** How many it holds, drawn muted after the name. */
count?: number;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */ /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean; pending?: boolean;
active: boolean; active: boolean;
@@ -28,12 +30,40 @@ export interface FilterChip {
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror // A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips). // measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) { function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>; if (chip.pending) return <ChipLabelPending value={chip.label} />;
return (
<>
{chip.label}
{/* 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 }: { chip: FilterChip }) { function TagChip({
chip,
onToggled,
}: {
chip: FilterChip;
onToggled: (key: string, from: HTMLElement) => void;
}) {
return ( 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, e.currentTarget);
chip.onToggle();
}}
pressed={chip.active}
>
<ChipText chip={chip} /> <ChipText chip={chip} />
</Chip> </Chip>
); );
@@ -53,7 +83,7 @@ export default function TagFilterChips({
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too). // width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) .map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|"); .join("|");
const visible = useChipOverflowFit<number>({ const visible = useChipOverflowFit<number>({
@@ -90,12 +120,54 @@ export default function TagFilterChips({
const pop = useAnchoredPopover({ const pop = useAnchoredPopover({
open, open,
onOpenChange: setOpen, 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", placement: "bottom-end",
}); });
const { setReference } = pop.refs; const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden }); 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 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; closed: boolean } | null>(null);
const foldRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const onToggled = (chipKey: string, from: HTMLElement) => {
const closed = !!foldRef.current?.contains(from);
follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey), closed };
requestAnimationFrame(() => (follow.current = null));
if (closed) setOpen(false);
};
useLayoutEffect(() => {
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();
// The closing popover gives the focus back to "+N" in a microtask it queued as it unmounted
// (seen in Chrome): this one, queued after it, has the last word.
if (el && target.closed) queueMicrotask(() => el.isConnected && el.focus());
});
const setMore = (el: HTMLButtonElement | null) => {
moreRef.current = el;
setReference(el);
};
return ( return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}> <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 {/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
@@ -124,10 +196,10 @@ export default function TagFilterChips({
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden"> <div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => ( {chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} /> <TagChip key={c.key} chip={c} onToggled={onToggled} />
))} ))}
{hidden > 0 && ( {hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}> <Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden} +{hidden}
</Chip> </Chip>
)} )}
@@ -138,9 +210,11 @@ export default function TagFilterChips({
ariaLabel={moreLabel} ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`} className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
> >
<div ref={foldRef} className="contents">
{chips.slice(visible).map((c) => ( {chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} /> <TagChip key={c.key} chip={c} onToggled={onToggled} />
))} ))}
</div>
</PopoverSurface> </PopoverSurface>
</div> </div>
); );
@@ -186,3 +186,58 @@ describe("buildChannelColumns — Added", () => {
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
}); });
}); });
describe("tagFilterChips", () => {
// 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",
color: null,
category: "topic",
system: false,
channel_count: 17,
subscribed_count: 15,
};
const chips = tagFilterChips({
userTags: [tag],
tags: { isPending: false } as never,
picked: ["99"],
noTagOn: false,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
});
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();
});
// The row folds from its end into "+N", so a picked tag late in the list was folded away: at 1024 px
// "music" sat behind "+11" with nothing on the row saying a filter was on. The picks lead the row.
it("puts the picked tags first, keeping their order, then the rest in theirs", () => {
const tag = (id: number, name: string) => ({ id, name, system: false }) as Tag;
const row = (picked: string[], noTagOn = false) =>
tagFilterChips({
userTags: [tag(1, "ai"), tag(2, "docu"), tag(3, "music"), tag(4, "nature")],
tags: { isPending: false } as never,
picked,
noTagOn,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
}).map((c) => c.label + (c.active ? "*" : ""));
expect(row(["4", "3"])).toEqual(["music*", "nature*", "ai", "docu", "channels.tags.noTag"]);
expect(row(["99", "3"])).toEqual([
"99*",
"music*",
"ai",
"docu",
"nature",
"channels.tags.noTag",
]);
expect(row([], true)).toEqual(["channels.tags.noTag*", "ai", "docu", "music", "nature"]);
expect(row([])).toEqual(["ai", "docu", "music", "nature", "channels.tags.noTag"]);
});
});
@@ -51,11 +51,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
} }
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one /** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its * of the user's tags, then the picked ones of the user's tags and "No tag", then the rest, each group
* in the tags' order with "No tag" last. The row folds from its end into "+N", so every pick leads it:
* a filter that is on stays in sight, never only inside the fold. A stored pick can outlive its
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a * tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away — * visible, removable chip, and so does every pick while the tag list loads or after it failed, named
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule * by lib/tagPicks' rule (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
export function tagFilterChips(o: { export function tagFilterChips(o: {
userTags: Tag[]; userTags: Tag[];
tags: TagList; tags: TagList;
@@ -67,17 +68,11 @@ export function tagFilterChips(o: {
}): FilterChip[] { }): FilterChip[] {
const mine = new Set(o.userTags.map((tg) => String(tg.id))); const mine = new Set(o.userTags.map((tg) => String(tg.id)));
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id)); const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
return [ const known: FilterChip[] = [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
...o.userTags.map((tg) => ({ ...o.userTags.map((tg) => ({
key: `t${tg.id}`, key: `t${tg.id}`,
label: tg.name, label: tg.name,
count: tg.subscribed_count,
active: o.picked.includes(String(tg.id)), active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id), onToggle: () => o.toggle(tg.id),
})), })),
@@ -88,6 +83,17 @@ export function tagFilterChips(o: {
onToggle: o.toggleNoTag, onToggle: o.toggleNoTag,
}, },
]; ];
return [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
...known.filter((c) => c.active),
...known.filter((c) => !c.active),
];
} }
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations; /** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
@@ -50,8 +50,10 @@ export function useChannelActions({
const nameOf = (c: ManagedChannel) => const nameOf = (c: ManagedChannel) =>
c.title == null ? msg("channels.row.thisChannel") : raw(c.title); 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 = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() }); qc.invalidateQueries({ queryKey: qk.feedSummary() });
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other", category: "other",
system: false, system: false,
channel_count: 0, channel_count: 0,
subscribed_count: 0,
}); });
const docu = tag(1, "docu"); const docu = tag(1, "docu");
const history = tag(2, "history"); const history = tag(2, "history");
@@ -194,6 +194,8 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() }); 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() });
}, },
}); });
+3 -2
View File
@@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav";
import { JobThumb } from "../../features/downloads/downloadRow"; import { JobThumb } from "../../features/downloads/downloadRow";
import { jobTitle } from "../../features/downloads/downloadTitle"; import { jobTitle } from "../../features/downloads/downloadTitle";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { fileMeta } from "./fileMeta";
import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard"; import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard";
import { import {
MEDIA_BADGE, MEDIA_BADGE,
@@ -46,7 +47,7 @@ export function FileCard({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const title = jobTitle(file); const title = jobTitle(file);
const channel = file.display_uploader || file.asset?.uploader || null; const meta = fileMeta(file);
const clip = file.job_kind === "edit" || file.job_kind === "clip"; const clip = file.job_kind === "edit" || file.job_kind === "clip";
const media = file.asset?.media_kind !== "file"; const media = file.asset?.media_kind !== "file";
const thumb = <JobThumb job={file} box="h-full w-full" />; const thumb = <JobThumb job={file} box="h-full w-full" />;
@@ -102,7 +103,7 @@ export function FileCard({
<span className="flex flex-col gap-1"> <span className="flex flex-col gap-1">
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted"> <span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<SourceBadge file={file} /> <SourceBadge file={file} />
{channel && <span className="truncate">{channel}</span>} {meta && <span className="truncate">{meta}</span>}
</span> </span>
<span className="flex flex-wrap items-center gap-1 empty:hidden"> <span className="flex flex-wrap items-center gap-1 empty:hidden">
<PhoneBadge file={file} /> <PhoneBadge file={file} />
+2 -2
View File
@@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
import { FileCard } from "./FileCard"; import { FileCard } from "./FileCard";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders"; import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
import { fileMeta } from "./fileMeta";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions"; import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions"; import { useFolderActions } from "./useFolderActions";
@@ -157,7 +158,6 @@ function NameCell({
onRenamed: (name: string | null, byKey: boolean) => void; onRenamed: (name: string | null, byKey: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip"; const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming) if (renaming)
return ( return (
@@ -180,7 +180,7 @@ function NameCell({
<span title={jobTitle(file)}>{title}</span> <span title={jobTitle(file)}>{title}</span>
)} )}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted"> <span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{channel ?? "—"}</span> <span className="truncate">{fileMeta(file) || "—"}</span>
{clip && ( {clip && (
<Badge> <Badge>
<Scissors /> <Scissors />
@@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({
), ),
})); }));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
const h = vi.hoisted(() => ({ const h = vi.hoisted(() => ({
copyText: vi.fn(async (_t: string) => true),
confirm: false, confirm: false,
revokeDownloadLink: vi.fn(), revokeDownloadLink: vi.fn(),
links: null as unknown[] | null, links: null as unknown[] | null,
@@ -72,6 +74,9 @@ afterEach(cleanup);
beforeEach(() => { beforeEach(() => {
h.links = null; h.links = null;
h.confirm = false; h.confirm = false;
h.createDownloadLink.mockReset();
h.copyText.mockReset();
h.copyText.mockResolvedValue(true);
}); });
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? ""; const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -96,6 +101,157 @@ describe("ShareDialog · a link row's state colours", () => {
}); });
}); });
describe("ShareDialog · opened to share", () => {
const open = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>,
);
};
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
id,
token: "t",
url,
allow_download: true,
has_password: false,
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);
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);
});
// 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")];
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 () => {
h.links = [];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
});
it("claims nothing when the browser refused the copy", async () => {
h.links = [row(2, "/file/b")];
h.copyText.mockResolvedValue(false);
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
await screen.findByRole("button", { name: "downloads.share.copy" });
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("offers the link in a new tab, beside Copy", async () => {
h.links = [row(2, "/file/b")];
open();
const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" });
expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`);
expect(openLink.getAttribute("target")).toBe("_blank");
expect(openLink.getAttribute("rel")).toContain("noopener");
});
it("has no Open for a link whose address cannot be shown", async () => {
h.links = [row(1, null)];
open();
await screen.findByText("downloads.share.urlHidden");
expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull();
expect(h.copyText).not.toHaveBeenCalled();
});
});
describe("ShareDialog · a link whose address cannot be shown again", () => { describe("ShareDialog · a link whose address cannot be shown again", () => {
it("says so in place of the address, with nothing to copy", async () => { it("says so in place of the address, with nothing to copy", async () => {
const link = (id: number, url: string | null) => ({ const link = (id: number, url: string | null) => ({
+99 -13
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { msg, notify, raw } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
@@ -12,15 +12,20 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
import { import {
fetchLinks, fetchLinks,
invalidateLinks, invalidateLinks,
linkExpired,
linksKey, linksKey,
liveLink,
makeLink, makeLink,
type LinkTarget, type LinkTarget,
} from "../../lib/downloads"; } from "../../lib/downloads";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { IconLink } from "../../components/ui/LinkButton";
import { copyText } from "../../lib/clipboard";
import { Input, InnerInput } from "../../components/ui/Input"; import { Input, InnerInput } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls"; import { CheckRow } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { Badge } from "../../components/ui/Badge";
import UserPicker from "./UserPicker"; import UserPicker from "./UserPicker";
import { useCopyFeedback } from "../../components/ui/useCopyFeedback"; import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
@@ -75,7 +80,19 @@ function UserShare({ job }: { job: DownloadJob }) {
// --- share by public link ------------------------------------------------------------------- // --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { function LinkRow({
link,
copiedOnOpen,
onCopied,
onChanged,
}: {
link: ShareLink;
/** 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(); const { t, i18n } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
const { copied, copy: copyValue } = useCopyFeedback(); const { copied, copy: copyValue } = useCopyFeedback();
@@ -84,7 +101,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const fullUrl = link.url && window.location.origin + link.url; const fullUrl = link.url && window.location.origin + link.url;
const copy = () => { const copy = () => {
if (fullUrl) void copyValue(fullUrl); if (!fullUrl) return;
onCopied();
void copyValue(fullUrl);
}; };
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -108,10 +127,13 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
}, },
}); });
// 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[] = []; const badges: string[] = [];
if (link.has_password) badges.push(t("downloads.share.hasPassword")); if (link.has_password) badges.push(t("downloads.share.hasPassword"));
if (link.allow_download) badges.push(t("downloads.share.downloadable")); if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at) if (link.expires_at && !expired)
badges.push( badges.push(
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }), t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
); );
@@ -122,10 +144,33 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Link2 className="w-4 h-4 text-muted shrink-0" /> <Link2 className="w-4 h-4 text-muted shrink-0" />
{fullUrl ? ( {fullUrl ? (
<> <>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} /> <InnerInput
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}> className={`text-xs text-muted truncate ${expired ? "line-through" : ""}`}
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />} 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> </IconButton>
)}
<IconLink
label={t("downloads.share.openNewTab")}
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="w-4 h-4" />
</IconLink>
</> </>
) : ( ) : (
// The server keeps no copy of this link's address it can read: the link still opens for // The server keeps no copy of this link's address it can read: the link still opens for
@@ -175,10 +220,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Trash2 className="w-4 h-4 text-danger" /> <Trash2 className="w-4 h-4 text-danger" />
</IconButton> </IconButton>
</div> </div>
{/* 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"> <div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })} <Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
</span> </span>
{expired && link.expires_at && (
<Badge tone="warn">
{t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })}
</Badge>
)}
{badges.map((b) => ( {badges.map((b) => (
<span <span
key={b} key={b}
@@ -243,9 +296,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
); );
} }
/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there:
* a browser may refuse a copy no click asked for, and then nothing may claim it. */
async function copiedLink(link: ShareLink | undefined): Promise<number | null> {
if (!link?.url) return null;
return (await copyText(window.location.origin + link.url)) ? link.id : null;
}
function LinkShare({ target }: { target: LinkTarget }) { function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// The link the dialog copied when it opened: sharing is almost always why it was opened.
const [copiedOnOpen, setCopiedOnOpen] = useState<number | null>(null);
const links = useQuery({ const links = useQuery({
queryKey: linksKey(target), queryKey: linksKey(target),
queryFn: () => fetchLinks(target), queryFn: () => fetchLinks(target),
@@ -265,19 +327,29 @@ function LinkShare({ target }: { target: LinkTarget }) {
expires_days: expiryDays, expires_days: expiryDays,
password: password.trim() || undefined, 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(); invalidate();
},
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); setCreating(false);
// Back to the form's own default for the next link: downloadable. // Back to the form's own default for the next link: downloadable.
setAllowDownload(true); setAllowDownload(true);
setExpiryDays(null); setExpiryDays(null);
setPassword(""); setPassword("");
}, },
meta: { failTitle: "downloads.share.linkFailed" },
}); });
// Open with a link already generated: sharing is almost always the goal, so mint a default // Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, 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, // user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button. // extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false); const autoTried = useRef(false);
@@ -290,11 +362,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
useEffect(() => { useEffect(() => {
if (!links.data || autoTried.current) return; if (!links.data || autoTried.current) return;
autoTried.current = true; autoTried.current = true;
if (links.data.length === 0) createLink(); // 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(live).then(setCopiedOnOpen);
}, [links.data, createLink]); }, [links.data, createLink]);
return ( return (
<div> <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"> <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")} <Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div> </div>
@@ -317,7 +397,13 @@ function LinkShare({ target }: { target: LinkTarget }) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{links.data.map((l) => ( {links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} /> <LinkRow
key={l.id}
link={l}
copiedOnOpen={copiedOnOpen === l.id}
onCopied={() => setCopiedOnOpen(null)}
onChanged={invalidate}
/>
))} ))}
{links.data.length === 0 && !creating && !create.isPending && ( {links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p> <p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
@@ -361,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => create.mutate()} onClick={createFromForm}
disabled={create.isPending} disabled={create.isPending}
> >
{t("downloads.share.createLink")} {t("downloads.share.createLink")}
@@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({
uploadStarted: vi.fn(), uploadStarted: vi.fn(),
uploadEnded: vi.fn(), uploadEnded: vi.fn(),
openFile: vi.fn(), openFile: vi.fn(),
fileFolders: vi.fn(),
createFolder: vi.fn(),
})); }));
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
@@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => {
}; };
}); });
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage }, api: {
uploadFile: h.uploadFile,
filesUsage: h.filesUsage,
fileFolders: h.fileFolders,
createFolder: h.createFolder,
},
})); }));
vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() })); vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts. // The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
@@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext"; import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications"; import { msg, type NotifText } from "../../lib/notifications";
const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({
id,
kind,
source_key,
name,
retention_days: null,
count: 0,
size_bytes: 0,
created_at: null,
has_link: false,
});
const FOLDERS = [
folder(1, "source", "upload", null),
folder(5, "own", null, "peat"),
folder(9, "source", "site:reddit.com", null),
];
const wrap = ( const wrap = (
onClose = vi.fn(), onClose: () => void = vi.fn(),
folderId: number | null = null, folderId: number | null = null,
initialFile: File | null = null, initialFile: File | null = null,
) => { ) => {
const qc = new QueryClient(); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const files = { const files = {
setSearch: vi.fn(), setSearch: vi.fn(),
setView: vi.fn(), setView: vi.fn(),
@@ -78,9 +102,207 @@ beforeEach(() => {
h.notify.mockClear(); h.notify.mockClear();
h.filesUsage.mockClear(); h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 }); h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
h.createFolder.mockReset();
h.fileFolders.mockReset();
h.fileFolders.mockResolvedValue(FOLDERS);
}); });
afterEach(cleanup); afterEach(cleanup);
describe("UploadDialog · where the file goes", () => {
const pick = () =>
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } });
const target = () => screen.findByTestId("upload-target");
const sentTo = async () => {
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId;
};
it("says the folder it was opened on", async () => {
wrap(vi.fn(), 5);
expect((await target()).textContent).toContain("peat");
});
it("says Uploads when it was opened on no folder, and sends there", async () => {
wrap();
expect((await target()).textContent).toContain("files.source.upload");
pick();
expect(await sentTo()).toBeNull();
});
it("says Uploads before the user has a folder of that name yet", async () => {
h.fileFolders.mockResolvedValue([FOLDERS[1]]);
wrap();
expect((await target()).textContent).toContain("files.source.upload");
});
it("offers only the folders that take an upload", async () => {
wrap();
fireEvent.click(await target());
expect(await screen.findByTestId("upload-target-5")).toBeTruthy();
expect(screen.getByTestId("upload-target-1")).toBeTruthy();
expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin
});
it("sends to the folder picked", async () => {
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-5"));
pick();
expect(await sentTo()).toBe(5);
});
it("makes a new folder from the same list and sends there", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" });
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: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one"));
await waitFor(async () => expect((await target()).textContent).toContain("New one"));
pick();
expect(await sentTo()).toBe(7);
});
it("will not upload while a new folder is on its way, to the wrong place", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
wrap();
pick();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
const submit = screen.getByText("downloads.upload.submit").closest("button")!;
expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true);
fireEvent.change(field, { target: { value: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalled());
fireEvent.click(submit);
expect(h.uploadFile).not.toHaveBeenCalled();
made({ ...FOLDERS[1], id: 7, name: "New one" });
await waitFor(() => expect(submit.disabled).toBe(false));
expect(await sentTo()).toBe(7);
});
// 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" } });
return { r, field };
};
const pressOn = (el: Element, field: HTMLElement, click = true) => {
fireEvent.pointerDown(el);
fireEvent.mouseDown(el);
fireEvent.blur(field);
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" });
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"));
});
it("says a new folder is being made, then gives the keyboard back the list", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
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" });
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()));
});
// 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());
fireEvent.click(await screen.findByTestId("upload-target-new"));
fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" });
expect((await target()).textContent).toContain("peat");
expect(h.createFolder).not.toHaveBeenCalled();
});
it("says when the folders could not be read, and offers Retry", async () => {
h.fileFolders.mockRejectedValue(new Error("down"));
wrap();
expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy();
h.fileFolders.mockResolvedValue(FOLDERS);
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect((await target()).textContent).toContain("files.source.upload");
});
});
describe("UploadDialog", () => { describe("UploadDialog", () => {
it("shows the picked file's name and uploads it with the optional name", async () => { it("shows the picked file's name and uploads it with the optional name", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
+140 -5
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react"; import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal, { ModalFooter } from "../../components/ui/Modal"; import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api"; import { api, type FileFolder } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core"; import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -17,6 +17,10 @@ import { formatBytes } from "../../lib/format";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { DropZone } from "../../components/ui/DropZone"; import { DropZone } from "../../components/ui/DropZone";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import FilterSelect from "../../components/ui/FilterSelect";
import { RenameField } from "../../components/ui/RenameField";
import { StateMessage } from "../../components/ui/QueryState";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
// Local-file ingest. A general upload — ANY file type, no format list: media // Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The // (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -30,6 +34,12 @@ const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// The job the upload's own notice announces (a done one), for the queue not to say it again. // The job the upload's own notice announces (a done one), for the queue not to say it again.
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null); const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
// What stands in the target list for Uploads before the user has that folder (the first upload makes
// it), and for the row that makes a folder.
const UPLOADS = "uploads";
const NEW_FOLDER = "new";
const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload";
export default function UploadDialog({ export default function UploadDialog({
folderId = null, folderId = null,
initialFile = null, initialFile = null,
@@ -65,6 +75,82 @@ export default function UploadDialog({
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it. // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0); const [pickSeq, setPickSeq] = useState(0);
const { openFile } = useFilesActions(); const { openFile } = useFilesActions();
// Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads.
const [target, setTarget] = useState(folderId);
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const [naming, setNaming] = useState(false);
const targetBox = useRef<HTMLDivElement>(null);
const refocusTarget = 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(() => {
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;
};
}, []);
const makeFolder = useMutation({
mutationFn: (folder: string) => api.createFolder(folder),
onSuccess: (made) => {
// In the list at once, so the target reads its name rather than waiting for the refetch.
qc.setQueryData<FileFolder[]>(qk.fileFolders(), (old) => [...(old ?? []), made]);
invalidateDownloads(qc);
setTarget(made.id);
},
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 on its place, which says it is being made.
useEffect(() => {
if (naming || !refocusTarget.current) return;
const box = targetBox.current;
const list = box?.querySelector("button");
if (!list) return box?.focus();
refocusTarget.current = false;
list.focus();
}, [naming, makeFolder.isPending]);
// Not while a folder is being named or made: the file would go to the last target, not that one.
const targetSettling = naming || makeFolder.isPending;
const targets = (foldersQ.data ?? []).filter(takesUploads);
const uploadsRow = targets.find(isUploads);
const targetOptions = [
...(uploadsRow ? [] : [{ value: UPLOADS, label: t("files.source.upload") }]),
...[...targets]
.sort((a, b) => Number(isUploads(b)) - Number(isUploads(a)))
.map((f) => ({ value: String(f.id), label: folderLabel(f, t) })),
{ value: NEW_FOLDER, label: t("files.folder.new") },
];
const targetValue = String(target ?? uploadsRow?.id ?? UPLOADS);
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);
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. // A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
const openUploaded = (job: DownloadJob) => { const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null); if (job.status === "done") openFile(job.id, job.folder_id ?? null);
@@ -86,7 +172,7 @@ export default function UploadDialog({
// button holds focus it swallows the activation itself, and while it does not it is natively // button holds focus it swallows the activation itself, and while it does not it is natively
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be // disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
// described as the guard. It is here for the next caller of `submit`, not for the only one. // described as the guard. It is here for the next caller of `submit`, not for the only one.
if (!file || busy || tooBig) return; if (!file || busy || tooBig || targetSettling) return;
const ac = new AbortController(); const ac = new AbortController();
abortRef.current = ac; abortRef.current = ac;
setPct(0); setPct(0);
@@ -95,7 +181,7 @@ export default function UploadDialog({
try { try {
const job = await api.uploadFile(file, { const job = await api.uploadFile(file, {
displayName, displayName,
folderId, folderId: target,
onProgress: setPct, onProgress: setPct,
signal: ac.signal, signal: ac.signal,
}); });
@@ -182,6 +268,55 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX} maxLength={DISPLAY_NAME_MAX}
/> />
<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>
) : naming ? (
<RenameField
initial=""
label={t("files.folder.newName")}
onDone={(folder, byKey) => {
refocusTarget.current = byKey;
setNaming(false);
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.
// 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
label={t("downloads.upload.targetLabel")}
options={targetOptions}
selected={[targetValue]}
onChange={pickTarget}
testId="upload-target"
/>
)}
{busy && (
<span className="text-sm text-muted">
{targetOptions.find((o) => o.value === targetValue)?.label}
</span>
)}
</div>
)}
{busy && ( {busy && (
<div className="mt-5" aria-live="polite"> <div className="mt-5" aria-live="polite">
<div <div
@@ -223,7 +358,7 @@ export default function UploadDialog({
variant="primary" variant="primary"
size="sm" size="sm"
onClick={submit} onClick={submit}
disabled={!file || busy || tooBig} disabled={!file || busy || tooBig || targetSettling}
aria-describedby={tooBig ? refusalId : undefined} aria-describedby={tooBig ? refusalId : undefined}
> >
{t("downloads.upload.submit")} {t("downloads.upload.submit")}
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// A crop's numbers count the picture as the browser reports it, its shown size, which differs from the
// file's own on a video with wide pixels. The size goes along, or the server cuts the wrong part.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } 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) => key, i18n: { language: "en" } }),
}));
import VideoEditor from "./VideoEditor";
import { api, type DownloadJob } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
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: 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);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "editor.cropOff" }));
fireEvent.click(screen.getByRole("button", { name: "editor.create" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
const crop = enqueue.mock.calls[0]?.[0].edit_spec.crop;
expect(crop).toMatchObject({ frame_w: 1920, frame_h: 1080, w: 1536, h: 864 });
});
});
+4 -2
View File
@@ -226,14 +226,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const willJoin = output === "join" && kept.length > 1; const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate; const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current; const v = videoRef.current;
const vw = v?.videoWidth || vdim.w; const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h; const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined; if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a // crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp. // straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined; const px = cropToSourcePx(crop, vw, vh);
// The size they count in goes along: it is the shown one, and the file's own can differ.
return px ? { ...px, frame_w: vw, frame_h: vh } : undefined;
}; };
const create = useMutation({ const create = useMutation({
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { fileMeta } from "./fileMeta";
import type { LibraryFile } from "../../lib/api";
const file = (uploader: string | null, container: string | null) =>
({ display_uploader: uploader, asset: { uploader: null, container } }) as unknown as LibraryFile;
describe("fileMeta", () => {
it("names who made it and its format", () => {
expect(fileMeta(file("Someone", "mp4"))).toBe("Someone · MP4");
});
it("has the format alone when nobody is named", () => {
expect(fileMeta(file(null, "gz"))).toBe("GZ");
});
it("has the name alone when no format is known", () => {
expect(fileMeta(file("Someone", null))).toBe("Someone");
});
it("is empty when neither is known", () => {
expect(fileMeta(file(null, null))).toBe("");
});
it("reads the file's own uploader when none was typed", () => {
const f = { display_uploader: null, asset: { uploader: "Site", container: "webm" } };
expect(fileMeta(f as unknown as LibraryFile)).toBe("Site · WEBM");
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { LibraryFile } from "../../lib/api";
/** A file's line under its name, in the table and on its card: who made it and its format. */
export function fileMeta(file: LibraryFile): string {
const who = file.display_uploader || file.asset?.uploader;
const format = file.asset?.container?.toUpperCase();
return [who, format].filter(Boolean).join(" · ");
}
@@ -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);
});
});
+19 -1
View File
@@ -80,6 +80,9 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
dir: "desc" as const, dir: "desc" as const,
}); });
// What the Recently added rail's See all opens on.
const ADDED_SORT: NonNullable<SortState> = Object.freeze({ key: "added", dir: "desc" as const });
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am // Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
// I looking at" control now, like the feed's Watch-state pill, not a rail widget). // I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
@@ -579,7 +582,22 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
onRestartCard={onRestartCard} onRestartCard={onRestartCard}
onInfoCard={onInfo} onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })} onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })} // Each opens the list its rail is the start of: the watch state goes with it, and
// entering in-progress brings the last-watched sort (the effect above).
onSeeAllRecent={() => {
// Newest first is the list's point, so no earlier sort comes back as it leaves in-progress.
preInProgressSort.current = null;
setSort(ADDED_SORT);
setShow("all");
sub.open({ kind: "grid" });
}}
onSeeAllContinue={() => {
// 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" });
}}
/> />
</div> </div>
); );
@@ -105,6 +105,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
await screen.findByText("plex.discover.continueWatching"); await screen.findByText("plex.discover.continueWatching");
@@ -144,6 +145,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/> />
</QueryClientProvider>, </QueryClientProvider>,
); );
@@ -179,12 +181,38 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={onGenre} onGenre={onGenre}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
fireEvent.click(await screen.findByText("Drama")); fireEvent.click(await screen.findByText("Drama"));
expect(onGenre).toHaveBeenCalledWith("Drama"); expect(onGenre).toHaveBeenCalledWith("Drama");
}); });
it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => {
const onSeeAllContinue = vi.fn();
const onSeeAllRecent = vi.fn();
plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] });
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={onSeeAllContinue}
/>,
);
fireEvent.click(
await screen.findByRole("button", {
name: "common.seeAllOf:plex.discover.continueWatching",
}),
);
expect(onSeeAllContinue).toHaveBeenCalledOnce();
expect(onSeeAllRecent).not.toHaveBeenCalled();
});
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
plexDiscover.mockResolvedValue(fullPayload); plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] }); plexSurprise.mockResolvedValue({ scope: "both", items: [] });
@@ -197,9 +225,13 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={onSeeAllRecent} onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={noop}
/>, />,
); );
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); // 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(); expect(onSeeAllRecent).toHaveBeenCalledOnce();
}); });
@@ -214,6 +246,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
await screen.findByText("Random Pick"); await screen.findByText("Random Pick");
@@ -250,6 +283,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/> />
</QueryClientProvider> </QueryClientProvider>
); );
@@ -297,6 +331,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/> />
</QueryClientProvider>, </QueryClientProvider>,
); );
@@ -338,6 +373,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
await screen.findByText("plex.discover.empty"); await screen.findByText("plex.discover.empty");
@@ -363,6 +399,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
fireEvent.click(await screen.findByLabelText("plex.markWatched")); fireEvent.click(await screen.findByLabelText("plex.markWatched"));
@@ -386,6 +423,7 @@ describe("PlexDiscover · keyboard", () => {
onInfoCard={noop} onInfoCard={noop}
onGenre={noop} onGenre={noop}
onSeeAllRecent={noop} onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>, />,
); );
await screen.findAllByText("Random Pick"); await screen.findAllByText("Random Pick");
@@ -101,6 +101,7 @@ export default function PlexDiscover({
onInfoCard, onInfoCard,
onGenre, onGenre,
onSeeAllRecent, onSeeAllRecent,
onSeeAllContinue,
}: { }: {
scope: string; scope: string;
onOpenCard: (c: PlexCard) => void; onOpenCard: (c: PlexCard) => void;
@@ -108,6 +109,7 @@ export default function PlexDiscover({
onInfoCard: (c: PlexCard) => void; onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void; onGenre: (genre: string) => void;
onSeeAllRecent: () => void; onSeeAllRecent: () => void;
onSeeAllContinue: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -233,6 +235,7 @@ export default function PlexDiscover({
<CardRail <CardRail
title={t("plex.discover.continueWatching")} title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")} ariaLabel={t("plex.discover.continueWatching")}
onSeeAll={onSeeAllContinue}
cards={d.continue_watching} cards={d.continue_watching}
{...railProps} {...railProps}
/> />
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2")); await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
}); });
}); });
describe("PlexInfo size", () => {
const show = (over: Partial<PlexItemDetail>) =>
wrap(<PlexInfo onPlayItem={noop} detail={{ ...detail, ...over }} variant="page" />);
it("names the file's size beside the runtime", () => {
show({ size_bytes: 15_000_000_000 });
expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy();
});
it("names none when it is not known", () => {
show({ size_bytes: null });
expect(screen.getByText("Test Movie")).toBeTruthy();
expect(screen.queryByText(/ GB$/)).toBeNull();
});
});
+4 -1
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
@@ -452,6 +452,9 @@ export default function PlexInfo({
{detail.duration_seconds ? ( {detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span> <span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null} ) : null}
{detail.size_bytes ? (
<span className={mutedCls}>{formatBytes(detail.size_bytes, lang)}</span>
) : null}
{detail.studio && ( {detail.studio && (
<Filterable <Filterable
tag tag
@@ -1121,6 +1121,80 @@ describe("PlexPlayer — clip mode", () => {
expect(screen.getByTestId("clip-panel")).toBeTruthy(); expect(screen.getByTestId("clip-panel")).toBeTruthy();
}); });
describe("the wait before an auto-skip's countdown", () => {
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const advance = (ms: number) =>
act(() => {
vi.advanceTimersByTime(ms);
});
// Into the marker with the clock already faked, so the countdown's interval is the fake one's.
const enterMarker = (paused: boolean) => {
vi.useFakeTimers();
const v = video();
v._paused = paused;
v._t = 2910;
act(() => void v.dispatchEvent(new Event("timeupdate")));
};
it("holds the countdown for 3 s after the marker starts, then runs it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
// Leaving a final marker writes "watched" as it skips, in the same tick: the next item's
// session comes only after promises this clock does not run.
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false);
advance(3500); // past what the delay alone would have waited
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
it("a delay of 0 is still immediate", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2910);
await waitFor(() => expect(lastStart(session)?.[0]).toBe("b"));
});
it("waits out the pause: a paused video does not use up the 3 s", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
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");
});
});
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => { it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 }); prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = { const withCredits = {
+43
View File
@@ -24,6 +24,20 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
describe("WatchPage share paths", () => {
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
"reads the token off %s",
async (path) => {
window.history.replaceState({}, "", path);
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
render(<WatchPage />);
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
);
},
);
});
describe("WatchPage background play", () => { describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => { it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg"); window.history.replaceState({}, "", "/watch/tok-bg");
@@ -267,6 +281,35 @@ describe("WatchPage honest failures", () => {
return page; 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("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 () => { it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429"); window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429)); const page = await tryPassword(async () => res(429));
+22 -9
View File
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
import { IconLink, LinkButton } from "../components/ui/LinkButton"; import { IconLink, LinkButton } from "../components/ui/LinkButton";
import { T, lang } from "./watchStrings"; import { T, lang } from "./watchStrings";
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them. // Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses // Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app // plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference). // language preference).
@@ -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. // What the link answers before it is opened: a password link says only which kind it is.
type Answer = type Answer =
| (Meta & { needs_password?: false }) | (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. // Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string { function displayUrl(url: string): string {
@@ -83,16 +83,23 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone"; return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
} }
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
* any link (`main.SHARE_PREFIXES`). */
function shareToken(path: string): string {
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
}
export default function WatchPage() { export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? ""; const token = shareToken(window.location.pathname);
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing // Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so. // to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState< const [status, setStatus] = useState<
"loading" | "ready" | "password" | "gone" | "preparing" | "error" "loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone"); >(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null); const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked. // What a password link opens, for its prompt's words, until it is unlocked. A server from before
const [locked, setLocked] = useState<"file" | "folder">("file"); // 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 [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const errorId = useId(); const errorId = useId();
@@ -118,7 +125,7 @@ export default function WatchPage() {
.then((m) => { .then((m) => {
if (typeof m === "string") setStatus(m); if (typeof m === "string") setStatus(m);
else if (m.needs_password) { else if (m.needs_password) {
setLocked(m.kind ?? "file"); setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
setStatus("password"); setStatus("password");
} else { } else {
setMeta(m); setMeta(m);
@@ -240,7 +247,11 @@ export default function WatchPage() {
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}> <div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4"> <div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" />{" "} <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> </div>
<Input <Input
className="w-full" className="w-full"
@@ -248,7 +259,9 @@ export default function WatchPage() {
value={password} value={password}
autoFocus autoFocus
onChange={(e) => setPassword(e.target.value)} 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} placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined} aria-describedby={error ? errorId : undefined}
/> />
@@ -266,7 +279,7 @@ export default function WatchPage() {
disabled={!password || unlocking} disabled={!password || unlocking}
className="mt-4" className="mt-4"
> >
{T.watch} {locked === "video" ? T.watch : T.open}
</Button> </Button>
</div> </div>
</div> </div>
@@ -28,3 +28,12 @@ describe("the watch page's document language", () => {
expect(document.documentElement.lang).toBe(want); expect(document.documentElement.lang).toBe(want);
}); });
}); });
// One button, two kinds of link: it says the same kind of word for both, as the app's own buttons
// do in Hungarian — a noun (Letöltés, Megnyitás), not a first-person verb on one of them.
describe("the password form's button", () => {
it("reads as a noun for a video and for anything else in Hungarian", async () => {
const { T } = await pageIn("hu-HU");
expect([T.watch, T.open]).toEqual(["Megtekintés", "Megnyitás"]);
});
});
+7 -1
View File
@@ -27,6 +27,8 @@ const STR = {
crashHint: "Reload the page to try again.", crashHint: "Reload the page to try again.",
reload: "Reload", reload: "Reload",
passwordTitleFolder: "This folder is password-protected", passwordTitleFolder: "This folder is password-protected",
passwordTitleFile: "This file is password-protected",
open: "Open",
folderEmpty: "This folder is empty.", folderEmpty: "This folder is empty.",
play: "Play", play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`), files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
@@ -37,7 +39,7 @@ const STR = {
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.", goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett", passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót", passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem", watch: "Megtekintés",
wrong: "Hibás jelszó.", wrong: "Hibás jelszó.",
download: "Letöltés", download: "Letöltés",
file: "Fájl", file: "Fájl",
@@ -55,6 +57,8 @@ const STR = {
crashHint: "Töltsd újra az oldalt, és próbáld újra.", crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés", reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett", 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.", folderEmpty: "Ez a mappa üres.",
play: "Lejátszás", play: "Lejátszás",
files: (n: number) => `${n} fájl`, files: (n: number) => `${n} fájl`,
@@ -83,6 +87,8 @@ const STR = {
crashHint: "Lade die Seite neu und versuche es erneut.", crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden", reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt", passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
passwordTitleFile: "Diese Datei ist passwortgeschützt",
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.", folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen", play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`), files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
+23
View File
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
return path.join(" → "); return path.join(" → ");
} }
describe("the paths a share link opens on", () => {
// The server's list: each of the places below that has to know them follows it.
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
const prefixes = [
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
].map((m) => m[1]);
it("are read off the server", () => {
expect(prefixes).toEqual(["watch", "file", "folder"]);
});
it("are all kept out of search indexes", () => {
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
});
it("all boot the player alone in `vite dev` and all give up their token", () => {
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
});
});
describe("the /watch entry", () => { describe("the /watch entry", () => {
it("loads nothing the app alone may load", () => { it("loads nothing the app alone may load", () => {
const seen = reach(join(SRC, "watchMain.tsx")); const seen = reach(join(SRC, "watchMain.tsx"));
+3
View File
@@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage";
import { T } from "./watch/watchStrings"; import { T } from "./watch/watchStrings";
import "./index.css"; import "./index.css";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a // The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and // friend: signed out, often on a phone, often on mobile data — so it imports the player page and
@@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild";
// A tab left open across a release reloads onto the new build instead of failing on a chunk. // A tab left open across a release reloads onto the new build instead of failing on a chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload }); const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });
+3 -3
View File
@@ -32,18 +32,18 @@ const proxy = {
"/healthz": proxyOpts, "/healthz": proxyOpts,
}; };
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads // Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
// only the player page. In a build the backend picks the file by path (`main.py`); this does the // only the player page. In a build the backend picks the file by path (`main.py`); this does the
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app. // same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
const watchEntry = (): Plugin => ({ const watchEntry = (): Plugin => ({
name: "siftlode-watch-entry", name: "siftlode-watch-entry",
configureServer(server) { configureServer(server) {
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/watch/")) req.url = "/watch.html"; if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
next(); next();
}); });
}, },
// The server answers every /watch/ link with the built watch.html, so a build without it would // The server answers every share link with the built watch.html, so a build without it would
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here — // ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet. // after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
enforce: "post", enforce: "post",