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

@ -52,19 +52,25 @@ function UsersRoles({ me }: { me: Me }) {
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
// Which row currently has an action in flight, so we disable ONLY that row's button (mutations
// are single-flight, so one id suffices) rather than greying every row (SC1).
const [pendingId, setPendingId] = useState<number | null>(null);
const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
},
onSettled: () => setPendingId(null),
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
});
const suspend = useMutation({
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
api.setUserSuspended(id, suspended),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({
@ -74,14 +80,17 @@ function UsersRoles({ me }: { me: Me }) {
}),
});
},
onSettled: () => setPendingId(null),
// Guards (demo / self / last admin) surface via the global error dialog.
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
onSettled: () => setPendingId(null),
});
const onToggle = async (u: AdminUserRow) => {
@ -162,7 +171,7 @@ function UsersRoles({ me }: { me: Me }) {
>
<button
onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending}
disabled={u.is_demo || self || pendingId === u.id}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
>
<Shield className="w-3.5 h-3.5" />
@ -182,7 +191,7 @@ function UsersRoles({ me }: { me: Me }) {
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
@ -196,7 +205,7 @@ function UsersRoles({ me }: { me: Me }) {
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
@ -217,6 +226,7 @@ function UsersRoles({ me }: { me: Me }) {
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
@ -233,9 +243,21 @@ function AdminInvites() {
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onSuccess: (_d, _id) => {
refresh();
notify({ level: "success", message: t("settings.invites.denied") });
},
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.invites.denyTitle"),
message: t("settings.invites.denyConfirm", { email: i.email }),
confirmLabel: t("settings.invites.deny"),
danger: true,
});
if (ok) deny.mutate(i.id);
};
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => {
@ -261,7 +283,7 @@ function AdminInvites() {
key={i.id}
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending}
/>
))}
@ -329,9 +351,21 @@ function AdminDemo() {
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
});
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
@ -362,7 +396,7 @@ function AdminDemo() {
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
onClick={() => remove.mutate(r.id)}
onClick={() => onRemove(r)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}

View file

@ -48,6 +48,7 @@ export default function Channels({
onFilterByTag,
onFocusChannel,
focusChannelName,
focusChannelToken,
statusFilter,
setStatusFilter,
view,
@ -61,6 +62,9 @@ export default function Channels({
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table
@ -92,6 +96,10 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
@ -189,7 +197,8 @@ export default function Channels({
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
}, [focusChannelName]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
// The header's reset intent clears the search + tag chips (skip the initial mount).
const resetRef = useRef(filtersResetToken);
useEffect(() => {

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
import { onMessage } from "../lib/messagesSocket";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import ChatThread from "./ChatThread";
@ -39,6 +39,16 @@ export default function ChatDock({ meId }: { meId: number }) {
[qc, meId]
);
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
useEffect(
() =>
onUnread(() => {
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
}),
[qc]
);
if (chats.length === 0) return null;
return (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">

View file

@ -6,12 +6,10 @@ import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, useKeyState } from "../lib/messaging";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
@ -66,13 +64,20 @@ export default function ChatThread({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
const lastIncoming = useRef(-1);
useEffect(() => {
if (q.dataUpdatedAt) {
if (!q.dataUpdatedAt) return;
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
}
}, [q.dataUpdatedAt, qc]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader

View file

@ -38,15 +38,23 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
// refetch does NOT (see the effect below).
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
useEffect(() => {
setDraft(baseline);
setSecretReset({});
}, [baseline]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
@ -67,12 +75,16 @@ export default function ConfigPanel() {
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await qc.invalidateQueries({ queryKey: ["admin-config"] });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch {

View file

@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
@ -13,8 +13,6 @@ import Avatar from "./Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
const POLL_MS = 20000;
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {

View file

@ -107,6 +107,10 @@ export default function PlayerModal({
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
const errorStreakRef = useRef(0);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
@ -502,6 +506,7 @@ export default function PlayerModal({
onStateChange: (e: any) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author });
@ -517,17 +522,47 @@ export default function PlayerModal({
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
onError: (e: any) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
// NOT mark the broken video watched (only the ended-handler does that).
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
},
});
});
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
// description-link video may not be in this user's library (the server would 404).
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] });

View file

@ -318,11 +318,33 @@ export default function Scheduler() {
});
const data = q.data;
// Tick once a second so the per-job countdowns move between polls.
// Tick once a second so the per-job countdowns move between polls — but only while the tab is
// visible; a hidden tab doesn't need to re-render the whole page every second (SB3).
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
let id: ReturnType<typeof setInterval> | undefined;
const start = () => {
if (id == null) id = setInterval(() => setTick((n) => n + 1), 1000);
};
const stop = () => {
if (id != null) {
clearInterval(id);
id = undefined;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else {
setTick((n) => n + 1); // snap the countdown to the correct value on return
start();
}
};
onVisibility(); // honor the current visibility on mount
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const pauseResume = useMutation({