Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3be568e36 | ||
|
|
a8cbc7b338 | ||
|
|
cf8dc80428 | ||
|
|
da74b8b9bf | ||
|
|
6944622085 | ||
|
|
2bdcebdb34 | ||
|
|
046dde44a4 | ||
|
|
74f58ea001 | ||
|
|
b515c16117 | ||
|
|
4a0d4b0964 | ||
|
|
cb8b99e0e7 | ||
|
|
e5c75702e8 | ||
|
|
4ef772d37b | ||
|
|
6986eb1937 | ||
|
|
41ad538edc | ||
|
|
7489cab3bc | ||
|
|
91256b10b0 | ||
|
|
b22e903174 | ||
|
|
d6e72d7576 | ||
|
|
e3a4593acf | ||
|
|
fa271a31b9 | ||
|
|
947efaba6e | ||
|
|
22d873ed49 | ||
|
|
d825aff30d | ||
|
|
9dd56e94d4 | ||
|
|
25ad2ee1ba | ||
|
|
c86ec7f7a5 | ||
|
|
0469812499 | ||
|
|
ee658b8bfc | ||
|
|
52b93d0014 | ||
|
|
c3ad7684ea | ||
|
|
a5666bfde9 | ||
|
|
f49dc6532f | ||
|
|
0d410592e2 | ||
|
|
7e5d4a7d93 | ||
|
|
24190e4c16 | ||
|
|
e96a562f14 |
@@ -7,6 +7,29 @@ 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).
|
||||
|
||||
## 0.114.2 — 2026-10-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Security: a library the sign-in relies on is updated, closing two published vulnerabilities.
|
||||
|
||||
## 0.114.1 — 2026-10-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
|
||||
|
||||
## 0.114.0 — 2026-10-29
|
||||
|
||||
### New
|
||||
|
||||
- Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.
|
||||
- After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open.
|
||||
|
||||
## 0.113.0 — 2026-10-28
|
||||
|
||||
### New
|
||||
|
||||
@@ -6,9 +6,9 @@ The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's
|
||||
after a write-back, its local cleanup after a delete.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import CursorResult, delete, func, select, update
|
||||
from sqlalchemy import CursorResult, delete, func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -271,6 +271,10 @@ def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> N
|
||||
|
||||
# --- a user's watch state --------------------------------------------------------------------
|
||||
|
||||
# How long after a watched mark a mid-item position is read as the finishing player's late tick
|
||||
# (`checkpoint`): the tick is every 10 s, plus the request's own latency and a retry.
|
||||
_LATE_CHECKPOINT_S = 30
|
||||
|
||||
|
||||
def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
|
||||
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
|
||||
@@ -352,16 +356,33 @@ def checkpoint(
|
||||
now: datetime,
|
||||
*,
|
||||
dirty: bool,
|
||||
) -> PlexState:
|
||||
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
|
||||
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
|
||||
) -> bool:
|
||||
"""Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
|
||||
to Plex — set before a push, so the reconcile re-pushes it if the push fails and the push can
|
||||
settle it once Plex confirms.
|
||||
|
||||
Not onto a row marked watched in the last `_LATE_CHECKPOINT_S`: that position is the player's
|
||||
tick sent just before its own finish (or a mark from another tab), and writing it made a watched
|
||||
item in progress again — on Plex too, through the resume push it carried. Decided by the write
|
||||
itself, since the mark can commit between any read here and the write. A rewatch is checkpointed
|
||||
as before once the mark is that far behind it."""
|
||||
st = state_row(db, user, item_id, st)
|
||||
st.position_seconds = position
|
||||
st.progress_updated_at = now
|
||||
values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
|
||||
if dirty:
|
||||
st.synced_to_plex = False
|
||||
values["synced_to_plex"] = False
|
||||
not_just_finished = or_(
|
||||
PlexState.status != "watched",
|
||||
PlexState.watched_at.is_(None),
|
||||
PlexState.watched_at <= now - timedelta(seconds=_LATE_CHECKPOINT_S),
|
||||
)
|
||||
result = db.execute(
|
||||
update(PlexState)
|
||||
.where(PlexState.id == st.id, not_just_finished)
|
||||
.values(values)
|
||||
.execution_options(synchronize_session="fetch")
|
||||
)
|
||||
db.flush()
|
||||
return st
|
||||
return isinstance(result, CursorResult) and result.rowcount > 0
|
||||
|
||||
|
||||
def forget_state(db: Session, st: PlexState) -> None:
|
||||
|
||||
+152
-75
@@ -66,7 +66,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
|
||||
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
||||
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
||||
# under the root" would take the neighbours with it.
|
||||
# under the root" would take the neighbours with it. A torn-down session's directory is renamed to
|
||||
# its own name + `_TOMB` + 8 hex digits before it is deleted (see `_detach`), so a crash between the
|
||||
# two leaves a name this sweep still recognises.
|
||||
#
|
||||
# The KEY is anchored to digits (a Plex rating_key is a number) — that is what keeps foreign names
|
||||
# out. A `.+` there let `snapshot_2024_backup_+1.00` through, and pairing `.+` with an optional tail
|
||||
@@ -74,8 +76,10 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
||||
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||
# and someone else's data.
|
||||
_TOMB = ".dead-"
|
||||
_SESSION_DIR_RE = re.compile(
|
||||
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
|
||||
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$"
|
||||
rf"|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{{2}}({re.escape(_TOMB)}[0-9a-f]{{8}})?$"
|
||||
)
|
||||
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
||||
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
||||
@@ -93,7 +97,7 @@ _MTIME_GRACE_S = 2.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
||||
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
|
||||
# the same film tore down the first one's session: the displaced viewer got a 404, reclaimed at its
|
||||
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
|
||||
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
||||
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
||||
@@ -641,29 +645,82 @@ def _probe_first_pts(seg: Path) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _kill(s: HlsSession) -> None:
|
||||
# Teardown is split in two, because every segment request and checkpoint takes `_lock`: waiting up to
|
||||
# 3 s for an ffmpeg to exit and deleting a whole segmented film under it stalled every other
|
||||
# viewer's playback for one viewer's seek or one reap pass. `_detach` (under the lock) signals the
|
||||
# process and renames its directory aside — so the deletion can never hit a NEW session that reuses
|
||||
# the path — and `_finish` (after the lock is released) waits and deletes.
|
||||
#
|
||||
# What the rename cannot cover is the dying ffmpeg itself: it writes by path, so until it has exited
|
||||
# it can still put a segment, or its final ENDLIST playlist, into a new session's directory on the
|
||||
# same path. `_dying` remembers it by that path, and `_spawn` waits it out — under the lock, on
|
||||
# purpose — before it creates the directory again.
|
||||
_dying: dict[Path, subprocess.Popen] = {}
|
||||
|
||||
|
||||
def _await_exit(key: str, proc: subprocess.Popen) -> None:
|
||||
try:
|
||||
s.proc.terminate()
|
||||
try:
|
||||
s.proc.wait(timeout=3)
|
||||
proc.wait(timeout=3)
|
||||
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
||||
# seconds gets KILL, which is exactly what this branch does.
|
||||
except subprocess.TimeoutExpired:
|
||||
s.proc.kill()
|
||||
proc.kill()
|
||||
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
||||
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
|
||||
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
|
||||
# `_sessions` rather than processes. One line is the only way that ever surfaces.
|
||||
log.info(
|
||||
"could not stop the transcode process",
|
||||
extra=logs.event("plex.stream.kill_failed", key=key, reason=type(exc).__name__),
|
||||
)
|
||||
|
||||
|
||||
def _detach(s: HlsSession) -> tuple[HlsSession, Path | None]:
|
||||
"""Caller holds _lock and has taken `s` out of `_sessions`. Hand the result to `_finish` once the
|
||||
lock is released."""
|
||||
try:
|
||||
s.proc.terminate()
|
||||
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
||||
log.info(
|
||||
"could not stop the transcode process",
|
||||
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
||||
)
|
||||
shutil.rmtree(s.dir, ignore_errors=True)
|
||||
_dying[s.dir] = s.proc
|
||||
tomb = s.dir.with_name(f"{s.dir.name}{_TOMB}{secrets.token_hex(4)}")
|
||||
try:
|
||||
s.dir.rename(tomb)
|
||||
# silent-ok: nothing on disk (ffmpeg died before writing, or a sweep took it) — nothing to delete.
|
||||
except FileNotFoundError:
|
||||
return s, None
|
||||
except OSError as exc:
|
||||
# Cannot be moved aside, so it cannot be deleted outside the lock either: the path may be a
|
||||
# new session's a moment from now. Deleted here instead, the old way.
|
||||
log.info(
|
||||
"a session directory could not be moved aside; deleted under the lock",
|
||||
extra=logs.event("plex.stream.tomb_failed", key=s.key, reason=type(exc).__name__),
|
||||
)
|
||||
shutil.rmtree(s.dir, ignore_errors=True)
|
||||
return s, None
|
||||
return s, tomb
|
||||
|
||||
|
||||
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
||||
least recently accessed sessions over it:
|
||||
def _finish(doomed: "list[tuple[HlsSession, Path | None]]") -> None:
|
||||
"""The slow half of a teardown, called WITHOUT `_lock`."""
|
||||
for s, tomb in doomed:
|
||||
_await_exit(s.key, s.proc)
|
||||
with _lock:
|
||||
if _dying.get(s.dir) is s.proc:
|
||||
del _dying[s.dir]
|
||||
if tomb is not None:
|
||||
shutil.rmtree(tomb, ignore_errors=True)
|
||||
|
||||
|
||||
def _enforce_caps(
|
||||
user_id: int, max_transcodes: int, max_sessions: int
|
||||
) -> "list[tuple[HlsSession, Path | None]]":
|
||||
"""Caller holds _lock, and `_finish`es what this returns once it has released it. Three bounds,
|
||||
applied after a session is registered, each dropping the least recently accessed sessions over it:
|
||||
|
||||
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||
box evicts itself before anyone else;
|
||||
@@ -676,6 +733,8 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
||||
rebuffering" has no other explanation anywhere, hence the line."""
|
||||
|
||||
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||
|
||||
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||
if len(pool) <= cap:
|
||||
return
|
||||
@@ -690,12 +749,13 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||
sessions=len(pool),
|
||||
),
|
||||
)
|
||||
_kill(sess)
|
||||
_sessions.pop(reg, None)
|
||||
doomed.append(_detach(sess))
|
||||
|
||||
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
||||
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
||||
cull(list(_sessions.items()), max_sessions, "total")
|
||||
return doomed
|
||||
|
||||
|
||||
def start_session(
|
||||
@@ -759,68 +819,79 @@ def start_session(
|
||||
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||
old = _sessions.pop(reg, None)
|
||||
if old is not None:
|
||||
_kill(old)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src,
|
||||
start_s,
|
||||
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
|
||||
# was moved aside under the lock is otherwise left for a restart's sweep.
|
||||
try:
|
||||
with _lock:
|
||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||
old = _sessions.pop(reg, None)
|
||||
if old is not None:
|
||||
doomed.append(_detach(old))
|
||||
# An ffmpeg still exiting from THIS path (the old session, or one stopped a moment ago)
|
||||
# would write into the directory about to be made; see `_dying`.
|
||||
prev = _dying.pop(directory, None)
|
||||
if prev is not None:
|
||||
_await_exit(key, prev)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src,
|
||||
start_s,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
loudnorm=loudnorm,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
proc,
|
||||
start_s,
|
||||
variants,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
loudnorm=loudnorm,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
variants,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
user_id=user_id,
|
||||
)
|
||||
_sessions[reg] = s
|
||||
# The one line that says which pipeline a session actually got. Everything downstream
|
||||
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
||||
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||
log.info(
|
||||
"an HLS session started",
|
||||
extra=logs.event(
|
||||
"plex.stream.session_started",
|
||||
key=key,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
variants=len(variants or ()),
|
||||
start_s=round(start_s, 1),
|
||||
),
|
||||
)
|
||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||
# capped to zero.
|
||||
_enforce_caps(
|
||||
user_id,
|
||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||
)
|
||||
user_id=user_id,
|
||||
)
|
||||
_sessions[reg] = s
|
||||
# The one line that says which pipeline a session actually got. Everything downstream
|
||||
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
||||
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||
log.info(
|
||||
"an HLS session started",
|
||||
extra=logs.event(
|
||||
"plex.stream.session_started",
|
||||
key=key,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
variants=len(variants or ()),
|
||||
start_s=round(start_s, 1),
|
||||
),
|
||||
)
|
||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||
# capped to zero.
|
||||
doomed += _enforce_caps(
|
||||
user_id,
|
||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||
)
|
||||
finally:
|
||||
_finish(doomed)
|
||||
return s
|
||||
|
||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||
@@ -974,6 +1045,11 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
|
||||
# session, absent for a single-audio one.
|
||||
if variant not in (s.variants or (None,)):
|
||||
return False
|
||||
# And the name must be the one ffmpeg SPELLS for that sequence: `int()` also reads `seg_007.ts`
|
||||
# and a non-ASCII digit run as a sequence the session may have reached, and neither file is
|
||||
# ever written — a yes there parked the request for the whole wait.
|
||||
if filename != segment_name(seq, variant):
|
||||
return False
|
||||
if seq == 0:
|
||||
return True
|
||||
try:
|
||||
@@ -1108,16 +1184,16 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
||||
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
|
||||
one a whole segmented copy of the film on a volume shared with the download center."""
|
||||
now = time.time()
|
||||
dropped = 0
|
||||
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||
with _lock:
|
||||
for reg, s in list(_sessions.items()):
|
||||
done = s.proc.poll() is not None
|
||||
idle = now - s.last_access
|
||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||
_kill(s)
|
||||
_sessions.pop(reg, None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
doomed.append(_detach(s))
|
||||
_finish(doomed)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
||||
@@ -1132,8 +1208,9 @@ def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
||||
s = _sessions.get((user_id, key))
|
||||
if s is None or s.id != session_id:
|
||||
return False
|
||||
_kill(s)
|
||||
_sessions.pop((user_id, key), None)
|
||||
doomed = [_detach(s)]
|
||||
_finish(doomed)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
||||
|
||||
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
|
||||
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
||||
translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
||||
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
|
||||
whatever sentence the route happened to hand-write. `refused` is the generic family
|
||||
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
||||
|
||||
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
|
||||
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
||||
pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail()."""
|
||||
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
||||
|
||||
|
||||
|
||||
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
|
||||
return imgcache.response(data, ct)
|
||||
|
||||
|
||||
def _supersede_resume_pushes(user_id: int, item_id: int, now: datetime) -> None:
|
||||
"""A watched mark supersedes every resume push still in flight for the item: stamping it as the
|
||||
latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep the map
|
||||
small) removed the very guard, and an older resume push could then land at Plex AFTER the
|
||||
scrobble and revive a resume point on a watched item. Eviction bounds the map."""
|
||||
with _push_throttle_lock:
|
||||
_last_push_at[(user_id, item_id)] = now
|
||||
|
||||
|
||||
def _push_resume_guarded(
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
@@ -329,12 +338,7 @@ def item_progress(
|
||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||
if near_end:
|
||||
flipped = service.mark_finished(db, user.id, it.id, now)
|
||||
# The finish supersedes every resume push still in flight for this item: stamping it as the
|
||||
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
|
||||
# the map small) removed the very guard, and an older resume push could then land at Plex
|
||||
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
|
||||
with _push_throttle_lock:
|
||||
_last_push_at[(user.id, it.id)] = now
|
||||
_supersede_resume_pushes(user.id, it.id, now)
|
||||
db.commit()
|
||||
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
||||
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
||||
@@ -352,7 +356,11 @@ def item_progress(
|
||||
push_now = _should_push_resume(user.id, it.id, now, final)
|
||||
# Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
||||
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
||||
service.checkpoint(db, user, it.id, st, position, now, dirty=push_now)
|
||||
if not service.checkpoint(db, user, it.id, st, position, now, dirty=push_now):
|
||||
# Marked watched a moment ago: this is the finishing player's late tick (see
|
||||
# `service.checkpoint`), and the answer is the same one a near-end checkpoint gets.
|
||||
db.commit()
|
||||
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
db.commit()
|
||||
if push_now:
|
||||
_push_watch(
|
||||
@@ -408,6 +416,8 @@ def item_state(
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||
return {"ok": True, "status": "new"}
|
||||
service.set_status(db, user, it.id, st, status)
|
||||
if status == "watched":
|
||||
_supersede_resume_pushes(user.id, it.id, datetime.now(timezone.utc))
|
||||
db.commit()
|
||||
if status == "watched" and prev_status != "watched":
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
@@ -427,6 +437,10 @@ def _bulk_state(
|
||||
always reads the committed marks."""
|
||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||
to_push = service.bulk_state(db, user_id, eps, watched)
|
||||
if watched:
|
||||
now = datetime.now(timezone.utc)
|
||||
for item_id, _rating_key, _action in to_push:
|
||||
_supersede_resume_pushes(user_id, item_id, now)
|
||||
if pushable and to_push:
|
||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||
@@ -632,9 +646,10 @@ def stream_hls_file(
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
# The session was torn down between the wait and the read — `_kill` rmtrees the whole
|
||||
# directory, and nothing holds `_lock` across those two steps. It has to present as the same
|
||||
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so
|
||||
# The session was torn down between the wait and the read — teardown moves the whole
|
||||
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
|
||||
# to present as the same 404 a gone session does: the player's recovery is keyed on the
|
||||
# STATUS (`code === 404`), so
|
||||
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
|
||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
||||
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||
|
||||
@@ -128,7 +128,8 @@ def update_view(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise refused(409, "view_name_taken", name=view.name) from None
|
||||
# The name asked for: the rollback has put the view's old name back on `view`.
|
||||
raise refused(409, "view_name_taken", name=name) from None
|
||||
return _serialize(view)
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ def update_tag(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise refused(409, "tag_name_taken", name=tag.name) from None
|
||||
# The name asked for: the rollback has put the tag's old name back on `tag`.
|
||||
raise refused(409, "tag_name_taken", name=name) from None
|
||||
return {
|
||||
"ok": True,
|
||||
"id": tag.id,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
|
||||
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
|
||||
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616).
|
||||
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
|
||||
APScheduler==3.11.3
|
||||
Authlib==1.9.1
|
||||
Mako==1.4.1
|
||||
@@ -39,7 +39,7 @@ httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
itsdangerous==2.2.0
|
||||
joserfc==1.7.4
|
||||
joserfc==1.7.6
|
||||
markdown-it-py==4.2.0
|
||||
mdurl==0.1.2
|
||||
mutagen==1.48.1
|
||||
|
||||
@@ -83,6 +83,7 @@ class _Proc:
|
||||
|
||||
class _Session:
|
||||
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
||||
self.key = name
|
||||
self.dir = tmp_path / name
|
||||
self.dir.mkdir()
|
||||
self.proc = _Proc(finished)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tearing a session down does not stall everybody else's playback.
|
||||
|
||||
Every segment request (`current_session`) and every checkpoint (`touch_session`) takes the one
|
||||
registry `_lock`. Stopping a session waits up to 3 s for its ffmpeg to exit and then deletes a whole
|
||||
segmented film, ~1200 files — and that used to happen under the same lock, so one viewer's seek, a
|
||||
cap cull or a reap pass held up every other viewer's HLS requests for seconds, each one a parked
|
||||
threadpool worker and its pooled DB connection.
|
||||
|
||||
What still has to be atomic is pinned too: a session directory is reused for the same item, offset
|
||||
and tag, so a new session on that path must not start before the old ffmpeg — which writes by path
|
||||
— has exited.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plex import stream
|
||||
|
||||
|
||||
class _SlowProc:
|
||||
"""An ffmpeg that takes a while to exit after TERM: `wait` blocks until `exited` is set."""
|
||||
|
||||
def __init__(self, order: list[str], name: str):
|
||||
self.order = order
|
||||
self.name = name
|
||||
self.waiting = threading.Event()
|
||||
self.exited = threading.Event()
|
||||
|
||||
def poll(self):
|
||||
return 0 if self.exited.is_set() else None
|
||||
|
||||
def terminate(self):
|
||||
pass
|
||||
|
||||
def kill(self):
|
||||
self.exited.set()
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waiting.set()
|
||||
if not self.exited.wait(timeout):
|
||||
raise stream.subprocess.TimeoutExpired("ffmpeg", timeout)
|
||||
self.order.append(f"{self.name} exited")
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, directory, proc, key: str):
|
||||
self.id = f"sid-{key}"
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.last_access = time.time()
|
||||
self.transcode = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(stream, "_sessions", live)
|
||||
monkeypatch.setattr(stream, "_dying", {}, raising=False)
|
||||
return live
|
||||
|
||||
|
||||
def test_stopping_a_session_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
|
||||
order: list[str] = []
|
||||
slow = _SlowProc(order, "a")
|
||||
d = tmp_path / "1_u1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_0.ts").write_bytes(b"x")
|
||||
sessions[(1, "a")] = _Session(d, slow, "a")
|
||||
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
|
||||
|
||||
stopper = threading.Thread(target=stream.stop_session, args=(1, "a", "sid-a"))
|
||||
stopper.start()
|
||||
try:
|
||||
assert slow.waiting.wait(2.0), "the stop never waited for its ffmpeg"
|
||||
got: list = []
|
||||
reader = threading.Thread(target=lambda: got.append(stream.current_session(2, "b")))
|
||||
reader.start()
|
||||
reader.join(1.0)
|
||||
assert not reader.is_alive(), "a segment request waited on another viewer's teardown"
|
||||
assert got and got[0] is sessions[(2, "b")]
|
||||
finally:
|
||||
slow.exited.set()
|
||||
stopper.join(5.0)
|
||||
# The teardown still finishes: the session is gone and nothing of its directory is left behind.
|
||||
assert (1, "a") not in sessions
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_a_reap_pass_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
|
||||
order: list[str] = []
|
||||
slow = _SlowProc(order, "a")
|
||||
d = tmp_path / "1_u1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
gone = _Session(d, slow, "a")
|
||||
gone.last_access = time.time() - stream._SESSION_IDLE_S - 5
|
||||
sessions[(1, "a")] = gone
|
||||
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
|
||||
|
||||
reaper = threading.Thread(target=stream.reap_idle)
|
||||
reaper.start()
|
||||
try:
|
||||
assert slow.waiting.wait(2.0), "the reap never waited for its ffmpeg"
|
||||
toucher = threading.Thread(target=stream.touch_session, args=(2, "b"))
|
||||
toucher.start()
|
||||
toucher.join(1.0)
|
||||
assert not toucher.is_alive(), "a checkpoint waited on another viewer's teardown"
|
||||
finally:
|
||||
slow.exited.set()
|
||||
reaper.join(5.0)
|
||||
assert list(sessions) == [(2, "b")]
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
class _FakeItem:
|
||||
rating_key = "12345"
|
||||
file_path = "/media/movie.mkv"
|
||||
playable = "remux"
|
||||
codec_video = "h264"
|
||||
|
||||
|
||||
def test_a_new_session_on_a_dying_ones_path_starts_only_after_it_exited(
|
||||
tmp_path, sessions, monkeypatch
|
||||
):
|
||||
"""The same item at the same second gets the same directory. The old ffmpeg writes by PATH, so
|
||||
while it is still winding down after TERM it can write a segment — or, on a clean exit, a final
|
||||
playlist with ENDLIST — into the NEW session's directory."""
|
||||
order: list[str] = []
|
||||
procs = iter([_SlowProc(order, "old"), _SlowProc(order, "new")])
|
||||
|
||||
def popen(*a, **k):
|
||||
order.append("popen")
|
||||
return next(procs)
|
||||
|
||||
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
|
||||
src = tmp_path / "movie.mkv"
|
||||
src.write_bytes(b"x")
|
||||
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
|
||||
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
|
||||
monkeypatch.setattr(stream.sysconfig, "get_bool", lambda db, k: False)
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda *a, **k: True)
|
||||
|
||||
first = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||
assert first is not None
|
||||
stopper = threading.Thread(target=stream.stop_session, args=(7, "12345", first.id))
|
||||
stopper.start()
|
||||
assert first.proc.waiting.wait(2.0)
|
||||
threading.Timer(0.3, first.proc.exited.set).start()
|
||||
second = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||
stopper.join(5.0)
|
||||
assert second is not None and second.dir == first.dir
|
||||
assert order.index("old exited") < order.index("popen", 1)
|
||||
assert second.dir.is_dir(), "the old teardown deleted the new session's directory"
|
||||
second.proc.exited.set()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's
|
||||
old name (the rollback that follows the conflict puts the old name back on the row). DB lane.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _client(db, monkeypatch, override_dep):
|
||||
u = User(email="taken@example.com")
|
||||
db.add(u)
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
state, "_configured_cache", True
|
||||
) # the request gate 503s an unconfigured app
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: u)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
|
||||
c = _client(db, monkeypatch, override_dep)
|
||||
gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"})
|
||||
assert gaming.status_code == 200
|
||||
hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json()
|
||||
|
||||
r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"})
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"}
|
||||
|
||||
|
||||
def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
|
||||
c = _client(db, monkeypatch, override_dep)
|
||||
assert c.post("/api/tags", json={"name": "music"}).status_code == 200
|
||||
news = c.post("/api/tags", json={"name": "news"}).json()
|
||||
|
||||
r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"})
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"}
|
||||
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexState
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||
from app.plex import service as plex_service
|
||||
from app.routes.plex import playback
|
||||
|
||||
@@ -81,3 +81,150 @@ def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_u
|
||||
)
|
||||
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||
|
||||
|
||||
def test_a_checkpoint_sent_before_the_finish_does_not_land_on_the_watched_row(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
"""The 10 s tick posted at 2699 of 3000 s (not near the end) races the credits auto-skip's
|
||||
"watched". When the mark commits first, the late checkpoint used to write its position onto the
|
||||
watched row and schedule a resume push after the scrobble: Plex showed the item in progress, and
|
||||
a Plex-authoritative pull imported it back."""
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||
client = as_user(user)
|
||||
assert client.post("/api/plex/item/m1/state", json={"status": "watched"}).status_code == 200
|
||||
|
||||
r = client.post(
|
||||
"/api/plex/item/m1/progress", json={"position_seconds": 2699, "duration_seconds": 3000}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
db.expire_all()
|
||||
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
|
||||
assert (st.status, st.position_seconds) == ("watched", 0)
|
||||
|
||||
|
||||
def test_a_rewatch_records_its_position_once_the_finish_is_behind_it(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
"""The other half: a watched item played again is still checkpointed — only a position arriving
|
||||
right on the heels of the mark is read as that mark's late tick."""
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
long_ago = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
db.add(PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=long_ago))
|
||||
db.commit()
|
||||
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||
r = as_user(user).post(
|
||||
"/api/plex/item/m1/progress", json={"position_seconds": 600, "duration_seconds": 3000}
|
||||
)
|
||||
assert r.json() == {"ok": True, "position_seconds": 600}
|
||||
db.expire_all()
|
||||
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
|
||||
assert (st.status, st.position_seconds) == ("watched", 600)
|
||||
|
||||
|
||||
def test_marking_watched_supersedes_a_resume_push_still_in_flight(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
"""The same guard the near-end finish has: a resume push scheduled before the mark must not reach
|
||||
Plex after its scrobble."""
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
|
||||
r = as_user(user).post("/api/plex/item/m1/state", json={"status": "watched"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||
|
||||
|
||||
def _state(db, user, item):
|
||||
db.expire_all()
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == item.id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
return st.status, st.position_seconds
|
||||
|
||||
|
||||
def test_a_mark_committed_after_the_checkpoints_read_still_refuses_it(db, make_user):
|
||||
"""The race itself, not its sequential shadow: the route read the row while it was still in
|
||||
progress, and the finish committed from another session before the checkpoint wrote. Only a write
|
||||
conditional on the row as it is NOW refuses that position."""
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=2690))
|
||||
db.commit()
|
||||
now = datetime.now(timezone.utc)
|
||||
read = plex_service.state_row(db, user, item.id) # what the checkpoint's route read: "new"
|
||||
assert read.status == "new"
|
||||
|
||||
other = SessionLocal()
|
||||
try:
|
||||
assert plex_service.mark_finished(other, user.id, item.id, now) is True
|
||||
other.commit()
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
assert plex_service.checkpoint(db, user, item.id, read, 2699, now, dirty=True) is False
|
||||
db.commit()
|
||||
assert _state(db, user, item) == ("watched", 0)
|
||||
|
||||
|
||||
def test_a_tick_seconds_behind_the_mark_is_refused_a_rewatch_a_minute_later_is_not(db, make_user):
|
||||
"""A position right behind the mark is that mark's late tick; one a minute later is a rewatch.
|
||||
Literal times on purpose: a window widened to an hour must fail here, not move with it."""
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
st = PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=now)
|
||||
db.add(st)
|
||||
db.commit()
|
||||
|
||||
st.watched_at = now - timedelta(seconds=20)
|
||||
db.commit()
|
||||
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is False
|
||||
db.commit()
|
||||
st.watched_at = now - timedelta(seconds=60)
|
||||
db.commit()
|
||||
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is True
|
||||
db.commit()
|
||||
assert _state(db, user, item) == ("watched", 600)
|
||||
|
||||
|
||||
def test_marking_a_season_watched_supersedes_its_episodes_resume_pushes(db, as_user, make_user):
|
||||
"""The bulk mark has the same guard as one item's: an episode's resume push scheduled before
|
||||
the season was marked must not reach Plex after the scrobble."""
|
||||
user = make_user()
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
sh = PlexShow(rating_key="sh1", library_id=lib.id, title="Show")
|
||||
db.add(sh)
|
||||
db.flush()
|
||||
se = PlexSeason(rating_key="se1", show_id=sh.id, season_number=1)
|
||||
db.add(se)
|
||||
db.flush()
|
||||
ep = PlexItem(
|
||||
rating_key="e1",
|
||||
library_id=lib.id,
|
||||
kind="episode",
|
||||
title="E1",
|
||||
show_id=sh.id,
|
||||
season_id=se.id,
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
assert playback._should_push_resume(user.id, ep.id, earlier, final=True)
|
||||
|
||||
r = as_user(user).post("/api/plex/season/se1/state", json={"watched": True})
|
||||
assert r.json() == {"ok": True, "changed": 1, "watched": True}
|
||||
assert playback._resume_push_superseded(user.id, ep.id, earlier) is True
|
||||
|
||||
@@ -198,6 +198,31 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "audio_langs"),
|
||||
[
|
||||
("seg_00.ts", None), # `int()` reads it as segment 0, which needs no predecessor
|
||||
("seg_007.ts", None), # …as segment 7, whose predecessor seg_6.ts is on disk
|
||||
("seg_٧.ts", None), # an Arabic-Indic 7: `\d` and `int()` both take it
|
||||
("seg_a0_07.ts", ["hu", "en"]),
|
||||
("seg_0_00.ts", ["hu", "en"]),
|
||||
],
|
||||
)
|
||||
def test_a_segment_name_ffmpeg_never_spells_is_refused(tmp_path, name, audio_langs):
|
||||
"""ffmpeg writes `seg_%d.ts`: no padding, ASCII digits. A name that only PARSES to a sequence the
|
||||
session has reached is still a file that will never appear, and saying yes to it parked the
|
||||
request for the full 20 s wait — the parking this guard exists to end."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
for v in plex_stream.session_variants(audio_langs) or (None,):
|
||||
(d / plex_stream.segment_name(6, v)).write_bytes(b"x")
|
||||
sess = _Session("sid-a", d, running=True, audio_langs=audio_langs)
|
||||
assert not plex_stream.artifact_may_arrive(sess, name)
|
||||
# The canonical spelling of the same next segment is still accepted.
|
||||
v = (plex_stream.session_variants(audio_langs) or (None,))[0]
|
||||
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(7, v))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("digits", [100, 5000])
|
||||
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
||||
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
|
||||
@@ -213,7 +238,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
|
||||
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
||||
client, monkeypatch, tmp_path, sessions
|
||||
):
|
||||
"""`_kill` rmtrees the whole directory and nothing holds `_lock` across the wait and the read,
|
||||
"""Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read,
|
||||
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
|
||||
session before destroying the old hls instance. The player's recovery is keyed on the 404
|
||||
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
|
||||
|
||||
@@ -348,7 +348,8 @@ def test_an_early_checkpoint_drops_a_bare_state_and_zeroes_a_marked_one(db, make
|
||||
user = make_user()
|
||||
lib = _lib(db)
|
||||
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
|
||||
st = service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
|
||||
assert service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
|
||||
st = queries.state(db, user, bare.id)
|
||||
assert (st.position_seconds, st.synced_to_plex) == (120, False)
|
||||
_state(db, user, hid, status="hidden", position_seconds=40)
|
||||
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
|
||||
@@ -429,7 +430,8 @@ def test_a_first_write_that_lost_the_race_updates_the_winners_row(db, make_user)
|
||||
user = make_user()
|
||||
it = _movie(db, _lib(db), "m-race")
|
||||
_state(db, user, it, position_seconds=10) # the request that won, committed
|
||||
st = service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
||||
assert service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
||||
st = queries.state(db, user, it.id)
|
||||
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
||||
st = service.set_status(db, user, it.id, None, "hidden")
|
||||
assert st.status == "hidden"
|
||||
|
||||
@@ -39,6 +39,7 @@ class _Proc:
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory):
|
||||
self.id = session_id
|
||||
self.key = "m1"
|
||||
self.dir = directory
|
||||
self.proc = _Proc()
|
||||
self.last_access = time.time()
|
||||
|
||||
@@ -26,6 +26,9 @@ from app.plex.stream import _SESSION_DIR_RE
|
||||
"12345_0_2_+0.00", # current shape, explicit audio_ord
|
||||
"12345_842_multi_-1.25", # multi-audio, negative offset
|
||||
"12345_842_multi_+12.50",
|
||||
# a torn-down session's directory, moved aside to be deleted outside the lock — left behind
|
||||
# only if the process died between the rename and the delete
|
||||
"12345_u7_842_multi_+12.50.dead-0123abcd",
|
||||
],
|
||||
)
|
||||
def test_our_session_directories_are_swept(name):
|
||||
@@ -47,6 +50,9 @@ def test_our_session_directories_are_swept(name):
|
||||
"snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
|
||||
"backup_2024_multi_+0.00",
|
||||
".config",
|
||||
"12345_u7_0_0_+0.00.dead-", # a tombstone suffix with no id
|
||||
"12345_u7_0_0_+0.00.dead-notours!",
|
||||
"backup_u7_0_0_+0.00.dead-0123abcd",
|
||||
],
|
||||
)
|
||||
def test_foreign_directories_are_left_alone(name):
|
||||
@@ -159,7 +165,7 @@ class TestSweepOrphans:
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later)."""
|
||||
"""Stand-in for the ffmpeg Popen — start_session only stores it (and teardown/reap poll it later)."""
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
@@ -1052,3 +1058,63 @@ class TestSessionsPerViewer:
|
||||
self._start(monkeypatch, user_id=uid)
|
||||
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
||||
assert oldest.proc.killed and not oldest.transcode
|
||||
|
||||
|
||||
class TestTeardownFinishes:
|
||||
"""A session taken down by a cap or replaced by its own viewer is only renamed aside under the
|
||||
lock; it is deleted after. The rename alone already moves the directory and `terminate()` alone
|
||||
already marks the proc killed — so what is pinned here is the tomb: a whole segmented copy of the
|
||||
film that only a restart's sweep would otherwise reclaim."""
|
||||
|
||||
capturing_env = TestTranscodeStartSession.capturing_env
|
||||
|
||||
@staticmethod
|
||||
def _left_behind(root):
|
||||
return sorted(p.name for p in root.iterdir() if stream._TOMB in p.name) + sorted(
|
||||
str(p) for p in stream._dying
|
||||
)
|
||||
|
||||
def test_a_culled_session_is_deleted_not_only_moved_aside(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_dying", {})
|
||||
first = TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="1")
|
||||
first.last_access -= 60
|
||||
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="2")
|
||||
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="3")
|
||||
assert first.proc.killed and not first.dir.exists()
|
||||
assert self._left_behind(capturing_env) == []
|
||||
|
||||
def test_a_viewers_restart_deletes_the_session_it_replaces(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_dying", {})
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stream, "_probe_first_pts", lambda seg: None
|
||||
) # no real segment to probe
|
||||
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
|
||||
new = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
|
||||
assert old.proc.killed and new.dir.exists() and not old.dir.exists()
|
||||
assert self._left_behind(capturing_env) == []
|
||||
|
||||
def test_a_restart_that_fails_to_start_still_deletes_what_it_replaced(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# The new ffmpeg cannot start (a full disk, no ffmpeg, no memory): the old session is already
|
||||
# moved aside, and nothing else in this process would ever delete it.
|
||||
monkeypatch.setattr(stream, "_dying", {})
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stream, "_probe_first_pts", lambda seg: None
|
||||
) # no real segment to probe
|
||||
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
|
||||
|
||||
def no_ffmpeg(*args, **kwargs):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", no_ffmpeg)
|
||||
with pytest.raises(OSError):
|
||||
stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
|
||||
assert old.proc.killed and not old.dir.exists()
|
||||
assert self._left_behind(capturing_env) == []
|
||||
|
||||
+6
-1
@@ -378,7 +378,11 @@ Which actions are visible and which live in a menu.
|
||||
managing the shares, re-download, the copies, cancel / delete / remove
|
||||
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||||
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||||
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
||||
"…" holds find / undo for an activity notice. The notification center lists what asks for the
|
||||
reader first ("Needs you · n": an unread row, a notice waiting on an action — one read during the
|
||||
visit keeps its place there until Mark all read), then one stream of both sources, newest first,
|
||||
and folds read rows older than a week under "Earlier (n)", a heading-style disclosure, closed — Back to a row
|
||||
that folded away since opens it on that row. Its band keeps Mark all read
|
||||
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
||||
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
||||
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
||||
@@ -824,3 +828,4 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
|
||||
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
|
||||
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
|
||||
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||
|
||||
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
|
||||
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
||||
await clickVisibleChannelLink(page);
|
||||
const title = await page.getByTestId("channel-title").textContent();
|
||||
await tab(page, "About").click();
|
||||
const fresh = await bookmarked(browser, page);
|
||||
|
||||
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
|
||||
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
|
||||
// view would move the feed before navigating, so Back would restore that moved position, not
|
||||
// the one under test. (This is what made the test fail while the app was in fact correct.)
|
||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
||||
await clickVisibleChannelLink(page);
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
+32
-14
@@ -33,23 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
|
||||
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
||||
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
||||
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
||||
* So the link must be the element hit at its own centre. Returns false if none found.
|
||||
* So the link must be the element hit at its own centre; none within the wait fails the test, with
|
||||
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
|
||||
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
|
||||
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
|
||||
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
|
||||
*/
|
||||
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
||||
export async function clickVisibleChannelLink(page: Page): Promise<void> {
|
||||
const links = page.getByTestId("video-card-channel").filter({ visible: true });
|
||||
const count = await links.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hittable = await links.nth(i).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit !== null && el.contains(hit);
|
||||
});
|
||||
if (hittable) {
|
||||
await links.nth(i).click();
|
||||
return true;
|
||||
let index = -1;
|
||||
const findHittable = async () => {
|
||||
const count = await links.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hittable = await links
|
||||
.nth(i)
|
||||
.evaluate(
|
||||
(el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit !== null && el.contains(hit);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
.catch(() => false);
|
||||
if (hittable) return i;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return -1;
|
||||
};
|
||||
await expect
|
||||
.poll(async () => (index = await findHittable()), {
|
||||
message: "a channel link hit at its own centre",
|
||||
timeout: 5000,
|
||||
})
|
||||
.toBeGreaterThan(-1);
|
||||
await links.nth(index).click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
|
||||
}));
|
||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||
import TagManager from "./features/channel/TagManager";
|
||||
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import type { NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
|
||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||
"plex.playlist.removeFromPlaylist",
|
||||
);
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
});
|
||||
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
|
||||
const field = await screen.findByDisplayValue("Films");
|
||||
fireEvent.change(field, { target: { value: "Movies" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
|
||||
});
|
||||
|
||||
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
|
||||
fireEvent.click(await screen.findByText("Gone"));
|
||||
// The server's own localized reason wins over the caller's fallback when the detail is
|
||||
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone tag"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }]));
|
||||
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
||||
notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
|
||||
}));
|
||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
|
||||
import UploadDialog from "./modules/files/UploadDialog";
|
||||
import DownloadDialog from "./features/downloads/DownloadDialog";
|
||||
import { api, HttpError } from "./lib/api";
|
||||
import { msg, type NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("Marathon"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||
});
|
||||
|
||||
it("says so when enabling editing is refused", async () => {
|
||||
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||
});
|
||||
|
||||
it("says so when a CONFIRMED delete does not happen", async () => {
|
||||
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
|
||||
mount();
|
||||
pick();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
|
||||
});
|
||||
|
||||
it("says nothing when the user cancelled it themselves", async () => {
|
||||
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
|
||||
);
|
||||
await screen.findByText("Best");
|
||||
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,9 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
|
||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "info", message: "Saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("Saved") }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
|
||||
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Nope" }));
|
||||
act(() => void notify({ level: "error", message: raw("Nope") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
||||
@@ -311,6 +311,100 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("hands its trigger to the dialog left standing above it, which restores it on close", () => {
|
||||
// The standing upper dialog captured ITS restore target while the focus was inside the lower
|
||||
// one — a control that is gone once the lower closes. So the lower skipping its own restore
|
||||
// (above) handed the trigger to no one, and closing the upper left the focus on <body>.
|
||||
// The arrangement keyboard help or the error dialog opened over a dialog actually has.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
function Siblings() {
|
||||
const [lower, setLower] = useState(true);
|
||||
const [upper, setUpper] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{lower && (
|
||||
<Modal initialFocus="close" title="lower" onClose={() => {}}>
|
||||
<button onClick={() => setUpper(true)}>open upper</button>
|
||||
<button onClick={() => setLower(false)}>close lower</button>
|
||||
</Modal>
|
||||
)}
|
||||
{upper && (
|
||||
<Modal initialFocus="close" title="upper" onClose={() => {}}>
|
||||
<button onClick={() => setUpper(false)}>close upper</button>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Siblings />);
|
||||
screen.getByText("open upper").focus();
|
||||
act(() => void screen.getByText("open upper").click()); // opened from inside the lower dialog
|
||||
const inUpper = screen.getByText("close upper");
|
||||
inUpper.focus();
|
||||
// The lower closes on its own under the standing upper (`.click()` moves no focus).
|
||||
act(() => void screen.getByText("close lower").click());
|
||||
expect(screen.queryByText("close lower")).toBeNull();
|
||||
expect(document.activeElement).toBe(inUpper);
|
||||
act(() => void inUpper.click());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("restores the trigger when it closes with the focus in one of its menus", () => {
|
||||
// The popover engine portals a menu out of the dialog's root (it carries no aria-modal), so a
|
||||
// focus on a menu row read as "the user walked away": Back with a menu open inside a dialog
|
||||
// left the focus on <body>. The portal marker is the one `ui/AnchoredPopover` reads too.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
const portal = document.createElement("div");
|
||||
portal.setAttribute("data-floating-ui-portal", "");
|
||||
document.body.appendChild(portal);
|
||||
const { unmount } = render(
|
||||
<Modal initialFocus="close" title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
{createPortal(<button>menu-row</button>, portal)}
|
||||
</Modal>,
|
||||
);
|
||||
screen.getByText("menu-row").focus();
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
it("does not pull the focus out of a menu that stays open when it closes on its own", () => {
|
||||
// The other side of counting a menu row as held: a menu that is NOT this dialog's — a page's
|
||||
// "…" left open while a dialog finishes in the background — stays standing, and a dialog that
|
||||
// closes on its own (a write's success) must not take the focus from it to its trigger.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
const portal = document.createElement("div");
|
||||
portal.setAttribute("data-floating-ui-portal", "");
|
||||
const row = document.createElement("button");
|
||||
row.textContent = "page-menu-row";
|
||||
portal.appendChild(row);
|
||||
document.body.appendChild(portal);
|
||||
function SelfClosing() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return open ? (
|
||||
<Modal initialFocus="close" title="t" onClose={() => {}}>
|
||||
<button onClick={() => setOpen(false)}>finish</button>
|
||||
</Modal>
|
||||
) : null;
|
||||
}
|
||||
render(<SelfClosing />);
|
||||
row.focus();
|
||||
act(() => void screen.getByText("finish").click()); // `.click()` moves no focus
|
||||
expect(screen.queryByText("finish")).toBeNull();
|
||||
expect(document.activeElement).toBe(row);
|
||||
document.body.removeChild(trigger);
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
|
||||
// The other half of the same rule, and the branch nothing drove: taking focus back from where
|
||||
// the user deliberately put it is the rudest thing a closing dialog can do.
|
||||
@@ -361,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't share it." }));
|
||||
act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import PageScroller from "./PageScroller";
|
||||
import BackToTop from "./BackToTop";
|
||||
import { notifyNav } from "../../lib/history";
|
||||
import { revealedNavItem } from "../../lib/listNav";
|
||||
|
||||
beforeAll(() => {
|
||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
@@ -125,6 +126,38 @@ describe("PageScroller scroll memory", () => {
|
||||
expect(main.scrollTop).toBe(450);
|
||||
});
|
||||
|
||||
// Back focuses an item the restore did not expect — one that moved to another list while the page
|
||||
// was away — and scrolls it into view: the restore's next frame put the old offset back over it.
|
||||
it("gives the page up to an item brought into view while it restores", () => {
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
||||
const flush = (n: number) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const run = frames;
|
||||
frames = [];
|
||||
for (const cb of run) cb(0);
|
||||
}
|
||||
};
|
||||
setEntry(1);
|
||||
const { container } = render(
|
||||
<PageScroller scrollKey="e:k1">
|
||||
<div id="moved" />
|
||||
</PageScroller>,
|
||||
);
|
||||
const main = sizedMain(container);
|
||||
main.scrollTop = 450;
|
||||
fireEvent.scroll(main);
|
||||
setEntry(2);
|
||||
notifyNav();
|
||||
flush(1);
|
||||
setEntry(1);
|
||||
notifyNav(); // Back: the restore to 450 waits for its first frame
|
||||
main.scrollTop = 120; // the item scrolled into view
|
||||
revealedNavItem(container.querySelector("#moved")!);
|
||||
flush(5);
|
||||
expect(main.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
|
||||
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StickyTopProvider } from "./stickyTop";
|
||||
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
||||
import { CONTENT_ID } from "./landmarks";
|
||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
||||
import { NAV_REVEAL_EVENT } from "../../lib/listNav";
|
||||
|
||||
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
||||
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
||||
@@ -63,11 +64,15 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
|
||||
el.removeEventListener("wheel", stop);
|
||||
el.removeEventListener("touchstart", stop);
|
||||
el.removeEventListener("pointerdown", stop);
|
||||
el.removeEventListener(NAV_REVEAL_EVENT, stop);
|
||||
document.removeEventListener("keydown", stop, true);
|
||||
};
|
||||
el.addEventListener("wheel", stop, { passive: true });
|
||||
el.addEventListener("touchstart", stop, { passive: true });
|
||||
el.addEventListener("pointerdown", stop);
|
||||
// So does an item the keyboard brought into view (`lib/listNav`): Back focusing one that is not
|
||||
// where the restored offset shows.
|
||||
el.addEventListener(NAV_REVEAL_EVENT, stop);
|
||||
document.addEventListener("keydown", stop, true);
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
|
||||
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
||||
|
||||
const overlay = render(
|
||||
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
||||
const Docked = () => <button>dock</button>;
|
||||
const stop = dockUnderToasts(Docked);
|
||||
notify({ level: "info", message: "hello" });
|
||||
notify({ level: "info", message: raw("hello") });
|
||||
render(<Toaster />);
|
||||
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
|
||||
// The empty part of the region (beside and above a docked control) must not swallow the page's
|
||||
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
fireEvent.click(screen.getByText("boom"));
|
||||
expect(backdrop).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
||||
// assertively, each time a player (or any dialog) opened or closed.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
render(<Toaster overlay />);
|
||||
const old = screen.getByText("old news").closest(".toast-card")!;
|
||||
expect(old.getAttribute("role")).toBeNull();
|
||||
act(() => void notify({ level: "error", message: "fresh" }));
|
||||
act(() => void notify({ level: "error", message: raw("fresh") }));
|
||||
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// host last showed — re-read on every render, this host's own "first" turned silent the moment
|
||||
// "second" arrived (its own screen had become the handover by then).
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "first" }));
|
||||
act(() => void notify({ level: "error", message: "second" }));
|
||||
act(() => void notify({ level: "error", message: raw("first") }));
|
||||
act(() => void notify({ level: "error", message: raw("second") }));
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("first").getAttribute("role")).toBe("alert");
|
||||
expect(card("second").getAttribute("role")).toBe("alert");
|
||||
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
||||
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.querySelector("[aria-live]")!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
<Toaster overlay />
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "upload queued" });
|
||||
notify({ level: "success", message: raw("upload queued") });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
);
|
||||
}
|
||||
render(<Stage />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
fireEvent.click(screen.getByText("upload"));
|
||||
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
<Toaster overlay order={2} />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
||||
player.rerender(
|
||||
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
const t0 = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
now.mockReturnValue(t0 + 4000);
|
||||
render(<Toaster overlay />);
|
||||
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
|
||||
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
render(<Toaster overlay />);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
const host = hosts()[0]!.closest("[data-toast-region]")!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
|
||||
() =>
|
||||
void notify({
|
||||
level: "success",
|
||||
message: "Downloaded",
|
||||
action: { label: "Copy link", onClick: copy },
|
||||
secondaryAction: { label: "Open", onClick: open },
|
||||
message: raw("Downloaded"),
|
||||
action: { label: raw("Copy link"), onClick: copy },
|
||||
secondaryAction: { label: raw("Open"), onClick: open },
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
|
||||
// text used to be translated by the caller and kept so: after a language switch every toast and every
|
||||
// row of the notification center stayed in the old language, and so did the history after a reload.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import i18n from "../../i18n";
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
act(() => clearAll());
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
describe("a notice speaks the reader's language of the moment", () => {
|
||||
it("a toast raised in English reads Hungarian after the switch", async () => {
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
|
||||
const en = i18n.getFixedT("en")("common.copy.done");
|
||||
const hu = i18n.getFixedT("hu")("common.copy.done");
|
||||
expect(en).not.toBe(hu);
|
||||
expect(screen.getByText(en)).toBeTruthy();
|
||||
|
||||
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||
expect(screen.getByText(hu)).toBeTruthy();
|
||||
expect(screen.queryByText(en)).toBeNull();
|
||||
});
|
||||
|
||||
it("its button is said again too, beside the text it acts on", async () => {
|
||||
render(<Toaster />);
|
||||
act(
|
||||
() =>
|
||||
void notify({
|
||||
message: msg("common.copy.done"),
|
||||
action: { label: msg("common.undo"), onClick: () => {} },
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||
|
||||
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a server refusal is said again in the language of the moment", async () => {
|
||||
render(<Toaster />);
|
||||
act(
|
||||
() =>
|
||||
void notify({
|
||||
level: "error",
|
||||
message: { detail: { code: "not_found", reason: "view" } },
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
|
||||
|
||||
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
|
||||
const text = msg("settings.takeout.read", {
|
||||
name: raw("watch-history.json"),
|
||||
count: 12345,
|
||||
n: { number: 12345 },
|
||||
});
|
||||
const en = sayNotif(text);
|
||||
await i18n.changeLanguage("hu");
|
||||
const hu = sayNotif(text);
|
||||
expect(en).toContain("12,345");
|
||||
expect(hu).not.toContain("12,345");
|
||||
expect(hu).toContain("watch-history.json");
|
||||
expect(hu).not.toBe(en);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the history kept from before", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("shows a notice stored as text as it was stored", async () => {
|
||||
localStorage.setItem("siftlode.owner", "5");
|
||||
localStorage.setItem(
|
||||
"siftlode.notifications",
|
||||
JSON.stringify([
|
||||
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
|
||||
]),
|
||||
);
|
||||
const notifications = await import("../../lib/notifications");
|
||||
const { claimLocalData } = await import("../../lib/storage");
|
||||
claimLocalData(5);
|
||||
const n = notifications.getNotifications()[0]!;
|
||||
expect(n.message).toEqual(raw("Old text"));
|
||||
expect(n.title).toEqual(raw("Old title"));
|
||||
expect(notifications.sayNotif(n.message)).toBe("Old text");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -37,7 +37,7 @@ function Harness() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "Uploaded" });
|
||||
notify({ level: "success", message: raw("Uploaded") });
|
||||
setOpen(false); // …in the SAME handler: one batch, one commit
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||||
import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
|
||||
import { createStore } from "../../lib/store";
|
||||
import { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
@@ -162,9 +162,11 @@ export default function Toaster({
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||
{toast.title && (
|
||||
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
|
||||
)}
|
||||
<div className="text-sm wrap-break-word">
|
||||
{toast.message}
|
||||
{sayNotif(toast.message, t)}
|
||||
{toast.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{toast.repeat}
|
||||
@@ -174,10 +176,10 @@ export default function Toaster({
|
||||
{(toast.action || toast.secondaryAction) && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||
{[toast.action, toast.secondaryAction].map(
|
||||
(action) =>
|
||||
(action, i) =>
|
||||
action && (
|
||||
<Button
|
||||
key={action.label}
|
||||
key={i}
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
@@ -185,7 +187,7 @@ export default function Toaster({
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
{action.label}
|
||||
{sayNotif(action.label, t)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Copy, Link2 } from "lucide-react";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
||||
@@ -17,12 +17,12 @@ type Named = "title" | "name";
|
||||
|
||||
export function useCopyItems() {
|
||||
const { t } = useTranslation();
|
||||
const copy = (text: string, done: string) => {
|
||||
const copy = (text: string, done: NotifText) => {
|
||||
void copyText(text).then((ok) =>
|
||||
notify(
|
||||
ok
|
||||
? { level: "success", message: done }
|
||||
: { level: "error", message: t("common.copy.failed") },
|
||||
: { level: "error", message: msg("common.copy.failed") },
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -43,7 +43,7 @@ export function useCopyItems() {
|
||||
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
inGesture: true,
|
||||
onSelect: () => copy(text, t("common.copy.done")),
|
||||
onSelect: () => copy(text, msg("common.copy.done")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -54,7 +54,7 @@ export function useCopyItems() {
|
||||
label: linkLabel ?? t("common.copy.link"),
|
||||
icon: <Link2 className="w-4 h-4" />,
|
||||
inGesture: true,
|
||||
onSelect: () => copy(link, t("common.copy.linkDone")),
|
||||
onSelect: () => copy(link, msg("common.copy.linkDone")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -75,6 +75,16 @@ export function lockBodyScroll(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
// A popover engine surface (`ui/AnchoredPopover`): portalled out of the dialog's root and carrying no
|
||||
// aria-modal, so a focus on one of a dialog's menu rows is inside the dialog all the same.
|
||||
const FLOATING = "[data-floating-ui-portal]";
|
||||
|
||||
// Restore targets handed to a dialog left standing above one that closed (see the cleanup below):
|
||||
// that dialog's own target was captured while the focus was in the one that closed, and is gone.
|
||||
// Keyed by the standing dialog's aria-modal element; a WeakMap, so a dialog that never closes
|
||||
// through this hook leaves nothing behind.
|
||||
const handedRestore = new WeakMap<Element, HTMLElement>();
|
||||
|
||||
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||
@@ -128,6 +138,11 @@ export function useDialogChrome(
|
||||
// Today that reads the same either way (a detached owner and a null one both fail every
|
||||
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
|
||||
const ownerAtOpen = ownerRef?.current ?? scope;
|
||||
// This dialog's aria-modal element, the key a closing dialog under it hands its target by.
|
||||
const modalAtOpen =
|
||||
ownerAtOpen?.closest('[aria-modal="true"]') ??
|
||||
ownerAtOpen?.querySelector('[aria-modal="true"]') ??
|
||||
null;
|
||||
if (scope && !scope.contains(document.activeElement)) {
|
||||
const back = focusedRef.current;
|
||||
const target =
|
||||
@@ -144,10 +159,12 @@ export function useDialogChrome(
|
||||
// it — and while it held the focus we read that as the user walking away. Then both closing
|
||||
// together left the focus on <body>: ours skipped the restore, and the child's own
|
||||
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
||||
// and it is the ordinary case — an error dialog raised over a dialog.
|
||||
// and it is the ordinary case — an error dialog raised over a dialog. And one of its own
|
||||
// menus (see FLOATING): Back with a menu open closed the dialog with the focus on a menu row.
|
||||
heldRef.current =
|
||||
(owner?.contains(active) ?? false) ||
|
||||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
|
||||
(active instanceof Element &&
|
||||
(active.closest('[aria-modal="true"]') != null || active.closest(FLOATING) != null));
|
||||
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
||||
// gone by then, so restoring onto it would strand the focus exactly as before.
|
||||
if (scopeRef.current?.contains(document.activeElement))
|
||||
@@ -172,16 +189,28 @@ export function useDialogChrome(
|
||||
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
||||
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
||||
// scrim — where Enter still activated it.
|
||||
//
|
||||
// Skipped, the trigger is handed to that standing dialog instead: ITS target was captured
|
||||
// while the focus was in ours, so it is gone now, and it falls back to the one handed to it.
|
||||
// A popover still standing (a menu of a dialog that stays open) is not ours to take from
|
||||
// either, though there is no dialog to hand the trigger to.
|
||||
const owner = ownerAtOpen;
|
||||
const active = document.activeElement;
|
||||
const target = restoreTo?.isConnected
|
||||
? restoreTo
|
||||
: modalAtOpen
|
||||
? handedRestore.get(modalAtOpen)
|
||||
: undefined;
|
||||
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
||||
const standingAbove =
|
||||
above !== null &&
|
||||
above.isConnected &&
|
||||
!above.contains(owner as Node | null) &&
|
||||
!(owner?.contains(above) ?? false);
|
||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
||||
restoreTo.focus();
|
||||
const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
|
||||
if (!heldRef.current || !target?.isConnected) return;
|
||||
if (standingAbove) handedRestore.set(above, target);
|
||||
else if (!standingMenu) target.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
||||
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"modules/notifications/NotificationsPanel.tsx": [
|
||||
1,
|
||||
"the Earlier (n) fold's heading (a disclosure: its text is its label)",
|
||||
],
|
||||
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||
|
||||
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
|
||||
]);
|
||||
|
||||
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
||||
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
||||
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
||||
// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
|
||||
// namespace is everything before the first dot.
|
||||
const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g;
|
||||
|
||||
describe("components/ui/ speaks only platform i18n namespaces", () => {
|
||||
it("names no feature namespace", () => {
|
||||
@@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => {
|
||||
|
||||
it("sees a feature namespace when one is used", () => {
|
||||
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: h.notify,
|
||||
}));
|
||||
|
||||
import { msg } from "../../lib/notifications";
|
||||
import { useCopyFeedback } from "./useCopyFeedback";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||
expect(result.current.copied).toBe(true);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
|
||||
act(() => void vi.advanceTimersByTime(2000));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(result.current.copied).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
|
||||
// How long a Copy button reads "Copied" after it worked.
|
||||
const COPIED_MS = 2000;
|
||||
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
|
||||
*
|
||||
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
|
||||
const copy = async (value: string) => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
notify({ level: "success", message: t("common.copy.done") });
|
||||
notify({ level: "success", message: msg("common.copy.done") });
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
return;
|
||||
}
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
notify({ level: "warning", message: msg("common.copy.failed") });
|
||||
};
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
[
|
||||
{
|
||||
"version": "0.114.2",
|
||||
"date": "2026-10-30",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Security: a library the sign-in relies on is updated, closing two published vulnerabilities."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.114.1",
|
||||
"date": "2026-10-30",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.114.0",
|
||||
"date": "2026-10-29",
|
||||
"new": [
|
||||
"Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left."
|
||||
],
|
||||
"fixed": [
|
||||
"Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.",
|
||||
"After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.113.0",
|
||||
"date": "2026-10-28",
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
[
|
||||
{
|
||||
"version": "0.114.2",
|
||||
"date": "2026-10-30",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Biztonság: frissült egy könyvtár, amelyre a bejelentkezés épül, így két nyilvános sebezhetőség záródott be."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.114.1",
|
||||
"date": "2026-10-30",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.114.0",
|
||||
"date": "2026-10-29",
|
||||
"new": [
|
||||
"Értesítések: elöl az, ami rád vár, utána minden más egy folyamban, a legújabb elöl. Az egy hétnél régebbi olvasott értesítések a Korábbiak alá csukódnak, és a Vissza arra a sorra tér vissza, ahonnan elmentél."
|
||||
],
|
||||
"fixed": [
|
||||
"Plex: az epizód vége előtt közvetlenül elküldött mentés már nem hagyja megnézetlenül, és egy elutasított befejezés sem ragad be.",
|
||||
"Párbeszédablak bezárása után a billentyűfókusz oda tér vissza, ahol volt, egy nyitva maradó menüben is."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.113.0",
|
||||
"date": "2026-10-28",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify, raw } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
@@ -137,22 +137,22 @@ export default function TagManager({
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
const onFailed = (e: unknown, fallback: NotifText) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
@@ -81,9 +81,9 @@ export interface ComfortPin {
|
||||
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
|
||||
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
|
||||
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
|
||||
remove: (pinId: number, title: string) => void;
|
||||
remove: (pinId: number, title: NotifText) => void;
|
||||
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
|
||||
item: (target: PinTarget | null, title: string) => ActionMenuItem[];
|
||||
item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
}, [qc]);
|
||||
|
||||
const add = useCallback(
|
||||
async (target: PinTarget, title: string) => {
|
||||
async (target: PinTarget, title: NotifText) => {
|
||||
try {
|
||||
await api.addComfortPin(target.kind, target.ref);
|
||||
// The shelf lives on another page, so without this the click has no visible consequence at
|
||||
// all from the feed — the one place a toast genuinely carries the whole feedback.
|
||||
notify({ level: "success", message: t("home.pin.added", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.added", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.addFailed"));
|
||||
notifyActionError(e, msg("home.pin.addFailed"));
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(pinId: number, title: string) => {
|
||||
(pinId: number, title: NotifText) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.removeComfortPin(pinId);
|
||||
notify({ level: "success", message: t("home.pin.removed", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.removed", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.removeFailed"));
|
||||
notifyActionError(e, msg("home.pin.removeFailed"));
|
||||
}
|
||||
invalidate();
|
||||
})();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(target: PinTarget, title: string) => {
|
||||
(target: PinTarget, title: NotifText) => {
|
||||
const pinId = pinIdOf(target);
|
||||
if (pinId == null) void add(target, title);
|
||||
else remove(pinId, title);
|
||||
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
);
|
||||
|
||||
const item = useCallback(
|
||||
(target: PinTarget | null, title: string): ActionMenuItem[] => {
|
||||
(target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
|
||||
if (!target) return [];
|
||||
const pinId = pinIdOf(target);
|
||||
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
const timer = window.setTimeout(() => {
|
||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
openComposer(id);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, wait);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
|
||||
import { closeComposer } from "../../lib/debugRecorder";
|
||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import {
|
||||
draftSteps,
|
||||
@@ -217,7 +217,7 @@ function ComposerBody({
|
||||
meta: { failTitle: "recorder.composer.sendFailed" },
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
notify({ level: "success", message: t("recorder.composer.sentOk") });
|
||||
notify({ level: "success", message: msg("recorder.composer.sentOk") });
|
||||
},
|
||||
// A failure half-way still created the issue: show what the server now has.
|
||||
onSettled: async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { flushNow, liveSession } from "../../lib/diagnostics";
|
||||
import { log } from "../../lib/log";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { updateMeCache, useMe } from "../../lib/useMe";
|
||||
|
||||
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
|
||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||
export function useDebugRecording() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const session = liveSession(me?.debug_session);
|
||||
@@ -54,7 +52,7 @@ export function useDebugRecording() {
|
||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(null);
|
||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
||||
notify({ level: "success", message: msg("recorder.pill.stopped") });
|
||||
openComposer(r.session.id);
|
||||
},
|
||||
});
|
||||
@@ -78,7 +76,7 @@ export function useDebugRecording() {
|
||||
);
|
||||
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
||||
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
||||
notify({ level: "success", message: t("recorder.pill.shotSaved") });
|
||||
notify({ level: "success", message: msg("recorder.pill.shotSaved") });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,9 +93,9 @@ export function useDebugRecording() {
|
||||
const n = nextStep(sessionId);
|
||||
const text = label.trim();
|
||||
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
||||
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
|
||||
notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
|
||||
},
|
||||
[sessionId, t],
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
||||
@@ -111,7 +109,7 @@ export function useDebugRecording() {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
notifyActionError(err, msg("recorder.pill.shotFailed"));
|
||||
}
|
||||
hide(false);
|
||||
if (image) shoot.mutate({ id: session.id, image });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
|
||||
|
||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
import { profileName } from "../../lib/vocab";
|
||||
@@ -63,15 +63,15 @@ export default function DownloadDialog({
|
||||
setLastProfile(chosen);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.dialog.added"),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||
message: msg("downloads.dialog.added"),
|
||||
action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
||||
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||
// open with nothing said.
|
||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||
notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import DownloadTray from "./DownloadTray";
|
||||
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
|
||||
poll([job(1, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
const notice = h.notify.mock.calls[0]![0] as {
|
||||
title: string;
|
||||
message: string;
|
||||
action: { label: string; onClick: () => void };
|
||||
secondaryAction: { label: string; onClick: () => void };
|
||||
title: NotifText;
|
||||
message: NotifText;
|
||||
action: { label: NotifText; onClick: () => void };
|
||||
secondaryAction: { label: NotifText; onClick: () => void };
|
||||
};
|
||||
expect(notice.title).toBe("tray.done.title:Clip 1");
|
||||
expect(notice.message).toBe("tray.done.phone");
|
||||
expect(notice.action.label).toBe("tray.done.copyLink");
|
||||
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
|
||||
expect(notice.message).toEqual(msg("tray.done.phone"));
|
||||
expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
|
||||
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
|
||||
notice.action.onClick();
|
||||
expect(h.share).toHaveBeenCalled();
|
||||
notice.secondaryAction.onClick();
|
||||
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
poll([job(1, "done"), job(2, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
|
||||
"tray.done.title:Clip 1",
|
||||
"tray.done.title:Clip 2",
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
|
||||
msg("tray.done.title", { title: raw("Clip 1") }),
|
||||
msg("tray.done.title", { title: raw("Clip 2") }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
|
||||
const view = mount();
|
||||
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: msg("tray.done.file") }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -257,7 +261,9 @@ describe("DownloadTray", () => {
|
||||
poll([job(1, "done", { source_key: "upload" })], view);
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
|
||||
expect.objectContaining({
|
||||
title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "../../lib/downloadQueue";
|
||||
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify, raw } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
@@ -191,16 +191,16 @@ export default function DownloadTray() {
|
||||
notify({
|
||||
level: "success",
|
||||
// An upload the worker had to convert first is done here too — it was never downloaded.
|
||||
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
|
||||
title,
|
||||
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
|
||||
title: raw(title),
|
||||
}),
|
||||
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
||||
message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
||||
action: {
|
||||
label: t("tray.done.copyLink"),
|
||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
||||
label: msg("tray.done.copyLink"),
|
||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||
},
|
||||
secondaryAction: {
|
||||
label: t("tray.done.open"),
|
||||
label: msg("tray.done.open"),
|
||||
onClick: () => openFile(job.id, job.folder_id),
|
||||
},
|
||||
});
|
||||
@@ -298,7 +298,7 @@ export default function DownloadTray() {
|
||||
id: "copyLink",
|
||||
label: t("tray.done.copyLink"),
|
||||
icon: <Link className="w-4 h-4" />,
|
||||
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
||||
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||
},
|
||||
]);
|
||||
const menuItems = menu.target
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
||||
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
||||
@@ -56,7 +57,7 @@ export function useDownloadActions() {
|
||||
? api.resumeDownload(id)
|
||||
: api.cancelDownload(id),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
||||
onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
|
||||
});
|
||||
|
||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
||||
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
||||
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
const isDirty = displayView != null && activeView == null;
|
||||
|
||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
|
||||
let ok = true;
|
||||
try {
|
||||
await fn();
|
||||
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
});
|
||||
if (!ok) return false;
|
||||
if (stickyId === v.id) setStickyId(null);
|
||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
|
||||
},
|
||||
reorder: async (nextIds) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||
);
|
||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||
await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
|
||||
const notifyActionError = vi.fn();
|
||||
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
|
||||
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
||||
return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
|
||||
});
|
||||
|
||||
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
|
||||
display_name: "The bit",
|
||||
});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
||||
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
|
||||
);
|
||||
// Its View opens the tray on the new clip.
|
||||
(
|
||||
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||||
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
||||
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
|
||||
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
|
||||
import { api, type ClipRequest } from "../../lib/api";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { invalidateDownloads } from "../../lib/downloads";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
import { log } from "../../lib/log";
|
||||
@@ -197,14 +197,14 @@ export default function ClipPanel({
|
||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("clip.queued"),
|
||||
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
||||
message: msg("clip.queued"),
|
||||
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
||||
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
||||
notifyActionError(err, t("clip.failed"));
|
||||
notifyActionError(err, msg("clip.failed"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Video, type VideoStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
||||
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [own] = useState(!hosted);
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const write = writeVideoState(qc, id, status);
|
||||
write
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
|
||||
return write;
|
||||
});
|
||||
const close = useStableCallback(() => setVideo(null));
|
||||
|
||||
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
|
||||
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
||||
});
|
||||
|
||||
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||
try {
|
||||
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
||||
act(() => void notify({ level: "error", message: "Connection lost" }));
|
||||
act(() => void notify({ level: "error", message: raw("Connection lost") }));
|
||||
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
|
||||
} finally {
|
||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||
|
||||
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
|
||||
import { VideoMenu } from "../video/VideoMenu";
|
||||
import { announceVideoState } from "../video/stateNotice";
|
||||
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
||||
* decided at the list, and the account keeps the last one (the owner's pick). */
|
||||
@@ -100,7 +101,7 @@ export function RelatedVideos({
|
||||
announceVideoState(id, status, v, () => onState(id, "new"));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("errors.writeFailed.videoState"));
|
||||
notifyActionError(e, msg("errors.writeFailed.videoState"));
|
||||
settle(seq);
|
||||
});
|
||||
};
|
||||
@@ -118,7 +119,7 @@ export function RelatedVideos({
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
);
|
||||
settle(seq);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { LinkedPlayer } from "./LinkedPlayer";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import { clearParams } from "../../lib/urlState";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
|
||||
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
|
||||
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
|
||||
open: (video: Video, inList: boolean) => void,
|
||||
scope: string | number | null = null,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
const [linkedScope] = useState(scope);
|
||||
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
|
||||
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => {
|
||||
notifyActionError(err, t("player.linkedFailed"));
|
||||
notifyActionError(err, msg("player.linkedFailed"));
|
||||
clearParams(...PLAYER_KEYS);
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
|
||||
// into or out of its library's manual collections, and names a new one — created with this title in
|
||||
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
|
||||
else await api.plexCollectionAddItem(c.id, ratingKey);
|
||||
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
|
||||
made = true;
|
||||
} catch (e) {
|
||||
made = madeOnPlex(e);
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
||||
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
|
||||
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
|
||||
} catch (e) {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
);
|
||||
}
|
||||
refresh();
|
||||
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
|
||||
} catch (e) {
|
||||
// The create may already have landed when the add fails — `refresh()` in `finally` is what
|
||||
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -74,6 +74,8 @@ describe("a season's name", () => {
|
||||
["Season 1", 1, "plex.seasonTitle(1)"],
|
||||
["Specials", 0, "plex.specials"],
|
||||
["Book One: Water", 1, "Book One: Water"],
|
||||
["Season 4", null, "Season 4"],
|
||||
["", null, "plex.playlist.seasonUnknown"],
|
||||
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
|
||||
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
||||
expect(screen.getByText(shown)).toBeTruthy();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { raw } from "../../lib/notifications";
|
||||
|
||||
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
|
||||
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
|
||||
@@ -151,7 +152,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
||||
]
|
||||
: []),
|
||||
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
||||
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
||||
...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||
@@ -122,7 +123,7 @@ function MembershipPanel({
|
||||
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
||||
);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -137,7 +138,7 @@ function MembershipPanel({
|
||||
await addPlaylistItem(qc, pl, videoId);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
notifyActionError(e, msg("playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import i18n from "../../i18n";
|
||||
import { notify, resolveVideo } from "../../lib/notifications";
|
||||
import { msg, notify, raw, resolveVideo } from "../../lib/notifications";
|
||||
import type { Video, VideoStatus } from "../../lib/api";
|
||||
|
||||
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
|
||||
@@ -24,9 +24,9 @@ export function announceVideoState(
|
||||
const hidden = status === "hidden";
|
||||
notify({
|
||||
message: video?.title
|
||||
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
|
||||
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
|
||||
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||
meta: {
|
||||
kind: hidden ? "video-hidden" : "video-watched",
|
||||
videoId: id,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { useNavigationActions } from "../navigation/navigationContext";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
|
||||
import { raw } from "../../lib/notifications";
|
||||
|
||||
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
|
||||
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
||||
@@ -198,7 +199,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
|
||||
...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
|
||||
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
import { api, type Me } from "../../lib/api";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
|
||||
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
const { unmount } = mount();
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast);
|
||||
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
expect(panel.contains(dismissToast)).toBe(false);
|
||||
|
||||
@@ -89,7 +89,7 @@ const quotaActions = enumValues("quota.py");
|
||||
// ruff wraps over several lines still matches.
|
||||
const adminCodes = [
|
||||
...(
|
||||
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
|
||||
readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
|
||||
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
|
||||
)?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
"clearConfirmTitle": "Clear every notification?",
|
||||
"clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.",
|
||||
"markRead": "Mark read",
|
||||
"unreadName": "{{name}}, unread",
|
||||
"dismiss": "Dismiss",
|
||||
"rowMenu": "Notification actions",
|
||||
"menu": "Notifications actions",
|
||||
"sectionSystem": "System",
|
||||
"sectionActivity": "Activity",
|
||||
"sectionNeedsYou_one": "Needs you · {{count}}",
|
||||
"sectionNeedsYou_other": "Needs you · {{count}}",
|
||||
"sectionRecent": "Recent",
|
||||
"earlier_one": "Earlier ({{count}})",
|
||||
"earlier_other": "Earlier ({{count}})",
|
||||
"andMore_one": "and {{count}} more",
|
||||
"andMore_other": "and {{count}} more",
|
||||
"openScheduler": "Open Scheduler",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"playShow": "Play the show",
|
||||
"playSeason": "Play the season",
|
||||
"removeFromPlaylist": "Remove from the playlist",
|
||||
"seasonOfShow": "{{show}} · {{season}}",
|
||||
"removedToast": "“{{title}}” removed from the playlist",
|
||||
"undoRemoveFailed": "Couldn't put it back in the playlist"
|
||||
},
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
"clearConfirmTitle": "Törlöd az összes értesítést?",
|
||||
"clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.",
|
||||
"markRead": "Olvasottnak jelöl",
|
||||
"unreadName": "{{name}}, olvasatlan",
|
||||
"dismiss": "Elrejtés",
|
||||
"rowMenu": "Értesítés műveletei",
|
||||
"menu": "Értesítések műveletei",
|
||||
"sectionSystem": "Rendszer",
|
||||
"sectionActivity": "Tevékenység",
|
||||
"sectionNeedsYou_one": "Rád vár · {{count}}",
|
||||
"sectionNeedsYou_other": "Rád vár · {{count}}",
|
||||
"sectionRecent": "Legutóbbiak",
|
||||
"earlier_one": "Korábbiak ({{count}})",
|
||||
"earlier_other": "Korábbiak ({{count}})",
|
||||
"andMore_one": "és még {{count}}",
|
||||
"andMore_other": "és még {{count}}",
|
||||
"openScheduler": "Ütemező megnyitása",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"playShow": "Sorozat lejátszása",
|
||||
"playSeason": "Évad lejátszása",
|
||||
"removeFromPlaylist": "Eltávolítás a listáról",
|
||||
"seasonOfShow": "{{show}} · {{season}}",
|
||||
"removedToast": "„{{title}}” eltávolítva a listáról",
|
||||
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
reloadAfterRefusal,
|
||||
} from "./entryUrl";
|
||||
import { HttpError } from "./api/core";
|
||||
import { raw } from "./notifications";
|
||||
|
||||
const notice = (search: string, hash = "") =>
|
||||
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
|
||||
@@ -27,13 +28,13 @@ describe("a switch of account, told to the account signed in", () => {
|
||||
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
|
||||
level: "info",
|
||||
key: "header.account.switchRequested",
|
||||
values: { email: "b+tag@example.com" },
|
||||
values: { email: raw("b+tag@example.com") },
|
||||
});
|
||||
});
|
||||
|
||||
it("names the address the request lives under when that is another spelling", () => {
|
||||
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
|
||||
email: "b.ob@x.com",
|
||||
email: raw("b.ob@x.com"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify: vi.fn(() => 0),
|
||||
}));
|
||||
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import { HttpError, api } from "./api";
|
||||
import { alreadyReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify, raw, sayNotif } from "./notifications";
|
||||
import { localizeDetail } from "./api/refusalText";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
|
||||
);
|
||||
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "EDIT_FAILED");
|
||||
notifyActionError(err, raw("EDIT_FAILED"));
|
||||
expect(notifyMock.mock.calls).toEqual([
|
||||
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
|
||||
[
|
||||
{
|
||||
level: "error",
|
||||
title: raw("EDIT_FAILED"),
|
||||
message: { detail: { code: "collection", reason: "name_taken" } },
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
|
||||
i18n.t("plex.collection.nameTaken"),
|
||||
);
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
|
||||
);
|
||||
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "FAILED");
|
||||
notifyActionError(err, raw("FAILED"));
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does toast one the caller was left to handle", () => {
|
||||
notifyActionError(new HttpError(404, "gone"), "FAILED");
|
||||
notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one failure once, however many callers try", () => {
|
||||
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
|
||||
const err = new HttpError(404, "gone");
|
||||
notifyActionError(err, "FIRST");
|
||||
notifyActionError(err, "SECOND");
|
||||
notifyActionError(err, raw("FIRST"));
|
||||
notifyActionError(err, raw("SECOND"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("says nothing about an upload the user cancelled", () => {
|
||||
notifyActionError(new HttpError(0, "aborted"), "FAILED");
|
||||
notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
|
||||
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
|
||||
// English a route hand-wrote went straight into the toast — "No user with that email." in an
|
||||
// otherwise Hungarian UI, while the caller's translated fallback sat unused.
|
||||
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
|
||||
notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED",
|
||||
message: i18n.t("errors.notFound.generic"),
|
||||
title: raw("TRANSLATED"),
|
||||
message: msg("errors.notFound.generic"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
|
||||
// The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
|
||||
// reader's language AND more specific than anything a status can say — quota numbers, which row
|
||||
// is gone.
|
||||
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
|
||||
code: "not_found",
|
||||
reason: "channel",
|
||||
});
|
||||
notifyActionError(err, "TRANSLATED FALLBACK");
|
||||
notifyActionError(err, raw("TRANSLATED FALLBACK"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED FALLBACK",
|
||||
message: "Ez a csatorna már nincs a könyvtárban.",
|
||||
title: raw("TRANSLATED FALLBACK"),
|
||||
message: { detail: { code: "not_found", reason: "channel" } },
|
||||
});
|
||||
// Said at display through localizeDetail, in the language of that moment.
|
||||
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
|
||||
localizeDetail({ code: "not_found", reason: "channel" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
|
||||
[0, "errors.reason.unreachable"], // uploadReq's network failure
|
||||
[500, "errors.generic"],
|
||||
])("a %s reads %s", (status, key) => {
|
||||
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
|
||||
const reason = failureReason(new HttpError(status, "hand-written English"));
|
||||
expect(reason).toEqual(msg(key));
|
||||
expect(sayNotif(reason)).toBe(i18n.t(key));
|
||||
});
|
||||
|
||||
it("a request that never reached the server reads as unreachable", () => {
|
||||
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
||||
expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
|
||||
i18n.t("errors.reason.unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
it("anything else is the app's own fault, not the server's", () => {
|
||||
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
|
||||
expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
|
||||
i18n.t("errors.reason.unexpected"),
|
||||
);
|
||||
});
|
||||
|
||||
it("every reason exists in both languages", () => {
|
||||
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
|
||||
describe("notifyKnownFailure", () => {
|
||||
it("shows the caller's specific sentence and marks the failure reported", () => {
|
||||
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
||||
notifyKnownFailure(err, "EMAIL IS OFF");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
||||
notifyActionError(err, "GENERIC");
|
||||
notifyKnownFailure(err, raw("EMAIL IS OFF"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
|
||||
notifyActionError(err, raw("GENERIC"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
|
||||
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
|
||||
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
import { msg, notify, type NotifText } from "./notifications";
|
||||
|
||||
const GATEWAY = new Set([502, 503, 504]);
|
||||
|
||||
/**
|
||||
* WHY a write failed, in the reader's language — the line under the toast's title.
|
||||
* WHY a write failed — the line under the toast's title, said in the reader's language when shown.
|
||||
*
|
||||
* The server's own reason wins: `req` has already run the structured detail through
|
||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* The server's own reason wins: the structured detail, which `localizeDetail` says, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
|
||||
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
|
||||
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
|
||||
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
|
||||
* says it.
|
||||
*/
|
||||
export function failureReason(err: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
export function failureReason(err: unknown): NotifText {
|
||||
const t = msg;
|
||||
if (err instanceof HttpError) {
|
||||
if (err.detailData != null && err.detail) return err.detail;
|
||||
if (err.detailData != null && err.detail) return { detail: err.detailData };
|
||||
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
|
||||
// change did not go through, which the connection notice alone does not say.
|
||||
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
|
||||
@@ -36,13 +34,13 @@ export function failureReason(err: unknown): string {
|
||||
|
||||
/**
|
||||
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
|
||||
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
|
||||
* names WHAT failed (the caller's "Couldn't hide the channel"), the line under it says
|
||||
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
|
||||
* the app-wide mutation reporter in lib/queryClient.
|
||||
*/
|
||||
export function notifyActionError(
|
||||
err: unknown,
|
||||
title: string,
|
||||
title: NotifText,
|
||||
level: "error" | "warning" = "error",
|
||||
): void {
|
||||
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
|
||||
@@ -57,7 +55,7 @@ export function notifyActionError(
|
||||
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
|
||||
* so the app-wide mutation reporter does not put a second, generic toast next to it.
|
||||
*/
|
||||
export function notifyKnownFailure(err: unknown, message: string): void {
|
||||
export function notifyKnownFailure(err: unknown, message: NotifText): void {
|
||||
if (alreadyReported(err)) return;
|
||||
notify({ level: "error", message });
|
||||
markReported(err);
|
||||
|
||||
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError }));
|
||||
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
||||
vi.mock("../notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../notifications")>()),
|
||||
notify,
|
||||
remove,
|
||||
getActiveToasts,
|
||||
}));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// error, structured-error localization, the connectivity toast, the identity headers,
|
||||
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
|
||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||
import { getActiveToasts, notify, remove } from "../notifications";
|
||||
import { getActiveToasts, msg, notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes, formatDecimal } from "../format";
|
||||
import { localizeDetail } from "./refusalText";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
|
||||
? (err.detailData as { reason?: string } | undefined)?.reason
|
||||
: undefined;
|
||||
|
||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
||||
|
||||
function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
|
||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||
// "check your input" message rather than leaking them into a translated sentence.
|
||||
if (Array.isArray(d)) return t("errors.validation");
|
||||
const o = d as
|
||||
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
|
||||
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
|
||||
if (o?.code === "clip")
|
||||
return t(`clip.errors.${o.reason}`, {
|
||||
defaultValue: t("clip.failed"),
|
||||
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
|
||||
});
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
if (o?.code === "reset")
|
||||
return o.reason === "weak_password"
|
||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
||||
// because the ceiling and the route's own cap are the same figure and the edge answers first —
|
||||
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
|
||||
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
|
||||
// the generic "couldn't carry out that action", the one message no retry can fix.
|
||||
//
|
||||
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
|
||||
// to read this at all depends on the client having finished sending: measured readable up to
|
||||
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
|
||||
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
|
||||
// happens when that check could not run.
|
||||
//
|
||||
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
|
||||
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
|
||||
// about a file told the issue composer's user to pick a smaller one — of nothing. The
|
||||
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
|
||||
if (o?.code === "too_large") return t("errors.tooLarge");
|
||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
if (o?.code === "url_blocked")
|
||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||||
// The one-step "move to another list". Both reasons mean the same thing to a reader —
|
||||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||||
if (o?.code === "collection")
|
||||
return o.reason === "name_taken"
|
||||
? t("plex.collection.nameTaken")
|
||||
: t("plex.collection.editFailed");
|
||||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||
if (o?.code === "youtube")
|
||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||||
// reads as the generic failure rather than a key.
|
||||
if (o?.code === "refused")
|
||||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
||||
// "try again", which no retry could fix.
|
||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
// generically rather than printing a key.
|
||||
if (o?.code === "not_found")
|
||||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
||||
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
||||
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
||||
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
|
||||
if (id !== null) remove(id);
|
||||
connectivityNotifId = notify({
|
||||
level: "error",
|
||||
title: i18n.t("errors.offline.title"),
|
||||
message: i18n.t("errors.offline.body"),
|
||||
title: msg("errors.offline.title"),
|
||||
message: msg("errors.offline.body"),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
|
||||
vi.mock("../notifications", () => ({
|
||||
vi.mock("../notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../notifications")>()),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
|
||||
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
|
||||
// moment, and the notice store cannot import the request core that imports it.
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes, formatDecimal } from "../format";
|
||||
|
||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
||||
|
||||
export function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
|
||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||
// "check your input" message rather than leaking them into a translated sentence.
|
||||
if (Array.isArray(d)) return t("errors.validation");
|
||||
const o = d as
|
||||
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
|
||||
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
|
||||
if (o?.code === "clip")
|
||||
return t(`clip.errors.${o.reason}`, {
|
||||
defaultValue: t("clip.failed"),
|
||||
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
|
||||
});
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
if (o?.code === "reset")
|
||||
return o.reason === "weak_password"
|
||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
||||
// because the ceiling and the route's own cap are the same figure and the edge answers first —
|
||||
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
|
||||
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
|
||||
// the generic "couldn't carry out that action", the one message no retry can fix.
|
||||
//
|
||||
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
|
||||
// to read this at all depends on the client having finished sending: measured readable up to
|
||||
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
|
||||
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
|
||||
// happens when that check could not run.
|
||||
//
|
||||
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
|
||||
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
|
||||
// about a file told the issue composer's user to pick a smaller one — of nothing. The
|
||||
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
|
||||
if (o?.code === "too_large") return t("errors.tooLarge");
|
||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
if (o?.code === "url_blocked")
|
||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||||
// The one-step "move to another list". Both reasons mean the same thing to a reader —
|
||||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||||
if (o?.code === "collection")
|
||||
return o.reason === "name_taken"
|
||||
? t("plex.collection.nameTaken")
|
||||
: t("plex.collection.editFailed");
|
||||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||
if (o?.code === "youtube")
|
||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||||
// reads as the generic failure rather than a key.
|
||||
if (o?.code === "refused")
|
||||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
||||
// "try again", which no retry could fix.
|
||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
// generically rather than printing a key.
|
||||
if (o?.code === "not_found")
|
||||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
return t("errors.generic");
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
import { api, HttpError } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.updateFailed"),
|
||||
message: i18n.t("errors.reason.forbidden"),
|
||||
title: msg("channels.tags.updateFailed"),
|
||||
message: msg("errors.reason.forbidden"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.createFailed"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("channels.tags.createFailed"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.writeFailed.channelPriority"),
|
||||
message: i18n.t("errors.reason.unreachable"),
|
||||
title: msg("errors.writeFailed.channelPriority"),
|
||||
message: msg("errors.reason.unreachable"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
|
||||
// account's stored history with it.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { raw, sayNotif, type NotifText } from "./notifications";
|
||||
|
||||
const entry = (id: number, message: string) => ({
|
||||
id,
|
||||
level: "info",
|
||||
message,
|
||||
message: raw(message),
|
||||
ts: 1,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
@@ -19,10 +20,10 @@ const entry = (id: number, message: string) => ({
|
||||
});
|
||||
const storedMessages = () =>
|
||||
(
|
||||
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
|
||||
message: string;
|
||||
JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as {
|
||||
message: NotifText;
|
||||
}[]
|
||||
).map((n) => n.message);
|
||||
).map((n) => sayNotif(n.message));
|
||||
|
||||
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
|
||||
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
|
||||
@@ -47,14 +48,14 @@ beforeEach(() => {
|
||||
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
|
||||
it("keep the account's notification history and settings once it is claimed", async () => {
|
||||
localStorage.setItem("siftlode.owner", "5");
|
||||
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")]));
|
||||
localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "older")]));
|
||||
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
|
||||
const notifications = await import("./notifications");
|
||||
const { claimLocalData } = await import("./storage");
|
||||
|
||||
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
|
||||
notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
|
||||
claimLocalData(5);
|
||||
notifications.notify({ message: "after the claim" });
|
||||
notifications.notify({ message: raw("after the claim") });
|
||||
|
||||
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
|
||||
expect(notifications.getNotifSettings().durationMs).toBe(0);
|
||||
@@ -75,12 +76,12 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
|
||||
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
|
||||
it("starts the new account's notices from an empty history", async () => {
|
||||
localStorage.setItem("siftlode.owner", "4");
|
||||
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")]));
|
||||
localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "four's")]));
|
||||
const notifications = await import("./notifications");
|
||||
const { claimLocalData } = await import("./storage");
|
||||
|
||||
claimLocalData(5);
|
||||
notifications.notify({ message: "as five" });
|
||||
notifications.notify({ message: raw("as five") });
|
||||
|
||||
expect(storedMessages()).toEqual(["as five"]);
|
||||
});
|
||||
|
||||
@@ -21,18 +21,21 @@ vi.mock("./api", () => ({
|
||||
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
|
||||
}));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
import { qk } from "./queryKeys";
|
||||
import { msg, type NotifText } from "./notifications";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
|
||||
type ToastArg = {
|
||||
level: string;
|
||||
message: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
message: NotifText;
|
||||
action?: { label: NotifText; onClick: () => void };
|
||||
};
|
||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||
|
||||
@@ -55,7 +58,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 42 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/tok7`);
|
||||
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
|
||||
@@ -63,8 +66,8 @@ describe("quickShareLink", () => {
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("success");
|
||||
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
|
||||
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||
arg.action!.onClick();
|
||||
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
@@ -72,7 +75,7 @@ describe("quickShareLink", () => {
|
||||
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/old`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -85,7 +88,7 @@ describe("quickShareLink", () => {
|
||||
{ id: 3, url: "/watch/older" },
|
||||
]);
|
||||
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/older`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -97,7 +100,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||
const client = qc();
|
||||
|
||||
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
|
||||
const url = await quickShareLink(client, { kind: "file", id: 6 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/new`);
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||
@@ -113,7 +116,7 @@ describe("quickShareLink", () => {
|
||||
const client = qc();
|
||||
client.setQueryData(qk.fileFolders(), []);
|
||||
|
||||
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
|
||||
const url = await quickShareLink(client, { kind: "folder", id: 5 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/fold`);
|
||||
expect(folderLinks).toHaveBeenCalledWith(5);
|
||||
@@ -129,26 +132,26 @@ describe("quickShareLink", () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 2 });
|
||||
|
||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("warning");
|
||||
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
|
||||
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||
});
|
||||
|
||||
it("reports an error and returns null when link creation fails", async () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 9 });
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// The download queue, the per-card download index, the library (its listings and folder counts) and
|
||||
@@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
||||
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
|
||||
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
|
||||
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||
export async function quickShareLink(
|
||||
qc: QueryClient,
|
||||
target: LinkTarget,
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
|
||||
let path;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
@@ -65,18 +61,18 @@ export async function quickShareLink(
|
||||
}
|
||||
if (!path) throw new Error("the new link came back without its address");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||
notifyActionError(e, msg("downloads.share.linkFailed"));
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + path;
|
||||
const action = {
|
||||
label: t("downloads.share.openNewTab"),
|
||||
label: msg("downloads.share.openNewTab"),
|
||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||
};
|
||||
notify(
|
||||
(await copyText(url))
|
||||
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||
? { level: "success", message: msg("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: msg("downloads.share.copyFailed"), action },
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
||||
// with nothing personal in them (login, deleted, account) ride the query string.
|
||||
|
||||
import type { NotifLevel } from "./notifications";
|
||||
import { raw, type NotifLevel, type NotifValue } from "./notifications";
|
||||
import { refusalReason } from "./api/core";
|
||||
import { hrefHere } from "./urlState";
|
||||
|
||||
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
|
||||
export interface AccountNotice {
|
||||
level: NotifLevel;
|
||||
key: string;
|
||||
values?: Record<string, string>;
|
||||
values?: Record<string, NotifValue>;
|
||||
}
|
||||
|
||||
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as
|
||||
@@ -110,7 +110,7 @@ export function accountSwitchNotice(
|
||||
const access = hash.get("access");
|
||||
const email = hash.get("linked") || hash.get("email");
|
||||
if (access === "requested" && email)
|
||||
return { level: "info", key: "header.account.switchRequested", values: { email } };
|
||||
return { level: "info", key: "header.account.switchRequested", values: { email: raw(email) } };
|
||||
if (access === "requested" || access === "denied")
|
||||
return { level: "warning", key: "header.account.switchNotApproved" };
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FOLD_AFTER_MS, entryKey, orderInbox, type InboxOrder } from "./inboxOrder";
|
||||
import type { AppNotification } from "./api";
|
||||
import type { Notification as ClientNotif } from "./notifications";
|
||||
|
||||
const NOW = Date.parse("2026-10-28T12:00:00Z");
|
||||
const ago = (ms: number) => NOW - ms;
|
||||
const H = 3600 * 1000;
|
||||
const D = 24 * H;
|
||||
|
||||
const server = (id: number, at: number | null, read: boolean) =>
|
||||
({
|
||||
id,
|
||||
type: "download_done",
|
||||
title: "t",
|
||||
body: null,
|
||||
data: null,
|
||||
read,
|
||||
dismissed: false,
|
||||
created_at: at === null ? null : new Date(at).toISOString(),
|
||||
}) as AppNotification;
|
||||
const client = (id: number, at: number, extra: Partial<ClientNotif> = {}) =>
|
||||
({
|
||||
id,
|
||||
level: "info",
|
||||
message: "m",
|
||||
requiresInteraction: false,
|
||||
ts: at,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
repeat: 1,
|
||||
...extra,
|
||||
}) as ClientNotif;
|
||||
|
||||
const keys = (o: InboxOrder) => ({
|
||||
needsYou: o.needsYou.map(entryKey),
|
||||
recent: o.recent.map(entryKey),
|
||||
earlier: o.earlier.map(entryKey),
|
||||
});
|
||||
const order = (s: AppNotification[], c: ClientNotif[], held: ReadonlySet<string> = new Set()) =>
|
||||
keys(orderInbox(s, c, { now: NOW, held }));
|
||||
|
||||
describe("orderInbox", () => {
|
||||
it("puts a waiting access-requests nudge above every server row, read or not", () => {
|
||||
const o = order(
|
||||
[server(1, ago(1 * H), false), server(2, ago(1 * D), true)],
|
||||
[client(1, ago(5 * 60 * 1000), { meta: { kind: "access-requests" } })],
|
||||
);
|
||||
expect(o.needsYou).toEqual(["client:1", "server:1"]);
|
||||
expect(o.recent).toEqual(["server:2"]);
|
||||
});
|
||||
|
||||
it("an old unread row still asks: it never folds or drifts below newer read ones", () => {
|
||||
const o = order([server(1, ago(30 * D), false), server(2, ago(1 * H), true)], []);
|
||||
expect(o.needsYou).toEqual(["server:1"]);
|
||||
expect(o.recent).toEqual(["server:2"]);
|
||||
expect(o.earlier).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges both sources into one stream, newest first", () => {
|
||||
const o = order(
|
||||
[server(1, ago(3 * H), true), server(2, ago(1 * H), true)],
|
||||
[client(7, ago(2 * H)), client(8, ago(4 * H))],
|
||||
);
|
||||
expect(o.recent).toEqual(["server:2", "client:7", "server:1", "client:8"]);
|
||||
});
|
||||
|
||||
it("folds read rows older than a week, and only those", () => {
|
||||
const o = order(
|
||||
[server(1, ago(FOLD_AFTER_MS + H), true), server(2, ago(FOLD_AFTER_MS - H), true)],
|
||||
[client(3, ago(9 * D))],
|
||||
);
|
||||
expect(o.recent).toEqual(["server:2"]);
|
||||
expect(o.earlier).toEqual(["server:1", "client:3"]);
|
||||
});
|
||||
|
||||
it("keeps a row that asked earlier in the visit in its place once read, from either source", () => {
|
||||
// Opening the center marks the client notices read: the band must not empty as it renders; and
|
||||
// a server row marked read must not jump lists under the reader.
|
||||
const o = order(
|
||||
[server(1, ago(30 * D), true), server(2, ago(3 * H), true)],
|
||||
[client(4, ago(2 * H)), client(5, ago(1 * H))],
|
||||
new Set(["client:4", "server:1"]),
|
||||
);
|
||||
expect(o.needsYou).toEqual(["client:4", "server:1"]);
|
||||
expect(o.recent).toEqual(["client:5", "server:2"]);
|
||||
expect(o.earlier).toEqual([]);
|
||||
});
|
||||
|
||||
it("an unread client notice (raised while the center is open) asks", () => {
|
||||
const o = order([], [client(6, ago(H), { read: false })]);
|
||||
expect(o.needsYou).toEqual(["client:6"]);
|
||||
});
|
||||
|
||||
it("a notice waiting on an interaction asks until it is dealt with", () => {
|
||||
const o = order(
|
||||
[],
|
||||
[
|
||||
client(1, ago(H), { requiresInteraction: true, dismissed: false }),
|
||||
client(2, ago(H), { requiresInteraction: true, dismissed: true }),
|
||||
],
|
||||
);
|
||||
expect(o.needsYou).toEqual(["client:1"]);
|
||||
expect(o.recent).toEqual(["client:2"]);
|
||||
});
|
||||
|
||||
it("a server row of no known date sorts last and never folds", () => {
|
||||
const o = order([server(1, null, true), server(2, ago(30 * D), true)], [client(3, ago(H))]);
|
||||
expect(o.recent).toEqual(["client:3", "server:1"]);
|
||||
expect(o.earlier).toEqual(["server:2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AppNotification } from "./api";
|
||||
import type { Notification as ClientNotif } from "./notifications";
|
||||
|
||||
// The notification center's order, in ONE rule for both sources. It used to be two fixed sections,
|
||||
// the server's above this browser's — so an admin's "access requests waiting" sat under up to a
|
||||
// hundred server rows, week-old read "Download ready" notices among them. Now what asks for the
|
||||
// reader comes first, then everything else as one time-ordered stream, and read rows older than a
|
||||
// week fold away (kept, not deleted).
|
||||
|
||||
export type InboxEntry =
|
||||
| { src: "server"; n: AppNotification; at: number }
|
||||
| { src: "client"; n: ClientNotif; at: number };
|
||||
|
||||
export interface InboxOrder {
|
||||
/** Unread, or waiting on an action: newest first. Never folded, whatever its age. */
|
||||
needsYou: InboxEntry[];
|
||||
/** The rest from the last week (or of no known date), newest first. */
|
||||
recent: InboxEntry[];
|
||||
/** Read rows older than `FOLD_AFTER_MS`, newest first — shown only on request. */
|
||||
earlier: InboxEntry[];
|
||||
}
|
||||
|
||||
export const FOLD_AFTER_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
// A row of no known date (a server row stored without one) sorts last and never folds: its age is
|
||||
// unknown, and hiding it on a guess is how a row goes missing.
|
||||
const NO_DATE = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function serverAt(n: AppNotification): number {
|
||||
const at = n.created_at ? Date.parse(n.created_at) : NaN;
|
||||
return Number.isNaN(at) ? NO_DATE : at;
|
||||
}
|
||||
|
||||
/** A row's key across both sources: their ids are separate sequences, and they share one list now. */
|
||||
export const keyOf = (src: InboxEntry["src"], id: number): string => `${src}:${id}`;
|
||||
export const entryKey = (e: InboxEntry): string => keyOf(e.src, e.n.id);
|
||||
|
||||
/** Whether a client notice asks for the reader by what it is: an access-requests nudge for as long
|
||||
* as it exists (its producer removes it when nothing is pending), a notice that waits on an
|
||||
* interaction until it is dealt with. */
|
||||
export const clientAsks = (n: ClientNotif): boolean =>
|
||||
n.meta?.kind === "access-requests" || (n.requiresInteraction && !n.dismissed);
|
||||
|
||||
export function orderInbox(
|
||||
server: readonly AppNotification[],
|
||||
client: readonly ClientNotif[],
|
||||
/** `held`: the rows that asked earlier in this visit (entry keys). They keep their place once read —
|
||||
* a row marked read must not jump to another list under the reader, taking the keyboard focus to
|
||||
* <body> as it remounts there — and they are how a client notice stays up at all: opening the
|
||||
* center marks every client notice read, so "unread" alone would empty the band as it renders. */
|
||||
{ now, held }: { now: number; held: ReadonlySet<string> },
|
||||
): InboxOrder {
|
||||
const out: InboxOrder = { needsYou: [], recent: [], earlier: [] };
|
||||
const place = (e: InboxEntry, asks: boolean) => {
|
||||
if (asks) out.needsYou.push(e);
|
||||
else if (e.at !== NO_DATE && now - e.at > FOLD_AFTER_MS) out.earlier.push(e);
|
||||
else out.recent.push(e);
|
||||
};
|
||||
for (const n of server)
|
||||
place({ src: "server", n, at: serverAt(n) }, !n.read || held.has(keyOf("server", n.id)));
|
||||
for (const n of client)
|
||||
place(
|
||||
{ src: "client", n, at: n.ts },
|
||||
!n.read || held.has(keyOf("client", n.id)) || clientAsks(n),
|
||||
);
|
||||
// Newest first; equal times keep their source's own order (Array.sort is stable).
|
||||
const newest = (a: InboxEntry, b: InboxEntry) => (b.at === a.at ? 0 : b.at > a.at ? 1 : -1);
|
||||
out.needsYou.sort(newest);
|
||||
out.recent.sort(newest);
|
||||
out.earlier.sort(newest);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Back to a page whose item changed lists while it was away. The notification center holds a row
|
||||
// that asked for the reader in "Needs you" for the visit; on the next visit the same row, read now,
|
||||
// sits in the stream below. The restore matched the unnamed list by its place, missed the name there
|
||||
// and fell back to the saved index — the focus landed on the neighbour, and Enter opened the wrong
|
||||
// notification. Its own file: listNav keeps module-level state.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import {
|
||||
NAV_REVEAL_EVENT,
|
||||
navItemAttrs,
|
||||
navPosition,
|
||||
pendingNavName,
|
||||
restoreNavPosition,
|
||||
useListNav,
|
||||
} from "./listNav";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// `count`: a virtualized list's, which counts rows it has yet to render.
|
||||
function List({ items, count }: { items: string[]; count?: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, count });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{items.map((t, i) => (
|
||||
<div key={`${t}-${i}`} {...navItemAttrs(i, t)}>
|
||||
<button data-nav-open="">{t}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `tray`: a list of the app's own, outside the page (the running downloads), whose rows can share a
|
||||
// name with the page's.
|
||||
const Page = ({
|
||||
lists,
|
||||
tray,
|
||||
counts = [],
|
||||
}: {
|
||||
lists: string[][];
|
||||
tray?: string[];
|
||||
counts?: number[];
|
||||
}) => (
|
||||
<>
|
||||
<main>
|
||||
{lists.map((items, i) => (
|
||||
<List key={i} items={items} count={counts[i]} />
|
||||
))}
|
||||
</main>
|
||||
{tray && <List items={tray} />}
|
||||
</>
|
||||
);
|
||||
|
||||
const button = (name: string) =>
|
||||
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
|
||||
const focused = () => document.activeElement?.closest("[data-nav-index]")?.textContent;
|
||||
const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
|
||||
|
||||
async function leaveAndReturn(
|
||||
before: string[][],
|
||||
from: string,
|
||||
after: string[][],
|
||||
tray?: string[],
|
||||
) {
|
||||
const { unmount } = render(<Page lists={before} tray={tray} />);
|
||||
button(from).focus();
|
||||
const pos = navPosition()!;
|
||||
unmount(); // the page switch
|
||||
render(<Page lists={after} tray={tray} />); // Back
|
||||
const cancel = restoreNavPosition(pos);
|
||||
await wait(120);
|
||||
cancel();
|
||||
}
|
||||
|
||||
describe("Back to an item that moved to another list", () => {
|
||||
it("finds it by its name in the list it moved to, not its neighbour at the old place", async () => {
|
||||
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
|
||||
["download"],
|
||||
["subscribed", "older"],
|
||||
]);
|
||||
expect(focused()).toBe("subscribed");
|
||||
});
|
||||
|
||||
it("finds it when the list it left is gone altogether", async () => {
|
||||
await leaveAndReturn([["subscribed"], ["older"]], "subscribed", [["subscribed", "older"]]);
|
||||
expect(focused()).toBe("subscribed");
|
||||
});
|
||||
|
||||
it("does not guess between two items of that name elsewhere: the saved place", async () => {
|
||||
await leaveAndReturn([["twin", "download"], ["older"]], "twin", [
|
||||
["download"],
|
||||
["twin", "older", "twin"],
|
||||
]);
|
||||
expect(focused()).toBe("download");
|
||||
});
|
||||
|
||||
it("brings an item found in another list into view; one in its own list stays put", async () => {
|
||||
// The scroll restore put the page where the item WAS: found elsewhere, it would hold the focus
|
||||
// off screen.
|
||||
const reveal = vi.fn();
|
||||
Element.prototype.scrollIntoView = reveal;
|
||||
// ...and tells the page's scroll restore, which would otherwise put the old offset back over it.
|
||||
const told = vi.fn();
|
||||
document.addEventListener(NAV_REVEAL_EVENT, told);
|
||||
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
|
||||
["download"],
|
||||
["subscribed", "older"],
|
||||
]);
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(told).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
reveal.mockClear();
|
||||
told.mockClear();
|
||||
await leaveAndReturn([["subscribed", "download"]], "download", [["download", "subscribed"]]);
|
||||
expect(focused()).toBe("download");
|
||||
expect(reveal).not.toHaveBeenCalled();
|
||||
expect(told).not.toHaveBeenCalled();
|
||||
document.removeEventListener(NAV_REVEAL_EVENT, told);
|
||||
});
|
||||
|
||||
it("never takes it from a list outside the page: the download tray's row of that name", async () => {
|
||||
// A feed card and its download in the tray are both named by the video's title.
|
||||
await leaveAndReturn(
|
||||
[["subscribed", "download"]],
|
||||
"subscribed",
|
||||
[["download"]],
|
||||
["subscribed"],
|
||||
);
|
||||
expect(focused()).toBe("download");
|
||||
});
|
||||
|
||||
it("waits for its own list to render the place before looking in another one", async () => {
|
||||
// A virtualized list puts its rows back a frame after the page: the item is still to come at
|
||||
// the first look, and the one of that name in the rail below is not it.
|
||||
const { unmount } = render(<Page lists={[["older", "subscribed"], ["subscribed-2"]]} />);
|
||||
button("subscribed").focus();
|
||||
const pos = navPosition()!;
|
||||
unmount();
|
||||
const { rerender } = render(<Page lists={[["older"], ["subscribed"]]} counts={[2]} />);
|
||||
const cancel = restoreNavPosition(pos);
|
||||
await wait(60);
|
||||
rerender(<Page lists={[["older", "subscribed"], ["subscribed"]]} counts={[2]} />);
|
||||
await wait(120);
|
||||
cancel();
|
||||
expect(document.activeElement?.closest("main > div")).toBe(
|
||||
document.querySelector("main")!.firstElementChild,
|
||||
);
|
||||
});
|
||||
|
||||
it("looks in another list at its last look when its own never renders the place it counts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { unmount } = render(<Page lists={[["older", "subscribed"], ["subscribed-2"]]} />);
|
||||
button("subscribed").focus();
|
||||
const pos = navPosition()!;
|
||||
unmount();
|
||||
// Its own list still counts two, and keeps showing one; the item is in the next list.
|
||||
render(<Page lists={[["older"], ["subscribed"]]} counts={[2]} />);
|
||||
const cancel = restoreNavPosition(pos);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
||||
expect(focused()).toBeUndefined(); // still giving its own list the chance
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1100));
|
||||
expect(focused()).toBe("subscribed");
|
||||
cancel();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the name a Back is still looking for", () => {
|
||||
const pos = { list: 0, lists: 1, index: 0, name: "subscribed" };
|
||||
|
||||
it("is there while it looks, and gone once cancelled", () => {
|
||||
const cancel = restoreNavPosition(pos);
|
||||
expect(pendingNavName()).toBe("subscribed");
|
||||
cancel();
|
||||
expect(pendingNavName()).toBeNull();
|
||||
});
|
||||
|
||||
it("is gone once the focus went somewhere else", async () => {
|
||||
render(<button>elsewhere</button>);
|
||||
const cancel = restoreNavPosition(pos);
|
||||
button("elsewhere").focus();
|
||||
await wait(80);
|
||||
expect(pendingNavName()).toBeNull();
|
||||
cancel();
|
||||
});
|
||||
|
||||
it("is gone once it gave up", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancel = restoreNavPosition(pos);
|
||||
await vi.advanceTimersByTimeAsync(2100);
|
||||
expect(pendingNavName()).toBeNull();
|
||||
cancel();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+97
-22
@@ -42,6 +42,15 @@ const lists = new Map<HTMLElement, NavList>();
|
||||
// The item we gave a tabindex to, so the next move can take it back — and whether we named it too.
|
||||
let roved: HTMLElement | null = null;
|
||||
let named = false;
|
||||
// A named item's label follows its spoken state while it holds the focus: Mark read hands the
|
||||
// focus to the row BEFORE the row reads read, and the label given then said "unread" until the
|
||||
// keyboard moved on.
|
||||
const relabel =
|
||||
typeof MutationObserver === "undefined"
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
if (named && roved) roved.setAttribute("aria-label", spokenName(roved));
|
||||
});
|
||||
|
||||
const INDEX = "data-nav-index";
|
||||
const LIST = "data-nav-list";
|
||||
@@ -56,14 +65,24 @@ const REVEAL_FRAMES = 10;
|
||||
* `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a
|
||||
* card with a gap round it has room for; an item flush against its neighbours (a log line) or against
|
||||
* the edge of a clipping card (a Plex playlist show header) does not: the ring ran past the list, or
|
||||
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. */
|
||||
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag.
|
||||
*
|
||||
* `label` is what is SPOKEN instead of `name`, when it carries a state the item can lose while the
|
||||
* page is away (a notification's "unread"): `name` is also how Back finds the item again, and a
|
||||
* name that changed with the state would miss it. */
|
||||
export const navItemAttrs = (
|
||||
index: number,
|
||||
name?: string | null,
|
||||
{ inset = false }: { inset?: boolean } = {},
|
||||
): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({
|
||||
{ inset = false, label }: { inset?: boolean; label?: string } = {},
|
||||
): {
|
||||
"data-nav-index": number;
|
||||
"data-nav-name"?: string;
|
||||
"data-nav-label"?: string;
|
||||
"data-nav-inset"?: "";
|
||||
} => ({
|
||||
[INDEX]: index,
|
||||
...(name ? { "data-nav-name": name } : null),
|
||||
...(label ? { "data-nav-label": label } : null),
|
||||
...(inset ? { "data-nav-inset": "" } : null),
|
||||
});
|
||||
|
||||
@@ -204,11 +223,23 @@ function itemName(el: HTMLElement): string {
|
||||
).trim();
|
||||
}
|
||||
|
||||
/** What a screen reader hears while the item holds the focus: its `label` when it gives one. */
|
||||
const spokenName = (el: HTMLElement): string => el.getAttribute("data-nav-label") || itemName(el);
|
||||
|
||||
/** The event an item brought into view sends up its page: a scroll restore still under way gives the
|
||||
* page up to it (`ui/PageScroller`) — on Back, an item found in another list than the one it was
|
||||
* left in is not where the restored offset shows, and the restore's next frame scrolled it away. */
|
||||
export const NAV_REVEAL_EVENT = "navreveal";
|
||||
export const revealedNavItem = (el: HTMLElement): void => {
|
||||
el.dispatchEvent(new Event(NAV_REVEAL_EVENT, { bubbles: true }));
|
||||
};
|
||||
|
||||
function focusItem(el: HTMLElement, reveal = true): void {
|
||||
if (roved !== el) {
|
||||
if (named) roved?.removeAttribute("role");
|
||||
if (named) roved?.removeAttribute("aria-label");
|
||||
roved?.removeAttribute("tabindex");
|
||||
relabel?.disconnect();
|
||||
roved = null;
|
||||
named = false;
|
||||
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
|
||||
@@ -219,7 +250,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
|
||||
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
|
||||
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
|
||||
// opens. A row (<tr>) keeps its own role.
|
||||
const name = itemName(el);
|
||||
const name = spokenName(el);
|
||||
if (
|
||||
el.tagName === "DIV" &&
|
||||
name &&
|
||||
@@ -229,13 +260,17 @@ function focusItem(el: HTMLElement, reveal = true): void {
|
||||
el.setAttribute("role", "group");
|
||||
el.setAttribute("aria-label", name);
|
||||
named = true;
|
||||
relabel?.observe(el, { attributeFilter: ["data-nav-label", "data-nav-name"] });
|
||||
}
|
||||
}
|
||||
}
|
||||
el.focus({ preventScroll: true });
|
||||
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
|
||||
// header or past the bottom edge scrolls just far enough to clear it.
|
||||
if (reveal) el.scrollIntoView?.({ block: "nearest" });
|
||||
if (reveal) {
|
||||
el.scrollIntoView?.({ block: "nearest" });
|
||||
revealedNavItem(el);
|
||||
}
|
||||
}
|
||||
|
||||
function focusIndex(
|
||||
@@ -459,18 +494,37 @@ export function navPosition(): NavPosition | null {
|
||||
// look — on a timer, not animation frames: a hidden tab runs none.
|
||||
const RESTORE_MS = 2000;
|
||||
const RESTORE_POLL_MS = 50;
|
||||
// The restore under way, while it looks for its item.
|
||||
let pending: NavPosition | null = null;
|
||||
|
||||
/** The item in `list` to hand the focus back to: the saved place while it still holds the saved
|
||||
/** The item to hand the focus back to: in `list`, the saved place while it still holds the saved
|
||||
* name, else the nearest item of that name (names repeat — a log console's identical request
|
||||
* lines), else the saved place. */
|
||||
function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
||||
const at = itemAt(list, pos.index);
|
||||
if (!pos.name || (at && itemName(at) === pos.name)) return at;
|
||||
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
||||
const named = ownItems(list.el)
|
||||
.filter((it) => itemName(it) === pos.name)
|
||||
.sort((a, b) => distance(a) - distance(b));
|
||||
return named[0] ?? at;
|
||||
* lines). Then the one item of that name in another of the page's lists: an item can change lists
|
||||
* while the page was away (a notification asked for the reader on the visit it was opened from and
|
||||
* sits in the stream on the next), and the place left behind is now its neighbour's. Only when the
|
||||
* name is the page's alone — of two, either could be the one — and only the PAGE's lists: the
|
||||
* running-downloads tray is the app's, on every page, and its row of a download is named like the
|
||||
* feed card it came from. Not while `list` has yet to render a saved place it still counts (a
|
||||
* virtualized list puts its rows back a frame after the page), until the `last` look: the item is
|
||||
* likely still to come there. Else the saved place. */
|
||||
function restoreTarget(
|
||||
all: readonly NavList[],
|
||||
list: NavList | undefined,
|
||||
pos: NavPosition,
|
||||
last: boolean,
|
||||
): HTMLElement | null {
|
||||
const at = list ? itemAt(list, pos.index) : null;
|
||||
if (list && (!pos.name || (at && itemName(at) === pos.name))) return at;
|
||||
if (!pos.name) return null;
|
||||
const named = (l: NavList) => ownItems(l.el).filter((it) => itemName(it) === pos.name);
|
||||
if (list) {
|
||||
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
||||
const here = named(list).sort((a, b) => distance(a) - distance(b));
|
||||
if (here[0]) return here[0];
|
||||
}
|
||||
if (list && !at && pos.index < list.count() && !last) return null;
|
||||
const [only, ...more] = all.filter((l) => l.el.closest("main")).flatMap(named);
|
||||
return only && more.length === 0 ? only : at;
|
||||
}
|
||||
|
||||
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
|
||||
@@ -478,15 +532,21 @@ function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
||||
* watched in between, after the page's cache expired — shifts every place after it, and matching by
|
||||
* place landed the focus in the wrong rail. An unnamed list only by its
|
||||
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
|
||||
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
|
||||
* list N may be another list, and <body> is the honest outcome — unless the item's name is the
|
||||
* page's alone (`restoreTarget`). Gives up when the time is up, and at
|
||||
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
|
||||
* where the scroll restore put it, never scrolled to. (A stand-in the focus keeper placed when the view
|
||||
* where the scroll restore put it, never scrolled to — but one found in another list is brought into
|
||||
* view. (A stand-in the focus keeper placed when the view
|
||||
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
|
||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const deadline = Date.now() + RESTORE_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
pending = pos;
|
||||
const done = () => {
|
||||
if (pending === pos) pending = null;
|
||||
};
|
||||
const attempt = () => {
|
||||
if (!focusIsUnclaimed()) return;
|
||||
if (!focusIsUnclaimed()) return done();
|
||||
const all = ordered();
|
||||
const list = pos.label
|
||||
? all.find((l) => labelOf(l) === pos.label)
|
||||
@@ -499,18 +559,33 @@ export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
// The saved place still holding the saved name wins, and among several items of that name the
|
||||
// nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two
|
||||
// downloads of one video), and the first of them is rarely the one left behind.
|
||||
const el = list ? restoreTarget(list, pos) : null;
|
||||
if (el) return focusItem(el, false);
|
||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
const last = Date.now() + RESTORE_POLL_MS >= deadline;
|
||||
const el = restoreTarget(all, list, pos, last);
|
||||
// Where the scroll restore put it — unless it is found in another list than the one it was left
|
||||
// in: the restored scroll is the old place's, and the item would hold the focus off screen.
|
||||
if (el) {
|
||||
done();
|
||||
return focusItem(el, !list?.el.contains(el));
|
||||
}
|
||||
if (last) done();
|
||||
else timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
};
|
||||
// Not inside the popstate dispatch that landed: a sub-view's own listener (`useHistorySubview`)
|
||||
// may run after ours and has not switched the view yet — focus still sits in the view being left,
|
||||
// and "focus is already somewhere" would give up for good (measured in Chrome:
|
||||
// Plex show → Back left focus on <body>). One task later the view has rendered.
|
||||
timer = setTimeout(attempt, 0);
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
}
|
||||
|
||||
/** The name of the item a Back is still looking for, while it looks: a page that keeps some of its
|
||||
* rows folded away (the notification center's Earlier) opens the fold holding it — a list it does
|
||||
* not render is one the restore cannot find the item in. Read it as the page mounts. */
|
||||
export const pendingNavName = (): string | null => pending?.name ?? null;
|
||||
|
||||
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
|
||||
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
|
||||
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The notice history changed shape (a notice keeps what to say, not the said text). A build from
|
||||
// before it reads the old key and renders its rows as text, so an object there crashed its
|
||||
// Notifications page after a rollback. The history now lives under a key of its own: the old one is
|
||||
// read once, while the new one does not exist yet, and never written.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { msg, raw, sayNotif, type NotifText } from "./notifications";
|
||||
|
||||
const OLD_KEY = "siftlode.notifications";
|
||||
|
||||
// A row as the build before wrote it: its text already said.
|
||||
const oldRow = (id: number, message: string) => ({
|
||||
id,
|
||||
level: "info",
|
||||
message,
|
||||
ts: 1,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
repeat: 1,
|
||||
requiresInteraction: false,
|
||||
});
|
||||
|
||||
const saidHistory = (key: string) =>
|
||||
(JSON.parse(localStorage.getItem(key) ?? "[]") as { message: NotifText }[]).map((n) =>
|
||||
sayNotif(n.message),
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
await import("./notifications");
|
||||
await import("./storage");
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.resetModules(); // the store loads at import: each case needs a fresh one
|
||||
});
|
||||
|
||||
describe("the notice history's own key", { timeout: 30_000 }, () => {
|
||||
it("carries the old history forward and leaves the old key as the build before wrote it", async () => {
|
||||
const old = JSON.stringify([oldRow(7, "older")]);
|
||||
localStorage.setItem("siftlode.owner", "5");
|
||||
localStorage.setItem(OLD_KEY, old);
|
||||
const notifications = await import("./notifications");
|
||||
const { claimLocalData, LS } = await import("./storage");
|
||||
|
||||
claimLocalData(5);
|
||||
notifications.notify({ message: msg("common.copy.done") });
|
||||
|
||||
expect(LS.notifHistory).not.toBe(OLD_KEY);
|
||||
expect(localStorage.getItem(OLD_KEY)).toBe(old);
|
||||
expect(saidHistory(LS.notifHistory)).toEqual(["older", "Copied to the clipboard"]);
|
||||
});
|
||||
|
||||
it("reads the old key only while the new one does not exist", async () => {
|
||||
const { LS } = await import("./storage");
|
||||
localStorage.setItem("siftlode.owner", "5");
|
||||
localStorage.setItem(OLD_KEY, JSON.stringify([oldRow(7, "older")]));
|
||||
localStorage.setItem(
|
||||
LS.notifHistory,
|
||||
JSON.stringify([{ ...oldRow(8, ""), message: raw("newer") }]),
|
||||
);
|
||||
const notifications = await import("./notifications");
|
||||
const { claimLocalData } = await import("./storage");
|
||||
|
||||
claimLocalData(5);
|
||||
|
||||
expect(notifications.getNotifications().map((n) => sayNotif(n.message))).toEqual(["newer"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ts from "typescript";
|
||||
import { readSources } from "./rawSources.testkit";
|
||||
|
||||
// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says
|
||||
// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is
|
||||
// unknown, and i18next's options take any value — so the slips are caught here, on the syntax:
|
||||
// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]";
|
||||
// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`)
|
||||
// stays in the language it was raised in.
|
||||
|
||||
const isT = (e: ts.Expression) =>
|
||||
(ts.isIdentifier(e) && e.text === "t") ||
|
||||
(ts.isPropertyAccessExpression(e) && e.name.text === "t");
|
||||
const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression =>
|
||||
ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text);
|
||||
const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression);
|
||||
|
||||
function mixedNotifText(name: string, src: string): string[] {
|
||||
const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const out: string[] = [];
|
||||
const at = (n: ts.Node, what: string) =>
|
||||
out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`);
|
||||
const visit = (n: ts.Node) => {
|
||||
if (isTCall(n)) {
|
||||
for (const arg of n.arguments.slice(1)) {
|
||||
if (!ts.isObjectLiteralExpression(arg)) continue;
|
||||
for (const p of arg.properties) {
|
||||
if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"]))
|
||||
at(p, "a notice value handed to t()");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0]))
|
||||
at(n, "a said text kept in a notice");
|
||||
if (
|
||||
ts.isPropertyAssignment(n) &&
|
||||
ts.isIdentifier(n.name) &&
|
||||
n.name.text === "detail" &&
|
||||
isTCall(n.initializer)
|
||||
)
|
||||
at(n, "a said text kept as a notice's detail");
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("a notice's text and a plain t() do not mix", () => {
|
||||
it("holds across src", () => {
|
||||
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]);
|
||||
expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app
|
||||
expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"],
|
||||
['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"],
|
||||
['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"],
|
||||
['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"],
|
||||
['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"],
|
||||
])("sees %s", (src, what) => {
|
||||
expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })',
|
||||
't("channels.discovery.confirmSubscribe", { name: nameOf(c) })',
|
||||
"sayNotif(msg(key), t)",
|
||||
])("lets %s through", (src) => {
|
||||
expect(mixedNotifText("x.tsx", src)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
configureNotifications,
|
||||
getActiveToasts,
|
||||
notify,
|
||||
raw,
|
||||
} from "./notifications";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
@@ -19,14 +20,14 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const undo = { label: "Undo", onClick: () => {} };
|
||||
const undo = { label: raw("Undo"), onClick: () => {} };
|
||||
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
|
||||
|
||||
describe("an action toast's lifetime", () => {
|
||||
it("stays for the floor when the reader's toasts last 3 s", () => {
|
||||
configureNotifications({ durationMs: 3000 });
|
||||
const plain = notify({ message: "plain" });
|
||||
const withUndo = notify({ message: "removed", action: undo });
|
||||
const plain = notify({ message: raw("plain") });
|
||||
const withUndo = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(3001);
|
||||
expect(shown(plain)).toBe(false);
|
||||
expect(shown(withUndo)).toBe(true);
|
||||
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("keeps a longer setting", () => {
|
||||
configureNotifications({ durationMs: 12000 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
|
||||
expect(shown(id)).toBe(true);
|
||||
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
|
||||
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("never dismisses when the reader chose never", () => {
|
||||
configureNotifications({ durationMs: 0 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(shown(id)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are
|
||||
// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and
|
||||
// values, never the same object: a compare by reference would let every repeat stack.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearAll, getNotifications, msg, notify, raw } from "./notifications";
|
||||
|
||||
afterEach(() => clearAll());
|
||||
|
||||
describe("coalescing a repeated notice", () => {
|
||||
it("folds the same key and values into one entry, counted", () => {
|
||||
notify({
|
||||
level: "error",
|
||||
title: msg("errors.title"),
|
||||
message: msg("errors.reason.unreachable"),
|
||||
});
|
||||
notify({
|
||||
level: "error",
|
||||
title: msg("errors.title"),
|
||||
message: msg("errors.reason.unreachable"),
|
||||
});
|
||||
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("folds the same server refusal too", () => {
|
||||
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
|
||||
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
|
||||
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("keeps two notices apart when only a value differs", () => {
|
||||
notify({ message: msg("channels.notify.hidden", { name: raw("One") }) });
|
||||
notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) });
|
||||
expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,61 @@
|
||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// callbacks are live-only and dropped on reload.
|
||||
import type { TFunction } from "i18next";
|
||||
import i18n from "../i18n";
|
||||
import { localizeDetail } from "./api/refusalText";
|
||||
import { formatBytes, formatNumber } from "./format";
|
||||
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
|
||||
import { log, type Level } from "./log";
|
||||
|
||||
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
|
||||
// the reader's language each time it is shown. The text a caller translated at raise time was kept
|
||||
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
|
||||
// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched
|
||||
// in, since the card holds only that one); `detail` is a server refusal, localized at display like
|
||||
// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as
|
||||
// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`,
|
||||
// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses.
|
||||
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
|
||||
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
|
||||
export type NotifText =
|
||||
| { key: string; values?: Readonly<Record<string, NotifValue>> }
|
||||
| { raw: string }
|
||||
| { detail: unknown };
|
||||
|
||||
/** The notice text for `t(key, values)`, said when it is shown. */
|
||||
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
|
||||
values ? { key, values } : { key };
|
||||
/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */
|
||||
export const raw = (text: string): NotifText => ({ raw: text });
|
||||
|
||||
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
|
||||
export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string {
|
||||
if ("raw" in text) return text.raw;
|
||||
if ("detail" in text) return localizeDetail(text.detail);
|
||||
if (!text.values) return t(text.key);
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]),
|
||||
);
|
||||
return t(text.key, values);
|
||||
}
|
||||
|
||||
function sayValue(v: NotifValue, t: TFunction): string | number {
|
||||
if (typeof v !== "object") return v;
|
||||
// In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n.
|
||||
const lang = (t as { lng?: string }).lng ?? i18n.language;
|
||||
if ("number" in v) return formatNumber(v.number, lang);
|
||||
if ("bytes" in v) return formatBytes(v.bytes, lang);
|
||||
return sayNotif(v, t);
|
||||
}
|
||||
|
||||
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
|
||||
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
// A notice the history kept from before it said keys: its text, in the language it was raised in.
|
||||
const asText = (v: unknown): NotifText | undefined =>
|
||||
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
|
||||
|
||||
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
|
||||
const LOG_LEVEL: Record<NotifLevel, Level> = {
|
||||
info: "INFO",
|
||||
@@ -17,7 +69,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
interface NotifAction {
|
||||
label: string;
|
||||
// Said when shown, like the notice's text: a language switch must not leave the button behind.
|
||||
label: NotifText;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -56,8 +109,8 @@ export type NotifMeta =
|
||||
export interface Notification {
|
||||
id: number;
|
||||
level: NotifLevel;
|
||||
title?: string;
|
||||
message: string;
|
||||
title?: NotifText;
|
||||
message: NotifText;
|
||||
action?: NotifAction;
|
||||
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
|
||||
secondaryAction?: NotifAction;
|
||||
@@ -79,9 +132,9 @@ export interface Notification {
|
||||
}
|
||||
|
||||
export interface NotifyInput {
|
||||
message: string;
|
||||
message: NotifText;
|
||||
level?: NotifLevel;
|
||||
title?: string;
|
||||
title?: NotifText;
|
||||
action?: NotifAction;
|
||||
secondaryAction?: NotifAction;
|
||||
meta?: NotifMeta;
|
||||
@@ -175,17 +228,21 @@ let cachedUnread = 0;
|
||||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = readAccount<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// The old key only until the new one exists: its rows carry forward, said text as `raw`.
|
||||
const stored =
|
||||
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(LS.legacyNotifHistory, []);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
// Restored entries are history only. Mark them dismissed so they don't
|
||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map(
|
||||
return stored.map(
|
||||
(n) =>
|
||||
({
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
title: asText((n as { title?: unknown }).title),
|
||||
message: asText((n as { message?: unknown }).message) ?? raw(""),
|
||||
action: undefined,
|
||||
secondaryAction: undefined,
|
||||
dismissed: true,
|
||||
@@ -261,9 +318,12 @@ const COALESCE_WINDOW_MS = 5000;
|
||||
export function notify(input: NotifyInput): number {
|
||||
const requiresInteraction = input.requiresInteraction ?? false;
|
||||
const level = input.level ?? "info";
|
||||
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
|
||||
// In English, whatever the reader's language (a server refusal aside: it says itself in the
|
||||
// reader's), so one log reads the same for every reader.
|
||||
const en = i18n.getFixedT("en");
|
||||
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
|
||||
level,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.title ? { title: sayNotif(input.title, en) } : {}),
|
||||
...(input.meta ? { kind: input.meta.kind } : {}),
|
||||
});
|
||||
const now = Date.now();
|
||||
@@ -294,8 +354,8 @@ export function notify(input: NotifyInput): number {
|
||||
!n.action &&
|
||||
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||
n.level === level &&
|
||||
(n.title ?? "") === (input.title ?? "") &&
|
||||
n.message === input.message,
|
||||
sameText(n.title, input.title) &&
|
||||
sameText(n.message, input.message),
|
||||
);
|
||||
if (prior) {
|
||||
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { msg, raw, type NotifText } from "./notifications";
|
||||
|
||||
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
|
||||
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
|
||||
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
|
||||
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
}
|
||||
|
||||
/** The same name in a notice, said in the reader's language when it is shown. */
|
||||
export function playlistNameText(p: { kind: string; name: string }): NotifText {
|
||||
return p.kind === "watch_later" ? msg("playlists.watchLater") : raw(p.name);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { PlexCard, PlexTmdb } from "./api";
|
||||
import { msg, raw, sayNotif, type NotifText } from "./notifications";
|
||||
|
||||
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
|
||||
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
||||
@@ -241,9 +242,17 @@ export function seasonTitle(
|
||||
se: { title?: string | null; season_number?: number | null },
|
||||
t: TFunction,
|
||||
): string {
|
||||
return sayNotif(seasonTitleText(se), t);
|
||||
}
|
||||
|
||||
/** The season's name as a notice keeps it: said in the reader's language each time it is shown. */
|
||||
export function seasonTitleText(se: {
|
||||
title?: string | null;
|
||||
season_number?: number | null;
|
||||
}): NotifText {
|
||||
const n = se.season_number;
|
||||
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
|
||||
if (n === 0) return t("plex.specials");
|
||||
if (n != null) return t("plex.seasonTitle", { n });
|
||||
return se.title || t("plex.playlist.seasonUnknown");
|
||||
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
|
||||
if (n === 0) return msg("plex.specials");
|
||||
if (n != null) return msg("plex.seasonTitle", { n });
|
||||
return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The finish latch's refusal path, against a real query cache.
|
||||
//
|
||||
// A refusal answers ONE finish: a replay begun while it was in flight starts a new watch, and a
|
||||
// refusal that lands after it must not take back the finish that watch later makes — that left the
|
||||
// cache saying unwatched, mid-episode, for an item the server had watched.
|
||||
//
|
||||
// And a refusal while the player is still on the item (the up-next card, the default end state) must
|
||||
// not empty the detail that player observes: the card is gated on it, so it dropped, came back with its
|
||||
// countdown restarted when the refetch landed, and never came back if that refetch failed too. The
|
||||
// entry is dropped when the player leaves, where the next open has to fetch the truth.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { type ReactNode } from "react";
|
||||
import { usePlexProgress } from "./plexPlayerHooks";
|
||||
import { api, type PlexItemDetail } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import i18n, { plexLang } from "../i18n";
|
||||
|
||||
const RK = "4242";
|
||||
const key = () => qk.plexItem(RK, plexLang(i18n.language));
|
||||
const detail = { id: RK, status: "new", position_seconds: 600 } as unknown as PlexItemDetail;
|
||||
|
||||
function deferred() {
|
||||
let reject: (e: unknown) => void = () => {};
|
||||
const promise = new Promise((_, rej) => {
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, reject };
|
||||
}
|
||||
|
||||
const flush = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe("usePlexProgress — a refused finish", () => {
|
||||
let qc: QueryClient;
|
||||
let video: HTMLVideoElement;
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
video = document.createElement("video");
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue({ ok: true } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setup = (observe: boolean) => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
// Stable, as the player's own refs are: the effect re-runs on a new ref object, and a re-run
|
||||
// starts the item over.
|
||||
const refs = {
|
||||
videoRef: { current: video },
|
||||
absRef: { current: 1500 },
|
||||
durationRef: { current: 3000 },
|
||||
};
|
||||
return renderHook(
|
||||
() => {
|
||||
useQuery({
|
||||
queryKey: key(),
|
||||
queryFn: () => Promise.resolve(detail),
|
||||
enabled: observe,
|
||||
});
|
||||
return usePlexProgress({
|
||||
id: RK,
|
||||
qc,
|
||||
...refs,
|
||||
});
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
["before", true],
|
||||
["after", false],
|
||||
])(
|
||||
"a refusal landing after a replay, %s its next finish, takes back neither",
|
||||
async (_, early) => {
|
||||
qc.setQueryData(key(), detail);
|
||||
const { result } = setup(false);
|
||||
const first = deferred();
|
||||
act(() => result.current.finish(first.promise));
|
||||
act(() => void video.dispatchEvent(new Event("play"))); // the viewer plays it again
|
||||
if (early) {
|
||||
first.reject(new Error("refused")); // only now is the first finish refused
|
||||
await flush();
|
||||
}
|
||||
act(() => result.current.finish(Promise.resolve()));
|
||||
await flush();
|
||||
if (!early) {
|
||||
first.reject(new Error("refused"));
|
||||
await flush();
|
||||
}
|
||||
act(() => result.current.settle());
|
||||
expect(qc.getQueryData(key())).toMatchObject({ status: "watched", position_seconds: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the detail the still-open player observes, and drops it when the player leaves", async () => {
|
||||
const { result, unmount } = setup(true);
|
||||
await flush();
|
||||
expect(qc.getQueryData(key())).toBeDefined();
|
||||
const refused = deferred();
|
||||
act(() => result.current.finish(refused.promise));
|
||||
refused.reject(new Error("refused"));
|
||||
await flush();
|
||||
expect(qc.getQueryData(key())).toBeDefined();
|
||||
unmount();
|
||||
expect(qc.getQueryData(key())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,14 @@ export function usePlexProgress(opts: {
|
||||
// write; this closes the other three. What the refusal does take back is the
|
||||
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
|
||||
let finishRefused = false;
|
||||
// Whether the player has left the item (`settle` ran): what a refusal does to the cached detail
|
||||
// depends on whether the player is still observing it.
|
||||
let left = false;
|
||||
// Which finish a refusal answers. A replay starts a new watch, so a refusal of an earlier finish
|
||||
// that lands after it must not raise `finishRefused` for the finish that watch makes later — the
|
||||
// leave then skipped its watched patch, and the cache said unwatched, mid-item, for an item the
|
||||
// server had watched.
|
||||
let finishGen = 0;
|
||||
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
|
||||
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
|
||||
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
|
||||
@@ -261,6 +269,7 @@ export function usePlexProgress(opts: {
|
||||
const onPlayAgain = () => {
|
||||
finished = false;
|
||||
finishRefused = false;
|
||||
finishGen = finishGen + 1;
|
||||
};
|
||||
video?.addEventListener("play", onPlayAgain);
|
||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||||
@@ -273,12 +282,14 @@ export function usePlexProgress(opts: {
|
||||
const settle = () => {
|
||||
// Match the language-keyed detail query so the optimistic resume patch lands.
|
||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||
left = true;
|
||||
if (finished) {
|
||||
// A refused finish claims nothing: the server has the item unwatched at the position the
|
||||
// rollback restored, and the catch below has already dropped this entry, so the next open
|
||||
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
|
||||
// refusal handler removed.
|
||||
if (!finishRefused)
|
||||
// rollback restored. Re-patching "watched / 0" here would put back the very lie the refusal
|
||||
// handler took back, and the entry the player kept observing (see the catch below) may
|
||||
// still be the one from before the rollback — dropped, the next open fetches the truth.
|
||||
if (finishRefused) qc.removeQueries({ queryKey: key });
|
||||
else
|
||||
qc.setQueryData(key, (old) =>
|
||||
old ? { ...old, status: "watched", position_seconds: 0 } : old,
|
||||
);
|
||||
@@ -293,6 +304,9 @@ export function usePlexProgress(opts: {
|
||||
settleRef.current = settle;
|
||||
finishRef.current = (confirmed?: Promise<unknown>) => {
|
||||
finished = true;
|
||||
finishRefused = false;
|
||||
finishGen = finishGen + 1;
|
||||
const gen = finishGen;
|
||||
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
|
||||
// watched write is then refused, what is taken back is the CLAIM, not the latch (see
|
||||
// `finishRefused`): the cached detail said watched / position 0 while the server still had
|
||||
@@ -306,6 +320,7 @@ export function usePlexProgress(opts: {
|
||||
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
|
||||
const ended = video?.ended === true;
|
||||
void confirmed?.catch(() => {
|
||||
if (gen !== finishGen) return;
|
||||
finishRefused = true;
|
||||
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
|
||||
// On the play-to-the-end path `pos` IS the end of the media, and the backend reads a
|
||||
@@ -316,10 +331,15 @@ export function usePlexProgress(opts: {
|
||||
// credits-skip caller captures a mid-episode position and is unaffected.
|
||||
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
|
||||
api.plexProgress(id, pos, dur, true).catch(() => {});
|
||||
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
|
||||
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
|
||||
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
|
||||
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
|
||||
// Still on the item (the up-next card is the default end state), the player observes this
|
||||
// entry: emptying it dropped the card — gated on the detail — and its countdown restarted
|
||||
// when the refetch landed, or the card never came back if that failed too. So it is
|
||||
// refetched under the player, and dropped when the player leaves (`settle`). Already left,
|
||||
// `settle` may have patched it to watched / 0 on the way out, and an invalidated query with
|
||||
// no observer keeps serving that lie: dropped now, the next open fetches the truth.
|
||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||
if (left) qc.removeQueries({ queryKey: key });
|
||||
else void qc.invalidateQueries({ queryKey: key });
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type PlexCard } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||
@@ -12,7 +12,6 @@ import { notifyActionError } from "./actionErrors";
|
||||
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
|
||||
export async function toggleWatchState(
|
||||
card: PlexCard,
|
||||
t: TFunction,
|
||||
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
|
||||
) {
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
@@ -21,7 +20,7 @@ export async function toggleWatchState(
|
||||
await api.plexSetState(card.id, next);
|
||||
} catch (e) {
|
||||
handlers.revert?.();
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
handlers.resync();
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
// silence on a 403/404/429 or a lost connection — 34 of them.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify: vi.fn(() => 0),
|
||||
}));
|
||||
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "./queryClient";
|
||||
import { HttpError, req } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify, raw } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => {
|
||||
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
expect(notifyMock.mock.calls).toEqual([
|
||||
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }],
|
||||
[{ level: "error", title: msg(TITLE), message: msg("errors.notFound.generic") }],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -47,8 +49,8 @@ describe("a failed mutation", () => {
|
||||
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.title"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("errors.title"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }),
|
||||
expect.objectContaining({ message: msg("errors.reason.unreachable") }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,9 +68,9 @@ describe("a failed mutation", () => {
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
|
||||
meta: { failTitle: TITLE },
|
||||
onError: (e) => notifyActionError(e, "OWN TITLE"),
|
||||
onError: (e) => notifyActionError(e, raw("OWN TITLE")),
|
||||
});
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]);
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([raw("OWN TITLE")]);
|
||||
});
|
||||
|
||||
it("is still reported when the mutation's onError only rolls back", async () => {
|
||||
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
|
||||
onError: rollback,
|
||||
});
|
||||
expect(rollback).toHaveBeenCalled();
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]);
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([msg(TITLE)]);
|
||||
});
|
||||
|
||||
it("gets no toast when the page shows the failure itself", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { log } from "./log";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
|
||||
* the rest of a key is filter state, which the base level does not keep. The status is what
|
||||
@@ -42,7 +42,7 @@ type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
|
||||
*/
|
||||
function reportMutationFailure(err: unknown, meta: FailMeta): void {
|
||||
if (meta?.errorShownInline) return;
|
||||
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0);
|
||||
setTimeout(() => notifyActionError(err, msg(meta?.failTitle ?? "errors.title")), 0);
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
|
||||
@@ -100,7 +100,12 @@ const KEYS = {
|
||||
// flag). A YT.Player instance is created per video and starts at full volume, so without this
|
||||
// every next video is loud again (same idea as plexPlayerPrefs).
|
||||
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
|
||||
notifHistory: { key: "siftlode.notifications", kind: "cache" },
|
||||
// The notice history: what each notice says (lib/notifications NotifText), not the said text.
|
||||
notifHistory: { key: "siftlode.notifHistory", kind: "cache" },
|
||||
// DEPRECATED — the history as said text, which a build from before the change still reads (and
|
||||
// renders as text: an object there crashed its Notifications page after a rollback). Read once while
|
||||
// `notifHistory` does not exist yet, then inert. Do not write.
|
||||
legacyNotifHistory: { key: "siftlode.notifications", kind: "cache" },
|
||||
notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
|
||||
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
|
||||
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
|
||||
return { ...actual, accountSwitchNotice: () => notice() };
|
||||
});
|
||||
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
|
||||
const { msg, raw } = await import("./notifications");
|
||||
|
||||
afterEach(() => {
|
||||
notifyMock.mockClear();
|
||||
@@ -39,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
|
||||
notice.mockReturnValue({
|
||||
level: "info",
|
||||
key: "header.account.switchRequested",
|
||||
values: { email: "b@example.com" },
|
||||
values: { email: raw("b@example.com") },
|
||||
});
|
||||
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
|
||||
initialProps: { signedIn: false },
|
||||
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
message: 'header.account.switchRequested {"email":"b@example.com"}',
|
||||
message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { accountSwitchNotice } from "./entryUrl";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
|
||||
/** Say, once per load, what the entry URL tells the account signed in here about a switch of
|
||||
* account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that
|
||||
@@ -14,6 +14,6 @@ export function useAccountSwitchNotice(signedIn: boolean): void {
|
||||
if (!signedIn || said.current) return;
|
||||
said.current = true;
|
||||
const notice = accountSwitchNotice();
|
||||
if (notice) notify({ level: notice.level, message: t(notice.key, notice.values) });
|
||||
if (notice) notify({ level: notice.level, message: msg(notice.key, notice.values) });
|
||||
}, [signedIn, t]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user