release: 0.114.0 (feature/e13-notifications-order)
This commit is contained in:
@@ -7,6 +7,17 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.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
|
## 0.113.0 — 2026-10-28
|
||||||
|
|
||||||
### New
|
### 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.
|
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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
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 --------------------------------------------------------------------
|
# --- 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:
|
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
|
"""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,
|
now: datetime,
|
||||||
*,
|
*,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
) -> PlexState:
|
) -> bool:
|
||||||
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
|
"""Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
|
||||||
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
|
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 = state_row(db, user, item_id, st)
|
||||||
st.position_seconds = position
|
values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
|
||||||
st.progress_updated_at = now
|
|
||||||
if dirty:
|
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()
|
db.flush()
|
||||||
return st
|
return isinstance(result, CursorResult) and result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def forget_state(db: Session, st: PlexState) -> None:
|
def forget_state(db: Session, st: PlexState) -> None:
|
||||||
|
|||||||
+95
-18
@@ -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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||||
# and someone else's data.
|
# and someone else's data.
|
||||||
|
_TOMB = ".dead-"
|
||||||
_SESSION_DIR_RE = re.compile(
|
_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
|
# 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
|
# 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()
|
_lock = threading.Lock()
|
||||||
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
# 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
|
# 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.)
|
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
||||||
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
||||||
@@ -641,29 +645,82 @@ def _probe_first_pts(seg: Path) -> float | None:
|
|||||||
return 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:
|
try:
|
||||||
s.proc.terminate()
|
|
||||||
try:
|
try:
|
||||||
s.proc.wait(timeout=3)
|
proc.wait(timeout=3)
|
||||||
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
||||||
# seconds gets KILL, which is exactly what this branch does.
|
# seconds gets KILL, which is exactly what this branch does.
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
s.proc.kill()
|
proc.kill()
|
||||||
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
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
|
# 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
|
# 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.
|
# `_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(
|
log.info(
|
||||||
"could not stop the transcode process",
|
"could not stop the transcode process",
|
||||||
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
||||||
)
|
)
|
||||||
|
_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)
|
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:
|
def _finish(doomed: "list[tuple[HlsSession, Path | None]]") -> None:
|
||||||
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
"""The slow half of a teardown, called WITHOUT `_lock`."""
|
||||||
least recently accessed sessions over it:
|
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
|
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||||
box evicts itself before anyone else;
|
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
|
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."""
|
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:
|
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||||
if len(pool) <= cap:
|
if len(pool) <= cap:
|
||||||
return
|
return
|
||||||
@@ -690,12 +749,13 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
|||||||
sessions=len(pool),
|
sessions=len(pool),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_kill(sess)
|
|
||||||
_sessions.pop(reg, None)
|
_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[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
||||||
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
||||||
cull(list(_sessions.items()), max_sessions, "total")
|
cull(list(_sessions.items()), max_sessions, "total")
|
||||||
|
return doomed
|
||||||
|
|
||||||
|
|
||||||
def start_session(
|
def start_session(
|
||||||
@@ -759,12 +819,21 @@ def start_session(
|
|||||||
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
# 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.
|
# 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
|
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||||
|
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:
|
with _lock:
|
||||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
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
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
old = _sessions.pop(reg, None)
|
old = _sessions.pop(reg, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
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)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
@@ -816,11 +885,13 @@ def start_session(
|
|||||||
)
|
)
|
||||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||||
# capped to zero.
|
# capped to zero.
|
||||||
_enforce_caps(
|
doomed += _enforce_caps(
|
||||||
user_id,
|
user_id,
|
||||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
_finish(doomed)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
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.
|
# session, absent for a single-audio one.
|
||||||
if variant not in (s.variants or (None,)):
|
if variant not in (s.variants or (None,)):
|
||||||
return False
|
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:
|
if seq == 0:
|
||||||
return True
|
return True
|
||||||
try:
|
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
|
`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."""
|
one a whole segmented copy of the film on a volume shared with the download center."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
dropped = 0
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
with _lock:
|
with _lock:
|
||||||
for reg, s in list(_sessions.items()):
|
for reg, s in list(_sessions.items()):
|
||||||
done = s.proc.poll() is not None
|
done = s.proc.poll() is not None
|
||||||
idle = now - s.last_access
|
idle = now - s.last_access
|
||||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||||
_kill(s)
|
|
||||||
_sessions.pop(reg, None)
|
_sessions.pop(reg, None)
|
||||||
dropped += 1
|
doomed.append(_detach(s))
|
||||||
return dropped
|
_finish(doomed)
|
||||||
|
return len(doomed)
|
||||||
|
|
||||||
|
|
||||||
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
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))
|
s = _sessions.get((user_id, key))
|
||||||
if s is None or s.id != session_id:
|
if s is None or s.id != session_id:
|
||||||
return False
|
return False
|
||||||
_kill(s)
|
|
||||||
_sessions.pop((user_id, key), None)
|
_sessions.pop((user_id, key), None)
|
||||||
|
doomed = [_detach(s)]
|
||||||
|
_finish(doomed)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
return imgcache.response(data, ct)
|
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(
|
def _push_resume_guarded(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
item_id: int,
|
item_id: int,
|
||||||
@@ -329,12 +338,7 @@ def item_progress(
|
|||||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||||
if near_end:
|
if near_end:
|
||||||
flipped = service.mark_finished(db, user.id, it.id, now)
|
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
|
_supersede_resume_pushes(user.id, it.id, now)
|
||||||
# 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
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
# 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.
|
# 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)
|
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
|
# 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.
|
# `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()
|
db.commit()
|
||||||
if push_now:
|
if push_now:
|
||||||
_push_watch(
|
_push_watch(
|
||||||
@@ -408,6 +416,8 @@ def item_state(
|
|||||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||||
return {"ok": True, "status": "new"}
|
return {"ok": True, "status": "new"}
|
||||||
service.set_status(db, user, it.id, st, status)
|
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()
|
db.commit()
|
||||||
if status == "watched" and prev_status != "watched":
|
if status == "watched" and prev_status != "watched":
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
@@ -427,6 +437,10 @@ def _bulk_state(
|
|||||||
always reads the committed marks."""
|
always reads the committed marks."""
|
||||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||||
to_push = service.bulk_state(db, user_id, eps, watched)
|
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:
|
if pushable and to_push:
|
||||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
# 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.
|
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||||
@@ -632,9 +646,10 @@ def stream_hls_file(
|
|||||||
try:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
# The session was torn down between the wait and the read — `_kill` rmtrees the whole
|
# The session was torn down between the wait and the read — teardown moves the whole
|
||||||
# directory, and nothing holds `_lock` across those two steps. It has to present as the same
|
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
|
||||||
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so
|
# 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
|
# 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
|
# 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
|
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class _Proc:
|
|||||||
|
|
||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
||||||
|
self.key = name
|
||||||
self.dir = tmp_path / name
|
self.dir = tmp_path / name
|
||||||
self.dir.mkdir()
|
self.dir.mkdir()
|
||||||
self.proc = _Proc(finished)
|
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()
|
||||||
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db import SessionLocal
|
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.plex import service as plex_service
|
||||||
from app.routes.plex import playback
|
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 r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
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"
|
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])
|
@pytest.mark.parametrize("digits", [100, 5000])
|
||||||
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
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
|
"""`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(
|
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
||||||
client, monkeypatch, tmp_path, sessions
|
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
|
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
|
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"."""
|
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()
|
user = make_user()
|
||||||
lib = _lib(db)
|
lib = _lib(db)
|
||||||
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
|
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)
|
assert (st.position_seconds, st.synced_to_plex) == (120, False)
|
||||||
_state(db, user, hid, status="hidden", position_seconds=40)
|
_state(db, user, hid, status="hidden", position_seconds=40)
|
||||||
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
|
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()
|
user = make_user()
|
||||||
it = _movie(db, _lib(db), "m-race")
|
it = _movie(db, _lib(db), "m-race")
|
||||||
_state(db, user, it, position_seconds=10) # the request that won, committed
|
_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)
|
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
||||||
st = service.set_status(db, user, it.id, None, "hidden")
|
st = service.set_status(db, user, it.id, None, "hidden")
|
||||||
assert st.status == "hidden"
|
assert st.status == "hidden"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _Proc:
|
|||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, session_id: str, directory):
|
def __init__(self, session_id: str, directory):
|
||||||
self.id = session_id
|
self.id = session_id
|
||||||
|
self.key = "m1"
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = _Proc()
|
self.proc = _Proc()
|
||||||
self.last_access = time.time()
|
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_0_2_+0.00", # current shape, explicit audio_ord
|
||||||
"12345_842_multi_-1.25", # multi-audio, negative offset
|
"12345_842_multi_-1.25", # multi-audio, negative offset
|
||||||
"12345_842_multi_+12.50",
|
"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):
|
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
|
"snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
|
||||||
"backup_2024_multi_+0.00",
|
"backup_2024_multi_+0.00",
|
||||||
".config",
|
".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):
|
def test_foreign_directories_are_left_alone(name):
|
||||||
@@ -159,7 +165,7 @@ class TestSweepOrphans:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeProc:
|
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):
|
def poll(self):
|
||||||
return None
|
return None
|
||||||
@@ -1052,3 +1058,63 @@ class TestSessionsPerViewer:
|
|||||||
self._start(monkeypatch, user_id=uid)
|
self._start(monkeypatch, user_id=uid)
|
||||||
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
||||||
assert oldest.proc.killed and not oldest.transcode
|
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
|
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
|
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
|
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 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
|
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
|
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 | 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 | 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-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 |
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
|||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify } from "../../lib/notifications";
|
||||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
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.
|
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||||
const press = (key: string, shiftKey = false) =>
|
const press = (key: string, shiftKey = false) =>
|
||||||
@@ -311,6 +311,100 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
|||||||
document.body.removeChild(trigger);
|
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", () => {
|
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 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.
|
// the user deliberately put it is the rudest thing a closing dialog can do.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import PageScroller from "./PageScroller";
|
import PageScroller from "./PageScroller";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
import { notifyNav } from "../../lib/history";
|
import { notifyNav } from "../../lib/history";
|
||||||
|
import { revealedNavItem } from "../../lib/listNav";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||||
@@ -125,6 +126,38 @@ describe("PageScroller scroll memory", () => {
|
|||||||
expect(main.scrollTop).toBe(450);
|
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.
|
// 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", () => {
|
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
|
||||||
let frames: FrameRequestCallback[] = [];
|
let frames: FrameRequestCallback[] = [];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { StickyTopProvider } from "./stickyTop";
|
|||||||
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
||||||
import { CONTENT_ID } from "./landmarks";
|
import { CONTENT_ID } from "./landmarks";
|
||||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
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
|
// 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
|
// 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("wheel", stop);
|
||||||
el.removeEventListener("touchstart", stop);
|
el.removeEventListener("touchstart", stop);
|
||||||
el.removeEventListener("pointerdown", stop);
|
el.removeEventListener("pointerdown", stop);
|
||||||
|
el.removeEventListener(NAV_REVEAL_EVENT, stop);
|
||||||
document.removeEventListener("keydown", stop, true);
|
document.removeEventListener("keydown", stop, true);
|
||||||
};
|
};
|
||||||
el.addEventListener("wheel", stop, { passive: true });
|
el.addEventListener("wheel", stop, { passive: true });
|
||||||
el.addEventListener("touchstart", stop, { passive: true });
|
el.addEventListener("touchstart", stop, { passive: true });
|
||||||
el.addEventListener("pointerdown", stop);
|
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);
|
document.addEventListener("keydown", stop, true);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
@@ -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.
|
/** 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
|
* `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
|
* 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
|
// 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.
|
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
|
||||||
const ownerAtOpen = ownerRef?.current ?? scope;
|
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)) {
|
if (scope && !scope.contains(document.activeElement)) {
|
||||||
const back = focusedRef.current;
|
const back = focusedRef.current;
|
||||||
const target =
|
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
|
// 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
|
// 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,
|
// `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 =
|
heldRef.current =
|
||||||
(owner?.contains(active) ?? false) ||
|
(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
|
// 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.
|
// gone by then, so restoring onto it would strand the focus exactly as before.
|
||||||
if (scopeRef.current?.contains(document.activeElement))
|
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
|
// 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
|
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
||||||
// scrim — where Enter still activated it.
|
// 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 owner = ownerAtOpen;
|
||||||
const active = document.activeElement;
|
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 above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
||||||
const standingAbove =
|
const standingAbove =
|
||||||
above !== null &&
|
above !== null &&
|
||||||
above.isConnected &&
|
above.isConnected &&
|
||||||
!above.contains(owner as Node | null) &&
|
!above.contains(owner as Node | null) &&
|
||||||
!(owner?.contains(above) ?? false);
|
!(owner?.contains(above) ?? false);
|
||||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
|
||||||
restoreTo.focus();
|
if (!heldRef.current || !target?.isConnected) return;
|
||||||
|
if (standingAbove) handedRestore.set(above, target);
|
||||||
|
else if (!standingMenu) target.focus();
|
||||||
};
|
};
|
||||||
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
}, [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"],
|
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||||
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
"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/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||||
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.113.0",
|
||||||
"date": "2026-10-28",
|
"date": "2026-10-28",
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.113.0",
|
||||||
"date": "2026-10-28",
|
"date": "2026-10-28",
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
"clearConfirmTitle": "Clear every notification?",
|
"clearConfirmTitle": "Clear every notification?",
|
||||||
"clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.",
|
"clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.",
|
||||||
"markRead": "Mark read",
|
"markRead": "Mark read",
|
||||||
|
"unreadName": "{{name}}, unread",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"rowMenu": "Notification actions",
|
"rowMenu": "Notification actions",
|
||||||
"menu": "Notifications actions",
|
"menu": "Notifications actions",
|
||||||
"sectionSystem": "System",
|
"sectionNeedsYou_one": "Needs you · {{count}}",
|
||||||
"sectionActivity": "Activity",
|
"sectionNeedsYou_other": "Needs you · {{count}}",
|
||||||
|
"sectionRecent": "Recent",
|
||||||
|
"earlier_one": "Earlier ({{count}})",
|
||||||
|
"earlier_other": "Earlier ({{count}})",
|
||||||
"andMore_one": "and {{count}} more",
|
"andMore_one": "and {{count}} more",
|
||||||
"andMore_other": "and {{count}} more",
|
"andMore_other": "and {{count}} more",
|
||||||
"openScheduler": "Open Scheduler",
|
"openScheduler": "Open Scheduler",
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
"clearConfirmTitle": "Törlöd az összes értesítést?",
|
"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.",
|
"clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.",
|
||||||
"markRead": "Olvasottnak jelöl",
|
"markRead": "Olvasottnak jelöl",
|
||||||
|
"unreadName": "{{name}}, olvasatlan",
|
||||||
"dismiss": "Elrejtés",
|
"dismiss": "Elrejtés",
|
||||||
"rowMenu": "Értesítés műveletei",
|
"rowMenu": "Értesítés műveletei",
|
||||||
"menu": "Értesítések műveletei",
|
"menu": "Értesítések műveletei",
|
||||||
"sectionSystem": "Rendszer",
|
"sectionNeedsYou_one": "Rád vár · {{count}}",
|
||||||
"sectionActivity": "Tevékenység",
|
"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_one": "és még {{count}}",
|
||||||
"andMore_other": "és még {{count}}",
|
"andMore_other": "és még {{count}}",
|
||||||
"openScheduler": "Ütemező megnyitása",
|
"openScheduler": "Ütemező megnyitása",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+96
-21
@@ -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.
|
// 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 roved: HTMLElement | null = null;
|
||||||
let named = false;
|
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 INDEX = "data-nav-index";
|
||||||
const LIST = "data-nav-list";
|
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
|
* `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
|
* 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
|
* 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 = (
|
export const navItemAttrs = (
|
||||||
index: number,
|
index: number,
|
||||||
name?: string | null,
|
name?: string | null,
|
||||||
{ inset = false }: { inset?: boolean } = {},
|
{ inset = false, label }: { inset?: boolean; label?: string } = {},
|
||||||
): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({
|
): {
|
||||||
|
"data-nav-index": number;
|
||||||
|
"data-nav-name"?: string;
|
||||||
|
"data-nav-label"?: string;
|
||||||
|
"data-nav-inset"?: "";
|
||||||
|
} => ({
|
||||||
[INDEX]: index,
|
[INDEX]: index,
|
||||||
...(name ? { "data-nav-name": name } : null),
|
...(name ? { "data-nav-name": name } : null),
|
||||||
|
...(label ? { "data-nav-label": label } : null),
|
||||||
...(inset ? { "data-nav-inset": "" } : null),
|
...(inset ? { "data-nav-inset": "" } : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,11 +223,23 @@ function itemName(el: HTMLElement): string {
|
|||||||
).trim();
|
).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 {
|
function focusItem(el: HTMLElement, reveal = true): void {
|
||||||
if (roved !== el) {
|
if (roved !== el) {
|
||||||
if (named) roved?.removeAttribute("role");
|
if (named) roved?.removeAttribute("role");
|
||||||
if (named) roved?.removeAttribute("aria-label");
|
if (named) roved?.removeAttribute("aria-label");
|
||||||
roved?.removeAttribute("tabindex");
|
roved?.removeAttribute("tabindex");
|
||||||
|
relabel?.disconnect();
|
||||||
roved = null;
|
roved = null;
|
||||||
named = false;
|
named = false;
|
||||||
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
|
// 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
|
// 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
|
// 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.
|
// opens. A row (<tr>) keeps its own role.
|
||||||
const name = itemName(el);
|
const name = spokenName(el);
|
||||||
if (
|
if (
|
||||||
el.tagName === "DIV" &&
|
el.tagName === "DIV" &&
|
||||||
name &&
|
name &&
|
||||||
@@ -229,13 +260,17 @@ function focusItem(el: HTMLElement, reveal = true): void {
|
|||||||
el.setAttribute("role", "group");
|
el.setAttribute("role", "group");
|
||||||
el.setAttribute("aria-label", name);
|
el.setAttribute("aria-label", name);
|
||||||
named = true;
|
named = true;
|
||||||
|
relabel?.observe(el, { attributeFilter: ["data-nav-label", "data-nav-name"] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.focus({ preventScroll: true });
|
el.focus({ preventScroll: true });
|
||||||
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
|
// `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.
|
// 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(
|
function focusIndex(
|
||||||
@@ -459,18 +494,37 @@ export function navPosition(): NavPosition | null {
|
|||||||
// look — on a timer, not animation frames: a hidden tab runs none.
|
// look — on a timer, not animation frames: a hidden tab runs none.
|
||||||
const RESTORE_MS = 2000;
|
const RESTORE_MS = 2000;
|
||||||
const RESTORE_POLL_MS = 50;
|
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
|
* name, else the nearest item of that name (names repeat — a log console's identical request
|
||||||
* lines), else the saved place. */
|
* lines). Then the one item of that name in another of the page's lists: an item can change lists
|
||||||
function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
* while the page was away (a notification asked for the reader on the visit it was opened from and
|
||||||
const at = itemAt(list, pos.index);
|
* sits in the stream on the next), and the place left behind is now its neighbour's. Only when the
|
||||||
if (!pos.name || (at && itemName(at) === pos.name)) return at;
|
* 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 distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
||||||
const named = ownItems(list.el)
|
const here = named(list).sort((a, b) => distance(a) - distance(b));
|
||||||
.filter((it) => itemName(it) === pos.name)
|
if (here[0]) return here[0];
|
||||||
.sort((a, b) => distance(a) - distance(b));
|
}
|
||||||
return named[0] ?? at;
|
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
|
/** 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
|
* 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 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,
|
* 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
|
* 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. */
|
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
|
||||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||||
const deadline = Date.now() + RESTORE_MS;
|
const deadline = Date.now() + RESTORE_MS;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
pending = pos;
|
||||||
|
const done = () => {
|
||||||
|
if (pending === pos) pending = null;
|
||||||
|
};
|
||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
if (!focusIsUnclaimed()) return;
|
if (!focusIsUnclaimed()) return done();
|
||||||
const all = ordered();
|
const all = ordered();
|
||||||
const list = pos.label
|
const list = pos.label
|
||||||
? all.find((l) => labelOf(l) === 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
|
// 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
|
// 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.
|
// downloads of one video), and the first of them is rarely the one left behind.
|
||||||
const el = list ? restoreTarget(list, pos) : null;
|
const last = Date.now() + RESTORE_POLL_MS >= deadline;
|
||||||
if (el) return focusItem(el, false);
|
const el = restoreTarget(all, list, pos, last);
|
||||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
// 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`)
|
// 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,
|
// 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:
|
// 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.
|
// Plex show → Back left focus on <body>). One task later the view has rendered.
|
||||||
timer = setTimeout(attempt, 0);
|
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
|
/** 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
|
* 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:
|
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
|
||||||
let finishRefused = false;
|
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.
|
// `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
|
// 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.
|
// 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 = () => {
|
const onPlayAgain = () => {
|
||||||
finished = false;
|
finished = false;
|
||||||
finishRefused = false;
|
finishRefused = false;
|
||||||
|
finishGen = finishGen + 1;
|
||||||
};
|
};
|
||||||
video?.addEventListener("play", onPlayAgain);
|
video?.addEventListener("play", onPlayAgain);
|
||||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
// 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 = () => {
|
const settle = () => {
|
||||||
// Match the language-keyed detail query so the optimistic resume patch lands.
|
// Match the language-keyed detail query so the optimistic resume patch lands.
|
||||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||||
|
left = true;
|
||||||
if (finished) {
|
if (finished) {
|
||||||
// A refused finish claims nothing: the server has the item unwatched at the position the
|
// 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
|
// rollback restored. Re-patching "watched / 0" here would put back the very lie the refusal
|
||||||
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
|
// handler took back, and the entry the player kept observing (see the catch below) may
|
||||||
// refusal handler removed.
|
// still be the one from before the rollback — dropped, the next open fetches the truth.
|
||||||
if (!finishRefused)
|
if (finishRefused) qc.removeQueries({ queryKey: key });
|
||||||
|
else
|
||||||
qc.setQueryData(key, (old) =>
|
qc.setQueryData(key, (old) =>
|
||||||
old ? { ...old, status: "watched", position_seconds: 0 } : old,
|
old ? { ...old, status: "watched", position_seconds: 0 } : old,
|
||||||
);
|
);
|
||||||
@@ -293,6 +304,9 @@ export function usePlexProgress(opts: {
|
|||||||
settleRef.current = settle;
|
settleRef.current = settle;
|
||||||
finishRef.current = (confirmed?: Promise<unknown>) => {
|
finishRef.current = (confirmed?: Promise<unknown>) => {
|
||||||
finished = true;
|
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
|
// 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
|
// 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
|
// `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.
|
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
|
||||||
const ended = video?.ended === true;
|
const ended = video?.ended === true;
|
||||||
void confirmed?.catch(() => {
|
void confirmed?.catch(() => {
|
||||||
|
if (gen !== finishGen) return;
|
||||||
finishRefused = true;
|
finishRefused = true;
|
||||||
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
|
// 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
|
// 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.
|
// credits-skip caller captures a mid-episode position and is unaffected.
|
||||||
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
|
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
|
||||||
api.plexProgress(id, pos, dur, true).catch(() => {});
|
api.plexProgress(id, pos, dur, true).catch(() => {});
|
||||||
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
|
// Still on the item (the up-next card is the default end state), the player observes this
|
||||||
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
|
// entry: emptying it dropped the card — gated on the detail — and its countdown restarted
|
||||||
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
|
// when the refetch landed, or the card never came back if that failed too. So it is
|
||||||
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
|
// 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 () => {
|
return () => {
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The notification center's order: what asks for the reader comes first, whichever source it is
|
||||||
|
// from, then one time-ordered stream; read rows older than a week fold under "Earlier (n)". It was
|
||||||
|
// two fixed sections, the server's above this browser's, so an admin's "access requests waiting"
|
||||||
|
// sat under every server row — week-old read "Download ready" notices among them.
|
||||||
|
// i18n is key-passthrough, so labels read as keys.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { api, type AppNotification } from "../../lib/api";
|
||||||
|
import type { Notification as ClientNotif } from "../../lib/notifications";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
server: [] as unknown[],
|
||||||
|
client: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({
|
||||||
|
i18n: { language: "en" },
|
||||||
|
t: (key: string, o?: { count?: number }) =>
|
||||||
|
o?.count === undefined ? key : `${key}:${o.count}`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => vi.fn() }));
|
||||||
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
getNotifications: () => h.client,
|
||||||
|
getUnreadCount: () => 0,
|
||||||
|
markAllRead: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
|
||||||
|
vi.mock("../channels/channelsContext", () => ({
|
||||||
|
useChannelsActions: () => ({ focusChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../feed/feedFiltersContext", () => ({
|
||||||
|
useFeedFilters: () => ({}),
|
||||||
|
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||||
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import NotificationsPanel from "./NotificationsPanel";
|
||||||
|
import { focusNavItem, restoreNavPosition } from "../../lib/listNav";
|
||||||
|
|
||||||
|
// The server list goes through the real live query, so an optimistic patch of its cache is seen.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(api, "notifications").mockImplementation(async () => ({
|
||||||
|
items: h.server as AppNotification[],
|
||||||
|
total: h.server.length,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const H = 3600 * 1000;
|
||||||
|
const D = 24 * H;
|
||||||
|
const server = (id: number, ageMs: number, read: boolean) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
type: "download_done",
|
||||||
|
title: `Server ${id}`,
|
||||||
|
body: null,
|
||||||
|
data: { title: `Ready ${id}` },
|
||||||
|
read,
|
||||||
|
dismissed: false,
|
||||||
|
created_at: new Date(Date.now() - ageMs).toISOString(),
|
||||||
|
}) as unknown as AppNotification;
|
||||||
|
const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
level: "success",
|
||||||
|
message: `Activity ${id}.`,
|
||||||
|
requiresInteraction: false,
|
||||||
|
ts: Date.now() - ageMs,
|
||||||
|
read: true,
|
||||||
|
dismissed: true,
|
||||||
|
transient: false,
|
||||||
|
repeat: 1,
|
||||||
|
...extra,
|
||||||
|
}) as unknown as ClientNotif;
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
const tree = () => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
{/* The page's scroller, as in the app: Back finds a row in another of the page's lists. */}
|
||||||
|
<main>
|
||||||
|
<NotificationsPanel />
|
||||||
|
</main>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { rerender } = render(tree());
|
||||||
|
return { qc, rerender: () => rerender(tree()) };
|
||||||
|
}
|
||||||
|
// Every test has a "Ready 1" on screen once the server list has landed.
|
||||||
|
const loaded = () => screen.findByText("Ready 1");
|
||||||
|
// The rows of the list under a section's heading.
|
||||||
|
const listUnder = (heading: string) =>
|
||||||
|
[...screen.getByText(heading).closest("section")!.querySelectorAll("[data-nav-index]")].map(
|
||||||
|
(r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+/)?.[0],
|
||||||
|
);
|
||||||
|
// Every row on screen, in document order, by its text.
|
||||||
|
const rows = () =>
|
||||||
|
[...document.querySelectorAll<HTMLElement>("[data-nav-index]")].map(
|
||||||
|
(r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+|common\.accessRequestsTitle/)?.[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("NotificationsPanel · order", () => {
|
||||||
|
it("puts a waiting access-requests nudge first, above every server row", async () => {
|
||||||
|
h.server = [server(1, 1 * H, false), server(2, 1 * D, true)];
|
||||||
|
h.client = [
|
||||||
|
client(9, 5 * 60 * 1000, {
|
||||||
|
level: "warning",
|
||||||
|
title: "common.accessRequestsTitle",
|
||||||
|
message: "1 pending",
|
||||||
|
meta: { kind: "access-requests" },
|
||||||
|
}),
|
||||||
|
client(8, 2 * D),
|
||||||
|
];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
expect(rows()).toEqual(["common.accessRequestsTitle", "Ready 1", "Ready 2", "Activity 8"]);
|
||||||
|
expect(screen.getByText("inbox.sectionNeedsYou:2")).toBeTruthy();
|
||||||
|
expect(screen.getByText("inbox.sectionRecent")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds read rows older than a week under Earlier (n), closed until opened", async () => {
|
||||||
|
h.server = [server(1, 1 * H, true), server(2, 10 * D, true)];
|
||||||
|
h.client = [client(3, 9 * D)];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
expect(rows()).toEqual(["Ready 1"]);
|
||||||
|
const fold = screen.getByRole("button", { name: "inbox.earlier:2" });
|
||||||
|
expect(fold.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
// A section heading like "Needs you" and "Recent", its text the disclosure's button.
|
||||||
|
expect(fold.closest("h2")).not.toBeNull();
|
||||||
|
fireEvent.click(fold);
|
||||||
|
expect(fold.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(rows()).toEqual(["Ready 1", "Activity 3", "Ready 2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back to a row that folded away while the page was gone opens Earlier on it", async () => {
|
||||||
|
// Read during the visit it was opened from, the row kept its place in Needs you; on the next
|
||||||
|
// visit it is read and over a week old, and folded under Earlier the restore found no list
|
||||||
|
// holding it — the focus fell on the neighbour at its old place.
|
||||||
|
h.server = [server(1, 8 * D, false), server(2, 9 * D, true)];
|
||||||
|
h.client = [];
|
||||||
|
const cancel = restoreNavPosition({
|
||||||
|
list: 0,
|
||||||
|
lists: 2,
|
||||||
|
index: 0,
|
||||||
|
name: "inbox.downloadDone.title: Ready 2",
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 2/),
|
||||||
|
);
|
||||||
|
cancel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "inbox.earlier:1" }).getAttribute("aria-expanded"),
|
||||||
|
).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open Earlier for a namesake there while the row Back left is still in sight", async () => {
|
||||||
|
// A video downloaded twice, a scheduler run that failed the same way again: names repeat. The
|
||||||
|
// extra list took the place match away, the name stood twice on the page, and the focus fell to
|
||||||
|
// <body> with Earlier open for nothing.
|
||||||
|
const twin = { ...server(3, 9 * D, true), data: { title: "Ready 1" } } as AppNotification;
|
||||||
|
h.server = [server(1, 1 * H, true), twin];
|
||||||
|
h.client = [];
|
||||||
|
const cancel = restoreNavPosition({
|
||||||
|
list: 0,
|
||||||
|
lists: 1,
|
||||||
|
index: 0,
|
||||||
|
name: "inbox.downloadDone.title: Ready 1",
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 1/),
|
||||||
|
);
|
||||||
|
cancel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "inbox.earlier:1" }).getAttribute("aria-expanded"),
|
||||||
|
).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens Earlier on a row whose title ends in a space, as Back saved it trimmed", async () => {
|
||||||
|
// A caption cut at the title's length often ends on a space.
|
||||||
|
const spaced = { ...server(2, 9 * D, true), data: { title: "Ready 2 " } } as AppNotification;
|
||||||
|
h.server = [server(1, 8 * D, false), spaced];
|
||||||
|
h.client = [];
|
||||||
|
const cancel = restoreNavPosition({
|
||||||
|
list: 0,
|
||||||
|
lists: 2,
|
||||||
|
index: 0,
|
||||||
|
name: "inbox.downloadDone.title: Ready 2",
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 2/),
|
||||||
|
);
|
||||||
|
cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("numbers each list's rows from 0 for the keyboard walk", async () => {
|
||||||
|
h.server = [server(1, 1 * H, false), server(2, 2 * H, true), server(3, 10 * D, true)];
|
||||||
|
h.client = [client(4, 3 * H)];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inbox.earlier:1" }));
|
||||||
|
const lists = [...document.querySelectorAll("[data-nav-list]")].map((l) =>
|
||||||
|
[...l.querySelectorAll("[data-nav-index]")].map((r) => r.getAttribute("data-nav-index")),
|
||||||
|
);
|
||||||
|
expect(lists).toEqual([["0"], ["0", "1"], ["0"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a row marked read keeps its place in Needs you for the visit, in the same list", async () => {
|
||||||
|
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
|
||||||
|
h.server = [server(1, 1 * H, false), server(2, 2 * H, true)];
|
||||||
|
h.client = [];
|
||||||
|
const { rerender } = mount();
|
||||||
|
await loaded();
|
||||||
|
const before = screen.getByText("Ready 1").closest("[data-nav-index]");
|
||||||
|
fireEvent.click(within(before as HTMLElement).getByRole("button", { name: "inbox.markRead" }));
|
||||||
|
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; // what the refetch after the write reads
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
|
||||||
|
);
|
||||||
|
await act(async () => rerender());
|
||||||
|
expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"]);
|
||||||
|
// The same element, not a remount in another list: the keyboard focus stays on it.
|
||||||
|
expect(screen.getByText("Ready 1").closest("[data-nav-index]")).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Mark all read empties Needs you, the client notices of the visit included", async () => {
|
||||||
|
vi.spyOn(api, "markAllNotificationsRead").mockImplementation(async () => {
|
||||||
|
h.server = [server(1, 1 * H, true)];
|
||||||
|
return { ok: true } as never;
|
||||||
|
});
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [client(2, 2 * H, { read: false })];
|
||||||
|
const { rerender } = mount();
|
||||||
|
await loaded();
|
||||||
|
expect(listUnder("inbox.sectionNeedsYou:2")).toEqual(["Ready 1", "Activity 2"]);
|
||||||
|
h.client = [client(2, 2 * H, { read: true })]; // what the store's markAllRead leaves
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
|
||||||
|
await act(async () => rerender());
|
||||||
|
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||||
|
await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled());
|
||||||
|
expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1", "Activity 2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marking a row read from its button hands the focus to the row, not <body>", async () => {
|
||||||
|
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
const button = within(row).getByRole("button", { name: "inbox.markRead" });
|
||||||
|
button.focus();
|
||||||
|
h.server = [server(1, 1 * H, true)]; // what the refetch after the write reads
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(row.isConnected).toBe(true);
|
||||||
|
expect(document.activeElement).toBe(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a band of rows already read still offers Mark all read, which empties it", async () => {
|
||||||
|
// Opening the center marked this browser's notices read: the band holds them, and nothing on
|
||||||
|
// the page is unread any more.
|
||||||
|
h.server = [server(1, 1 * H, true)];
|
||||||
|
h.client = [client(2, 2 * H, { read: false })];
|
||||||
|
const { rerender } = mount();
|
||||||
|
await loaded();
|
||||||
|
h.client = [client(2, 2 * H, { read: true })]; // what the mount's markAllRead leaves
|
||||||
|
await act(async () => rerender());
|
||||||
|
expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Activity 2"]);
|
||||||
|
const button = screen.getByRole("button", { name: "inbox.markAllRead" });
|
||||||
|
// Unavailable reads either way: natively while unfocused, aria-disabled while it holds the focus.
|
||||||
|
expect(button.hasAttribute("disabled") || button.getAttribute("aria-disabled") === "true").toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
fireEvent.click(button);
|
||||||
|
await act(async () => rerender());
|
||||||
|
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a read that lands before Mark all read commits does not put the rows back", async () => {
|
||||||
|
let commit!: () => void;
|
||||||
|
vi.spyOn(api, "markAllNotificationsRead").mockImplementation(
|
||||||
|
() => new Promise((resolve) => (commit = () => resolve({ ok: true } as never))),
|
||||||
|
);
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
const { qc } = mount();
|
||||||
|
await loaded();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
|
||||||
|
await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled());
|
||||||
|
// The poll (or another row's write) reads the list before the server has committed the write.
|
||||||
|
void qc.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(qc.getQueryData<{ items: AppNotification[] }>(["notifications"])?.items[0]?.read).toBe(
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
h.server = [server(1, 1 * H, true)];
|
||||||
|
await act(async () => commit());
|
||||||
|
await waitFor(() => expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull());
|
||||||
|
expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the rows stay out of the band until the read after the write has landed", async () => {
|
||||||
|
// The write settling is not the list being fresh: a refetch over the network takes a moment,
|
||||||
|
// and until it lands the cache still holds what the read before the commit said.
|
||||||
|
let commit!: () => void;
|
||||||
|
vi.spyOn(api, "markAllNotificationsRead").mockImplementation(
|
||||||
|
() => new Promise((resolve) => (commit = () => resolve({ ok: true } as never))),
|
||||||
|
);
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
const { qc } = mount();
|
||||||
|
await loaded();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
|
||||||
|
await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled());
|
||||||
|
void qc.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(qc.getQueryData<{ items: AppNotification[] }>(["notifications"])?.items[0]?.read).toBe(
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let land!: () => void;
|
||||||
|
vi.mocked(api.notifications).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
land = () => resolve({ items: [server(1, 1 * H, true)], total: 1 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await act(async () => commit());
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||||
|
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||||
|
await act(async () => land());
|
||||||
|
await waitFor(() => expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"]));
|
||||||
|
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer Mark all read for a band that only holds what asks by its kind", async () => {
|
||||||
|
// An access-requests nudge stays up for as long as requests wait: the gesture cannot empty
|
||||||
|
// that band, and a button that does nothing when pressed is not offered.
|
||||||
|
h.server = [server(1, 1 * H, true)];
|
||||||
|
h.client = [
|
||||||
|
client(9, 5 * 60 * 1000, {
|
||||||
|
level: "warning",
|
||||||
|
title: "common.accessRequestsTitle",
|
||||||
|
message: "1 pending",
|
||||||
|
meta: { kind: "access-requests" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
expect(screen.getByText("inbox.sectionNeedsYou:1")).toBeTruthy();
|
||||||
|
const button = screen.getByRole("button", { name: "inbox.markAllRead" });
|
||||||
|
expect(button.hasAttribute("disabled") || button.getAttribute("aria-disabled") === "true").toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refused Mark all read brings the rows back at once, not at the next poll", async () => {
|
||||||
|
vi.spyOn(api, "markAllNotificationsRead").mockRejectedValue(new Error("offline"));
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
const reads = vi.mocked(api.notifications).mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(api.notifications).mock.calls.length).toBeGreaterThan(reads),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a row is unread while the keyboard is on it; its name for Back stays the same", async () => {
|
||||||
|
// The unread dot is only drawn: a read row held in Needs you sounded the same as an unread one.
|
||||||
|
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
|
||||||
|
h.server = [server(1, 1 * H, false), server(2, 2 * H, true)];
|
||||||
|
h.client = [];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
const spoken = (text: string) => {
|
||||||
|
const row = screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
act(() => focusNavItem(row.closest("section")!, Number(row.getAttribute("data-nav-index"))));
|
||||||
|
return [
|
||||||
|
document.activeElement?.getAttribute("aria-label"),
|
||||||
|
row.getAttribute("data-nav-name"),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect(spoken("Ready 1")).toEqual(["inbox.unreadName", "inbox.downloadDone.title: Ready 1"]);
|
||||||
|
expect(spoken("Ready 2")).toEqual([
|
||||||
|
"inbox.downloadDone.title: Ready 2",
|
||||||
|
"inbox.downloadDone.title: Ready 2",
|
||||||
|
]);
|
||||||
|
// Marked read from its button, the row takes the focus before it reads read: what it is called
|
||||||
|
// follows once it does.
|
||||||
|
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
const button = within(row).getByRole("button", { name: "inbox.markRead" });
|
||||||
|
button.focus();
|
||||||
|
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(row).queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(row);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(row.getAttribute("aria-label")).toBe("inbox.downloadDone.title: Ready 1"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a row marked read reads read as it takes the focus, not when the write lands", async () => {
|
||||||
|
// The button hands the focus to its row first: a screen reader announced "…, unread" right
|
||||||
|
// after the reader marked it read.
|
||||||
|
vi.spyOn(api, "markNotificationRead").mockImplementation(() => new Promise(() => {}));
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
const button = within(row).getByRole("button", { name: "inbox.markRead" });
|
||||||
|
button.focus();
|
||||||
|
// Within the press's own task (the label is read out when the task ends): no render later.
|
||||||
|
fireEvent.click(button);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(document.activeElement).toBe(row);
|
||||||
|
expect(row.getAttribute("aria-label")).toBe("inbox.downloadDone.title: Ready 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refused Mark read brings the row back unread", async () => {
|
||||||
|
vi.spyOn(api, "markNotificationRead").mockRejectedValue(new Error("offline"));
|
||||||
|
h.server = [server(1, 1 * H, false)];
|
||||||
|
h.client = [];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "inbox.markRead" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(row).queryByRole("button", { name: "inbox.markRead" })).not.toBeNull(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names each row by what it is about, so two of one kind tell apart", async () => {
|
||||||
|
// A screen reader hears the name while the keyboard is on the row, and Back finds the row it
|
||||||
|
// left by it: "Download ready" alone was every download's name.
|
||||||
|
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
|
||||||
|
h.client = [client(3, 3 * H, { title: "Something broke", message: "Unexpected error." })];
|
||||||
|
mount();
|
||||||
|
await loaded();
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll("[data-nav-index]")].map((r) =>
|
||||||
|
r.getAttribute("data-nav-name"),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"inbox.downloadDone.title: Ready 1",
|
||||||
|
"inbox.downloadDone.title: Ready 2",
|
||||||
|
"Something broke: Unexpected error.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useSyncExternalStore, type ReactNode } from "react";
|
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -41,8 +43,10 @@ import {
|
|||||||
type VideoWatchedMeta,
|
type VideoWatchedMeta,
|
||||||
} from "../../lib/notifications";
|
} from "../../lib/notifications";
|
||||||
import { channelYouTubeUrl, formatDate } from "../../lib/format";
|
import { channelYouTubeUrl, formatDate } from "../../lib/format";
|
||||||
|
import { clientAsks, entryKey, keyOf, orderInbox, type InboxEntry } from "../../lib/inboxOrder";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { LEVEL_STYLE } from "../../components/ui/toastLevels";
|
import { LEVEL_STYLE } from "../../components/ui/toastLevels";
|
||||||
import { PageToolbar } from "../../components/ui/PageShell";
|
import { PageToolbar } from "../../components/ui/PageShell";
|
||||||
import { PageMenu } from "../../components/ui/PageMenu";
|
import { PageMenu } from "../../components/ui/PageMenu";
|
||||||
@@ -55,7 +59,7 @@ import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
|||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { SectionLabel } from "../../components/ui/Headings";
|
import { SectionLabel } from "../../components/ui/Headings";
|
||||||
import NavGrid from "../../components/ui/NavGrid";
|
import NavGrid from "../../components/ui/NavGrid";
|
||||||
import { navItemAttrs } from "../../lib/listNav";
|
import { focusNavItem, navItemAttrs, pendingNavName } from "../../lib/listNav";
|
||||||
import { useContextMenu, type ContextMenuController } from "../../components/ui/useContextMenu";
|
import { useContextMenu, type ContextMenuController } from "../../components/ui/useContextMenu";
|
||||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||||
import { MoreButton } from "../../components/ui/MoreButton";
|
import { MoreButton } from "../../components/ui/MoreButton";
|
||||||
@@ -65,16 +69,17 @@ import { HOVER_REVEAL } from "../../components/ui/reveal";
|
|||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||||
|
|
||||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
// The single notification center, over two sources: the durable, server-backed notifications, and
|
||||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
// this browser's client-side events (action confirmations, errors, with their inline undo/find
|
||||||
// their inline undo/find actions) that used to live behind a separate bell — folded in here so
|
// actions) that used to live behind a separate bell — folded in here so there's one indicator and
|
||||||
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
|
// one place to look. Transient toasts still pop via the Toaster. Both sources share one order
|
||||||
|
// (`lib/inboxOrder`): what asks for the reader first, then one stream, old read rows folded.
|
||||||
const POLL_MS = 8000;
|
const POLL_MS = 8000;
|
||||||
|
|
||||||
const byId = (n: { id: number }) => n.id;
|
const byId = (n: { id: number }) => n.id;
|
||||||
|
|
||||||
export default function NotificationsPanel() {
|
export default function NotificationsPanel() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
const { openFile } = useFilesActions();
|
const { openFile } = useFilesActions();
|
||||||
const filters = useFeedFilters();
|
const filters = useFeedFilters();
|
||||||
@@ -93,6 +98,21 @@ export default function NotificationsPanel() {
|
|||||||
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
||||||
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
|
|
||||||
|
// The rows that asked for the reader in this visit keep their place in "Needs you" once read
|
||||||
|
// (`lib/inboxOrder`): seeded with the client notices unread at open, which the effect below marks
|
||||||
|
// read, and grown by every row that asks while the center is open. Mark all read empties it — the
|
||||||
|
// one gesture that means "I have seen the lot". The fold is measured from the time the center
|
||||||
|
// opened, fixed for the visit, so a row does not fold away under the reader.
|
||||||
|
const [held, setHeld] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(getNotifications().flatMap((n) => (n.read ? [] : [keyOf("client", n.id)]))),
|
||||||
|
);
|
||||||
|
const [openedAt] = useState(() => Date.now());
|
||||||
|
const [earlierOpen, setEarlierOpen] = useState(false);
|
||||||
|
// Back to a row that folded away since it was opened (read on that visit, and over a week old):
|
||||||
|
// Earlier opens on it, or the restore finds no list holding it and the focus falls on the row now
|
||||||
|
// at its old place. Read once, as the center opens; settled once the server's list is in.
|
||||||
|
const [seeking, setSeeking] = useState(pendingNavName);
|
||||||
|
|
||||||
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
|
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
|
||||||
// items stay unread until acted on individually.
|
// items stay unread until acted on individually.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,14 +123,36 @@ export default function NotificationsPanel() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.notifications() });
|
qc.invalidateQueries({ queryKey: qk.notifications() });
|
||||||
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
||||||
};
|
};
|
||||||
|
// A row marked read reads read at once, in the press's own render: its Mark read button hands the
|
||||||
|
// focus to the row, and a row still unread then was announced "…, unread" until the write landed.
|
||||||
|
const [readNow, setReadNow] = useState<ReadonlySet<number>>(() => new Set());
|
||||||
const readMut = useMutation({
|
const readMut = useMutation({
|
||||||
mutationFn: api.markNotificationRead,
|
mutationFn: api.markNotificationRead,
|
||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
|
// Refused: the row reads unread again.
|
||||||
|
onError: (_e, id) =>
|
||||||
|
setReadNow((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
meta: { failTitle: "errors.writeFailed.notifRead" },
|
meta: { failTitle: "errors.writeFailed.notifRead" },
|
||||||
});
|
});
|
||||||
|
function markRead(id: number) {
|
||||||
|
setReadNow((s) => new Set(s).add(id));
|
||||||
|
readMut.mutate(id);
|
||||||
|
}
|
||||||
const readAllMut = useMutation({
|
const readAllMut = useMutation({
|
||||||
mutationFn: api.markAllNotificationsRead,
|
mutationFn: api.markAllNotificationsRead,
|
||||||
onSuccess: refresh,
|
// Settled, not only succeeded: a refused write brings the rows back unread at once, rather than
|
||||||
|
// leaving them read until the next poll moves them under the reader.
|
||||||
|
// And it settles once the read after it has landed: until then the cache holds what a read
|
||||||
|
// before the commit said, and the band would take those rows back the moment the mask below lifts.
|
||||||
|
onSettled: () =>
|
||||||
|
Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: qk.notifications() }),
|
||||||
|
qc.invalidateQueries({ queryKey: qk.notifUnread() }),
|
||||||
|
]),
|
||||||
meta: { failTitle: "errors.writeFailed.notifReadAll" },
|
meta: { failTitle: "errors.writeFailed.notifReadAll" },
|
||||||
});
|
});
|
||||||
const dismissMut = useMutation({
|
const dismissMut = useMutation({
|
||||||
@@ -124,12 +166,42 @@ export default function NotificationsPanel() {
|
|||||||
meta: { failTitle: "errors.writeFailed.notifClear" },
|
meta: { failTitle: "errors.writeFailed.notifClear" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// While Mark all read is on its way, a read that lands before the server commits it (the poll, or
|
||||||
|
// another row's write refreshing the list) still says "unread": those rows are read already, or
|
||||||
|
// the band would take them back and hold them for the rest of the visit.
|
||||||
|
const shownServer = serverItems.map((n) =>
|
||||||
|
n.read || !(readAllMut.isPending || readNow.has(n.id)) ? n : { ...n, read: true },
|
||||||
|
);
|
||||||
|
const order = orderInbox(shownServer, clientItems, { now: openedAt, held });
|
||||||
|
const asking = order.needsYou.map(entryKey).filter((k) => !held.has(k));
|
||||||
|
if (asking.length > 0) setHeld(new Set([...held, ...asking]));
|
||||||
|
if (seeking && q.data) {
|
||||||
|
setSeeking(null);
|
||||||
|
// Only a row that can be nowhere else: a namesake in Earlier while the row itself is still in
|
||||||
|
// sight would open it for nothing, and the name standing twice on the page loses the row.
|
||||||
|
const sought = (e: InboxEntry) => entryName(e, t, i18n.language) === seeking;
|
||||||
|
if (!order.needsYou.some(sought) && !order.recent.some(sought) && order.earlier.some(sought))
|
||||||
|
setEarlierOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
const serverUnread = serverItems.some((n) => !n.read);
|
const serverUnread = serverItems.some((n) => !n.read);
|
||||||
const hasAny = serverItems.length > 0 || clientItems.length > 0;
|
const hasAny = serverItems.length > 0 || clientItems.length > 0;
|
||||||
const hasUnread = serverUnread || clientUnread > 0;
|
// A band of rows already read still offers the gesture that empties it — but not one that only
|
||||||
|
// holds what asks by its kind (an access-requests nudge): pressing it would change nothing.
|
||||||
|
const canMarkAll =
|
||||||
|
serverUnread ||
|
||||||
|
clientUnread > 0 ||
|
||||||
|
order.needsYou.some((e) => e.src === "server" || !clientAsks(e.n));
|
||||||
|
|
||||||
function markAll() {
|
function markAll() {
|
||||||
markAllClientRead();
|
markAllClientRead();
|
||||||
|
// Read at once, not when the write lands: until then the rows still read unread, and the band
|
||||||
|
// would take them straight back. A failed write refetches the truth when it settles.
|
||||||
|
void qc.cancelQueries({ queryKey: qk.notifications() });
|
||||||
|
qc.setQueryData<{ items: AppNotification[]; total: number }>(qk.notifications(), (d) =>
|
||||||
|
d ? { ...d, items: d.items.map((n) => (n.read ? n : { ...n, read: true })) } : d,
|
||||||
|
);
|
||||||
|
setHeld(new Set());
|
||||||
if (serverUnread) readAllMut.mutate();
|
if (serverUnread) readAllMut.mutate();
|
||||||
else refresh();
|
else refresh();
|
||||||
}
|
}
|
||||||
@@ -261,7 +333,7 @@ export default function NotificationsPanel() {
|
|||||||
id: "read",
|
id: "read",
|
||||||
label: t("inbox.markRead"),
|
label: t("inbox.markRead"),
|
||||||
icon: icon(Check),
|
icon: icon(Check),
|
||||||
onSelect: () => readMut.mutate(n.id),
|
onSelect: () => markRead(n.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -340,6 +412,37 @@ export default function NotificationsPanel() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One row of either source; `i` is its place in the list it is rendered into.
|
||||||
|
const entry = (e: InboxEntry, i: number) =>
|
||||||
|
e.src === "server" ? (
|
||||||
|
<NotificationRow
|
||||||
|
key={entryKey(e)}
|
||||||
|
n={e.n}
|
||||||
|
t={t}
|
||||||
|
navIndex={i}
|
||||||
|
rowProps={serverMenu.triggerProps(e.n)}
|
||||||
|
more={more(serverMenu, e.n)}
|
||||||
|
onRead={() => markRead(e.n.id)}
|
||||||
|
onDismiss={() => dismissMut.mutate(e.n.id)}
|
||||||
|
onOpenScheduler={openScheduler}
|
||||||
|
onOpenTakeout={openTakeout}
|
||||||
|
onOpenFile={() => openDone(e.n)}
|
||||||
|
onOpenTokens={openTokens}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClientActivityRow
|
||||||
|
key={entryKey(e)}
|
||||||
|
n={e.n}
|
||||||
|
t={t}
|
||||||
|
navIndex={i}
|
||||||
|
rowProps={clientMenu.triggerProps(e.n)}
|
||||||
|
more={more(clientMenu, e.n)}
|
||||||
|
onFocusChannel={onFocusChannel}
|
||||||
|
onReviewAccess={reviewAccess}
|
||||||
|
onRemove={() => removeClient(e.n.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// --- The page's own menu: the header's "…" ------------------------------------------------------
|
// --- The page's own menu: the header's "…" ------------------------------------------------------
|
||||||
const headerMenu = useContextMenu<"inbox">();
|
const headerMenu = useContextMenu<"inbox">();
|
||||||
const headerItems: ActionMenuItem[] = [
|
const headerItems: ActionMenuItem[] = [
|
||||||
@@ -347,7 +450,7 @@ export default function NotificationsPanel() {
|
|||||||
id: "read-all",
|
id: "read-all",
|
||||||
label: t("inbox.markAllRead"),
|
label: t("inbox.markAllRead"),
|
||||||
icon: icon(CheckCheck),
|
icon: icon(CheckCheck),
|
||||||
disabled: !hasUnread || readAllMut.isPending,
|
disabled: !canMarkAll || readAllMut.isPending,
|
||||||
onSelect: markAll,
|
onSelect: markAll,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -381,7 +484,7 @@ export default function NotificationsPanel() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={markAll}
|
onClick={markAll}
|
||||||
disabled={!hasUnread || readAllMut.isPending}
|
disabled={!canMarkAll || readAllMut.isPending}
|
||||||
icon={<CheckCheck />}
|
icon={<CheckCheck />}
|
||||||
>
|
>
|
||||||
{t("inbox.markAllRead")}
|
{t("inbox.markAllRead")}
|
||||||
@@ -409,51 +512,41 @@ export default function NotificationsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{serverItems.length > 0 && (
|
{order.needsYou.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
|
<SectionLabel className="mb-2">
|
||||||
<NavGrid className="flex flex-col gap-2">
|
{t("inbox.sectionNeedsYou", { count: order.needsYou.length })}
|
||||||
{serverItems.map((n, i) => {
|
</SectionLabel>
|
||||||
return (
|
<NavGrid className="flex flex-col gap-2">{order.needsYou.map(entry)}</NavGrid>
|
||||||
<NotificationRow
|
|
||||||
key={n.id}
|
|
||||||
n={n}
|
|
||||||
t={t}
|
|
||||||
navIndex={i}
|
|
||||||
rowProps={serverMenu.triggerProps(n)}
|
|
||||||
more={more(serverMenu, n)}
|
|
||||||
onRead={() => readMut.mutate(n.id)}
|
|
||||||
onDismiss={() => dismissMut.mutate(n.id)}
|
|
||||||
onOpenScheduler={openScheduler}
|
|
||||||
onOpenTakeout={openTakeout}
|
|
||||||
onOpenFile={() => openDone(n)}
|
|
||||||
onOpenTokens={openTokens}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</NavGrid>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{clientItems.length > 0 && (
|
{order.recent.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
|
<SectionLabel className="mb-2">{t("inbox.sectionRecent")}</SectionLabel>
|
||||||
<NavGrid className="flex flex-col gap-2">
|
<NavGrid className="flex flex-col gap-2">{order.recent.map(entry)}</NavGrid>
|
||||||
{clientItems.map((n, i) => {
|
</section>
|
||||||
return (
|
)}
|
||||||
<ClientActivityRow
|
{order.earlier.length > 0 && (
|
||||||
key={n.id}
|
<section>
|
||||||
n={n}
|
{/* A heading like its siblings, its text the disclosure's button (the ARIA
|
||||||
t={t}
|
disclosure pattern); 24px tall so the target clears the WCAG 2.5.8 floor. */}
|
||||||
navIndex={i}
|
<SectionLabel className={earlierOpen ? "mb-2" : undefined}>
|
||||||
rowProps={clientMenu.triggerProps(n)}
|
<Pressable
|
||||||
more={more(clientMenu, n)}
|
aria-expanded={earlierOpen}
|
||||||
onFocusChannel={onFocusChannel}
|
onClick={() => setEarlierOpen((o) => !o)}
|
||||||
onReviewAccess={reviewAccess}
|
className="min-h-6 inline-flex items-center gap-1 uppercase hover:text-fg transition"
|
||||||
onRemove={() => removeClient(n.id)}
|
>
|
||||||
/>
|
{earlierOpen ? (
|
||||||
);
|
<ChevronDown className="size-3.5" aria-hidden />
|
||||||
})}
|
) : (
|
||||||
</NavGrid>
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{t("inbox.earlier", { count: order.earlier.length })}
|
||||||
|
</Pressable>
|
||||||
|
</SectionLabel>
|
||||||
|
{earlierOpen && (
|
||||||
|
<NavGrid className="flex flex-col gap-2">{order.earlier.map(entry)}</NavGrid>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -517,7 +610,19 @@ function RowEnd({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{onRead && (
|
{onRead && (
|
||||||
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
|
<IconButton
|
||||||
|
label={t("inbox.markRead")}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
// The button goes once the row reads read, and the focus on it would fall to <body>:
|
||||||
|
// hand it to the row first, where j / k carry on.
|
||||||
|
const row = e.currentTarget.closest<HTMLElement>("[data-nav-index]");
|
||||||
|
if (row?.parentElement && document.activeElement === e.currentTarget)
|
||||||
|
focusNavItem(row.parentElement, Number(row.getAttribute("data-nav-index")));
|
||||||
|
onRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -535,36 +640,12 @@ function RowEnd({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationRow({
|
/** What a server row says: its title and body — in the reader's language for a known type. */
|
||||||
n,
|
function serverRowText(
|
||||||
t,
|
n: AppNotification,
|
||||||
navIndex,
|
t: TFunction,
|
||||||
rowProps,
|
lang: string,
|
||||||
more,
|
): { title: string; body: string | null } {
|
||||||
onRead,
|
|
||||||
onDismiss,
|
|
||||||
onOpenScheduler,
|
|
||||||
onOpenTakeout,
|
|
||||||
onOpenFile,
|
|
||||||
onOpenTokens,
|
|
||||||
}: {
|
|
||||||
n: AppNotification;
|
|
||||||
t: TFunction;
|
|
||||||
navIndex: number;
|
|
||||||
rowProps: Record<string, unknown>;
|
|
||||||
more: ReactNode;
|
|
||||||
onRead: () => void;
|
|
||||||
onDismiss: () => void;
|
|
||||||
onOpenScheduler: () => void;
|
|
||||||
onOpenTakeout: () => void;
|
|
||||||
onOpenFile: () => void;
|
|
||||||
onOpenTokens: () => void;
|
|
||||||
}) {
|
|
||||||
const lang = useTranslation().i18n.language;
|
|
||||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
|
||||||
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
|
|
||||||
? n.data!.videos
|
|
||||||
: [];
|
|
||||||
// Known notification types are rendered from i18n (so they're trilingual) using the typed
|
// Known notification types are rendered from i18n (so they're trilingual) using the typed
|
||||||
// payload; the server-stored title/body are an English fallback for any unknown type.
|
// payload; the server-stored title/body are an English fallback for any unknown type.
|
||||||
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||||
@@ -619,9 +700,65 @@ function NotificationRow({
|
|||||||
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
|
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
|
||||||
body = d.title || n.body;
|
body = d.title || n.body;
|
||||||
}
|
}
|
||||||
|
return { title, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's name, for a screen reader and for Back to find it by: what it is about, not its kind.
|
||||||
|
* Trimmed as Back saves it (`lib/listNav`), or a title ending in a space never matches its row. */
|
||||||
|
const rowName = (title: string, body: string | null | undefined): string =>
|
||||||
|
(body ? `${title}: ${body}` : title).trim();
|
||||||
|
const clientRowName = (n: ClientNotif): string =>
|
||||||
|
n.title ? rowName(n.title, n.message) : rowName(n.message, null);
|
||||||
|
const entryName = (e: InboxEntry, t: TFunction, lang: string): string => {
|
||||||
|
if (e.src === "client") return clientRowName(e.n);
|
||||||
|
const { title, body } = serverRowText(e.n, t, lang);
|
||||||
|
return rowName(title, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationRow({
|
||||||
|
n,
|
||||||
|
t,
|
||||||
|
navIndex,
|
||||||
|
rowProps,
|
||||||
|
more,
|
||||||
|
onRead,
|
||||||
|
onDismiss,
|
||||||
|
onOpenScheduler,
|
||||||
|
onOpenTakeout,
|
||||||
|
onOpenFile,
|
||||||
|
onOpenTokens,
|
||||||
|
}: {
|
||||||
|
n: AppNotification;
|
||||||
|
t: TFunction;
|
||||||
|
navIndex: number;
|
||||||
|
rowProps: Record<string, unknown>;
|
||||||
|
more: ReactNode;
|
||||||
|
onRead: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onOpenScheduler: () => void;
|
||||||
|
onOpenTakeout: () => void;
|
||||||
|
onOpenFile: () => void;
|
||||||
|
onOpenTokens: () => void;
|
||||||
|
}) {
|
||||||
|
const lang = useTranslation().i18n.language;
|
||||||
|
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||||
|
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
|
||||||
|
? n.data!.videos
|
||||||
|
: [];
|
||||||
|
const { title, body } = serverRowText(n, t, lang);
|
||||||
|
const isTakeout = serverKind(n) === "takeout";
|
||||||
|
const isScheduler = serverKind(n) === "scheduler" || isTakeout;
|
||||||
|
const isDownloadDone = serverKind(n) === "download";
|
||||||
|
const isTokenExpiring = serverKind(n) === "token";
|
||||||
|
// Named by what it is about, not its kind alone: every "Download ready" read the same to a screen
|
||||||
|
// reader, and Back could not tell the row it was opened from by its name.
|
||||||
|
const name = rowName(title, body);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...navItemAttrs(navIndex, title)}
|
// The unread state is spoken too (the dot is only drawn), apart from the name Back finds it by.
|
||||||
|
{...navItemAttrs(navIndex, name, {
|
||||||
|
label: n.read ? undefined : t("inbox.unreadName", { name }),
|
||||||
|
})}
|
||||||
{...rowProps}
|
{...rowProps}
|
||||||
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
|
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
|
||||||
n.read ? "opacity-70" : ""
|
n.read ? "opacity-70" : ""
|
||||||
@@ -717,7 +854,7 @@ function ClientActivityRow({
|
|||||||
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...navItemAttrs(navIndex, n.title || n.message)}
|
{...navItemAttrs(navIndex, clientRowName(n))}
|
||||||
{...rowProps}
|
{...rowProps}
|
||||||
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
|
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user