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:
parent
4e80e2b39b
commit
f0198f2e0a
19 changed files with 302 additions and 45 deletions
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue