release: 0.87.0 (improvement/watch-background-audio)
This commit is contained in:
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.87.0 — 2026-09-15
|
||||
|
||||
A shared or library video keeps playing when you switch apps on an iPad, and a new tab opens as the account it belongs to without flashing another page first. Facebook reels keep their titles and an mp3 stays an mp3.
|
||||
|
||||
### Features
|
||||
|
||||
- Background play on iPad (Brave): the shared-video page, the library player and the Plex player keep playing in another app and on the locked screen.
|
||||
- The shared-video page has a Repeat toggle, on by default.
|
||||
- Switching modules no longer flashes a loading screen: the pages load quietly in the background, and a slow answer shows its spinner only after a moment.
|
||||
|
||||
### Fixes
|
||||
|
||||
- A new tab honours your Startup choice and opens as its own account — no Home page flash, no other account's language or settings on the first frame.
|
||||
- A signed-in Facebook reel keeps its real title instead of “Video” or “Facebook”, and an mp3 download stays an mp3 (it used to be wrapped into an mp4 an iPad would not play).
|
||||
- A tab whose account was signed out, suspended or signed out everywhere acts as nobody and comes back as the account still signed in, instead of writing into another account or sticking on the sign-in page.
|
||||
- Switching or adding an account no longer saves the video you were watching into the other account's resume position.
|
||||
- A new tab keeps the account's notification history instead of replacing it with its own first notice.
|
||||
- The shared-video page tells a failed load, a wrong password, too many attempts and an unreachable server apart, with Retry where it helps. Its password limit counts per viewer, so one person guessing cannot lock everyone else out.
|
||||
- Form errors on sign-in, the message passphrase, password change and setup are read out by screen readers — again on a second identical failure — and the setup wizard names what is wrong instead of a generic error.
|
||||
|
||||
### Internal
|
||||
|
||||
- WebKit and iPad projects in the Playwright suite; the Vite dev proxy forwards websockets.
|
||||
- Four deep-review rounds (12 → 8 → 8 → 8, the last one targeted), every regression test mutation-checked; half of the later rounds' findings were older defects, fixed here.
|
||||
|
||||
## 0.86.0 — 2026-09-14
|
||||
|
||||
A failed download now tells you why it failed and what you can do about it, instead of repeating yt-dlp's raw error. Facebook comes first: downloads that used to die on “Cannot parse data” go through, and a Facebook group post downloads its own video instead of a different one from the page.
|
||||
|
||||
+51
-16
@@ -269,21 +269,29 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
|
||||
def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]:
|
||||
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
||||
"""Which account this connection acts as, and whether that's the session's default account.
|
||||
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this
|
||||
exact per-tab resolution instead of re-implementing it.
|
||||
|
||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||
plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header,
|
||||
but ONLY for an account already in the wallet — so a tab can't impersonate an account that never
|
||||
authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default.
|
||||
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
||||
`?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that
|
||||
never authenticated here. A connection that names no account acts as the cookie default.
|
||||
|
||||
A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still
|
||||
pinned to an account that was signed out in another tab. It must not fall back to the default:
|
||||
that tab would go on acting as an account it never showed, its player checkpoints and pref saves
|
||||
landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes
|
||||
`strict=False` and gets the default, which is how such a tab learns who it now is (the frontend
|
||||
re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted,
|
||||
suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it
|
||||
takes the same way back.
|
||||
"""
|
||||
default_id = conn.session.get("user_id")
|
||||
wallet = conn.session.get("account_ids") or []
|
||||
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
||||
# plain <a> file download). Both are wallet-gated below, so neither can impersonate an
|
||||
# account that never signed into this browser.
|
||||
# plain <a> file download). Both are wallet-gated below.
|
||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||
if hdr:
|
||||
try:
|
||||
@@ -292,28 +300,46 @@ def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]:
|
||||
hid = None
|
||||
if hid is not None and hid in wallet:
|
||||
return hid, hid == default_id
|
||||
if strict:
|
||||
return None, False
|
||||
return default_id, True
|
||||
|
||||
|
||||
def drop_from_wallet(session: dict, user_id: int) -> None:
|
||||
"""Take one account out of this browser's wallet (`account_ids`); the session default is untouched."""
|
||||
session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id]
|
||||
|
||||
|
||||
def _end_account_session(request: Request, user_id: int, is_default: bool) -> None:
|
||||
"""An account whose session here is no longer valid. Only nuke the whole session when it is the
|
||||
SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid).
|
||||
Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed
|
||||
in here and gets the default. Left in, it answered "signed out" — the landing page, with the default
|
||||
still signed in, and no way off it but closing the tab."""
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
else:
|
||||
drop_from_wallet(request.session, user_id)
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id, is_default = resolved_user_id(request)
|
||||
return _session_user(request, db, *resolved_user_id(request))
|
||||
|
||||
|
||||
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if user is None or not user.is_active or user.is_suspended:
|
||||
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
|
||||
# But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale
|
||||
# per-tab header account just 401s that one tab (other tabs' default may still be valid).
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||
_end_account_session(request, user_id, is_default)
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
||||
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
||||
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
_end_account_session(request, user_id, is_default)
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
# Always keep the session default in the switchable list — covers sessions created before
|
||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||
@@ -328,9 +354,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
||||
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it).
|
||||
The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets
|
||||
the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an
|
||||
account refused right here, which `_session_user` has just taken out of the wallet."""
|
||||
user_id, is_default = resolved_user_id(request, strict=False)
|
||||
try:
|
||||
return current_user(request, db)
|
||||
return _session_user(request, db, user_id, is_default)
|
||||
except HTTPException:
|
||||
if is_default:
|
||||
return None
|
||||
try:
|
||||
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class Settings(BaseSettings):
|
||||
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
|
||||
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
|
||||
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
|
||||
# deploy. Comma-separated. See routes.auth._client_ip + the deploy docs.
|
||||
# deploy. Comma-separated. See ratelimit.client_key + the deploy docs.
|
||||
trusted_proxy_ips: str = ""
|
||||
|
||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
||||
|
||||
@@ -61,6 +61,10 @@ class SourceAdapter:
|
||||
return False
|
||||
|
||||
# --- metadata -----------------------------------------------------------------------------
|
||||
def title(self, info: dict) -> str | None:
|
||||
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
|
||||
return info.get("title")
|
||||
|
||||
def uploader_url(self, info: dict) -> str | None:
|
||||
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
|
||||
`channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
|
||||
@@ -158,6 +162,23 @@ class FacebookAdapter(SourceAdapter):
|
||||
def retriable(self, error_text: str) -> bool:
|
||||
return FACEBOOK_PARSE_WALL in error_text.lower()
|
||||
|
||||
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
|
||||
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
|
||||
# signed-out read titles the page with that same text behind the engagement counts, cut short.
|
||||
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
|
||||
|
||||
def title(self, info: dict) -> str | None:
|
||||
raw = info.get("title")
|
||||
if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES:
|
||||
return raw
|
||||
line = next(
|
||||
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
|
||||
)
|
||||
# The whole first line, never cut here with an ellipsis: normalize_title's NFKC turns "…" into
|
||||
# "..." and then collapses it to ".", which read as a finished sentence ("… live at."). The
|
||||
# stored title is unbounded; display_name and the file name clip it themselves.
|
||||
return line or raw # no description either (the 18+ reel): nothing better on the page
|
||||
|
||||
def uploader_url(self, info: dict) -> str | None:
|
||||
# Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id`
|
||||
# (the page id) that resolves at facebook.com/<id>.
|
||||
|
||||
@@ -11,7 +11,7 @@ in prose):
|
||||
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
||||
asset (the load-bearing cross-user privacy fix).
|
||||
|
||||
Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift.
|
||||
Both key the private identity on `cookie_salt` / `host_key_for_url` here, so they can't drift.
|
||||
|
||||
Security controls enforced here:
|
||||
* **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated
|
||||
@@ -101,8 +101,9 @@ def cookie_salt(user_id: int, host_key: str) -> str:
|
||||
|
||||
|
||||
def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
|
||||
"""The salted `format_sig` an authed download for (user, host) resolves to — the private asset
|
||||
identity. Both enqueue (to create it) and the worker (to recognise it) compute it HERE."""
|
||||
"""The salted `format_sig` an authed download for (user, host) resolves to under the current
|
||||
signature version — the private asset enqueue creates, and one of the identities `is_authed_asset`
|
||||
recognises. Neither calls it; the tests use it to hold the two to each other."""
|
||||
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
||||
|
||||
|
||||
@@ -118,14 +119,17 @@ def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None
|
||||
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
|
||||
kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a
|
||||
Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download,
|
||||
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
|
||||
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie).
|
||||
Either signature version counts (`formats.format_sigs`): a job queued before a deploy that bumped
|
||||
the version still downloads into the asset it was given."""
|
||||
host_key = host_key_for_url(url)
|
||||
if host_key is None:
|
||||
return False
|
||||
if asset.format_sig == authed_format_sig(spec, user_id, host_key):
|
||||
salt = cookie_salt(user_id, host_key)
|
||||
if asset.format_sig in formats.format_sigs(spec, salt=salt):
|
||||
return True
|
||||
return asset.id is not None and asset.format_sig == formats.format_sig(
|
||||
spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key))
|
||||
return asset.id is not None and asset.format_sig in formats.format_sigs(
|
||||
spec, salt=rebuild_salt(asset.id, salt)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ _DEFAULTS = {
|
||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||
_SIG_VERSION = 4
|
||||
# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every
|
||||
# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that
|
||||
# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive.
|
||||
_AUDIO_SIG_VERSION = 5
|
||||
|
||||
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||
|
||||
@@ -96,20 +100,34 @@ def normalize(spec: dict) -> dict:
|
||||
return s
|
||||
|
||||
|
||||
def format_sig(spec: dict, salt: str | None = None) -> str:
|
||||
def _sig_version(s: dict) -> int:
|
||||
return _AUDIO_SIG_VERSION if s["mode"] == "a" else _SIG_VERSION
|
||||
|
||||
|
||||
def format_sig(spec: dict, salt: str | None = None, *, version: int | None = None) -> str:
|
||||
"""Stable short hash of the output-affecting spec — the MediaAsset cache key component.
|
||||
|
||||
`salt` forks a distinct, non-dedup identity for the same spec (used by force-redownload so it
|
||||
always re-fetches into its own asset instead of sharing a cached file with other jobs)."""
|
||||
always re-fetches into its own asset instead of sharing a cached file with other jobs).
|
||||
`version` hashes under a signature version other than the current one (see `format_sigs`)."""
|
||||
s = normalize(spec)
|
||||
data = {k: s[k] for k in _SIG_KEYS}
|
||||
data["_v"] = _SIG_VERSION
|
||||
data["_v"] = _sig_version(s) if version is None else version
|
||||
if salt is not None:
|
||||
data["_salt"] = salt
|
||||
payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def format_sigs(spec: dict, salt: str | None = None) -> tuple[str, str]:
|
||||
"""`format_sig` under the current signature version and under the one before it. An asset keeps
|
||||
the signature it was created with, so a job queued before a deploy that bumped the version and
|
||||
run after it downloads into an asset carrying the previous one — which must still be recognised
|
||||
as that user's private asset (`credentials.is_authed_asset`), or it downloads without its cookie."""
|
||||
previous = _sig_version(normalize(spec)) - 1
|
||||
return format_sig(spec, salt), format_sig(spec, salt, version=previous)
|
||||
|
||||
|
||||
def build_ydl_opts(
|
||||
spec: dict,
|
||||
outtmpl: str,
|
||||
|
||||
@@ -113,9 +113,10 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
|
||||
# (e.g. a title that was only a social engagement prefix); the completion path later sets
|
||||
# the final title with its own source_ref fallback.
|
||||
asset.title = normalize_title(info.get("title")) or info.get("id")
|
||||
adapter = adapters.resolve_adapter(None, None, info)
|
||||
asset.title = normalize_title(adapter.title(info)) or info.get("id")
|
||||
asset.uploader = info.get("uploader") or info.get("channel")
|
||||
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
asset.thumbnail_url = info.get("thumbnail")
|
||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||
@@ -403,8 +404,12 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
|
||||
if spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk.
|
||||
|
||||
An audio download (mode "a") is never touched: yt-dlp already extracted it to .mp3 / .m4a, which
|
||||
every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4,
|
||||
which iPad's WebKit refuses to play (prod job 268)."""
|
||||
if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
@@ -509,7 +514,7 @@ def _download(
|
||||
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(info.get("title")) or source_ref,
|
||||
title=normalize_title(adapter.title(info)) or source_ref,
|
||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""A tiny in-process sliding-window rate limiter.
|
||||
|
||||
Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any
|
||||
Generic groundwork: keyed by an arbitrary string (e.g. a client — `client_key`), so it can throttle any
|
||||
endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
|
||||
is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
|
||||
processes — if we ever run multiple workers, swap the backing store for Redis behind the same
|
||||
``allow()`` interface.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_events: int, window_seconds: float):
|
||||
@@ -25,6 +30,14 @@ class RateLimiter:
|
||||
with self._lock:
|
||||
self._hits.clear()
|
||||
|
||||
def exhausted(self, key: str) -> bool:
|
||||
"""Whether ``key``'s window is already full (``allow`` would refuse it), WITHOUT counting an
|
||||
attempt — for a limit that counts only some outcomes: ask it first, ``allow`` the ones that
|
||||
count."""
|
||||
cutoff = time.monotonic() - self.window
|
||||
with self._lock:
|
||||
return sum(1 for t in self._hits.get(key, []) if t > cutoff) >= self.max_events
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
"""Record an attempt for ``key`` and return whether it is within the limit.
|
||||
|
||||
@@ -49,3 +62,40 @@ class RateLimiter:
|
||||
else:
|
||||
del self._hits[k]
|
||||
return True
|
||||
|
||||
|
||||
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
|
||||
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
||||
try:
|
||||
ip = ipaddress.ip_address(s.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
||||
|
||||
|
||||
def client_key(request: Request) -> str:
|
||||
"""Best-effort client identity for rate limiting: its IP, or for IPv6 the /64 the address sits in — a
|
||||
subscriber is handed a whole /64, so keyed on the full address one client would have endless identities
|
||||
to spread its attempts over. X-Forwarded-For is client-controlled, so we trust it
|
||||
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
|
||||
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
|
||||
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
|
||||
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
|
||||
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
|
||||
|
||||
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
|
||||
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
|
||||
do NOT add that flag, or the trust check below would compare against a forgeable value."""
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
client = peer
|
||||
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
|
||||
if _norm_ip(peer) in trusted:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
|
||||
if parts:
|
||||
client = parts[-1]
|
||||
ip = _norm_ip(client)
|
||||
if isinstance(ip, ipaddress.IPv6Address):
|
||||
return str(ipaddress.IPv6Network((ip, 64), strict=False))
|
||||
return client
|
||||
|
||||
+10
-40
@@ -3,7 +3,6 @@
|
||||
Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token
|
||||
helpers) stays in app/auth.py, imported below."""
|
||||
|
||||
import ipaddress
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
@@ -40,7 +39,7 @@ from app.auth import (
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import canonical_email, valid_email
|
||||
|
||||
@@ -172,37 +171,6 @@ def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
|
||||
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
||||
try:
|
||||
ip = ipaddress.ip_address(s.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
|
||||
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
|
||||
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
|
||||
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
|
||||
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
|
||||
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
|
||||
|
||||
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
|
||||
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
|
||||
do NOT add that flag, or the trust check below would compare against a forgeable value."""
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
|
||||
if _norm_ip(peer) in trusted:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
|
||||
if parts:
|
||||
return parts[-1]
|
||||
return peer
|
||||
|
||||
|
||||
class InviteRequest(NamedTuple):
|
||||
"""What an access request settled on.
|
||||
|
||||
@@ -631,6 +599,8 @@ async def logout(request: Request):
|
||||
out in one tab doesn't disturb another tab running a different account. If the account being
|
||||
removed was the session default, promote the most recent remaining account (or clear the
|
||||
session when none remain)."""
|
||||
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
|
||||
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
|
||||
active, is_default = resolved_user_id(request)
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
||||
request.session["account_ids"] = remaining
|
||||
@@ -672,7 +642,7 @@ async def request_access(
|
||||
email = (body.email or "").strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not _request_access_limiter.allow(_client_ip(request)):
|
||||
if not _request_access_limiter.allow(client_key(request)):
|
||||
return {"status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
@@ -689,7 +659,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
||||
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
|
||||
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||
if not _demo_limiter.allow(_client_ip(request)):
|
||||
if not _demo_limiter.allow(client_key(request)):
|
||||
return {"authenticated": False}
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email) and is_demo_allowed(db, email):
|
||||
@@ -835,7 +805,7 @@ def register(
|
||||
)
|
||||
if not sysconfig.get_bool(db, "allow_registration"):
|
||||
raise HTTPException(status_code=403, detail="Registration is currently closed.")
|
||||
if not _register_limiter.allow(_client_ip(request)):
|
||||
if not _register_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
|
||||
# Do the existence check + account creation OFF the response path (a background task with its own
|
||||
@@ -1003,7 +973,7 @@ def verify_email_resend(
|
||||
and re-registering an already-registered email is a silent no-op, so a user who lost the first
|
||||
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _verify_resend_limiter.allow(_client_ip(request)):
|
||||
if not _verify_resend_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email):
|
||||
@@ -1062,7 +1032,7 @@ def password_login(
|
||||
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
|
||||
Once the password is proven correct, the owner gets a specific reason if their account is
|
||||
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
|
||||
if not _login_limiter.allow(_client_ip(request)):
|
||||
if not _login_limiter.allow(client_key(request)):
|
||||
raise _login_refused(429, "rate_limited")
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
@@ -1093,7 +1063,7 @@ def password_reset_request(
|
||||
) -> dict:
|
||||
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _reset_limiter.allow(_client_ip(request)):
|
||||
if not _reset_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"}
|
||||
email = (body.email or "").strip().lower()
|
||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||
@@ -1142,7 +1112,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
|
||||
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
|
||||
answer it must degrade to the old behaviour instead of declaring a live link dead.
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
if not _reset_check_limiter.allow(client_key(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
drop_from_wallet,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
has_write_scope,
|
||||
@@ -65,24 +66,26 @@ def switch_account(
|
||||
target = body.user_id
|
||||
if target not in (request.session.get("account_ids") or []):
|
||||
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
|
||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||
# wallet session, logging out every account here. (B-core-9)
|
||||
u = db.get(User, target)
|
||||
if u is None:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=404, detail="That account no longer exists.")
|
||||
if not is_allowed(db, u.email):
|
||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
||||
if not u.is_active:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
||||
if u.is_suspended:
|
||||
# Don't make a suspended account the active one — the next request's current_user would
|
||||
# see the suspension and clear the WHOLE wallet session, logging out every account here.
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=403, detail="That account is suspended.")
|
||||
# Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the
|
||||
# account's epoch): making it the session default would trip current_user's epoch check on the next
|
||||
# request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the
|
||||
# switch list and refuse, rather than logging everyone out. (B-core-9)
|
||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||
request.session["account_ids"] = [
|
||||
a for a in (request.session.get("account_ids") or []) if a != target
|
||||
]
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
||||
)
|
||||
|
||||
@@ -423,9 +423,10 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||
"""Live push channel: authenticated by the session cookie, registers the connection so
|
||||
sent messages reach this user's open tabs instantly. We don't consume client→server frames
|
||||
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
|
||||
# Which signed-in account this socket acts as — the SHARED per-tab resolution (wallet-gated
|
||||
# ?account= override, session default otherwise). A browser WebSocket can't send the
|
||||
# X-Siftlode-Account header, so resolved_user_id falls through to the ?account= query param.
|
||||
# Which signed-in account this socket acts as — the SHARED per-tab resolution (a wallet-gated
|
||||
# ?account=, the session default when none is named, nobody for an account no longer signed in
|
||||
# here). A browser WebSocket can't send the X-Siftlode-Account header, so resolved_user_id falls
|
||||
# through to the ?account= query param.
|
||||
uid, _ = resolved_user_id(ws)
|
||||
if not uid:
|
||||
await ws.close(code=1008)
|
||||
|
||||
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
|
||||
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@@ -17,13 +17,21 @@ from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
# Slow password guessing per link (the token itself is 192-bit, so it needs no throttle).
|
||||
# Slow password guessing on a link (the token itself is 192-bit, so it needs no throttle). Counted per
|
||||
# viewer — the link AND the client (`client_key`) — so a client guessing wrong shuts out only itself, and a
|
||||
# busy link's own unlocks don't count against its next viewer.
|
||||
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
|
||||
# ...under a ceiling on WRONG passwords for the link from every client together, so guessing spread over
|
||||
# many addresses stays slow too; unlocking with the password never adds to it. The price of the ceiling:
|
||||
# someone with the link but not its password, guessing from 50 / 10 = 5 networks, can keep the link shut
|
||||
# for everyone until the window passes. It is checked before the password and counted after it, so guesses
|
||||
# already running when it fills are still checked — as many as the route's threadpool runs at once.
|
||||
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
||||
|
||||
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
@@ -83,11 +91,16 @@ class WatchUnlockIn(BaseModel):
|
||||
|
||||
|
||||
@router.post("/watch/{token}/unlock")
|
||||
def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db)) -> dict:
|
||||
def watch_unlock(
|
||||
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Exchange the link password for a signed grant carried by the media URL."""
|
||||
if not _unlock_limit.allow(token):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
|
||||
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
|
||||
# would grow the limiter without bound.
|
||||
link = _link_or_404(db, token)
|
||||
viewer = f"{token}|{client_key(request)}"
|
||||
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
|
||||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
job, asset = _ready_target(db, link)
|
||||
@@ -96,6 +109,7 @@ def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db))
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
if not verify_password(body.password, link.password_hash):
|
||||
_unlock_failures.allow(token)
|
||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
|
||||
@@ -11,7 +11,7 @@ echo "Starting Siftlode API..."
|
||||
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
|
||||
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
|
||||
#
|
||||
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts
|
||||
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. ratelimit.client_key trusts
|
||||
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
|
||||
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
|
||||
# reopening the rate-limit bypass (SA3).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Per-source adapter selection + the source-specific policy each one carries. These lock the
|
||||
behaviour that used to live inline in worker.build_uploader_url / backfill._should_refetch."""
|
||||
|
||||
from app.titles import normalize_title
|
||||
from app.downloads.adapters import (
|
||||
FacebookAdapter,
|
||||
RedditAdapter,
|
||||
@@ -139,3 +140,28 @@ def test_wants_metadata_backfill_matches_old_should_refetch():
|
||||
def test_nfo_uniqueid_type_is_the_adapter_key():
|
||||
assert YouTubeAdapter().nfo_uniqueid_type() == "youtube"
|
||||
assert SourceAdapter().nfo_uniqueid_type() == "generic"
|
||||
|
||||
|
||||
def test_title_is_yt_dlps_for_a_source_without_placeholders():
|
||||
assert SourceAdapter().title({"title": "Video", "description": "text"}) == "Video"
|
||||
assert SourceAdapter().title({}) is None
|
||||
|
||||
|
||||
def test_facebook_placeholder_title_takes_the_posts_text():
|
||||
# Measured: signed in, yt-dlp titles every reel "Video" (an 18+ reel read as Chrome: "Facebook"),
|
||||
# while the post's text is still in the description.
|
||||
a = FacebookAdapter()
|
||||
info = {"title": "Video", "description": "\n Tune of The Day\nsecond line"}
|
||||
assert a.title(info) == "Tune of The Day"
|
||||
assert a.title({**info, "title": "Facebook"}) == "Tune of The Day"
|
||||
# A real title (signed out) is left for normalize_title, engagement prefix and all.
|
||||
assert a.title({**info, "title": "456K views | Tune"}) == "456K views | Tune"
|
||||
# Nothing better on the page: keep what yt-dlp gave.
|
||||
assert a.title({"title": "Facebook", "description": None}) == "Facebook"
|
||||
|
||||
|
||||
def test_facebook_title_from_a_long_description_is_the_whole_line_after_cleaning():
|
||||
# Dev UAT: a cut with "…" came out of normalize_title as "… live at." (NFKC "..." collapsed to "."),
|
||||
# a sentence that seemed to end. The whole first line survives the cleaning intact instead.
|
||||
line = 'Tune of The Day, Argy playing his version of the classic "Resurrection" with Paul Oakenfold live at Space'
|
||||
assert normalize_title(FacebookAdapter().title({"title": "Video", "description": line})) == line
|
||||
|
||||
@@ -12,6 +12,7 @@ Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth import ACTIVE_ACCOUNT_HEADER
|
||||
@@ -57,6 +58,75 @@ def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user
|
||||
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
||||
|
||||
|
||||
def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client, make_user):
|
||||
# Two accounts in one browser: a tab pinned to Alice, Bob the default. Alice signs out in another
|
||||
# tab. The pinned tab used to go on acting as the default, Bob — its player checkpoints and pref
|
||||
# saves landed in his account, and its own Sign out signed HIM out.
|
||||
alice = make_user(email="alice3@example.com", password=_PW)
|
||||
make_user(email="bob3@example.com", password=_PW)
|
||||
assert _login(client, "alice3@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob3@example.com", _PW).status_code == 200 # Bob is now the default
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200 # Alice signs out
|
||||
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in, the session intact
|
||||
# The bootstrap probe still answers as the default: how the stale tab learns who it is now.
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
|
||||
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
|
||||
client, make_user, db, ended
|
||||
):
|
||||
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
|
||||
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
|
||||
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
|
||||
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
|
||||
alice = make_user(email="alice4@example.com", password=_PW)
|
||||
make_user(email="bob4@example.com", password=_PW)
|
||||
assert _login(client, "alice4@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob4@example.com", _PW).status_code == 200
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
else:
|
||||
alice.is_suspended = True
|
||||
db.commit()
|
||||
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
|
||||
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
|
||||
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
|
||||
client, make_user, db, monkeypatch, ended
|
||||
):
|
||||
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
|
||||
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
|
||||
# made the default, the next request found it inactive and signed every account here out.
|
||||
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
|
||||
alice = make_user(email="alice5@example.com", password=_PW)
|
||||
make_user(email="bob5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
elif ended == "suspended":
|
||||
alice.is_suspended = True
|
||||
else:
|
||||
alice.is_active = False
|
||||
db.commit()
|
||||
|
||||
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
|
||||
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
|
||||
|
||||
|
||||
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
|
||||
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
|
||||
|
||||
@@ -72,6 +72,21 @@ def test_authed_sig_differs_per_user_and_from_the_shared_sig():
|
||||
assert a != shared and b != shared and a != b
|
||||
|
||||
|
||||
def test_an_authed_asset_queued_before_a_signature_version_bump_still_gets_its_cookie(monkeypatch):
|
||||
# A job keeps the asset it was queued with. A deploy that bumps the signature version between the
|
||||
# queueing and the download used to make the user's private asset stop counting as theirs, so the
|
||||
# download went without its cookie and failed on the login wall.
|
||||
spec = formats.normalize({"mode": "a"})
|
||||
queued = MediaAsset(format_sig=credentials.authed_format_sig(spec, 1, "facebook"))
|
||||
rebuild = credentials.rebuild_salt(7, credentials.cookie_salt(1, "facebook"))
|
||||
rebuilt = MediaAsset(id=7, format_sig=formats.format_sig(spec, salt=rebuild))
|
||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1) # the deploy
|
||||
|
||||
for asset in (queued, rebuilt):
|
||||
assert credentials.is_authed_asset(asset, spec, 1, FB_URL)
|
||||
assert not credentials.is_authed_asset(asset, spec, 2, FB_URL) # still private to its user
|
||||
|
||||
|
||||
def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path):
|
||||
staging = tmp_path / "staging"
|
||||
staging.mkdir()
|
||||
|
||||
@@ -314,3 +314,131 @@ def test_0084_backfills_only_the_facebook_parse_failures_it_measured(db):
|
||||
finally:
|
||||
if not upgraded:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _ffmpeg_media(path, *codec_args):
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440"]
|
||||
+ list(codec_args)
|
||||
+ ["-t", "2", str(path)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
def test_a_signed_in_facebook_download_is_titled_with_its_posts_text(db, monkeypatch, tmp_path):
|
||||
# Prod jobs 265–268 (2026-09-14): with a cookie yt-dlp titles every reel "Video", so the row, the
|
||||
# file name and the share preview all said "Video". The post's text is in the description.
|
||||
u = _user(db, "fb-title@example.com")
|
||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||
media = staging / "2131884261040652.m4a"
|
||||
_ffmpeg_media(media, "-c:a", "aac")
|
||||
return {
|
||||
"id": "2131884261040652",
|
||||
"ext": "m4a",
|
||||
"title": "Video",
|
||||
"description": "Tune of The Day, Argy playing his version",
|
||||
"extractor_key": "Facebook",
|
||||
"filepath": str(media),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
pipeline._download(j.id, a.id, "url", _REEL, {"mode": "a"}, u.id)
|
||||
|
||||
db.expire_all()
|
||||
asset, job = db.get(MediaAsset, a.id), db.get(DownloadJob, j.id)
|
||||
assert asset.title == job.display_name == "Tune of The Day, Argy playing his version"
|
||||
assert "Tune_of_The_Day" in asset.rel_path
|
||||
|
||||
|
||||
def test_the_early_title_takes_the_posts_text_too(db):
|
||||
u = _user(db, "fb-early-title@example.com")
|
||||
a, _ = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||
a.title = None
|
||||
db.commit()
|
||||
|
||||
pipeline._fill_asset_meta_early(
|
||||
a.id,
|
||||
{
|
||||
"id": "1",
|
||||
"title": "Video",
|
||||
"description": "Almás palacsinta",
|
||||
"extractor_key": "Facebook",
|
||||
},
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, a.id).title == "Almás palacsinta"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
def test_an_mp3_download_is_stored_as_an_mp3(db, monkeypatch, tmp_path):
|
||||
# Prod job 268: the "Audio (MP3)" profile's extracted .mp3 was remuxed into an .mp4 and served as
|
||||
# video/mp4, which iPad's WebKit will not play. It stays an .mp3 (served as audio/mpeg).
|
||||
u = _user(db, "fb-mp3@example.com")
|
||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||
media = staging / "2131884261040652.mp3"
|
||||
_ffmpeg_media(media, "-c:a", "libmp3lame")
|
||||
return {"id": "2131884261040652", "ext": "mp3", "title": "Reel", "filepath": str(media)}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
pipeline._download(
|
||||
j.id, a.id, "url", _REEL, formats.normalize({"mode": "a", "audio_format": "mp3"}), u.id
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, a.id)
|
||||
assert (asset.container, asset.acodec, asset.media_kind) == ("mp3", "mp3", "audio")
|
||||
assert asset.rel_path.endswith(".mp3")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
@pytest.mark.parametrize("mode", ["av", "v"])
|
||||
def test_a_video_download_is_still_made_browser_playable(db, monkeypatch, tmp_path, mode):
|
||||
# The mp3 fix skips the browser-playable pass for AUDIO only: a video that arrives in a container
|
||||
# browsers won't play (an .mkv) is still remuxed into an .mp4.
|
||||
u = _user(db, f"fb-mkv-{mode}@example.com")
|
||||
a, j = _asset_and_job(
|
||||
db, u, sig=f"mkv-{mode}", asset_status="downloading", job_status="running"
|
||||
)
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||
media = staging / "1736360027626346.mkv"
|
||||
audio = ["-f", "lavfi", "-i", "sine=frequency=440", "-c:a", "aac"] if mode == "av" else []
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=size=64x64:rate=10",
|
||||
]
|
||||
+ audio
|
||||
+ ["-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(media)],
|
||||
check=True,
|
||||
)
|
||||
return {"id": "1736360027626346", "ext": "mkv", "title": "Reel", "filepath": str(media)}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
pipeline._download(j.id, a.id, "url", _REEL, formats.normalize({"mode": mode}), u.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, a.id)
|
||||
assert (asset.container, asset.vcodec) == ("mp4", "h264")
|
||||
assert asset.rel_path.endswith(".mp4")
|
||||
|
||||
@@ -158,3 +158,24 @@ def test_a_normal_cut_list_is_untouched():
|
||||
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == 2
|
||||
|
||||
|
||||
def test_audio_versions_its_cache_identity_apart_from_video(monkeypatch):
|
||||
# The .mp3/.m4a-stays-itself fix must re-derive cached remuxed audio WITHOUT re-downloading every
|
||||
# cached video: bumping one version moves only its own mode's signature.
|
||||
audio, video = {"mode": "a", "audio_format": "mp3"}, {"mode": "av"}
|
||||
before = formats.format_sig(audio), formats.format_sig(video)
|
||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1)
|
||||
assert formats.format_sig(audio) != before[0]
|
||||
assert formats.format_sig(video) == before[1]
|
||||
monkeypatch.setattr(formats, "_SIG_VERSION", formats._SIG_VERSION + 1)
|
||||
assert formats.format_sig(video) != before[1]
|
||||
|
||||
|
||||
def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
|
||||
# Before the fix every mode hashed with version 4, so an audio signature still at 4 would find the
|
||||
# cached mp3-remuxed-into-mp4 assets iPad refuses (prod job 268) instead of re-deriving them.
|
||||
audio = {"mode": "a", "audio_format": "mp3"}
|
||||
current = formats.format_sig(audio)
|
||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
||||
assert formats.format_sig(audio) != current
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The password unlock of a shared /watch link (routes/public.py), DB-lane.
|
||||
|
||||
Guesses are slowed per viewer (the link AND the client — an IPv6 client by its /64), under a per-link
|
||||
ceiling on wrong passwords, so a client guessing wrong shuts out only itself until it spreads its guesses
|
||||
over as many networks as the ceiling allows.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import probe as probemod
|
||||
from app.main import app
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import public
|
||||
|
||||
_PW = "open sesame"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
|
||||
owner = as_user(make_user())
|
||||
job = owner.post(
|
||||
"/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")}
|
||||
)
|
||||
assert job.status_code == 200, job.text
|
||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
||||
assert link.status_code == 200, link.text
|
||||
return f"/api/public/watch/{link.json()['token']}/unlock"
|
||||
|
||||
|
||||
def _from(ip: str) -> TestClient:
|
||||
# Its own socket peer: with no trusted proxy configured, that is the client's IP.
|
||||
return TestClient(app, client=(ip, 50000))
|
||||
|
||||
|
||||
def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_url):
|
||||
# The limit used to be keyed on the link alone: ten wrong guesses every five minutes kept every
|
||||
# viewer out, the ones with the password included.
|
||||
guesser = _from("203.0.113.5")
|
||||
for _ in range(10):
|
||||
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 403
|
||||
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 429
|
||||
|
||||
assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200
|
||||
|
||||
|
||||
def test_an_ipv6_client_is_one_viewer_across_its_64(unlock_url):
|
||||
# One subscriber gets a whole /64: keyed on the full address, it could rotate through endless viewers.
|
||||
for _ in range(10):
|
||||
wrong = _from("2001:db8:1:2::5").post(unlock_url, json={"password": "guess"})
|
||||
assert wrong.status_code == 403
|
||||
assert _from("2001:db8:1:2::6").post(unlock_url, json={"password": _PW}).status_code == 429
|
||||
|
||||
assert _from("2001:db8:1:3::5").post(unlock_url, json={"password": _PW}).status_code == 200
|
||||
|
||||
|
||||
def test_a_token_naming_no_link_leaves_nothing_in_the_limiter(unlock_url):
|
||||
# The limiter used to record the attempt before looking the link up: posting made-up tokens grew it by
|
||||
# an entry each, kept for the whole window.
|
||||
keys = len(public._unlock_limit._hits)
|
||||
for n in range(5):
|
||||
missing = _from("203.0.113.5").post(unlock_url.replace("/watch/", f"/watch/x{n}"), json={})
|
||||
assert missing.status_code == 404
|
||||
assert len(public._unlock_limit._hits) == keys
|
||||
|
||||
|
||||
def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch):
|
||||
monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300))
|
||||
viewer = _from("198.51.100.7")
|
||||
for _ in range(5): # unlocking with the password never adds to the ceiling
|
||||
assert viewer.post(unlock_url, json={"password": _PW}).status_code == 200
|
||||
|
||||
for n in range(3):
|
||||
wrong = _from(f"203.0.113.{n}").post(unlock_url, json={"password": "guess"})
|
||||
assert wrong.status_code == 403
|
||||
late = _from("203.0.113.9").post(unlock_url, json={"password": "guess"})
|
||||
assert late.status_code == 429
|
||||
@@ -57,6 +57,19 @@ def test_reset_clears_every_key():
|
||||
assert lim.allow("b") is True
|
||||
|
||||
|
||||
def test_exhausted_reports_a_full_window_without_counting_an_attempt():
|
||||
# The asking half of a limit that counts only some outcomes (a share link's unlock counts only
|
||||
# wrong passwords toward its ceiling): asking must never use up the window it asks about.
|
||||
lim = RateLimiter(max_events=2, window_seconds=300)
|
||||
|
||||
for _ in range(5):
|
||||
assert lim.exhausted("link") is False
|
||||
assert lim.allow("link") is True
|
||||
assert lim.exhausted("link") is False
|
||||
assert lim.allow("link") is True
|
||||
assert lim.exhausted("link") is True
|
||||
|
||||
|
||||
def test_expired_attempts_fall_out_of_the_window():
|
||||
# A zero-length window means every previous attempt is already outside it.
|
||||
lim = RateLimiter(max_events=1, window_seconds=0)
|
||||
|
||||
@@ -17,6 +17,16 @@ siftlode e2e-seed # (re)seed the test account only, no tests
|
||||
The localdev stack must be up (`siftlode dev`). `global.setup.ts` reseeds the account and logs in
|
||||
once, sharing the session via `e2e/.auth/state.json` (git-ignored).
|
||||
|
||||
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
|
||||
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile). Install the engines once per
|
||||
machine with `npx playwright install chromium webkit`; run one project with
|
||||
`npx playwright test --project=ipad`. WebKit catches what every iOS browser inherits from the engine
|
||||
(JS, CSS/layout, event order). It does **not** reproduce iPad media refusals: Playwright's WebKit
|
||||
decodes through the host OS (Media Foundation on Windows, GStreamer on Linux), and measured on
|
||||
2026-09-14 it played an mp3-in-mp4 and an AV1 mp4 that a real iPad refuses. Check a media format
|
||||
on a real iPad against the dev stack over the LAN (`http://<desktop LAN IP>:8080`, e.g. a
|
||||
login-free `/watch/<token>` share link).
|
||||
|
||||
**Target** — `PW_BASE_URL` (default `http://127.0.0.1:8080`):
|
||||
|
||||
- **`:8080`** (baked SPA) is authoritative and same-origin. After a **frontend change you must
|
||||
|
||||
@@ -67,13 +67,17 @@ setup("seed + authenticate", async ({ page }) => {
|
||||
const me = await (await page.request.get("/api/me")).json();
|
||||
expect(me.authenticated, "session not established after login").toBeTruthy();
|
||||
|
||||
// 4. Pin the UI locale and suppress the first-login onboarding wizard (a full-screen overlay
|
||||
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
|
||||
// that would intercept every click — the test account has no YouTube connection, so it
|
||||
// auto-opens). The dismissed flag is per-account: `siftlode.onboarding.dismissed.<id>`.
|
||||
// auto-opens), and opt the account out of starting on Home. Since E24 a bare URL lands on
|
||||
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
|
||||
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
|
||||
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
|
||||
await page.goto("/");
|
||||
await page.evaluate((id: number) => {
|
||||
localStorage.setItem("siftlode.lang", "en");
|
||||
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
|
||||
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
|
||||
}, me.id);
|
||||
mkdirSync(dirname(STORAGE), { recursive: true });
|
||||
await page.context().storageState({ path: STORAGE });
|
||||
|
||||
@@ -47,5 +47,19 @@ export default defineConfig({
|
||||
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
// WebKit catches engine-level differences (JS, CSS/layout, event order) that every iOS browser
|
||||
// inherits. It does NOT reproduce iPad MEDIA refusals: Playwright's WebKit decodes through the
|
||||
// host OS (Media Foundation on Windows, GStreamer on Linux), and measured on 2026-09-14 it played
|
||||
// an mp3-in-mp4 and an AV1 mp4 that iPad refuses. Media checks need a real iPad.
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "ipad",
|
||||
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+49
-82
@@ -2,16 +2,15 @@ import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { setLanguage, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
normalizeLayout,
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
seedLayout,
|
||||
type PanelId,
|
||||
type PanelLayout,
|
||||
} from "./lib/panelLayout";
|
||||
@@ -24,7 +23,9 @@ import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersP
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
import { LoadingState } from "./components/ui/QueryState";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
import Welcome from "./components/Welcome";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
@@ -38,7 +39,6 @@ import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
@@ -52,7 +52,7 @@ import { FRONTEND_VERSION } from "./lib/version";
|
||||
// The page modules (and their side rails) are code-split per route via the module registry
|
||||
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||
// two modals, which sit outside the registry-driven content column.
|
||||
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
||||
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
|
||||
const About = lazy(() => import("./components/About"));
|
||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||
|
||||
@@ -79,13 +79,20 @@ export default function App() {
|
||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
const queryClient = useQueryClient();
|
||||
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
|
||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
|
||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
||||
feed: loadLayout("feed"),
|
||||
plex: loadLayout("plex"),
|
||||
playlists: loadLayout("playlists"),
|
||||
}));
|
||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
|
||||
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
|
||||
// changed on another device is right on the first paint; the local copy covers a key the server
|
||||
// has never stored.
|
||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
|
||||
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
|
||||
return {
|
||||
feed: seedLayout("feed", prefs),
|
||||
plex: seedLayout("plex", prefs),
|
||||
playlists: seedLayout("playlists", prefs),
|
||||
};
|
||||
});
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||
@@ -104,8 +111,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
|
||||
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
@@ -119,26 +126,15 @@ export default function App() {
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
// We check this before anything else and render the wizard; `me` is held back until we know the
|
||||
// instance is set up (otherwise it would 503 against the setup-mode lock).
|
||||
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
|
||||
// and waits for: App mounts only once both have answered, so their caches are always populated here.
|
||||
const setupQuery = useQuery({
|
||||
queryKey: qk.setupStatus(),
|
||||
queryFn: api.setupStatus,
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
});
|
||||
const needsSetup = setupQuery.data?.configured === false;
|
||||
// Poll the session so an account suspended/deleted by an admin is noticed even with no
|
||||
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
|
||||
// Only poll while signed in (data present) — polling on the public login page is pointless and
|
||||
// its periodic refetch caused a visible flicker there.
|
||||
const meQuery = useQuery({
|
||||
queryKey: qk.me(),
|
||||
queryFn: api.me,
|
||||
enabled: setupQuery.isSuccess && !needsSetup,
|
||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||
});
|
||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
@@ -155,14 +151,25 @@ export default function App() {
|
||||
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||
);
|
||||
|
||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
||||
// tab always sends its own account header, so cross-tab changes can't reach it. An explicit
|
||||
// switch on THIS tab sets the override itself (so this no-ops then).
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||
// requestIdleCallback, hence the timer).
|
||||
useEffect(() => {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
const me = meQuery.data;
|
||||
if (!me) return;
|
||||
const run = () => {
|
||||
preloadPages([...moduleOrder(me), "settings"]);
|
||||
ChannelPage.preload().catch(() => {});
|
||||
};
|
||||
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
|
||||
if (idle) {
|
||||
const id = idle(run, { timeout: 3000 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
@@ -238,28 +245,6 @@ export default function App() {
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
||||
const raw = prefs[PREF_KEY[p]];
|
||||
if (raw) {
|
||||
const l = normalizeLayout(p, raw);
|
||||
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
|
||||
saveLayoutLocal(p, l);
|
||||
}
|
||||
});
|
||||
const lang = prefs.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||
// dedicated effect above, which also covers accounts that have no stored preferences.)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||
@@ -304,32 +289,14 @@ export default function App() {
|
||||
}
|
||||
|
||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||
if (setupQuery.isLoading)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
);
|
||||
if (needsSetup) return <SetupWizard />;
|
||||
|
||||
if (meQuery.isLoading)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
);
|
||||
// /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
|
||||
// network/5xx failure, and no data means "not signed in" → the public landing.
|
||||
if (meQuery.error)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
|
||||
{t("common.somethingWrong")}
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
||||
// probe — so no data here means "not signed in" → the public landing.
|
||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
||||
const pageFallback = (
|
||||
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
);
|
||||
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||
const pageFallback = <LoadingState className="flex-1 justify-center" />;
|
||||
|
||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
|
||||
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
|
||||
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
|
||||
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
|
||||
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
|
||||
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
|
||||
//
|
||||
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
||||
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BootGate from "./BootGate";
|
||||
import { NavigationProvider, useNavigation } from "./NavigationProvider";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const renders: { page: string; lang: string }[] = [];
|
||||
let mounts = 0;
|
||||
function Probe() {
|
||||
const { page } = useNavigation();
|
||||
const { i18n: inst } = useTranslation();
|
||||
renders.push({ page, lang: inst.language });
|
||||
useEffect(() => {
|
||||
mounts++;
|
||||
}, []);
|
||||
return <div data-testid="app">{page}</div>;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BootGate>
|
||||
<NavigationProvider>
|
||||
<Probe />
|
||||
</NavigationProvider>
|
||||
</BootGate>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
||||
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear(); // a NEW tab: nothing pinned yet
|
||||
window.history.replaceState(null, "", "/");
|
||||
renders.length = 0;
|
||||
mounts = 0;
|
||||
await i18n.changeLanguage("en");
|
||||
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("BootGate", () => {
|
||||
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||
localStorage.setItem("siftlode.page.5", "downloads");
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
||||
});
|
||||
|
||||
it("renders the app in the account's language from its first render", async () => {
|
||||
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
|
||||
mount();
|
||||
|
||||
await screen.findByTestId("app");
|
||||
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
|
||||
});
|
||||
|
||||
it("mounts nothing of the app while the identity is still unknown", async () => {
|
||||
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
|
||||
mount();
|
||||
await act(async () => {}); // the setup probe resolves; `me` never does
|
||||
|
||||
expect(screen.queryByTestId("app")).toBeNull();
|
||||
expect(renders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
|
||||
vi.spyOn(api, "me").mockResolvedValue(null);
|
||||
mount();
|
||||
|
||||
await screen.findByTestId("app");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
|
||||
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
|
||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
await screen.findByTestId("app");
|
||||
expect(me).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
|
||||
vi.mocked(api.setupStatus)
|
||||
.mockRejectedValueOnce(new Error("502"))
|
||||
.mockResolvedValue({ configured: true });
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
fireEvent.click(await retryButton());
|
||||
await screen.findByTestId("app");
|
||||
expect(renders.length).toBeGreaterThan(0);
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
|
||||
});
|
||||
|
||||
it("an unanswered first account probe is an error with a retry", async () => {
|
||||
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
fireEvent.click(await retryButton());
|
||||
await screen.findByTestId("app");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
});
|
||||
|
||||
it("a background refetch that fails keeps the app mounted, as it was", async () => {
|
||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
|
||||
me.mockRejectedValue(new Error("502"));
|
||||
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
||||
// react-query hands the error to its observers on a timer: let that render land before asserting.
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
expect(qc.getQueryState(qk.me())?.status).toBe("error");
|
||||
expect(screen.getByTestId("app")).toBeTruthy();
|
||||
expect(mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
|
||||
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||
localStorage.setItem("siftlode.page.5", "downloads");
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
});
|
||||
|
||||
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
|
||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
|
||||
me.mockResolvedValue(null); // the session ended
|
||||
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("an identity change reloads without moving the pin first", async () => {
|
||||
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
|
||||
// moved they would land under the NEW account's local keys (a player volume still pending, say).
|
||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
|
||||
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
|
||||
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
});
|
||||
|
||||
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
|
||||
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
|
||||
act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(mounts).toBe(1);
|
||||
expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import i18n, { isSupported, setLanguage } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
|
||||
//
|
||||
// Per-account state is everywhere — the landing page, the feed's filters, the Plex scope, the manager's
|
||||
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
|
||||
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
|
||||
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
|
||||
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
|
||||
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
||||
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
||||
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
||||
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
||||
//
|
||||
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
|
||||
// match the account the server answered as before they run. And the tree is only ever built for one
|
||||
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
|
||||
// was pinned to was signed out in another tab and the server now answers as the browser's default),
|
||||
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
|
||||
// account's query cache, whose keys are not per account.
|
||||
|
||||
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
|
||||
* loading visual, full screen. Its reveal counts from navigation start, so the gate's screen, the
|
||||
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
|
||||
export function BootScreen() {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<LoadingState since={0} delayMs={400} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BootGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
|
||||
// every other route is locked — so `me` is held back until the instance is known to be set up
|
||||
// (otherwise it would 503 against the setup-mode lock).
|
||||
const setupQuery = useQuery({
|
||||
queryKey: qk.setupStatus(),
|
||||
queryFn: api.setupStatus,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const needsSetup = setupQuery.data?.configured === false;
|
||||
// THE fetching `me` query; every other reader is a passive (enabled:false) observer of its cache.
|
||||
// Polled while signed in so an ended session is noticed with no interaction: /api/me answers a
|
||||
// signed-out (or suspended) caller with 200 and no account, the identity drops to "nobody", and the
|
||||
// tab reloads to the login page. Not polled signed out — that refetch is pointless and used to
|
||||
// flicker the public landing.
|
||||
const meQuery = useQuery({
|
||||
queryKey: qk.me(),
|
||||
queryFn: api.me,
|
||||
enabled: setupQuery.isSuccess && !needsSetup,
|
||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||
});
|
||||
const me = meQuery.data;
|
||||
|
||||
// Who this tab is, once known: an account id, `null` for "nobody" (signed out, or setup mode — App
|
||||
// renders the landing / the wizard for those), `undefined` while unknown or unanswerable.
|
||||
// Keyed on the DATA, not the status: a background refetch that fails keeps the last answer, so a
|
||||
// server blip never tears the whole app down to this screen.
|
||||
const identity: number | null | undefined = !setupQuery.data
|
||||
? undefined
|
||||
: needsSetup
|
||||
? null
|
||||
: me === undefined
|
||||
? undefined
|
||||
: (me?.id ?? null);
|
||||
|
||||
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
|
||||
// before the browser paints: the frame that first shows the app already has the account pinned.
|
||||
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
|
||||
// session's default account would silently swap identity when ANOTHER tab changes that default
|
||||
// (adds or switches an account); once pinned it sends its own account header. The server honours
|
||||
// that header only for an account still signed in to this browser, so a pin naming one that is
|
||||
// not is corrected here — otherwise the tab would run as the default account on the server while
|
||||
// reading and writing the gone account's local state.
|
||||
// • Adopt the account's language, which follows the account across devices — the local copy only
|
||||
// knows what this browser last showed.
|
||||
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
||||
useLayoutEffect(() => {
|
||||
if (identity === undefined) return;
|
||||
if (readyFor !== undefined) {
|
||||
// The tree was built for another identity — see the header. The pin stays as it is: the tree
|
||||
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
|
||||
// would land under the new account's local keys. The reloaded page's first probe corrects it.
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
|
||||
const lang = me?.preferences?.language;
|
||||
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
||||
setReadyFor(identity);
|
||||
// `me` is read for the identity it produced; a background refetch of the same account must not
|
||||
// re-run the adoption over a language the user has just changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity]);
|
||||
|
||||
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
|
||||
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
|
||||
// out": rendering the login page to a signed-in user would be a lie. After a retry the identity
|
||||
// lands and the tree mounts from scratch, like any first load.
|
||||
const failed =
|
||||
!setupQuery.data && setupQuery.isError
|
||||
? setupQuery
|
||||
: me === undefined && meQuery.isError
|
||||
? meQuery
|
||||
: null;
|
||||
if (failed)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<StateMessage tone="error" onRetry={() => failed.refetch()}>
|
||||
{t("common.somethingWrong")}
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
if (identity === undefined || readyFor !== identity) return <BootScreen />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -17,7 +17,6 @@ vi.mock("./NavigationProvider", () => ({
|
||||
}));
|
||||
|
||||
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { setActiveAccount } from "../lib/api/core";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
@@ -101,20 +100,17 @@ describe("ChannelsProvider — S8 shareable URL", () => {
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
|
||||
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
|
||||
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
|
||||
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort.1",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
||||
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
act(() => setActiveAccount(1)); // App pins the account after /api/me
|
||||
|
||||
// The present dim (tab) is adopted from the link...
|
||||
expect(result.current.view).toBe("hidden");
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
|
||||
import { getAccountRaw, LS } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { hydrateSort, type SortState } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useFilterState } from "../lib/useFilterState";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { rememberNavPosition } from "../lib/history";
|
||||
import {
|
||||
@@ -74,25 +73,16 @@ function readStoredFilters(): FilterMap {
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
|
||||
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
|
||||
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
|
||||
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
|
||||
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
|
||||
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
|
||||
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
|
||||
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
|
||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
|
||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
const state = channelsParamsToState(params, {
|
||||
return channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
|
||||
};
|
||||
}
|
||||
|
||||
type ChannelsState = {
|
||||
@@ -168,10 +158,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
|
||||
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
|
||||
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
|
||||
// heal them for display. setView layers the history logic (below) on top of the raw setter.
|
||||
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook).
|
||||
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
|
||||
// logic (below) on top of the raw setter.
|
||||
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
|
||||
const view = clampView(viewRaw);
|
||||
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
|
||||
@@ -185,43 +174,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const accountId = useActiveAccountId();
|
||||
const seededRef = useRef(false);
|
||||
const persistedRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
|
||||
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
|
||||
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
|
||||
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
|
||||
// entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
|
||||
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
|
||||
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view && state.view !== view) setViewRaw(state.view);
|
||||
if (present.filters) setFilters(state.filters);
|
||||
if (present.sort) setSort(state.sort);
|
||||
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
|
||||
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
|
||||
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
|
||||
// already skips (marked dirty above), so this only touches storage, not state.
|
||||
useEffect(() => {
|
||||
if (!urlSeed || accountId == null || persistedRef.current) return;
|
||||
persistedRef.current = true;
|
||||
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
|
||||
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view) setAccountRaw(LS.channelsView, state.view);
|
||||
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
|
||||
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
|
||||
}, [urlSeed, accountId]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
||||
@@ -237,7 +203,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
|
||||
setViewRaw(v); // persists (via usePersistedTab)
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
|
||||
@@ -18,7 +18,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
||||
// applied together via one Save/Discard bar; nothing hits
|
||||
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||
// Email/SMTP) stay together.
|
||||
const GROUP_ORDER = [
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The shared demo account has no subscriptions, so its feed opens on the whole library. The filters are
|
||||
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect
|
||||
// this replaced applied the demo default after mounting; with it gone, a seed that did not read `me`
|
||||
// would open the demo account on its empty subscriptions scope.
|
||||
//
|
||||
// A share link's filters are written to the account once at mount too, so the view the link opened
|
||||
// survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { FeedFiltersProvider, useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import type { Me } from "../lib/api";
|
||||
|
||||
const scopes: string[] = [];
|
||||
function Probe() {
|
||||
scopes.push(useFeedFilters().scope);
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount(me: Partial<Me>) {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 5, preferences: {}, ...me });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<Probe />
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
scopes.length = 0;
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("FeedFiltersProvider seed", () => {
|
||||
it("opens the shared demo account on the whole library from its first render", () => {
|
||||
mount({ is_demo: true });
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
expect(scopes).toEqual(scopes.map(() => "all"));
|
||||
});
|
||||
|
||||
it("opens any other account on its own scope", () => {
|
||||
mount({ is_demo: false });
|
||||
expect(scopes[0]).toBe("my");
|
||||
});
|
||||
|
||||
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
|
||||
window.history.replaceState(null, "", "/?scope=all");
|
||||
mount({ is_demo: false });
|
||||
expect(scopes[0]).toBe("all");
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
type Me,
|
||||
} from "../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
@@ -53,9 +53,8 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
||||
const FILTERS_KEY = LS.filters;
|
||||
|
||||
// This account's own persisted filters (per-account keys — never another account's). When the
|
||||
// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login
|
||||
// effect loads the real ones once the id arrives.
|
||||
// This account's own persisted filters (per-account keys — never another account's). No account
|
||||
// (signed out) → defaults.
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
@@ -87,10 +86,12 @@ export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
}
|
||||
|
||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||
function loadInitialFilters(): FeedFilters {
|
||||
function loadInitialFilters(me: Me | null | undefined): FeedFilters {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||
return loadAccountFilters(getActiveAccount());
|
||||
const f = loadAccountFilters(getActiveAccount());
|
||||
// The shared demo account has no subscriptions, so default it to the whole library.
|
||||
return me?.is_demo ? { ...f, scope: "all" } : f;
|
||||
}
|
||||
|
||||
interface FeedFiltersActions {
|
||||
@@ -108,18 +109,18 @@ export function useFeedFiltersActions(): FeedFiltersActions {
|
||||
}
|
||||
|
||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
const qc = useQueryClient();
|
||||
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
|
||||
// pinned and `me` is cached, so this is the account's own view from the first render.
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(() =>
|
||||
loadInitialFilters(qc.getQueryData<Me | null>(qk.me())),
|
||||
);
|
||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
|
||||
// Passive observer: App owns the fetching `qk.me()` query (gated on setup state); enabled:false
|
||||
// here reads its cache and re-renders when it lands, without triggering a second fetch (which
|
||||
// could 503 in setup mode). We only need id + is_demo, which don't change on background refetch.
|
||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
const setFilters = useCallback((next: FeedFilters) => {
|
||||
setFiltersState(next);
|
||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
||||
@@ -135,25 +136,12 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||
|
||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
||||
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
|
||||
// the same account never clobbers the filters you're editing.
|
||||
// A share link's filters (captured at mount) win over the account's stored view and are persisted
|
||||
// to it once, so the view the link opened also survives a later plain reload.
|
||||
useEffect(() => {
|
||||
const id = meQuery.data?.id;
|
||||
if (id == null) return;
|
||||
if (initialHadUrlFilters) {
|
||||
const key = accountKey(FILTERS_KEY, id);
|
||||
if (key) writeJSON(key, filters);
|
||||
return;
|
||||
}
|
||||
let f = loadAccountFilters(id);
|
||||
// The shared demo account has no subscriptions, so default it to the whole library.
|
||||
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
|
||||
setFiltersState(f);
|
||||
if (initialHadUrlFilters) setFilters(filters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
}, []);
|
||||
|
||||
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import { usePref } from "../lib/usePref";
|
||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||
//
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows
|
||||
// the account across devices. usePref carries that server-sync (seeded from the server pref, with the
|
||||
// localStorage mirror as the fallback) so this provider is just the two contexts over it.
|
||||
|
||||
interface FeedViewActions {
|
||||
setView: (v: FeedView) => void;
|
||||
|
||||
@@ -22,8 +22,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
});
|
||||
const configured = keyQ.data?.configured ?? false;
|
||||
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
||||
// have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled:
|
||||
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
|
||||
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
|
||||
// so hasPassword follows the cache when a refetch changes it.
|
||||
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
const hasPassword = meQ.data?.has_password ?? false;
|
||||
|
||||
@@ -32,9 +32,13 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [curPass, setCurPass] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
||||
// and a set in one handler commit together, leaving the node untouched).
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setAttempt((n) => n + 1);
|
||||
setErr(null);
|
||||
if (pass.length < 6) return setErr(t("messages.passTooShort"));
|
||||
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
|
||||
@@ -67,6 +71,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
setAttempt((n) => n + 1);
|
||||
setErr(null);
|
||||
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
|
||||
setBusy(true);
|
||||
@@ -106,7 +111,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
{err && (
|
||||
<div key={attempt} role="alert" className="text-sm text-danger">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
@@ -153,7 +162,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
{err && (
|
||||
<div key={attempt} role="alert" className="text-sm text-danger">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
@@ -90,6 +92,11 @@ describe("LibraryPlayer", () => {
|
||||
expect(document.body.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", () => {
|
||||
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
||||
expect(expose).toHaveBeenCalledWith(document.body.querySelector("video"));
|
||||
});
|
||||
|
||||
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
|
||||
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
@@ -47,6 +48,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={exposeVideoToBackgroundPlay}
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
||||
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { pinOnNextPage } from "../lib/activeAccount";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
@@ -71,7 +72,7 @@ export default function NavSidebar({
|
||||
} catch {
|
||||
/* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */
|
||||
}
|
||||
clearActiveAccount();
|
||||
pinOnNextPage(null);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
@@ -86,8 +87,9 @@ export default function NavSidebar({
|
||||
function switchTo(id: number) {
|
||||
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
|
||||
// identity. No server round-trip — req() sends the account header (validated against the
|
||||
// browser wallet server-side), so the cookie's default account is left untouched.
|
||||
setActiveAccount(id);
|
||||
// browser wallet server-side), so the cookie's default account is left untouched. The pin moves with
|
||||
// the reload, not before it: the page going away still unloads as the account it was built for.
|
||||
pinOnNextPage(id);
|
||||
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
||||
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
||||
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||
@@ -334,7 +336,7 @@ export default function NavSidebar({
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
clearActiveAccount();
|
||||
pinOnNextPage(null);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { urlWithParams, type Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
import {
|
||||
closeOverlaysThenNavigate,
|
||||
@@ -102,27 +101,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const channelViewRef = useRef(channelView);
|
||||
channelViewRef.current = channelView;
|
||||
|
||||
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
|
||||
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
|
||||
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
|
||||
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
|
||||
// skips this if the user already navigated in the pre-pin window.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
const stored = loadInitialPage();
|
||||
if (stored === pageRef.current) return;
|
||||
setPageState(stored);
|
||||
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
|
||||
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
|
||||
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
|
||||
// address bar too (urlWithParams keeps any ?chan/filter params already there).
|
||||
window.history.replaceState(
|
||||
{ ...(window.history.state || {}), sfPage: stored },
|
||||
"",
|
||||
urlWithParams({ page: stored === "feed" ? null : stored }),
|
||||
);
|
||||
});
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page) =>
|
||||
// Never under an open overlay — see the interface doc. The refs are read when the push
|
||||
@@ -131,7 +109,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
@@ -163,15 +140,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
notifyNav();
|
||||
}),
|
||||
[markDirty],
|
||||
[],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
||||
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
||||
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
||||
//
|
||||
// Like setPage it never lands under an open overlay: the player's channel link and the palette's
|
||||
// channel rows call it from inside one.
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||
// under an open overlay: the player's channel link and the palette's channel rows call it from
|
||||
// inside one.
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
closeOverlaysThenNavigate(() => {
|
||||
const url = urlWithParams({ chan: id });
|
||||
@@ -197,7 +171,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
|
||||
setYtSearch(q);
|
||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
||||
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { readSelectedId } from "./playlistsInit";
|
||||
@@ -43,19 +42,11 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||
const [search, setSearchState] = useState("");
|
||||
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
|
||||
|
||||
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
|
||||
// this provider mounts above <App>, which pins the account only after /api/me.
|
||||
const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId()));
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setSelectedId = useCallback(
|
||||
(id: number | null) => {
|
||||
markDirty();
|
||||
setSelectedIdState(id);
|
||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setSelectedId = useCallback((id: number | null) => {
|
||||
setSelectedIdState(id);
|
||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||
}, []);
|
||||
|
||||
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
|
||||
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
|
||||
@@ -98,4 +101,21 @@ describe("PlexPlayer under StrictMode", () => {
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
|
||||
});
|
||||
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/api/plex/direct/99079",
|
||||
start_s: 0,
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId="99079" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = await waitFor(() => document.querySelector("video")!);
|
||||
expect(expose).toHaveBeenCalledWith(video);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Hls from "hls.js";
|
||||
import {
|
||||
@@ -115,7 +116,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const detail = detailQ.data;
|
||||
const duration = detail?.duration_seconds ?? 0;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
|
||||
// can't start a detached <video> playing audio in the background.
|
||||
@@ -1048,7 +1049,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
prefs.subShadowColor,
|
||||
)}}`}</style>
|
||||
<video
|
||||
ref={videoRef}
|
||||
ref={(el) => {
|
||||
videoRef.current = el;
|
||||
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
|
||||
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
|
||||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
||||
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
||||
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
|
||||
// default saved view was never applied again.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { PlexProvider } from "./PlexProvider";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
function mount() {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NavigationProvider>
|
||||
<PlexProvider>
|
||||
<div />
|
||||
</PlexProvider>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("PlexProvider shared link", () => {
|
||||
it("persists the scope a link carries and leaves the dimensions it omits unwritten", () => {
|
||||
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
|
||||
mount();
|
||||
|
||||
expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie");
|
||||
expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||
@@ -10,7 +18,6 @@ import {
|
||||
type PlexUrlState,
|
||||
} from "../lib/plexUrlState";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
@@ -20,10 +27,8 @@ import { useNavigation } from "./NavigationProvider";
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
|
||||
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
|
||||
// <App>, which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
|
||||
// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
||||
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
|
||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||
// into (and is never greeted by a stale value from) the feed.
|
||||
@@ -95,7 +100,7 @@ export function usePlexActions(): PlexActions {
|
||||
return useContext(ActionsContext);
|
||||
}
|
||||
|
||||
// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
|
||||
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
|
||||
function readPlexScope(): string {
|
||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||
@@ -144,13 +149,11 @@ function readPlexFilters(): PlexFilters {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
|
||||
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
|
||||
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
|
||||
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
|
||||
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
|
||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
|
||||
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
||||
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
|
||||
type PlexUrlSeed = PlexUrlState & { present: PlexSeedPresent };
|
||||
function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
@@ -160,29 +163,27 @@ function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
},
|
||||
const present = {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
};
|
||||
return { ...state, present };
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
|
||||
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
|
||||
// the account (the effect further down), so the shared view also survives a later plain reload.
|
||||
const [urlSeed] = useState(readPlexUrlSeed);
|
||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
|
||||
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
@@ -203,87 +204,45 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
|
||||
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
|
||||
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
if (urlSeed) {
|
||||
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
|
||||
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
|
||||
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
|
||||
// default over the account's real stored value — re-read the stored value instead, exactly like a
|
||||
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
|
||||
else setScopeState(readPlexScope());
|
||||
if (present.show) setAccountRaw(LS.plexShow, state.show);
|
||||
else setShowState(readPlexShow());
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
|
||||
else setSortState(readPlexSort());
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
|
||||
else setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
}
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
});
|
||||
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
||||
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
||||
// account has picked filters, so the account's default saved view would never be applied again.
|
||||
useEffect(() => {
|
||||
if (!urlSeed) return;
|
||||
const { present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
||||
if (present.show) setAccountRaw(LS.plexShow, urlSeed.show);
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
||||
}, [urlSeed]);
|
||||
|
||||
const setScope = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setScopeState(v);
|
||||
setAccountRaw(LS.plexLibrary, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setShow = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setShowState(v);
|
||||
setAccountRaw(LS.plexShow, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setSort = useCallback(
|
||||
(v: SortState) => {
|
||||
markDirty();
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setFilters = useCallback(
|
||||
(f: PlexFilters) => {
|
||||
markDirty();
|
||||
const s = JSON.stringify(f);
|
||||
setFiltersRaw(s);
|
||||
setAccountRaw(LS.plexFilters, s);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setQ = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setQState(v);
|
||||
setAccountRaw(LS.plexQ, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setField = useCallback(
|
||||
(v: PlexSearchField) => {
|
||||
markDirty();
|
||||
setFieldState(v);
|
||||
setAccountRaw(LS.plexField, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setScope = useCallback((v: string) => {
|
||||
setScopeState(v);
|
||||
setAccountRaw(LS.plexLibrary, v);
|
||||
}, []);
|
||||
const setShow = useCallback((v: string) => {
|
||||
setShowState(v);
|
||||
setAccountRaw(LS.plexShow, v);
|
||||
}, []);
|
||||
const setSort = useCallback((v: SortState) => {
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||
}, []);
|
||||
const setFilters = useCallback((f: PlexFilters) => {
|
||||
const s = JSON.stringify(f);
|
||||
setFiltersRaw(s);
|
||||
setAccountRaw(LS.plexFilters, s);
|
||||
}, []);
|
||||
const setQ = useCallback((v: string) => {
|
||||
setQState(v);
|
||||
setAccountRaw(LS.plexQ, v);
|
||||
}, []);
|
||||
const setField = useCallback((v: PlexSearchField) => {
|
||||
setFieldState(v);
|
||||
setAccountRaw(LS.plexField, v);
|
||||
}, []);
|
||||
|
||||
const state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the
|
||||
// tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it
|
||||
// gone, a seed that read only this browser's copy would never show a performance mode or hints setting
|
||||
// changed on another device. And the seed is not an edit: it must not be saved straight back.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PrefsProvider, usePrefs } from "./PrefsProvider";
|
||||
import { api } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const seen: { perf: boolean; hints: boolean }[] = [];
|
||||
function Probe() {
|
||||
const { perf, hints } = usePrefs();
|
||||
seen.push({ perf, hints });
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
seen.length = 0;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PrefsProvider seed", () => {
|
||||
it("renders the account's server prefs from the first render, and does not save them back", () => {
|
||||
localStorage.setItem(accountKey(LS.perfMode, 5)!, "0"); // this browser last had it off
|
||||
localStorage.setItem(accountKey(LS.hints, 5)!, "1");
|
||||
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PrefsProvider>
|
||||
<Probe />
|
||||
</PrefsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(seen[0]).toEqual({ perf: true, hints: false });
|
||||
act(() => vi.advanceTimersByTime(1000)); // past the auto-save debounce
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,15 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
@@ -27,8 +28,8 @@ const PERF_KEY = LS.perfMode;
|
||||
|
||||
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
|
||||
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
|
||||
// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting
|
||||
// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
|
||||
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
|
||||
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
|
||||
type EditablePrefs = {
|
||||
theme: ThemePrefs;
|
||||
performanceMode: boolean;
|
||||
@@ -53,35 +54,47 @@ export function usePrefs(): PrefsController {
|
||||
}
|
||||
|
||||
export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||
const [hints, setHints] = useState(() => hintsEnabled());
|
||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
|
||||
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
|
||||
const baselineRef = useRef<EditablePrefs>({
|
||||
theme: loadLocalTheme(),
|
||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
const qc = useQueryClient();
|
||||
// The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me
|
||||
// has answered, so a theme changed on another device is right on the first paint instead of
|
||||
// switching after it. This browser's copy fills any key the server has never stored.
|
||||
const [seed] = useState<EditablePrefs>(() => {
|
||||
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
||||
return {
|
||||
theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
performanceMode:
|
||||
typeof prefs?.performanceMode === "boolean"
|
||||
? prefs.performanceMode
|
||||
: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||
notifications: prefs?.notifications
|
||||
? { ...getNotifSettings(), ...prefs.notifications }
|
||||
: getNotifSettings(),
|
||||
};
|
||||
});
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
||||
const [perf, setPerf] = useState(seed.performanceMode);
|
||||
const [hints, setHints] = useState(seed.hints);
|
||||
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
||||
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares
|
||||
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
|
||||
const baselineRef = useRef<EditablePrefs>(seed);
|
||||
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const saveTimer = useRef<number | undefined>(undefined);
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
|
||||
// stores too); persistence to the server is the auto-save effect below.
|
||||
useEffect(() => applyTheme(theme), [theme]);
|
||||
useEffect(() => {
|
||||
// stores too); persistence to the server is the auto-save effect below. The look is applied in
|
||||
// LAYOUT effects, before the browser paints, so the app's first frame is already in the account's
|
||||
// theme rather than in the baseline one main.tsx sets for the boot screen.
|
||||
useLayoutEffect(() => applyTheme(theme), [theme]);
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
||||
}, [perf]);
|
||||
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
|
||||
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
|
||||
// theme-appropriate image on <body> and switches the glass to translucent.
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const on = theme.bgImage && !perf;
|
||||
document.documentElement.dataset.backdrop = on ? "on" : "off";
|
||||
}, [theme.bgImage, perf]);
|
||||
@@ -89,8 +102,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => configureNotifications(notif), [notif]);
|
||||
|
||||
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
|
||||
// effects above; this is the only persistence path. `baselineRef` suppresses the echo when the
|
||||
// change came from adopting server prefs on login (`/me` merges, so a full payload is fine).
|
||||
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
|
||||
// saved straight back (`/me` merges, so a full payload is fine).
|
||||
const editablePrefs = useMemo<EditablePrefs>(
|
||||
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
|
||||
[theme, perf, hints, notif],
|
||||
@@ -98,7 +111,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
|
||||
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
|
||||
// Back at the last-saved value (the mount seed, or an edit undone) — nothing to
|
||||
// save; clear any "Saving…" left from a change that was reverted before it flushed.
|
||||
setSaveState((s) => (s === "saving" ? "idle" : s));
|
||||
return;
|
||||
@@ -132,31 +145,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||
[],
|
||||
);
|
||||
|
||||
// On login / account switch, adopt the server-stored prefs into the live states AND the auto-save
|
||||
// baseline (so adopting them doesn't echo a save back).
|
||||
useEffect(() => {
|
||||
const prefs = me?.preferences;
|
||||
if (!prefs) return;
|
||||
const adopted: EditablePrefs = {
|
||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
performanceMode:
|
||||
typeof prefs.performanceMode === "boolean"
|
||||
? prefs.performanceMode
|
||||
: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||
notifications: prefs.notifications
|
||||
? { ...getNotifSettings(), ...prefs.notifications }
|
||||
: getNotifSettings(),
|
||||
};
|
||||
setThemeState(adopted.theme);
|
||||
saveLocalTheme(adopted.theme);
|
||||
setPerf(adopted.performanceMode);
|
||||
setHints(adopted.hints);
|
||||
setNotif(adopted.notifications);
|
||||
baselineRef.current = adopted;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
|
||||
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
|
||||
const setTheme = useCallback((next: ThemePrefs) => {
|
||||
setThemeState(next);
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
||||
import { api, setActiveAccount, type SavedView } from "../lib/api";
|
||||
import { api, type SavedView } from "../lib/api";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -109,21 +109,6 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
|
||||
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
|
||||
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
|
||||
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
|
||||
act(() => setActiveAccount(ID));
|
||||
await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
|
||||
@@ -25,7 +25,6 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -179,19 +178,13 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing.
|
||||
//
|
||||
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
|
||||
// /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null.
|
||||
// canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored")
|
||||
// with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never
|
||||
// re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the
|
||||
// pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real.
|
||||
const accountId = useActiveAccountId();
|
||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
||||
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
||||
// before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
useEffect(() => {
|
||||
const data = viewsQuery.data;
|
||||
if (!data || accountId == null || seededRef.current.has(source)) return;
|
||||
if (!data || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||
if (!def) return;
|
||||
@@ -200,7 +193,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
|
||||
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewsQuery.data, source, accountId]);
|
||||
}, [viewsQuery.data, source]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
|
||||
@@ -24,8 +24,8 @@ import { useMe } from "../lib/useMe";
|
||||
|
||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
||||
// outcome. App owns the state + the save; this controller is
|
||||
// how the panel reads/writes it. See App.tsx.
|
||||
// outcome. PrefsProvider owns the state + the save; this controller
|
||||
// is how the panel reads/writes it.
|
||||
export type PrefsSaveState = SaveState;
|
||||
export interface PrefsController {
|
||||
theme: ThemePrefs;
|
||||
@@ -128,13 +128,11 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
|
||||
|
||||
function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
||||
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
|
||||
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
||||
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
|
||||
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
|
||||
// has to answer before /api/me lands, so the value must be readable synchronously from
|
||||
// localStorage — a landing module decided after the first paint would be a visible jump. The two
|
||||
// stored values come from navigationInit, which is what reads them back at boot.
|
||||
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
|
||||
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
||||
// values come from navigationInit, which is what reads them back at boot.
|
||||
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
||||
|
||||
return (
|
||||
@@ -253,8 +251,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
|
||||
function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
// Controlled by App's prefs draft (live preview via configureNotifications there); the
|
||||
// panel's Save bar persists it. The "send test" button below still fires immediately.
|
||||
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
|
||||
// it. The "send test" button below fires immediately.
|
||||
const { notif: cfg, setNotif } = prefs;
|
||||
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
|
||||
const autoOn = cfg.durationMs > 0;
|
||||
@@ -499,7 +497,11 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
autoComplete="new-password"
|
||||
className={inputCls}
|
||||
/>
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
{err && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
||||
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
||||
</Button>
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function SetupWizard() {
|
||||
const [stepIdx, setStepIdx] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
||||
// and a set in one handler commit together, leaving the node untouched).
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// Admin account.
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -74,19 +77,21 @@ export default function SetupWizard() {
|
||||
};
|
||||
|
||||
// Each step's "Next" action persists what it collected, then advances. Optional steps with empty
|
||||
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag).
|
||||
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag); a step's own
|
||||
// validation sets its message and stays.
|
||||
async function next() {
|
||||
setAttempt((n) => n + 1);
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
if (step === "admin") {
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
||||
throw { detail: t("setup.admin.invalidEmail") };
|
||||
return setError(t("setup.admin.invalidEmail"));
|
||||
if (password.length < PASSWORD_MIN)
|
||||
throw { detail: t("setup.admin.shortPassword", { n: PASSWORD_MIN }) };
|
||||
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
|
||||
await api.setupAdmin(token, email, password);
|
||||
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
|
||||
if (!gid.trim() || !gsecret.trim()) throw { detail: t("setup.google.bothRequired") };
|
||||
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
|
||||
await api.setupConfig(token, {
|
||||
google_client_id: gid.trim(),
|
||||
google_client_secret: gsecret.trim(),
|
||||
@@ -267,7 +272,11 @@ export default function SetupWizard() {
|
||||
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger mt-3">{error}</p>}
|
||||
{error && (
|
||||
<p key={attempt} role="alert" className="text-sm text-danger mt-3">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<button
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
||||
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||
import { render, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import WatchPage from "./WatchPage";
|
||||
|
||||
function mockMeta(meta: Record<string, unknown>) {
|
||||
@@ -18,6 +21,50 @@ function mockMeta(meta: Record<string, unknown>) {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("WatchPage background play", () => {
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-bg");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const { container } = render(<WatchPage />);
|
||||
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||
expect(expose).toHaveBeenCalledWith(container.querySelector("video"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchPage repeat", () => {
|
||||
it("repeats by default, for a video and for an audio-only asset", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-video");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const video = render(<WatchPage />);
|
||||
await waitFor(() => expect(video.container.querySelector("video")).not.toBeNull());
|
||||
expect(video.container.querySelector("video")!.loop).toBe(true);
|
||||
cleanup();
|
||||
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-audio");
|
||||
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
||||
const audio = render(<WatchPage />);
|
||||
await waitFor(() => expect(audio.container.querySelector("audio")).not.toBeNull());
|
||||
expect(audio.container.querySelector("audio")!.loop).toBe(true);
|
||||
});
|
||||
|
||||
it("the toggle turns it off, and the choice is remembered for the next visit", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-toggle");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const first = render(<WatchPage />);
|
||||
const toggle = await first.findByRole("button", { name: "Repeat" });
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(first.container.querySelector("video")!.loop).toBe(false);
|
||||
cleanup();
|
||||
|
||||
const second = render(<WatchPage />);
|
||||
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
|
||||
expect(second.container.querySelector("video")!.loop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchPage media element (S3 · 5a)", () => {
|
||||
@@ -74,3 +121,86 @@ describe("WatchPage media element (S3 · 5a)", () => {
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Only a 404 means a link is gone. Every other failure used to render the permanent "no longer
|
||||
// available" state too: a server restart, a dropped connection, and the unlock's own rate limit told a
|
||||
// viewer with a valid link that it had expired or been revoked, with no way to try again.
|
||||
describe("WatchPage honest failures", () => {
|
||||
const res = (status: number, body: unknown = {}) => ({
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
const GONE = "This link is no longer available.";
|
||||
|
||||
it("a server error is a failure with a retry, not a dead link", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-5xx");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(503))
|
||||
.mockResolvedValue(
|
||||
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
|
||||
),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
const retry = await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
|
||||
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("a dropped connection is a failure with a retry too", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-offline");
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
});
|
||||
|
||||
it("a 404 says the link is gone", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-404");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(res(404)));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText(GONE);
|
||||
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||
});
|
||||
|
||||
async function tryPassword(second: () => Promise<unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(200, { needs_password: true }))
|
||||
.mockImplementationOnce(second),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.change(await page.findByPlaceholderText("Enter password"), {
|
||||
target: { value: "guess" },
|
||||
});
|
||||
fireEvent.click(page.getByRole("button", { name: "Watch" }));
|
||||
return page;
|
||||
}
|
||||
|
||||
it("too many password tries says so, and keeps the form", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-429");
|
||||
const page = await tryPassword(async () => res(429));
|
||||
await page.findByText("Too many attempts. Try again in a few minutes.");
|
||||
const alert = page.getByRole("alert");
|
||||
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
|
||||
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
|
||||
alert.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("an unlock that gets no answer says so instead of doing nothing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
|
||||
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
|
||||
expect((await page.findByRole("alert")).textContent).toBe(
|
||||
"Couldn't check the password. Try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
@@ -80,6 +82,12 @@ const STR = {
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
repeat: "Repeat",
|
||||
failed: "Couldn't load this link.",
|
||||
failedHint: "Check your connection and try again.",
|
||||
retry: "Try again",
|
||||
tooMany: "Too many attempts. Try again in a few minutes.",
|
||||
unlockFailed: "Couldn't check the password. Try again.",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
@@ -93,6 +101,12 @@ const STR = {
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
repeat: "Ismétlés",
|
||||
failed: "Nem sikerült betölteni a linket.",
|
||||
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
|
||||
retry: "Újrapróbálás",
|
||||
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
|
||||
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
@@ -106,6 +120,12 @@ const STR = {
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
repeat: "Wiederholen",
|
||||
failed: "Der Link konnte nicht geladen werden.",
|
||||
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
|
||||
retry: "Erneut versuchen",
|
||||
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
||||
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -117,10 +137,13 @@ const T = STR[lang];
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone">("loading");
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = useId();
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
||||
// both without a per-element typed ref.
|
||||
@@ -130,22 +153,45 @@ export default function WatchPage() {
|
||||
};
|
||||
useMediaSession(mediaRef, meta, status === "ready");
|
||||
|
||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||
// (this page has no account); an unreadable storage just means the default.
|
||||
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
|
||||
const toggleRepeat = () => {
|
||||
const next = !repeat;
|
||||
setRepeat(next);
|
||||
writeJSON(LS.watchRepeat, next);
|
||||
};
|
||||
|
||||
// Bumped by Retry, which asks again.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
fetch(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||
.then((m: Meta) => {
|
||||
if (m.needs_password) setStatus("password");
|
||||
.then((r) => {
|
||||
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
|
||||
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
|
||||
// not a dead link.
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Meta>;
|
||||
})
|
||||
.then((m) => {
|
||||
if (!m) setStatus("gone");
|
||||
else if (m.needs_password) setStatus("password");
|
||||
else {
|
||||
setMeta(m);
|
||||
setStatus("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus("gone"));
|
||||
}, [token]);
|
||||
.catch(() => setStatus("error"));
|
||||
}, [token, attempt]);
|
||||
const retry = () => {
|
||||
setStatus("loading");
|
||||
setAttempt((n) => n + 1);
|
||||
};
|
||||
|
||||
const unlock = async () => {
|
||||
setUnlocking(true);
|
||||
@@ -160,12 +206,20 @@ export default function WatchPage() {
|
||||
setError(T.wrong);
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
// Unlocking is rate-limited per link: too many tries is not a dead link either.
|
||||
if (r.status === 429) {
|
||||
setError(T.tooMany);
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setError(T.unlockFailed);
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
@@ -180,7 +234,9 @@ export default function WatchPage() {
|
||||
</div>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="flex-1 grid place-items-center text-muted">{T.loading}</div>
|
||||
<div role="status" className="flex-1 grid place-items-center text-muted">
|
||||
{T.loading}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "gone" && (
|
||||
@@ -192,6 +248,19 @@ export default function WatchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex-1 grid place-items-center text-center">
|
||||
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
|
||||
<div role="alert">
|
||||
<div className="text-lg font-semibold">{T.failed}</div>
|
||||
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
|
||||
<Button variant="primary" size="md" onClick={retry} className="mt-4">
|
||||
{T.retry}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "password" && (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-sm rounded-2xl glass p-6">
|
||||
@@ -205,9 +274,15 @@ export default function WatchPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||
placeholder={T.passwordPlaceholder}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
||||
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
|
||||
{error && (
|
||||
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
@@ -259,25 +334,40 @@ export default function WatchPage() {
|
||||
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||
the surface (not the black video box) so it looks intentional without artwork. */
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
|
||||
<audio
|
||||
ref={setMedia}
|
||||
controls
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
<video
|
||||
ref={setMedia}
|
||||
ref={(el) => {
|
||||
setMedia(el);
|
||||
// So Brave iOS's background play keeps it going when the app is left.
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className="max-h-[80vh] max-w-full mx-auto block"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
|
||||
than squeezing the title into a column a word wide. */}
|
||||
<div className="mt-3 flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || "Video"}
|
||||
</h1>
|
||||
{meta.uploader &&
|
||||
(meta.uploader_url ? (
|
||||
<a
|
||||
@@ -292,14 +382,26 @@ export default function WatchPage() {
|
||||
<div className="text-sm text-muted">{meta.uploader}</div>
|
||||
))}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
|
||||
Button, whose variants differ only in colour. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={repeat}
|
||||
onClick={toggleRepeat}
|
||||
className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
<Repeat className="w-4 h-4" aria-hidden /> {T.repeat}
|
||||
</button>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
|
||||
@@ -398,6 +398,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
||||
// and a set in one handler commit together, leaving the node untouched).
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth
|
||||
// configured. Optimistic (most instances have it); the rare Google-less instance just sees it
|
||||
// disappear once the config loads.
|
||||
@@ -481,6 +484,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setAttempt((n) => n + 1);
|
||||
reset();
|
||||
const mail = email.trim().toLowerCase();
|
||||
if (
|
||||
@@ -686,7 +690,11 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{error && (
|
||||
<p key={attempt} role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
|
||||
@@ -22,8 +22,8 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
|
||||
//
|
||||
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
|
||||
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
|
||||
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (App owns the
|
||||
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
|
||||
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
|
||||
// the fetching query), so it re-runs the auto-open check when the account's grants change.
|
||||
|
||||
interface WizardActions {
|
||||
openWizard: () => void;
|
||||
@@ -43,7 +43,7 @@ export function useWizard(): WizardActions {
|
||||
|
||||
export function WizardProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
const openWizard = useCallback(() => setOpen(true), []);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A form's error is announced (role="alert"), and failing the same way again must be announced again. The
|
||||
// clear and the set of a validation failure commit together, so the alert node used to stay untouched on
|
||||
// the second attempt and a screen reader said nothing — a press that seemed to do nothing.
|
||||
//
|
||||
// i18n is mocked to a key-passthrough; the API and the E2EE module are stubbed so nothing reaches out.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
messageMyKey: () => Promise.resolve({ configured: true }),
|
||||
me: () => Promise.resolve({ authenticated: false }),
|
||||
setupInfo: () => Promise.resolve({ secrets_manageable: false }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/e2ee", () => ({ unlock: vi.fn(), setup: vi.fn(), clearDevice: vi.fn() }));
|
||||
|
||||
import KeyGate from "./KeyGate";
|
||||
import SetupWizard from "./SetupWizard";
|
||||
import Welcome from "./Welcome";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
// Fails twice the same way; the second alert must be a new node carrying the same message.
|
||||
async function failTwice(fail: () => void, message: string) {
|
||||
fail();
|
||||
const first = await screen.findByRole("alert");
|
||||
expect(first.textContent).toBe(message);
|
||||
fail();
|
||||
const second = screen.getByRole("alert");
|
||||
expect(second.textContent).toBe(message);
|
||||
expect(second).not.toBe(first);
|
||||
}
|
||||
|
||||
describe("a form failing the same way twice announces it twice", () => {
|
||||
it("the message passphrase gate", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<KeyGate meId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const unlock = await screen.findByText("messages.unlock");
|
||||
fireEvent.change(screen.getByPlaceholderText("messages.passphrase"), {
|
||||
target: { value: "abc" },
|
||||
});
|
||||
await failTwice(() => fireEvent.click(unlock), "messages.passTooShort");
|
||||
});
|
||||
|
||||
it("the sign-in card", async () => {
|
||||
render(
|
||||
<Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />,
|
||||
);
|
||||
const email = await screen.findByPlaceholderText("welcome.auth.email");
|
||||
fireEvent.change(email, { target: { value: "not-an-address" } });
|
||||
await failTwice(() => fireEvent.submit(email.closest("form")!), "welcome.auth.invalidEmail");
|
||||
});
|
||||
|
||||
it("the install wizard, with the step's own message rather than the generic one", async () => {
|
||||
window.history.replaceState(null, "", "/?token=setup-token");
|
||||
render(<SetupWizard />);
|
||||
fireEvent.click(await screen.findByText("setup.nav.next")); // past the intro
|
||||
fireEvent.change(await screen.findByPlaceholderText("setup.admin.email"), {
|
||||
target: { value: "not-an-address" },
|
||||
});
|
||||
await failTwice(
|
||||
() => fireEvent.click(screen.getByText("setup.nav.next")),
|
||||
"setup.admin.invalidEmail",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, type ComponentType } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||
import { useMe } from "../lib/useMe";
|
||||
@@ -21,22 +22,24 @@ import { useSavedViews } from "./SavedViewsProvider";
|
||||
// its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
|
||||
|
||||
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
|
||||
const Home = lazy(() => import("./Home"));
|
||||
const Feed = lazy(() => import("./Feed"));
|
||||
const Channels = lazy(() => import("./Channels"));
|
||||
const Playlists = lazy(() => import("./Playlists"));
|
||||
const PlexBrowse = lazy(() => import("./PlexBrowse"));
|
||||
const PlexSidebar = lazy(() => import("./PlexSidebar"));
|
||||
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
|
||||
const Messages = lazy(() => import("./Messages"));
|
||||
const DownloadCenter = lazy(() => import("./DownloadCenter"));
|
||||
const Stats = lazy(() => import("./Stats"));
|
||||
const Scheduler = lazy(() => import("./Scheduler"));
|
||||
const ConfigPanel = lazy(() => import("./ConfigPanel"));
|
||||
const AdminUsers = lazy(() => import("./AdminUsers"));
|
||||
const AuditLog = lazy(() => import("./AuditLog"));
|
||||
const SettingsPanel = lazy(() => import("./SettingsPanel"));
|
||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
|
||||
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
|
||||
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
|
||||
const Home = lazyWithPreload(() => import("./Home"));
|
||||
const Feed = lazyWithPreload(() => import("./Feed"));
|
||||
const Channels = lazyWithPreload(() => import("./Channels"));
|
||||
const Playlists = lazyWithPreload(() => import("./Playlists"));
|
||||
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
|
||||
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
||||
const Messages = lazyWithPreload(() => import("./Messages"));
|
||||
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
|
||||
const Stats = lazyWithPreload(() => import("./Stats"));
|
||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
||||
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
|
||||
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
||||
|
||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
|
||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||
@@ -80,8 +83,9 @@ function MessagesPage() {
|
||||
}
|
||||
|
||||
export type PageDef = {
|
||||
// Prop-free by construction: reads `me` + module state from hooks/providers.
|
||||
Component: ComponentType;
|
||||
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
|
||||
// a new page cannot silently opt out of preloadPages.
|
||||
Component: PreloadableComponent<object>;
|
||||
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
|
||||
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
|
||||
fadeTop?: boolean;
|
||||
@@ -94,12 +98,13 @@ export type PageDef = {
|
||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
home: { Component: Home, maxWidth: "80rem" },
|
||||
feed: { Component: FeedPage },
|
||||
// The two wrappers preload the lazy chunk they render.
|
||||
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
|
||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: MessagesPage },
|
||||
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
|
||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||
stats: { Component: Stats },
|
||||
scheduler: { Component: Scheduler },
|
||||
@@ -134,3 +139,13 @@ export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
||||
};
|
||||
|
||||
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
||||
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
||||
export function preloadPages(pages: Page[]): void {
|
||||
for (const p of pages) {
|
||||
PAGE_CONTENT[p].Component.preload().catch(() => {});
|
||||
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
|
||||
rail?.preload?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A loading state fades in only after a delay, and loading states that hand over to each other share
|
||||
// ONE clock. The bug it replaced: every LoadingState restarted its own 300 ms CSS delay, so a slow
|
||||
// first load went spinner → blank → spinner when the page chunk's Suspense fallback handed over to
|
||||
// the page's own "fetching" state. The clock is module state, so each test runs at its own time.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { LoadingState } from "./QueryState";
|
||||
|
||||
let now = 0;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The reveal delay the rendered loading state waits, in ms (0 = shown at once). */
|
||||
function revealDelay(container: HTMLElement): number {
|
||||
const el = container.querySelector<HTMLElement>("[role=status]")!;
|
||||
const m = /ease (\d+)ms/.exec(el.style.animation);
|
||||
return m ? Number(m[1]) : 0;
|
||||
}
|
||||
|
||||
describe("LoadingState reveal", () => {
|
||||
it("a loading state on its own waits the full delay", () => {
|
||||
now = 10_000;
|
||||
const { container } = render(<LoadingState />);
|
||||
expect(revealDelay(container)).toBe(300);
|
||||
});
|
||||
|
||||
it("one that takes over from a loading state on screen keeps that one's clock", () => {
|
||||
now = 20_000;
|
||||
const { container, rerender } = render(<LoadingState key="fallback" />);
|
||||
now = 20_200;
|
||||
rerender(<LoadingState key="page" />); // a new element: the fallback leaves as it mounts
|
||||
expect(revealDelay(container)).toBe(100);
|
||||
});
|
||||
|
||||
it("once the shared clock has run out, the next one in the hand-off shows at once", () => {
|
||||
now = 30_000;
|
||||
const { container, rerender } = render(<LoadingState key="fallback" />);
|
||||
now = 30_400;
|
||||
rerender(<LoadingState key="page" />);
|
||||
expect(revealDelay(container)).toBe(0);
|
||||
});
|
||||
|
||||
it("a loading state after a pause starts a new clock", () => {
|
||||
now = 40_000;
|
||||
const first = render(<LoadingState />);
|
||||
first.unmount();
|
||||
now = 40_500;
|
||||
const { container } = render(<LoadingState />);
|
||||
expect(revealDelay(container)).toBe(300);
|
||||
});
|
||||
|
||||
it("one that mounts just after another left, in a later commit, keeps that one's clock", () => {
|
||||
now = 45_000;
|
||||
const first = render(<LoadingState />);
|
||||
now = 45_050;
|
||||
first.unmount(); // a Suspense fallback leaves a commit before the page's own loading state mounts
|
||||
now = 45_080;
|
||||
const { container } = render(<LoadingState />);
|
||||
expect(revealDelay(container)).toBe(220);
|
||||
});
|
||||
|
||||
it("`since` counts the wait from an earlier start (the boot screen: navigation start)", () => {
|
||||
now = 50_250;
|
||||
const { container } = render(<LoadingState since={50_000} delayMs={400} />);
|
||||
expect(revealDelay(container)).toBe(150);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,55 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
|
||||
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
|
||||
// load that really takes a while still gets the spinner, a third of a second in.
|
||||
//
|
||||
// The delay is ONE clock across loading states that hand over to each other, not a per-element CSS
|
||||
// delay. One wait is often several elements in a row — the boot screen, then the page chunk's
|
||||
// Suspense fallback, then the page's own "fetching" state — and a delay restarted by each would blank
|
||||
// an already-visible spinner for another 300 ms at every hand-off. A loading state that mounts while
|
||||
// another is still on screen (or has just left it) continues that one's clock instead.
|
||||
const LOADING_REVEAL_MS = 300;
|
||||
const HANDOFF_MS = 100;
|
||||
const reveal = { live: 0, at: 0, lastGone: -Infinity };
|
||||
function revealWait(since: number | undefined, delayMs: number): number {
|
||||
const now = performance.now();
|
||||
if (reveal.live === 0 && now - reveal.lastGone > HANDOFF_MS) reveal.at = (since ?? now) + delayMs;
|
||||
return reveal.at - now;
|
||||
}
|
||||
|
||||
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
|
||||
// visual for a page/section's first load, so all of them read the same (and can be swapped for
|
||||
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
|
||||
export function LoadingState({ label, className = "" }: { label?: ReactNode; className?: string }) {
|
||||
export function LoadingState({
|
||||
label,
|
||||
className = "",
|
||||
since,
|
||||
delayMs = LOADING_REVEAL_MS,
|
||||
}: {
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
|
||||
* passes 0, navigation start. Only used when this state starts a new clock. */
|
||||
since?: number;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [wait] = useState(() => revealWait(since, delayMs));
|
||||
useEffect(() => {
|
||||
reveal.live++;
|
||||
return () => {
|
||||
reveal.live--;
|
||||
reveal.lastGone = performance.now();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
||||
style={wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function isSupported(code: string | null | undefined): code is LangCode {
|
||||
}
|
||||
|
||||
// Initial language before the server preference is known: stored choice, else the browser
|
||||
// language, else English. (After login, App adopts the server-persisted preference.)
|
||||
// language, else English. (Signed in, BootGate adopts the account's preference before the app renders.)
|
||||
function detectInitialLang(): LangCode {
|
||||
const stored = localStorage.getItem(LANG_KEY);
|
||||
if (isSupported(stored)) return stored;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The notification and hints stores read their per-account localStorage when they are imported. In a new
|
||||
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
|
||||
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
|
||||
// nobody's history, and the tab's first notice replaced the account's stored history with it.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const entry = (id: number, message: string) => ({
|
||||
id,
|
||||
level: "info",
|
||||
message,
|
||||
ts: 1,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
repeat: 1,
|
||||
requiresInteraction: false,
|
||||
});
|
||||
const storedMessages = (account: number) =>
|
||||
(
|
||||
JSON.parse(localStorage.getItem(`siftlode.notifications.${account}`) ?? "[]") as {
|
||||
message: string;
|
||||
}[]
|
||||
).map((n) => n.message);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.resetModules(); // both stores load at import: each test needs a fresh one
|
||||
});
|
||||
|
||||
describe("stores read at import, in a tab not yet pinned", () => {
|
||||
it("keep the account's notification history and settings once the tab is pinned", async () => {
|
||||
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
|
||||
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
|
||||
const notifications = await import("./notifications");
|
||||
const { setActiveAccount } = await import("./api/core");
|
||||
|
||||
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
|
||||
setActiveAccount(5);
|
||||
notifications.notify({ message: "after the pin" });
|
||||
|
||||
expect(storedMessages(5)).toEqual(["older", "before the pin", "after the pin"]);
|
||||
expect(notifications.getNotifSettings().durationMs).toBe(0);
|
||||
});
|
||||
|
||||
it("read the account's hints choice once the tab is pinned", async () => {
|
||||
localStorage.setItem("siftlode.hints.5", "0");
|
||||
const hints = await import("./hints");
|
||||
const { setActiveAccount } = await import("./api/core");
|
||||
|
||||
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
|
||||
setActiveAccount(5);
|
||||
expect(hints.hintsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pinned tab pinned to another account", () => {
|
||||
it("leaves the previous account's notices with that account", async () => {
|
||||
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
|
||||
sessionStorage.setItem("siftlode.activeAccount", "4");
|
||||
localStorage.setItem("siftlode.notifications.4", JSON.stringify([entry(7, "four's")]));
|
||||
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(8, "five's")]));
|
||||
const notifications = await import("./notifications");
|
||||
const { setActiveAccount } = await import("./api/core");
|
||||
|
||||
notifications.notify({ message: "as four" });
|
||||
setActiveAccount(5);
|
||||
notifications.notify({ message: "as five" });
|
||||
|
||||
expect(storedMessages(5)).toEqual(["five's", "as five"]);
|
||||
expect(storedMessages(4)).toEqual(["four's", "as four"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A switch or sign-out hands the tab's next page its account instead of moving the pin: the page being left
|
||||
// still unloads as its own account. The players' resume beacons read the pin as they fire on the way out,
|
||||
// and with the pin already moved they saved one account's playback position into the other.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "3");
|
||||
vi.resetModules(); // the hand-over is taken at import: each import below is a page load
|
||||
});
|
||||
|
||||
describe("pinOnNextPage", () => {
|
||||
it("leaves the pin alone until the next page, which takes the account over once", async () => {
|
||||
const leaving = await import("./activeAccount");
|
||||
leaving.pinOnNextPage(7);
|
||||
expect(leaving.activeAccountId()).toBe(3); // the unload still acts as 3
|
||||
|
||||
vi.resetModules();
|
||||
const next = await import("./activeAccount");
|
||||
expect(next.activeAccountId()).toBe(7);
|
||||
|
||||
next.pinActiveAccount(9); // BootGate corrects it
|
||||
vi.resetModules(); // and an F5 keeps the correction
|
||||
expect((await import("./activeAccount")).activeAccountId()).toBe(9);
|
||||
});
|
||||
|
||||
it("hands over no account at all, so the next page adopts the session's default", async () => {
|
||||
const leaving = await import("./activeAccount");
|
||||
leaving.pinOnNextPage(null);
|
||||
expect(leaving.activeAccountId()).toBe(3);
|
||||
|
||||
vi.resetModules();
|
||||
expect((await import("./activeAccount")).activeAccountId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,60 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
|
||||
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
|
||||
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
|
||||
// account id is a lower-level primitive than the storage helpers that consume it.
|
||||
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module
|
||||
// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage
|
||||
// on it, without a circular import — the account id is a lower-level primitive than the storage
|
||||
// helpers that consume it.
|
||||
//
|
||||
// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads
|
||||
// the page (handing the new pin over through `pinOnNextPage`), so nothing mounted ever sees the id change
|
||||
// underneath it. The one exception is state read at
|
||||
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
|
||||
// again through `onAccountPinned`.
|
||||
//
|
||||
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
|
||||
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
||||
|
||||
// Re-readers for module-level per-account state, run each time the tab is pinned.
|
||||
const pinListeners = new Set<() => void>();
|
||||
|
||||
export function onAccountPinned(reload: () => void): void {
|
||||
pinListeners.add(reload);
|
||||
}
|
||||
|
||||
export function pinActiveAccount(id: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pinListeners.forEach((reload) => reload());
|
||||
}
|
||||
|
||||
// The account the tab's NEXT page is pinned to, handed over by a switch or sign-out that is about to leave.
|
||||
// The pin itself stays until the page is gone: the unload still runs as the account the page was built for
|
||||
// (the players' resume beacons read the pin as they fire), and with the pin already moved it would save one
|
||||
// account's position into the other.
|
||||
const NEXT_ACCOUNT_KEY = "siftlode.nextAccount";
|
||||
|
||||
/** Pin the tab's next page to `id` — or to none, so it adopts the session's default account. */
|
||||
export function pinOnNextPage(id: number | null): void {
|
||||
try {
|
||||
sessionStorage.setItem(NEXT_ACCOUNT_KEY, id == null ? "" : String(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Taken over at import, before anything reads the pin.
|
||||
try {
|
||||
const next = sessionStorage.getItem(NEXT_ACCOUNT_KEY);
|
||||
if (next != null) {
|
||||
sessionStorage.removeItem(NEXT_ACCOUNT_KEY);
|
||||
if (next) sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, next);
|
||||
else sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
export function activeAccountId(): number | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
|
||||
@@ -29,22 +76,3 @@ export function withActiveAccount(path: string): string {
|
||||
if (id == null) return path;
|
||||
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
|
||||
}
|
||||
|
||||
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
|
||||
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
|
||||
// account) would never re-read once the account is finally known. That bites exactly once, on first
|
||||
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
|
||||
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
|
||||
const accountListeners = new Set<() => void>();
|
||||
function subscribeActiveAccount(cb: () => void): () => void {
|
||||
accountListeners.add(cb);
|
||||
return () => accountListeners.delete(cb);
|
||||
}
|
||||
export function notifyActiveAccountChange(): void {
|
||||
for (const cb of [...accountListeners]) cb();
|
||||
}
|
||||
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
|
||||
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
|
||||
export function useActiveAccountId(): number | null {
|
||||
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
|
||||
}
|
||||
|
||||
@@ -11,19 +11,11 @@ import {
|
||||
getActiveAccount,
|
||||
HttpError,
|
||||
setActiveAccount,
|
||||
clearActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
} from "./api/core";
|
||||
|
||||
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
|
||||
export {
|
||||
alreadyReported,
|
||||
HttpError,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
clearActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
};
|
||||
export { alreadyReported, HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
|
||||
|
||||
import { accountApi } from "./api/account";
|
||||
export type * from "./api/account";
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
ACTIVE_ACCOUNT_KEY: "k",
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
pinActiveAccount: () => {},
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
@@ -262,23 +262,7 @@ export function beacon(url: string, body: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveAccount(id: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
|
||||
notifyActiveAccountChange();
|
||||
}
|
||||
export function clearActiveAccount(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
notifyActiveAccountChange();
|
||||
}
|
||||
export const setActiveAccount = pinActiveAccount;
|
||||
|
||||
export async function req<T = unknown>(
|
||||
url: string,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// exposeVideoToBackgroundPlay: Brave iOS's background play hooks only a <video> that shows up as a
|
||||
// MutationObserver addedNode itself. A video mounted inside a new wrapper in one go never does — this
|
||||
// locks that the helper makes it one, once per element, without moving it.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { exposeVideoToBackgroundPlay } from "./browserBackgroundPlay";
|
||||
|
||||
// Brave's detection rule, re-implemented: count addedNodes that ARE a <video>.
|
||||
async function videosSeenWhile(act: () => void): Promise<number> {
|
||||
let seen = 0;
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const r of records) r.addedNodes.forEach((n) => n instanceof HTMLVideoElement && seen++);
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
act();
|
||||
await Promise.resolve(); // MutationObserver callbacks are microtasks
|
||||
observer.disconnect();
|
||||
return seen;
|
||||
}
|
||||
|
||||
function mountWrapped(): { wrapper: HTMLDivElement; video: HTMLVideoElement } {
|
||||
const wrapper = document.createElement("div");
|
||||
const before = document.createElement("span");
|
||||
const video = document.createElement("video");
|
||||
const after = document.createElement("span");
|
||||
wrapper.append(before, video, after);
|
||||
return { wrapper, video };
|
||||
}
|
||||
|
||||
describe("exposeVideoToBackgroundPlay", () => {
|
||||
it("a video mounted inside its wrapper is invisible to the rule until exposed", async () => {
|
||||
const { wrapper, video } = mountWrapped();
|
||||
expect(await videosSeenWhile(() => document.body.append(wrapper))).toBe(0);
|
||||
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(1);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it("keeps the video exactly where it was", () => {
|
||||
const { wrapper, video } = mountWrapped();
|
||||
document.body.append(wrapper);
|
||||
exposeVideoToBackgroundPlay(video);
|
||||
expect([...wrapper.children].map((c) => c.tagName)).toEqual(["SPAN", "VIDEO", "SPAN"]);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it("acts once per element, so a ref callback running on every render adds nothing", async () => {
|
||||
const { wrapper, video } = mountWrapped();
|
||||
document.body.append(wrapper);
|
||||
exposeVideoToBackgroundPlay(video);
|
||||
expect(await videosSeenWhile(() => exposeVideoToBackgroundPlay(video))).toBe(0);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it("ignores null and a detached element", () => {
|
||||
expect(() => exposeVideoToBackgroundPlay(null)).not.toThrow();
|
||||
expect(() => exposeVideoToBackgroundPlay(document.createElement("video"))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// Brave iOS keeps a web <video> playing when the browser leaves the foreground (Settings → Media → "Enable
|
||||
// Background Audio") by re-playing a video the OS paused. But it only hooks the <video> elements it SEES:
|
||||
// those present at document start, or a MutationObserver addedNode that IS a <video> (brave-core
|
||||
// ios/browser/web/media/resources/media_backgrounding.ts). React inserts a player's <video> inside its
|
||||
// freshly mounted wrapper in one go, so the video itself is never an added node and Brave never hooks it —
|
||||
// measured on iPad Brave 2026-09-14: the shared video stopped the moment the app was left; inserted as a
|
||||
// node of its own it kept playing, locked screen included.
|
||||
//
|
||||
// Re-inserting the element at its own position makes it an added node without changing the DOM React
|
||||
// manages. Playback is not interrupted: a media element removed and re-inserted within the same task is
|
||||
// back in the document by the next stable state, so the spec's pause-on-removal never runs.
|
||||
//
|
||||
// Only a <video> needs this: an <audio> already keeps playing in the background on its own.
|
||||
|
||||
const exposed = new WeakSet<HTMLVideoElement>();
|
||||
|
||||
/** Make a mounted <video> visible to the browser's background-play hook. Idempotent per element, so it is
|
||||
* safe from a ref callback that React calls on every render. */
|
||||
export function exposeVideoToBackgroundPlay(video: HTMLVideoElement | null): void {
|
||||
if (!video || exposed.has(video) || !video.parentNode) return;
|
||||
exposed.add(video);
|
||||
video.parentNode.insertBefore(video, video.nextSibling);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||
import { onAccountPinned } from "./activeAccount";
|
||||
import { createStore } from "./store";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
||||
|
||||
@@ -9,6 +10,8 @@ function load(): boolean {
|
||||
}
|
||||
|
||||
const store = createStore<boolean>(load());
|
||||
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
|
||||
onAccountPinned(() => store.set(load()));
|
||||
|
||||
export const hintsEnabled = store.get;
|
||||
export const subscribeHints = store.subscribe;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// lazyWithPreload: once preloaded, a page renders synchronously — no Suspense fallback, which on a module
|
||||
// switch is a "Loading…" flash in place of the page. And a page that mounted through the lazy path is
|
||||
// not remounted (its state kept) once the module is loaded.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { Suspense, useState, type ComponentType } from "react";
|
||||
import { render, screen, act, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { lazyWithPreload } from "./lazyWithPreload";
|
||||
|
||||
function Counter() {
|
||||
const [n, setN] = useState(0);
|
||||
return <button onClick={() => setN(n + 1)}>count {n}</button>;
|
||||
}
|
||||
const module = (): Promise<{ default: ComponentType<object> }> =>
|
||||
Promise.resolve({ default: Counter });
|
||||
const inSuspense = (C: ComponentType) => (
|
||||
<Suspense fallback={<span>fallback</span>}>
|
||||
<C />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("lazyWithPreload", () => {
|
||||
it("renders a preloaded page on its first render, without the fallback", async () => {
|
||||
const Page = lazyWithPreload(module);
|
||||
await Page.preload();
|
||||
render(inSuspense(Page));
|
||||
// Synchronously after render — no act/await, so a suspension would still be showing its fallback.
|
||||
expect(screen.queryByText("fallback")).toBeNull();
|
||||
expect(screen.getByText("count 0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("without a preload it suspends once, then renders", async () => {
|
||||
const Page = lazyWithPreload(module);
|
||||
render(inSuspense(Page));
|
||||
expect(screen.getByText("fallback")).toBeTruthy();
|
||||
expect(await screen.findByText("count 0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a page mounted through the lazy path keeps its state across later renders", async () => {
|
||||
// Mounted into a boundary that is ALREADY showing content — how a module switch mounts a page.
|
||||
const Page = lazyWithPreload(module);
|
||||
const shell = (show: boolean, tick: number) => (
|
||||
<Suspense fallback={<span>fallback</span>}>
|
||||
<i>shell {tick}</i>
|
||||
{show && <Page />}
|
||||
</Suspense>
|
||||
);
|
||||
const { rerender } = render(shell(false, 0));
|
||||
rerender(shell(true, 0));
|
||||
fireEvent.click(await screen.findByText("count 0")); // the module has loaded by now
|
||||
rerender(shell(true, 1)); // a later render of the same mount
|
||||
expect(screen.getByText("count 1")).toBeTruthy(); // not remounted as the loaded component
|
||||
});
|
||||
|
||||
it("retries a preload that failed", async () => {
|
||||
let calls = 0;
|
||||
const Page = lazyWithPreload(() =>
|
||||
++calls === 1
|
||||
? Promise.reject<{ default: ComponentType<object> }>(new Error("offline"))
|
||||
: module(),
|
||||
);
|
||||
await expect(Page.preload()).rejects.toThrow("offline");
|
||||
await act(() => Page.preload());
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createElement, lazy, type ComponentType } from "react";
|
||||
|
||||
// A route-level lazy component whose code can be fetched BEFORE its first render.
|
||||
//
|
||||
// React.lazy alone suspends on every first render, even when the chunk is already in the browser: the
|
||||
// module promise settles a tick later, so the Suspense fallback is committed and painted for a frame or
|
||||
// several. On a module switch that is a "Loading…" flash in place of the page, on every first visit.
|
||||
// Here, once `preload()` has resolved, the component renders the loaded module synchronously and never
|
||||
// suspends at all.
|
||||
//
|
||||
// Choosing per render (`loaded ?? Lazy`) cannot swap a mounted page's component type and remount it:
|
||||
// a render that suspends on `Lazy` is never committed (React discards a suspended new mount), and
|
||||
// `loaded` is set before `Lazy`'s own promise resolves — so every committed render of a mount sees the
|
||||
// loaded module. Measured, not assumed: the state test in lazyWithPreload.dom.test.tsx stayed green
|
||||
// with a per-mount pin removed, in a boundary that was already showing content.
|
||||
|
||||
export type PreloadableComponent<P extends object> = ComponentType<P> & {
|
||||
/** Fetch the component's code now. Idempotent; a failed fetch is retried by the next call. */
|
||||
preload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function lazyWithPreload<P extends object>(
|
||||
factory: () => Promise<{ default: ComponentType<P> }>,
|
||||
): PreloadableComponent<P> {
|
||||
let loaded: ComponentType<P> | null = null;
|
||||
let pending: Promise<void> | null = null;
|
||||
const preload = () =>
|
||||
(pending ??= factory().then(
|
||||
(m) => {
|
||||
loaded = m.default;
|
||||
},
|
||||
(err: unknown) => {
|
||||
pending = null;
|
||||
throw err;
|
||||
},
|
||||
));
|
||||
const Lazy = lazy(() => preload().then(() => ({ default: loaded as ComponentType<P> })));
|
||||
|
||||
function Preloadable(props: P) {
|
||||
return createElement(loaded ?? (Lazy as unknown as ComponentType<P>), props);
|
||||
}
|
||||
return Object.assign(Preloadable, { preload });
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// callbacks are live-only and dropped on reload.
|
||||
import { activeAccountId, onAccountPinned } from "./activeAccount";
|
||||
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
|
||||
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
@@ -205,6 +206,22 @@ function emit() {
|
||||
|
||||
recompute();
|
||||
|
||||
// The history and settings above are read at import, which in a new tab is before BootGate pins its
|
||||
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
|
||||
// tab's first write would replace the account's stored history with its own empty one. A notice raised
|
||||
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
|
||||
let loadedFor = activeAccountId();
|
||||
onAccountPinned(() => {
|
||||
const unpinned = loadedFor == null ? items : [];
|
||||
loadedFor = activeAccountId();
|
||||
config = loadSettings();
|
||||
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
|
||||
const history = load().map((n) => ({ ...n, id: counter++ }));
|
||||
items = [...history, ...unpinned].slice(-MAX_HISTORY);
|
||||
recompute();
|
||||
listeners.forEach((l) => l());
|
||||
});
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A panel's layout is seeded from the account's server prefs when the app mounts. The adopt effect this
|
||||
// replaced corrected it after mounting; with it gone, a seed that read only this browser's copy would
|
||||
// never show a layout changed on another device.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { seedLayout } from "./panelLayout";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
|
||||
import { accountKey, LS } from "./storage";
|
||||
|
||||
const local = { order: [], collapsed: { genre: true }, hidden: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
localStorage.setItem(accountKey(LS.plexLayout, 5)!, JSON.stringify(local));
|
||||
});
|
||||
|
||||
describe("seedLayout", () => {
|
||||
it("takes the account's server layout over this browser's copy", () => {
|
||||
const l = seedLayout("plex", {
|
||||
plexLayout: { order: [], collapsed: {}, hidden: { year: true } },
|
||||
});
|
||||
expect(l.hidden).toEqual({ year: true });
|
||||
expect(l.collapsed).toEqual({});
|
||||
});
|
||||
|
||||
it("uses this browser's copy for a layout the server has never stored", () => {
|
||||
expect(seedLayout("plex", {}).collapsed).toEqual({ genre: true });
|
||||
expect(seedLayout("plex", undefined).collapsed).toEqual({ genre: true });
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ export function defaultLayout(panel: PanelId): PanelLayout {
|
||||
return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} };
|
||||
}
|
||||
|
||||
export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
|
||||
function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
|
||||
const all = PANEL_ORDER[panel];
|
||||
const r = (raw ?? {}) as Partial<PanelLayout>;
|
||||
const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : [];
|
||||
@@ -62,10 +62,20 @@ export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLayout(panel: PanelId): PanelLayout {
|
||||
function loadLayout(panel: PanelId): PanelLayout {
|
||||
return normalizeLayout(panel, readAccount<unknown>(LS_KEY[panel], {}));
|
||||
}
|
||||
|
||||
// A panel's layout when the app mounts: the account's server copy (the `me` cache's preferences) when
|
||||
// it has one — so a layout changed on another device is right on the first paint — else this browser's.
|
||||
export function seedLayout(
|
||||
panel: PanelId,
|
||||
prefs: Record<string, unknown> | undefined,
|
||||
): PanelLayout {
|
||||
const raw = prefs?.[PREF_KEY[panel]];
|
||||
return raw ? normalizeLayout(panel, raw) : loadLayout(panel);
|
||||
}
|
||||
|
||||
export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void {
|
||||
writeAccount(LS_KEY[panel], l);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,30 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.87.0",
|
||||
date: "2026-09-15",
|
||||
summary:
|
||||
"A shared or library video keeps playing when you switch apps on an iPad, and a new tab opens as the account it belongs to without flashing another page first. Facebook reels keep their titles and an mp3 stays an mp3.",
|
||||
features: [
|
||||
"Background play on iPad (Brave): the shared-video page, the library player and the Plex player keep playing in another app and on the locked screen.",
|
||||
"The shared-video page has a Repeat toggle, on by default.",
|
||||
"Switching modules no longer flashes a loading screen: the pages load quietly in the background, and a slow answer shows its spinner only after a moment.",
|
||||
],
|
||||
fixes: [
|
||||
"A new tab honours your Startup choice and opens as its own account — no Home page flash, no other account's language or settings on the first frame.",
|
||||
"A signed-in Facebook reel keeps its real title instead of “Video” or “Facebook”, and an mp3 download stays an mp3 (it used to be wrapped into an mp4 an iPad would not play).",
|
||||
"A tab whose account was signed out, suspended or signed out everywhere acts as nobody and comes back as the account still signed in, instead of writing into another account or sticking on the sign-in page.",
|
||||
"Switching or adding an account no longer saves the video you were watching into the other account's resume position.",
|
||||
"A new tab keeps the account's notification history instead of replacing it with its own first notice.",
|
||||
"The shared-video page tells a failed load, a wrong password, too many attempts and an unreachable server apart, with Retry where it helps. Its password limit counts per viewer, so one person guessing cannot lock everyone else out.",
|
||||
"Form errors on sign-in, the message passphrase, password change and setup are read out by screen readers — again on a second identical failure — and the setup wizard names what is wrong instead of a generic error.",
|
||||
],
|
||||
chores: [
|
||||
"WebKit and iPad projects in the Playwright suite; the Vite dev proxy forwards websockets.",
|
||||
"Four deep-review rounds (12 → 8 → 8 → 8, the last one targeted), every regression test mutation-checked; half of the later rounds' findings were older defects, fixed here.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.86.0",
|
||||
date: "2026-09-14",
|
||||
|
||||
+15
-20
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { activeAccountId } from "./activeAccount";
|
||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
||||
|
||||
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
||||
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
||||
@@ -16,15 +15,13 @@ export const LS = {
|
||||
filters: "siftlode.filters",
|
||||
page: "siftlode.page",
|
||||
// E24: start every session on the Home surface rather than on the module you happened to leave
|
||||
// open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
|
||||
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
|
||||
// a landing page decided after the first paint would be a visible jump.
|
||||
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
|
||||
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
|
||||
// account is pinned — so the landing is decided right before the first paint, never corrected after.
|
||||
startOnHome: "siftlode.startOnHome",
|
||||
perfMode: "siftlode.perfMode",
|
||||
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
||||
// established tab paints your view straight away. A brand-new tab still starts on the default
|
||||
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
|
||||
// the per-account key can't be read.
|
||||
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
|
||||
// from only when the server has never stored the key.
|
||||
feedView: "siftlode.feedView",
|
||||
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
||||
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
|
||||
@@ -68,11 +65,14 @@ export const LS = {
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
seenVersion: "siftlode.seenVersion",
|
||||
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
|
||||
// that page runs outside the app, with no account at all.
|
||||
watchRepeat: "siftlode.watchRepeat",
|
||||
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
||||
} as const;
|
||||
|
||||
// --- per-account scoping -----------------------------------------------------------------
|
||||
// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
|
||||
// The active-account primitive (activeAccountId) lives in ./activeAccount
|
||||
// so it can be shared without a cycle; the helpers below key localStorage on it.
|
||||
|
||||
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
|
||||
@@ -154,24 +154,21 @@ export function writeJSON(key: string, value: unknown): void {
|
||||
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
|
||||
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
|
||||
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
|
||||
* can be used before an async-loaded option list is known, keeping hook order stable. Consumers
|
||||
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
|
||||
* one that mounts before the pin (a fresh tab's first paint) still restores. */
|
||||
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
|
||||
* mount: BootGate mounts every consumer after the account is pinned. */
|
||||
export function useAccountPersistedState(
|
||||
base: string,
|
||||
fallback = "",
|
||||
): [string, (value: string) => void] {
|
||||
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
||||
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
|
||||
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
|
||||
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
|
||||
// Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
|
||||
// ChannelsProvider's setView, which must not change identity on every tab switch.
|
||||
const set = useCallback(
|
||||
(next: string) => {
|
||||
markDirty();
|
||||
setValue(next);
|
||||
setAccountRaw(base, next);
|
||||
},
|
||||
[base, markDirty],
|
||||
[base],
|
||||
);
|
||||
return [value, set];
|
||||
}
|
||||
@@ -184,17 +181,15 @@ export function useAccountPersistedObject<T extends object>(
|
||||
defaults: T,
|
||||
): [T, (patch: Partial<T>) => void] {
|
||||
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||||
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
|
||||
const patch = useCallback(
|
||||
(p: Partial<T>) => {
|
||||
markDirty();
|
||||
setValue((prev) => {
|
||||
const next = { ...prev, ...p };
|
||||
writeAccount(base, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[base, markDirty],
|
||||
[base],
|
||||
);
|
||||
return [value, patch];
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// useAccountRehydrate re-reads per-account state when the tab's account is pinned after mount (first
|
||||
// load). useActiveAccountId is mocked with a plain variable so the transition can be driven by hand.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
let account: number | null = null;
|
||||
vi.mock("./activeAccount", () => ({
|
||||
useActiveAccountId: () => account,
|
||||
}));
|
||||
|
||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
||||
|
||||
describe("useAccountRehydrate", () => {
|
||||
beforeEach(() => {
|
||||
account = null;
|
||||
});
|
||||
|
||||
it("runs rehydrate when the account transitions from null to a value", () => {
|
||||
const rehydrate = vi.fn();
|
||||
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
|
||||
expect(rehydrate).not.toHaveBeenCalled(); // mount with no account: no transition
|
||||
account = 1;
|
||||
act(() => rerender());
|
||||
expect(rehydrate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not run when the account id is already known and stable", () => {
|
||||
account = 1;
|
||||
const rehydrate = vi.fn();
|
||||
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
|
||||
act(() => rerender());
|
||||
expect(rehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips rehydrate when marked dirty before the account pins (no clobber of a user edit)", () => {
|
||||
const rehydrate = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useAccountRehydrate(rehydrate));
|
||||
act(() => result.current()); // markDirty — the owning setter ran pre-pin
|
||||
account = 1;
|
||||
act(() => rerender());
|
||||
expect(rehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useActiveAccountId } from "./activeAccount";
|
||||
|
||||
// Re-read per-account persisted state when this tab's active account becomes known (or changes). The
|
||||
// provider tree above <App> mounts before the account is pinned, so its useState initializers seed from
|
||||
// an unknown account (empty defaults); once <App> pins it, `rehydrate` runs to load the real stored
|
||||
// value. Guarded so it never clobbers a value the user set in the brief pre-pin window: the returned
|
||||
// `markDirty` is called by the owning setter, and a dirty state skips the re-hydrate.
|
||||
//
|
||||
// Keyed on the account id ONLY — the user's own writes go through setAccountRaw, which does not change
|
||||
// the account id, so this effect can't loop on them.
|
||||
//
|
||||
// This is the localStorage-only counterpart of the `useEffect([me?.id])` server-prefs adopt pattern in
|
||||
// FeedFiltersProvider / FeedViewProvider / PrefsProvider (and App's shell prefs): those re-load from the
|
||||
// SERVER when the account query resolves; this re-loads from localStorage when the account is pinned.
|
||||
// A provider needs one or the other — not both — to survive a fresh tab's first paint.
|
||||
export function useAccountRehydrate(rehydrate: () => void): () => void {
|
||||
const accountId = useActiveAccountId();
|
||||
const dirty = useRef(false);
|
||||
const prev = useRef(accountId);
|
||||
// Latest closure in a ref so the effect depends on the account id alone (a fresh `rehydrate` each
|
||||
// render must not re-fire it), while still calling the current logic.
|
||||
const fn = useRef(rehydrate);
|
||||
fn.current = rehydrate;
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId === prev.current) return; // mount, and renders with no real transition
|
||||
prev.current = accountId;
|
||||
if (!dirty.current) fn.current();
|
||||
// Reset per account: `dirty` means "the user edited under THIS account". Today a real account
|
||||
// switch full-reloads (fresh instance), but clearing it here keeps the guard correct even if a
|
||||
// future switch stops reloading — the next account starts clean rather than inheriting dirtiness.
|
||||
dirty.current = false;
|
||||
}, [accountId]);
|
||||
|
||||
return useCallback(() => {
|
||||
dirty.current = true;
|
||||
}, []);
|
||||
}
|
||||
@@ -6,26 +6,19 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
|
||||
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
|
||||
const { store, ctl } = vi.hoisted(() => ({
|
||||
store: new Map<string, string>(),
|
||||
ctl: { account: 1 as number | null },
|
||||
}));
|
||||
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
|
||||
vi.mock("./storage", () => ({
|
||||
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
|
||||
getAccountRaw: (k: string) => store.get(k) ?? null,
|
||||
setAccountRaw: (k: string, v: string) => {
|
||||
if (ctl.account != null) store.set(k, v);
|
||||
store.set(k, v);
|
||||
},
|
||||
}));
|
||||
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
|
||||
|
||||
import { useFilterState } from "./useFilterState";
|
||||
|
||||
describe("useFilterState", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
ctl.account = 1;
|
||||
});
|
||||
|
||||
it("hydrates to an empty map when storage is empty", () => {
|
||||
@@ -77,42 +70,4 @@ describe("useFilterState", () => {
|
||||
// A seed that returns null falls back to an empty map.
|
||||
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
|
||||
});
|
||||
|
||||
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState.
|
||||
it("re-hydrates the stored map once the account is pinned after mount", () => {
|
||||
ctl.account = null; // fresh tab: no account at mount
|
||||
store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] }));
|
||||
const { result, rerender } = renderHook(() => useFilterState("k"));
|
||||
expect(result.current[0]).toEqual({}); // mount read with no account → empty
|
||||
act(() => {
|
||||
ctl.account = 1; // <App> pins the account
|
||||
rerender();
|
||||
});
|
||||
expect(result.current[0]).toEqual({ sync: "fully", tags: ["7"] });
|
||||
});
|
||||
|
||||
it("applies the migrate seed on the account-pin re-hydrate when the key is still empty", () => {
|
||||
ctl.account = null;
|
||||
// Mirrors migrateSyncChip: reads an account-scoped legacy key, so it yields null until pinned.
|
||||
const migrate = () => (ctl.account == null ? null : { sync: "needsFull" });
|
||||
const { result, rerender } = renderHook(() => useFilterState("k", migrate));
|
||||
expect(result.current[0]).toEqual({}); // migrate can't read the legacy key with no account yet
|
||||
act(() => {
|
||||
ctl.account = 1;
|
||||
rerender();
|
||||
});
|
||||
expect(result.current[0]).toEqual({ sync: "needsFull" }); // migrate carries the legacy value forward
|
||||
});
|
||||
|
||||
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
|
||||
ctl.account = null;
|
||||
store.set("k", JSON.stringify({ sync: "fully" })); // the stored value
|
||||
const { result, rerender } = renderHook(() => useFilterState("k"));
|
||||
act(() => result.current[1]({ sync: "needsFull" })); // user edits before the pin
|
||||
act(() => {
|
||||
ctl.account = 1;
|
||||
rerender();
|
||||
});
|
||||
expect(result.current[0]).toEqual({ sync: "needsFull" }); // the edit survives, not "fully"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
||||
import type { FilterMap } from "./tableColumns";
|
||||
|
||||
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
|
||||
@@ -14,22 +13,17 @@ export function useFilterState(
|
||||
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
|
||||
migrate?: () => FilterMap | null,
|
||||
): [FilterMap, (next: FilterMap) => void] {
|
||||
// Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
|
||||
// initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
|
||||
// the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
|
||||
const read = useCallback((): FilterMap => {
|
||||
// The account's stored map, or the migrate seed when its key is still empty.
|
||||
const [filters, setFilters] = useState<FilterMap>(() => {
|
||||
const raw = getAccountRaw(storageBase);
|
||||
return raw ? hydrate(raw) : (migrate?.() ?? {});
|
||||
}, [storageBase, migrate]);
|
||||
const [filters, setFilters] = useState<FilterMap>(read);
|
||||
const markDirty = useAccountRehydrate(() => setFilters(read()));
|
||||
});
|
||||
const set = useCallback(
|
||||
(next: FilterMap) => {
|
||||
markDirty();
|
||||
setFilters(next);
|
||||
setAccountRaw(storageBase, JSON.stringify(next));
|
||||
},
|
||||
[storageBase, markDirty],
|
||||
[storageBase],
|
||||
);
|
||||
return [filters, set];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type Me } from "./api";
|
||||
|
||||
// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
|
||||
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
|
||||
// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
|
||||
// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the
|
||||
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
|
||||
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
|
||||
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
|
||||
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
|
||||
// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
|
||||
// debounced writes, parse, and the raw useSavePref persister. The `me` cache is the
|
||||
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
|
||||
@@ -125,19 +125,6 @@ describe("usePref", () => {
|
||||
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
||||
});
|
||||
|
||||
it("adopts the server value when the account id changes (login / switch)", async () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "cards" }, 1);
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("cards");
|
||||
// A different account resolves. The passive me observer notifies asynchronously (react-query's
|
||||
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
|
||||
act(() => seedMe(qc, { view: "tiles" }, 2));
|
||||
await waitFor(() => expect(result.current[0]).toBe("tiles"));
|
||||
});
|
||||
|
||||
describe("debounceMs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
+17
-39
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import type { Me } from "./api/account";
|
||||
@@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) =>
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
||||
|
||||
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
|
||||
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
|
||||
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
|
||||
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
|
||||
// all three into one hook so a new server-synced pref is a one-liner.
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
|
||||
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
|
||||
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
|
||||
// server-synced pref is a one-liner.
|
||||
//
|
||||
// Two mirror strategies, unified here (pick via `localKey`):
|
||||
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
|
||||
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
|
||||
// reload — pass `localKey`.
|
||||
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
|
||||
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
|
||||
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
|
||||
// app tree only once /api/me has answered, and a tab whose account changes reloads, so there is nothing
|
||||
// to adopt after mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||
|
||||
export interface UsePrefOptions<T> {
|
||||
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
|
||||
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
|
||||
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
|
||||
* server payload lacks the key. */
|
||||
localKey?: string;
|
||||
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
||||
* local state + cache still update instantly; only the network write is deferred. */
|
||||
@@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||
);
|
||||
}
|
||||
|
||||
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
|
||||
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
|
||||
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
|
||||
* Returns `[value, setValue]`; the setter is stable. */
|
||||
export function usePref<T>(
|
||||
serverKey: string,
|
||||
defaultValue: T,
|
||||
@@ -79,11 +74,9 @@ export function usePref<T>(
|
||||
): [T, (next: T) => void] {
|
||||
const { localKey, debounceMs, parse } = options;
|
||||
const qc = useQueryClient();
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
|
||||
// Latest parse in a ref so the seed and the setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter). parse must be idempotent anyway.
|
||||
const parseRef = useRef(parse);
|
||||
parseRef.current = parse;
|
||||
const norm = useCallback((raw: unknown): T => {
|
||||
@@ -105,7 +98,7 @@ export function usePref<T>(
|
||||
const saveTimer = useRef<number | undefined>(undefined);
|
||||
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
||||
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
|
||||
// value, and a later adopt could then overwrite the newer local value with the stale server one.
|
||||
// value, and the next load — which seeds from the server first — would bring the old value back.
|
||||
const pendingSave = useRef<(() => void) | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -120,8 +113,8 @@ export function usePref<T>(
|
||||
(next: T) => {
|
||||
setValue(next);
|
||||
if (localKey) writeAccount(localKey, next);
|
||||
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
|
||||
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||
// Keep the `me` cache in step so other readers of this key (and a later mount of this hook,
|
||||
// which seeds from the cache) see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||
const payload = { [serverKey]: next };
|
||||
mergePrefsCache(qc, payload);
|
||||
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a
|
||||
@@ -141,20 +134,5 @@ export function usePref<T>(
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
);
|
||||
|
||||
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
|
||||
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
|
||||
// keep the current (seeded / user-set) value rather than clobbering it with the default.
|
||||
const prevId = useRef<number | null | undefined>(me?.id);
|
||||
useEffect(() => {
|
||||
if (me?.id === prevId.current) return;
|
||||
prevId.current = me?.id;
|
||||
const server = readServerPref(me, serverKey);
|
||||
if (server === undefined) return;
|
||||
const val = norm(server);
|
||||
setValue(val);
|
||||
if (localKey) writeAccount(localKey, val);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
@@ -6,26 +6,19 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
|
||||
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
|
||||
const { store, ctl } = vi.hoisted(() => ({
|
||||
store: new Map<string, string>(),
|
||||
ctl: { account: 1 as number | null },
|
||||
}));
|
||||
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
|
||||
vi.mock("./storage", () => ({
|
||||
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
|
||||
getAccountRaw: (k: string) => store.get(k) ?? null,
|
||||
setAccountRaw: (k: string, v: string) => {
|
||||
if (ctl.account != null) store.set(k, v);
|
||||
store.set(k, v);
|
||||
},
|
||||
}));
|
||||
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
|
||||
|
||||
import { useSortState } from "./useSortState";
|
||||
|
||||
describe("useSortState", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
ctl.account = 1;
|
||||
});
|
||||
|
||||
it("hydrates to the fallback when storage is empty", () => {
|
||||
@@ -62,29 +55,4 @@ describe("useSortState", () => {
|
||||
const { result } = renderHook(() => useSortState("k"));
|
||||
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
|
||||
});
|
||||
|
||||
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState.
|
||||
it("re-hydrates the stored sort once the account is pinned after mount", () => {
|
||||
ctl.account = null; // fresh tab: no account at mount
|
||||
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
|
||||
const { result, rerender } = renderHook(() => useSortState("k"));
|
||||
expect(result.current[0]).toBeNull(); // mount read with no account → default
|
||||
act(() => {
|
||||
ctl.account = 1; // <App> pins the account
|
||||
rerender();
|
||||
});
|
||||
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
|
||||
});
|
||||
|
||||
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
|
||||
ctl.account = null;
|
||||
store.set("k", JSON.stringify({ key: "views", dir: "desc" })); // the stored value
|
||||
const { result, rerender } = renderHook(() => useSortState("k"));
|
||||
act(() => result.current[1]({ key: "name", dir: "asc" })); // user edits before the pin
|
||||
act(() => {
|
||||
ctl.account = 1;
|
||||
rerender();
|
||||
});
|
||||
expect(result.current[0]).toEqual({ key: "name", dir: "asc" }); // the edit survives, not "views"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
||||
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
|
||||
|
||||
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
|
||||
@@ -14,17 +13,12 @@ export function useSortState(
|
||||
const [sort, setSort] = useState<SortState>(() =>
|
||||
hydrateSort(getAccountRaw(storageBase), options, fallback),
|
||||
);
|
||||
// When the account is pinned after mount (first load), re-read the stored sort for that account.
|
||||
const markDirty = useAccountRehydrate(() =>
|
||||
setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)),
|
||||
);
|
||||
const set = useCallback(
|
||||
(next: SortState) => {
|
||||
markDirty();
|
||||
setSort(next);
|
||||
setAccountRaw(storageBase, JSON.stringify(next));
|
||||
},
|
||||
[storageBase, markDirty],
|
||||
[storageBase],
|
||||
);
|
||||
return [sort, set];
|
||||
}
|
||||
|
||||
+38
-23
@@ -5,6 +5,7 @@ import { createQueryClient } from "./lib/queryClient";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import BootGate, { BootScreen } from "./components/BootGate";
|
||||
import { NavigationProvider } from "./components/NavigationProvider";
|
||||
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||
import { SavedViewsProvider } from "./components/SavedViewsProvider";
|
||||
@@ -18,13 +19,14 @@ import { WizardProvider } from "./components/WizardProvider";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
|
||||
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
|
||||
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers
|
||||
// can fetch them directly; the watch page is a login-free public player. Reached by full-page
|
||||
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
|
||||
const App = lazy(() => import("./App"));
|
||||
const App = lazyWithPreload(() => import("./App"));
|
||||
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
|
||||
const Terms = lazy(() => import("./components/legal/Terms"));
|
||||
const WatchPage = lazy(() => import("./components/WatchPage"));
|
||||
@@ -39,6 +41,11 @@ de.theme ||= "dark";
|
||||
de.scheme ||= "midnight";
|
||||
|
||||
const path = window.location.pathname;
|
||||
const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
|
||||
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
|
||||
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
|
||||
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
|
||||
if (!isPublicPage) App.preload().catch(() => {});
|
||||
const root =
|
||||
path === "/privacy" ? (
|
||||
<PrivacyPolicy />
|
||||
@@ -56,29 +63,37 @@ const root =
|
||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||
<FloatingTree>
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<PlexViewBridge>
|
||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||
{/* Nothing below renders until the tab knows which account it is: every provider
|
||||
seeds per-account state in its initializer (see BootGate). */}
|
||||
<BootGate>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<PlexViewBridge>
|
||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||
<SavedViewsProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</SavedViewsProvider>
|
||||
</PlexViewBridge>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
<SavedViewsProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||
loading when the gate opens continues it instead of blanking. */}
|
||||
<Suspense fallback={<BootScreen />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</SavedViewsProvider>
|
||||
</PlexViewBridge>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</BootGate>
|
||||
</ConfirmProvider>
|
||||
</FloatingTree>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -11,7 +11,10 @@ const target = "http://127.0.0.1:8080";
|
||||
// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.)
|
||||
const proxyOpts = { target, changeOrigin: false };
|
||||
const proxy = {
|
||||
"/api": proxyOpts,
|
||||
// ws: the in-app messages socket (/api/messages/ws) is a WebSocket. Without it Vite hands that upgrade
|
||||
// to nobody, so the socket never connects on :5173 — and the first reset on it (a closed tab, a dropped
|
||||
// connection) throws an unhandled ECONNRESET that takes the whole dev server down.
|
||||
"/api": { ...proxyOpts, ws: true },
|
||||
"/auth": proxyOpts,
|
||||
"/healthz": proxyOpts,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user