fix(deferred): close frontend UI/UX tails from the hygiene sweep

- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
This commit is contained in:
npeter83 2026-07-12 05:59:25 +02:00
parent 4e80e2b39b
commit f0198f2e0a
19 changed files with 302 additions and 45 deletions

View file

@ -209,13 +209,35 @@ export interface VersionInfo {
class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
constructor(status: number, detail?: string) {
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: any) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
this.detailData = detailData;
}
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: any): string {
const t = i18n.t.bind(i18n);
if (d?.code === "quota") {
if (d.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(d.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
return t("downloads.errors.quota.generic");
}
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
@ -337,9 +359,14 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
let detailData: any;
try {
const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} catch {
/* no JSON body */
}
@ -361,7 +388,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail);
}
throw new HttpError(r.status, detail);
throw new HttpError(r.status, detail, detailData);
}
return r.status === 204 ? null : r.json();
}
@ -601,6 +628,7 @@ export interface ConfigItem {
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
@ -1074,6 +1102,30 @@ export const api = {
},
{ idempotent: true }
),
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate) — mirrors
// plexProgressBeacon. A normal fetch is cancelled on unload and React effect cleanup doesn't run
// on a full reload, so the last position (esp. right after a seek) would be lost; keepalive lets
// it complete.
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void => {
const active = getActiveAccount();
try {
void fetch(`/api/videos/${encodeURIComponent(id)}/progress`, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
}).catch(() => {});
} catch {
/* ignore */
}
},
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }),

View file

@ -112,6 +112,15 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
return false;
}
// Install a freshly-imported private key as the current identity: drop any derived conversation
// keys, persist it to this device, and notify unlock subscribers. Shared by setup() and unlock().
async function installKey(userId: number, key: CryptoKey): Promise<void> {
privKey = key;
convKeys.clear();
await idbPut(userId, key);
emitUnlock();
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
@ -128,10 +137,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
const spki = await subtle.exportKey("spki", kp.publicKey);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
await installKey(userId, await importPrivate(pkcs8));
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
@ -154,10 +160,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
wrapKey,
bsrc(ub64(bundle.wrapped_private_key))
);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
await installKey(userId, await importPrivate(pkcs8));
}
// --- per-conversation encryption ---

View file

@ -11,8 +11,10 @@ interface IncomingMessage {
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
@ -21,7 +23,9 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side).
// query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`;
@ -43,6 +47,9 @@ function open() {
if (data?.type === "message" && data.message) {
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
}
} catch {
/* ignore malformed frames */
@ -87,3 +94,13 @@ export function onMessage(h: Handler): () => void {
}
};
}
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}

View file

@ -6,6 +6,9 @@ import { isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
export const POLL_MS = 20000;
// Set-once cache of partner public keys (the server is the directory; keys never change).
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {