From 3470dc6da8d3a3da8c115f642947ef6f28f14f04 Mon Sep 17 00:00:00 2001
From: smb
Date: Sat, 10 Oct 2026 00:58:41 +0200
Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=9A=80=20perf(dashboard):=20one=20r?=
=?UTF-8?q?ealtime=20socket,=20invalidation=20not=20polling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Realtime data layer
- Keep one shell-owned WebSocket per channel across same-channel navigation.
- Centralize realtime query invalidation and reconcile channel data after reconnect.
- Polling and worker relays
- Disable connected-state polling for chat voting, votekick, ads, and BELABOX while preserving offline fallback.
- Relay chat_voting, belabox, and votekick live-state hints to panel sockets.
- Refresh chat voting when a shared votekick ballot opens or closes.
- #387 regression coverage
- Cover socket reuse, query invalidation, fallback polling, worker relays, shared-ballot invalidation, frozen event snapshots, and overlay selection safety.
- Preserve module query ordering and stable layouts.
---
src/dashboard/ChannelVariablesPage.tsx | 60 +-
src/dashboard/OverlayEditorPage.tsx | 88 ++-
src/dashboard/OverlaysPage.tsx | 50 +-
src/dashboard/api.ts | 12 +-
src/dashboard/data/client.ts | 10 +-
src/dashboard/data/index.ts | 2 +-
src/dashboard/data/keys.ts | 20 +-
src/dashboard/data/lists.ts | 27 +-
src/dashboard/data/module-query.ts | 16 +-
src/dashboard/data/realtime.ts | 96 +++
src/dashboard/events/EventsPage.tsx | 2 -
src/dashboard/main.tsx | 233 ++++----
src/dashboard/realtime.ts | 559 +++++-------------
src/dashboard/stream-manager.tsx | 62 +-
src/modules/ads/panel/index.tsx | 67 +--
src/modules/ads/panel/service.ts | 4 +-
.../belabox/panel/immediate-actions.tsx | 60 +-
src/modules/belabox/panel/index.tsx | 119 ++--
src/modules/belabox/panel/service.ts | 11 +-
src/modules/chat_voting/panel/index.tsx | 61 +-
src/modules/chat_voting/panel/service.ts | 4 +-
src/modules/votekick/panel/index.tsx | 101 ++--
src/modules/votekick/panel/locale.ts | 3 +
src/modules/votekick/panel/service.ts | 4 +-
src/modules/votekick/routes.ts | 12 +
src/realtime-contract.ts | 28 +-
src/worker/durable/ChannelObject.ts | 5 +-
src/worker/module-overlay-realtime.ts | 6 +-
tests/components/ads-panel.test.tsx | 64 +-
.../belabox-alert-surfaces.test.tsx | 71 +--
tests/components/belabox-panel.test.tsx | 15 +-
.../channel-variables-page.test.tsx | 73 +--
tests/components/chat-voting-panel.test.tsx | 3 +-
tests/components/dashboard.test.tsx | 134 +++--
tests/components/events-page.test.tsx | 85 ++-
tests/components/overlays-page.test.tsx | 3 +-
tests/components/stream-manager.test.tsx | 44 +-
tests/components/votekick-panel.test.tsx | 43 +-
tests/e2e/host-stable-layout.spec.ts | 31 +-
tests/e2e/module-panels-stability.spec.ts | 18 +
tests/e2e/stable-layout.spec.ts | 39 ++
tests/unit/dashboard-query-data.test.tsx | 122 +++-
tests/unit/dashboard-realtime.test.ts | 176 ++++++
tests/unit/dispatch.test.ts | 1 +
tests/unit/module-overlay-realtime.test.ts | 24 +
tests/unit/stream-state-lookup.test.ts | 11 +
tests/unit/votekick-routes.test.ts | 116 +++-
tests/worker/channel-object.test.ts | 34 ++
48 files changed, 1619 insertions(+), 1210 deletions(-)
create mode 100644 src/dashboard/data/realtime.ts
create mode 100644 tests/unit/dashboard-realtime.test.ts
diff --git a/src/dashboard/ChannelVariablesPage.tsx b/src/dashboard/ChannelVariablesPage.tsx
index 58e095fa..a75332c8 100644
--- a/src/dashboard/ChannelVariablesPage.tsx
+++ b/src/dashboard/ChannelVariablesPage.tsx
@@ -7,7 +7,6 @@ import {
fetchOverlay,
fetchOverlays,
fetchOverlayTokens,
- fetchChannelVariables,
PanelApiError,
saveOverlay,
updateChannelVariable,
@@ -15,9 +14,11 @@ import {
type PanelOverlayElement,
} from "./api";
import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLanguage } from "./locale";
-import { useRealtimeVariableUpdates } from "./realtime";
import { Button, ConfirmDialog, EmptyCellValue, Field, Icon, InspectorActions, InspectorFieldRow, InspectorSection, ListDetail, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector, Switch } from "./ui";
import { CHANNEL_VARIABLE_MAXIMUM_COUNT, CHANNEL_VARIABLE_MAXIMUM_VALUE, CHANNEL_VARIABLE_MINIMUM_VALUE } from "../contracts/values";
+import { dashboardDataKeys } from "./data/keys";
+import { useDashboardQueryClient } from "./data";
+import { useChannelVariablesQuery, useOverlaysQuery } from "./data/lists";
interface ChannelVariablesPageProperties {
channelId: string;
@@ -32,6 +33,7 @@ interface ChannelVariablesPageProperties {
const normalizedVariableName = (value: string): string => value.trim().toLowerCase();
const variableNamePattern = /^[a-z][a-z0-9_]{0,31}$/u;
+const EMPTY_VARIABLES: readonly PanelChannelVariable[] = [];
const saveableOverlayElement = (element: PanelOverlayElement): Omit => ({
id: element.id,
kind: element.kind,
@@ -49,11 +51,13 @@ const saveableOverlayElement = (element: PanelOverlayElement): Omit([]);
+ const variablesQuery = useChannelVariablesQuery(channelId);
+ const queryClient = useDashboardQueryClient();
+ const variables = variablesQuery.data?.variables ?? EMPTY_VARIABLES;
+ const maximum = variablesQuery.data?.maximum ?? CHANNEL_VARIABLE_MAXIMUM_COUNT;
+ const loading = variablesQuery.isPending;
+ const loadFailed = variablesQuery.isError;
const [search, setSearch] = useState("");
- const [maximum, setMaximum] = useState(CHANNEL_VARIABLE_MAXIMUM_COUNT);
- const [loading, setLoading] = useState(true);
- const [loadFailed, setLoadFailed] = useState(false);
const [selectedName, setSelectedName] = useState(null);
const [creating, setCreating] = useState(false);
const [draftName, setDraftName] = useState("");
@@ -67,38 +71,26 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
const [pending, setPending] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [useOverlayOpen, setUseOverlayOpen] = useState(false);
+ const overlaysQuery = useOverlaysQuery(channelId, useOverlayOpen);
+ const overlayOptions = (overlaysQuery.data?.overlays ?? []).map(({ id, name, elementCount }) => ({ id, name, elementCount }));
+ const refetchVariables = variablesQuery.refetch;
const [legacyLinkStatus, setLegacyLinkStatus] = useState<{ channelId: string; hasLinks: boolean } | null>(null);
- const [overlayOptions, setOverlayOptions] = useState([]);
const [overlaySelection, setOverlaySelection] = useState("");
const [creatingOverlay, setCreatingOverlay] = useState(false);
const [newOverlayName, setNewOverlayName] = useState("");
const [overlayPending, setOverlayPending] = useState(false);
- const refreshRequest = useRef(0);
- const invalidateRefresh = useCallback((): void => { refreshRequest.current++; }, []);
const lastInitialSelection = useRef(undefined);
const refresh = useCallback(async (): Promise => {
- const requestId = ++refreshRequest.current;
- try {
- const result = await fetchChannelVariables(channelId);
- if (requestId !== refreshRequest.current) return;
- setVariables(result.variables);
- setMaximum(result.maximum);
- setLoadFailed(false);
- } catch (caught) {
- if (requestId !== refreshRequest.current) return;
- const loadErrorText = channelVariablesTexts(dashboardLanguage()).loadError;
- setLoadFailed(true);
- notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, loadErrorText) : loadErrorText });
- } finally {
- if (requestId === refreshRequest.current) setLoading(false);
- }
- }, [channelId]);
+ const result = await refetchVariables({ throwOnError: true });
+ if (result.isError) throw result.error;
+ }, [refetchVariables]);
useEffect(() => {
- void Promise.resolve().then(refresh);
- return () => { invalidateRefresh(); };
- }, [invalidateRefresh, refresh]);
+ if (!variablesQuery.isError) return;
+ const loadErrorText = channelVariablesTexts(dashboardLanguage()).loadError;
+ notify({ tone: "error", message: variablesQuery.error instanceof PanelApiError ? apiErrorText(variablesQuery.error.code, loadErrorText) : loadErrorText });
+ }, [variablesQuery.error, variablesQuery.isError]);
useEffect(() => {
let active = true;
@@ -110,8 +102,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
return () => { active = false; };
}, [channelId]);
- useRealtimeVariableUpdates({ channelId, refresh });
-
const selected = useMemo(() => variables.find((variable) => variable.name === selectedName) ?? null, [selectedName, variables]);
useLayoutEffect(() => {
if (editingValue) document.getElementById("channel-variable-inline-value")?.focus();
@@ -137,7 +127,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
setDraftReset(false);
setDraftSetValue(0);
setUseOverlayOpen(false);
- setOverlayOptions([]);
setOverlaySelection("");
setCreatingOverlay(false);
setNewOverlayName("");
@@ -151,7 +140,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
setDraftReset(variable.resetOnStreamStart);
setDraftSetValue(variable.value);
setUseOverlayOpen(false);
- setOverlayOptions([]);
setOverlaySelection("");
setCreatingOverlay(false);
setNewOverlayName("");
@@ -216,7 +204,7 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
setPending(true);
try {
const response = await changeChannelVariableValue(channelId, selected.name, operation, amount);
- setVariables((current) => current.map((variable) => variable.name === selected.name ? { ...variable, ...response.variable } : variable));
+ await refresh();
setDraftSetValue(response.variable.value);
return true;
} catch (caught) {
@@ -238,8 +226,10 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o
if (selected === null || !canManageContent || overlayPending) return;
setOverlayPending(true);
try {
- const result = await fetchOverlays(channelId);
- setOverlayOptions(result.overlays.map(({ id, name, elementCount }) => ({ id, name, elementCount })));
+ const result = await queryClient.query({
+ queryKey: dashboardDataKeys.overlays(channelId),
+ queryFn: ({ signal }) => fetchOverlays(channelId, signal),
+ });
setOverlaySelection(result.overlays[0]?.id ?? "");
setCreatingOverlay(result.overlays.length === 0);
setNewOverlayName(labels.defaultOverlayName(selected.name));
diff --git a/src/dashboard/OverlayEditorPage.tsx b/src/dashboard/OverlayEditorPage.tsx
index 35440b19..7c7e0dea 100644
--- a/src/dashboard/OverlayEditorPage.tsx
+++ b/src/dashboard/OverlayEditorPage.tsx
@@ -23,9 +23,7 @@ import {
import { DisabledFieldReasonContext } from "./ui/DisabledFieldReason";
import {
createOverlay,
- fetchChannelVariables,
fetchModules,
- fetchOverlay,
PanelApiError,
saveOverlay,
type PanelChannelVariable,
@@ -36,7 +34,7 @@ import {
import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLanguage, dashboardTexts, formatNumber, overlaysTexts } from "./locale";
import { moduleName } from "./module-labels";
import { OverlayElementPalette, type OverlayElementPaletteOption } from "./OverlayElementPalette";
-import { useRealtimeVariableUpdates } from "./realtime";
+import { useChannelVariablesQuery, useOverlayQuery } from "./data/lists";
import { Button, CodeField, ColorField, ConfirmDialog, Field, Icon, LoadState, NumberField, PageHeader, SaveBar, Select, Switch, notify, type IconName, registerDashboardNavigationGuard, useDraftGuard } from "./ui";
import "./overlay-editor.css";
@@ -314,33 +312,63 @@ const conflictRevision = (error: PanelApiError): number | null => {
export function OverlayEditorPage({ channelId, overlayId, canManage, language, initialVariable, initialOverlayName, onBack, onOverlayCreated }: OverlayEditorPageProperties): ReactElement {
const dashboardLocale = dashboardLanguage();
const labels = useMemo(() => overlaysTexts(dashboardLocale), [dashboardLocale]);
- const [variables, setVariables] = useState([]);
+ const variablesQuery = useChannelVariablesQuery(channelId);
+ const overlayQuery = useOverlayQuery(channelId, overlayId, overlayId !== "new");
+ const refetchVariables = variablesQuery.refetch;
+ const refetchOverlay = overlayQuery.refetch;
+ const variables = variablesQuery.data?.variables ?? [];
const [moduleStates, setModuleStates] = useState([]);
const [session, setSession] = useState(null);
const [loadError, setLoadError] = useState(null);
const [loadGeneration, setLoadGeneration] = useState(0);
+ const variablesLoadError = variablesQuery.isError && variablesQuery.data === undefined
+ ? variablesQuery.error instanceof PanelApiError
+ ? apiErrorText(variablesQuery.error.code, labels.editorLoadError)
+ : labels.editorLoadError
+ : null;
+ const overlayLoadError = overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined
+ ? overlayQuery.error instanceof PanelApiError
+ ? apiErrorText(overlayQuery.error.code, labels.editorLoadError)
+ : labels.editorLoadError
+ : null;
+ const visibleLoadError = loadError ?? variablesLoadError ?? overlayLoadError;
const initialVariableRef = useRef(canManage ? initialVariable : undefined);
-
- const refreshVariables = useCallback(async (): Promise => {
- const result = await fetchChannelVariables(channelId);
- setVariables(result.variables);
- }, [channelId]);
-
- useRealtimeVariableUpdates({ channelId, refresh: refreshVariables });
+ const initializedSessionKey = useRef(null);
useEffect(() => {
+ const sessionKey = `${channelId}:${overlayId}:${String(loadGeneration)}`;
+ if (initializedSessionKey.current === sessionKey) return;
+ if (variablesQuery.isError && variablesQuery.data === undefined) {
+ const message = variablesQuery.error instanceof PanelApiError
+ ? apiErrorText(variablesQuery.error.code, labels.editorLoadError)
+ : labels.editorLoadError;
+ initializedSessionKey.current = sessionKey;
+ notify({ tone: "error", message });
+ return;
+ }
+ if (variablesQuery.data === undefined) return;
+ const variableData = variablesQuery.data;
+ if (overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined) {
+ const message = overlayQuery.error instanceof PanelApiError
+ ? apiErrorText(overlayQuery.error.code, labels.editorLoadError)
+ : labels.editorLoadError;
+ initializedSessionKey.current = sessionKey;
+ notify({ tone: "error", message });
+ return;
+ }
+ if (overlayId !== "new" && overlayQuery.data === undefined) return;
let disposed = false;
- const overlayRequest = overlayId === "new"
- ? Promise.resolve({ overlay: {
+ const overlay = overlayId === "new"
+ ? {
id: "new", channelId, name: initialOverlayName ?? "", width: 1920, height: 1080, css: "", revision: 1,
createdAt: "", updatedAt: "", elements: [],
- } satisfies PanelOverlay })
- : fetchOverlay(channelId, overlayId);
+ } satisfies PanelOverlay
+ : overlayQuery.data?.overlay;
+ if (overlay === undefined) return;
const moduleRequest = fetchModules(channelId).catch(() => ({ modules: [] }));
- void Promise.all([overlayRequest, fetchChannelVariables(channelId), moduleRequest])
- .then(([overlayResult, variableResult, moduleResult]) => {
+ void moduleRequest
+ .then((moduleResult) => {
if (disposed) return;
- setVariables(variableResult.variables);
setModuleStates(moduleResult.modules);
const requestedVariable = initialVariableRef.current;
initialVariableRef.current = undefined;
@@ -349,7 +377,8 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i
currentUrl.searchParams.delete("variable");
window.history.replaceState(window.history.state, "", `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`);
}
- setSession(initialSession(overlayResult.overlay, variableResult.variables, requestedVariable));
+ setSession(initialSession(overlay, variableData.variables, requestedVariable));
+ initializedSessionKey.current = sessionKey;
})
.catch((caught: unknown) => {
if (disposed) return;
@@ -360,17 +389,28 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i
notify({ tone: "error", message });
});
return () => { disposed = true; };
- }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId]);
+ }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId, overlayQuery.data, overlayQuery.error, overlayQuery.isError, variablesQuery.data, variablesQuery.error, variablesQuery.isError]);
const reload = useCallback((): void => {
setSession(null);
setLoadError(null);
- setLoadGeneration((current) => current + 1);
- }, []);
+ void Promise.all([
+ refetchVariables({ throwOnError: true }),
+ overlayId === "new" ? Promise.resolve() : refetchOverlay({ throwOnError: true }),
+ ]).then(() => {
+ setLoadGeneration((current) => current + 1);
+ }).catch((caught: unknown) => {
+ const message = caught instanceof PanelApiError
+ ? apiErrorText(caught.code, labels.editorLoadError)
+ : labels.editorLoadError;
+ setLoadError(message);
+ notify({ tone: "error", message });
+ });
+ }, [labels.editorLoadError, overlayId, refetchOverlay, refetchVariables]);
- if (loadError !== null || session === null) return
+ if (visibleLoadError !== null || session === null) return
- {labels.editorLoading}
}
empty={}
error={}>
diff --git a/src/dashboard/OverlaysPage.tsx b/src/dashboard/OverlaysPage.tsx
index c03118c1..1b55d339 100644
--- a/src/dashboard/OverlaysPage.tsx
+++ b/src/dashboard/OverlaysPage.tsx
@@ -7,9 +7,7 @@ import streamElementsJs from "../../docs/embedding/streamelements/widget.js?raw"
import {
createOverlay,
deleteOverlay,
- fetchOverlay,
fetchOverlayAccesses,
- fetchOverlays,
fetchOverlayTokens,
importLegacyOverlay,
issueOverlayAccess,
@@ -27,6 +25,7 @@ import {
} from "./api";
import { apiErrorText, dashboardCommonTexts, dashboardLanguage, formatTimestamp, overlaysTexts } from "./locale";
import { ActionMenu, Button, ConfirmDialog, EmptyCellValue, Field, FormDialog, InspectorActions, InspectorSection, Led, ListDetail, ListPaginationFooter, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector } from "./ui";
+import { useOverlayQuery, useOverlaysQuery } from "./data/lists";
interface OverlaysPageProperties {
channelId: string;
@@ -36,6 +35,7 @@ interface OverlaysPageProperties {
}
const blankOverlayName = "";
+const EMPTY_OVERLAYS: readonly PanelOverlaySummary[] = [];
const maskOverlaySecret = (overlayUrl: string): string => overlayUrl.replace(/([#&]token=)[^&]*/u, "$1••••••");
const copyPromiseToClipboard = async (text: Promise): Promise => {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
@@ -232,18 +232,19 @@ function OverlaySetupAssistant({
export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEditor }: OverlaysPageProperties): ReactElement {
const language = dashboardLanguage();
const labels = overlaysTexts(language);
- const [overlays, setOverlays] = useState([]);
+ const overlaysQuery = useOverlaysQuery(channelId);
+ const overlays = overlaysQuery.data?.overlays ?? EMPTY_OVERLAYS;
+ const maximum = overlaysQuery.data?.maximum ?? 20;
+ const loading = overlaysQuery.isPending;
+ const loadFailed = overlaysQuery.isError;
const [search, setSearch] = useState("");
- const [maximum, setMaximum] = useState(20);
- const [loading, setLoading] = useState(true);
- const [loadFailed, setLoadFailed] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const [selectedId, setSelectedId] = useState(initialSelection ?? null);
const selectedIdRef = useRef(initialSelection ?? null);
+ const selectedOverlayQuery = useOverlayQuery(channelId, selectedId ?? "", selectedId !== null);
const [selectedOverlayData, setSelectedOverlayData] = useState<{
id: string;
- overlay: PanelOverlay;
accesses: readonly PanelOverlayAccess[];
} | null>(null);
const [creating, setCreating] = useState(false);
@@ -273,7 +274,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi
const [legacyImportError, setLegacyImportError] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [now, setNow] = useState(() => Date.now());
- const requestVersion = useRef(0);
const secretVersion = useRef(0);
const legacyRequestVersion = useRef(0);
const pageActiveRef = useRef(true);
@@ -321,31 +321,25 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi
channelIdRef.current = channelId;
}, [canManage, channelId, changeSelection, invalidateSecret]);
const selected = useMemo(() => overlays.find((overlay) => overlay.id === selectedId) ?? null, [overlays, selectedId]);
- const selectedOverlay = selectedOverlayData?.id === selectedId ? selectedOverlayData.overlay : null;
+ const refetchOverlays = overlaysQuery.refetch;
+ const selectedOverlay = selectedOverlayQuery.data?.overlay ?? null;
const accesses = selectedOverlayData?.id === selectedId ? selectedOverlayData.accesses : [];
const revokedAccesses = accesses.filter((access) => access.revokedAt !== null);
const setupAccess = accesses.find((access) => access.tokenId === setupAccessId) ?? null;
const load = useCallback(async (isActive: () => boolean = () => true): Promise => {
- const version = ++requestVersion.current;
try {
- const result = await fetchOverlays(channelId);
- if (!isActive() || version !== requestVersion.current) return;
- setOverlays(result.overlays);
- setMaximum(result.maximum);
- setLoadFailed(false);
+ const result = await refetchOverlays({ throwOnError: true });
+ if (!isActive() || result.data === undefined) return;
setError(null);
- if (selectedIdRef.current === null && initialSelection !== undefined && result.overlays.some((item) => item.id === initialSelection)) {
+ if (selectedIdRef.current === null && initialSelection !== undefined && result.data.overlays.some((item) => item.id === initialSelection)) {
changeSelection(initialSelection);
}
} catch (caught) {
- if (!isActive() || version !== requestVersion.current) return;
- setLoadFailed(true);
+ if (!isActive()) return;
notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.loadError) : labels.loadError });
- } finally {
- if (isActive() && version === requestVersion.current) setLoading(false);
}
- }, [channelId, changeSelection, initialSelection, labels.loadError]);
+ }, [changeSelection, initialSelection, labels.loadError, refetchOverlays]);
const loadLegacyTokens = useCallback(async (offset = 0, append = false): Promise => {
const version = ++legacyRequestVersion.current;
@@ -386,13 +380,9 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi
useEffect(() => {
if (selectedId === null) return;
let active = true;
- const requests: [Promise<{ overlay: PanelOverlay }>, Promise<{ accesses: readonly PanelOverlayAccess[] }>] = [
- fetchOverlay(channelId, selectedId),
- fetchOverlayAccesses(channelId, selectedId),
- ];
- void Promise.all(requests).then(([overlayResult, accessResult]) => {
+ void fetchOverlayAccesses(channelId, selectedId).then((accessResult) => {
if (!active) return;
- setSelectedOverlayData({ id: selectedId, overlay: overlayResult.overlay, accesses: accessResult.accesses });
+ setSelectedOverlayData({ id: selectedId, accesses: accessResult.accesses });
setError(null);
}).catch((caught: unknown) => {
if (!active) return;
@@ -404,12 +394,12 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi
const refreshSelected = async (): Promise => {
const overlayId = selectedIdRef.current;
if (overlayId === null) return;
- const [overlayResult, accessResult] = await Promise.all([
- fetchOverlay(channelId, overlayId),
+ const [, accessResult] = await Promise.all([
+ selectedOverlayQuery.refetch({ throwOnError: true }),
fetchOverlayAccesses(channelId, overlayId),
]);
if (channelIdRef.current === channelId && selectedIdRef.current === overlayId) {
- setSelectedOverlayData({ id: overlayId, overlay: overlayResult.overlay, accesses: accessResult.accesses });
+ setSelectedOverlayData({ id: overlayId, accesses: accessResult.accesses });
}
};
diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts
index 73e61aff..7ffaa29f 100644
--- a/src/dashboard/api.ts
+++ b/src/dashboard/api.ts
@@ -183,8 +183,8 @@ const overlayAccessesPath = (channelId: string, overlayId: string, tokenId?: str
return action === undefined ? accessPath : `${accessPath}/${action}`;
};
-export const fetchOverlays = (channelId: string): Promise<{ overlays: readonly PanelOverlaySummary[]; maximum: number; elementMaximum: number }> =>
- requestJson(overlaysPath(channelId));
+export const fetchOverlays = (channelId: string, signal?: AbortSignal): Promise<{ overlays: readonly PanelOverlaySummary[]; maximum: number; elementMaximum: number }> =>
+ requestJson(overlaysPath(channelId), signal === undefined ? {} : { signal });
export const createOverlay = (channelId: string, input: {
name: string;
@@ -201,8 +201,8 @@ export const importLegacyOverlay = (channelId: string, input: {
}): Promise<{ overlay: PanelOverlay; closingPending?: boolean }> =>
requestMutation(`${overlaysPath(channelId)}/import-legacy`, "POST", input);
-export const fetchOverlay = (channelId: string, overlayId: string): Promise<{ overlay: PanelOverlay }> =>
- requestJson(overlaysPath(channelId, overlayId));
+export const fetchOverlay = (channelId: string, overlayId: string, signal?: AbortSignal): Promise<{ overlay: PanelOverlay }> =>
+ requestJson(overlaysPath(channelId, overlayId), signal === undefined ? {} : { signal });
export type PanelOverlayDraft = Pick;
export interface PanelOverlayReconnectExpectation {
@@ -552,8 +552,8 @@ export interface PanelChannelVariablesResponse {
const variablesPath = (channelId: string, name?: string, suffix = ""): string =>
`${channelPath(channelId, "variables")}${name === undefined ? "" : `/${encodeURIComponent(name)}`}${suffix}`;
-export const fetchChannelVariables = (channelId: string): Promise =>
- requestJson(variablesPath(channelId));
+export const fetchChannelVariables = (channelId: string, signal?: AbortSignal): Promise =>
+ requestJson(variablesPath(channelId), signal === undefined ? {} : { signal });
export const createChannelVariable = (
channelId: string,
diff --git a/src/dashboard/data/client.ts b/src/dashboard/data/client.ts
index 8e4c88eb..bc1167c3 100644
--- a/src/dashboard/data/client.ts
+++ b/src/dashboard/data/client.ts
@@ -6,19 +6,23 @@ import {
} from "@tanstack/react-query";
import { PanelApiError, requestJson } from "../api";
-import { isChannelQueryKey } from "./keys";
+import { isChannelQueryKey, type ChannelQueryParams } from "./keys";
const requestChannelResource: QueryFunction = ({ queryKey, signal }) => {
if (!isChannelQueryKey(queryKey)) {
throw new Error("Dashboard queries must use a channel-scoped query key.");
}
+ if (queryKey[2] === "module") {
+ throw new Error("Module queries must provide their own query function.");
+ }
const [, channelId, resource, ...params] = queryKey;
const resourcePath = resource.split("/").map((segment) => encodeURIComponent(segment)).join("/");
const searchParams = new URLSearchParams();
for (const queryParams of params) {
- for (const name of Object.keys(queryParams).sort((a, b) => a.localeCompare(b))) {
- const value = queryParams[name];
+ const values = queryParams as ChannelQueryParams;
+ for (const name of Object.keys(values).sort((a, b) => a.localeCompare(b))) {
+ const value = values[name];
if (value !== null && value !== undefined) searchParams.append(name, String(value));
}
}
diff --git a/src/dashboard/data/index.ts b/src/dashboard/data/index.ts
index ec7c9d01..aa29d1e4 100644
--- a/src/dashboard/data/index.ts
+++ b/src/dashboard/data/index.ts
@@ -1,3 +1,3 @@
export { useQueryClient as useDashboardQueryClient } from "@tanstack/react-query";
export { moduleQueryKey, refetchModuleQueryData, runModuleQueryWrite, useModuleQuery } from "./module-query";
-export type { ModuleQueryFunction, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } from "./module-query";
+export type { ModuleQueryFunction, ModuleQueryOptions, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } from "./module-query";
diff --git a/src/dashboard/data/keys.ts b/src/dashboard/data/keys.ts
index b22fd48d..9a90ed7a 100644
--- a/src/dashboard/data/keys.ts
+++ b/src/dashboard/data/keys.ts
@@ -1,7 +1,8 @@
import type { PanelAuditFilters, PanelEventFilters } from "../../panel-contract";
export type ChannelQueryParams = Readonly>;
-export type ChannelQueryKey = readonly ["channel", string, string, ...ChannelQueryParams[]];
+export type ModuleQueryKey = readonly ["channel", string, "module", string, string];
+export type ChannelQueryKey = readonly ["channel", string, string, ...ChannelQueryParams[]] | ModuleQueryKey;
export const queryKeys = {
channels: () => ["channels"] as const,
@@ -11,17 +12,30 @@ export const queryKeys = {
resource,
...params,
],
+ module: (channelId: string, moduleId: string, part: string): ModuleQueryKey => [
+ "channel",
+ channelId,
+ "module",
+ moduleId,
+ part,
+ ],
};
export const isChannelQueryKey = (queryKey: readonly unknown[]): queryKey is ChannelQueryKey =>
queryKey[0] === "channel" &&
typeof queryKey[1] === "string" &&
- typeof queryKey[2] === "string" &&
- queryKey.slice(3).every((value) => typeof value === "object" && value !== null && !Array.isArray(value));
+ (queryKey[2] === "module"
+ ? typeof queryKey[3] === "string" && typeof queryKey[4] === "string" && queryKey.length === 5
+ : typeof queryKey[2] === "string" &&
+ queryKey.slice(3).every((value) => typeof value === "object" && value !== null && !Array.isArray(value)));
export const dashboardDataKeys = {
+ module: (channelId: string, moduleId: string) => ["channel", channelId, "module", moduleId] as const,
system: (channelId: string) => queryKeys.channel(channelId, "system"),
members: (channelId: string) => queryKeys.channel(channelId, "members"),
+ variables: (channelId: string) => queryKeys.channel(channelId, "variables"),
+ overlays: (channelId: string) => queryKeys.channel(channelId, "overlays"),
+ overlay: (channelId: string, overlayId: string) => queryKeys.channel(channelId, "overlays", { overlayId }),
audit: (channelId: string, filters: PanelAuditFilters) => queryKeys.channel(channelId, "audit-log", {
person: filters.person,
area: filters.area,
diff --git a/src/dashboard/data/lists.ts b/src/dashboard/data/lists.ts
index 02cf1957..318d77e0 100644
--- a/src/dashboard/data/lists.ts
+++ b/src/dashboard/data/lists.ts
@@ -1,6 +1,6 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
-import { fetchAuditLog, fetchEvents, fetchMembers, fetchSystemOverview } from "../api";
+import { fetchAuditLog, fetchChannelVariables, fetchEvents, fetchMembers, fetchOverlay, fetchOverlays, fetchSystemOverview } from "../api";
import type { PanelAuditFilters, PanelEventFilters } from "../../panel-contract";
import { dashboardDataKeys } from "./keys";
@@ -47,3 +47,28 @@ export const useSystemQuery = (channelId: string, enabled: boolean) => useQuery(
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === channelId ? previousData : undefined,
});
+
+export const useChannelVariablesQuery = (channelId: string) => useQuery({
+ queryKey: dashboardDataKeys.variables(channelId),
+ queryFn: ({ signal }) => fetchChannelVariables(channelId, signal),
+ refetchOnWindowFocus: false,
+ placeholderData: (previousData, previousQuery) =>
+ previousQuery?.queryKey[1] === channelId ? previousData : undefined,
+});
+
+export const useOverlaysQuery = (channelId: string, enabled = true) => useQuery({
+ queryKey: dashboardDataKeys.overlays(channelId),
+ queryFn: ({ signal }) => fetchOverlays(channelId, signal),
+ enabled,
+ refetchOnWindowFocus: false,
+ placeholderData: (previousData, previousQuery) =>
+ previousQuery?.queryKey[1] === channelId ? previousData : undefined,
+});
+
+export const useOverlayQuery = (channelId: string, overlayId: string, enabled = true) => useQuery({
+ queryKey: dashboardDataKeys.overlay(channelId, overlayId),
+ queryFn: ({ signal }) => fetchOverlay(channelId, overlayId, signal),
+ enabled,
+ refetchOnWindowFocus: false,
+ placeholderData: () => undefined,
+});
diff --git a/src/dashboard/data/module-query.ts b/src/dashboard/data/module-query.ts
index ec1f962d..76748a02 100644
--- a/src/dashboard/data/module-query.ts
+++ b/src/dashboard/data/module-query.ts
@@ -1,4 +1,4 @@
-import { hashKey, keepPreviousData, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
+import { hashKey, keepPreviousData, useQuery, useQueryClient, type Query, type QueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
@@ -17,6 +17,11 @@ export interface ModuleQueryWriteOptions {
relatedParts?: readonly ModuleQueryPart[];
}
+export interface ModuleQueryOptions {
+ enabled?: boolean;
+ refetchInterval?: number | false | ((query: Query) => number | false);
+}
+
export type ModuleQueryWriteFunction = (baselineRevision: ModuleQueryRevision) => Promise;
interface ReadOrder {
@@ -108,13 +113,14 @@ export const refetchModuleQueryData = (
};
export const moduleQueryKey = (channelId: string, moduleId: string, part: string) =>
- queryKeys.channel(channelId, `modules/${moduleId}/${part}`);
+ queryKeys.module(channelId, moduleId, part);
export const useModuleQuery = (
channelId: string,
moduleId: string,
part: string,
fn: ModuleQueryFunction,
+ options: ModuleQueryOptions = {},
) => {
const queryClient = useQueryClient();
const queryKey = moduleQueryKey(channelId, moduleId, part);
@@ -127,10 +133,12 @@ export const useModuleQuery = (
return useQuery({
queryKey,
queryFn: ({ signal }) => readModuleData(queryClient, queryKey, fn, signal),
+ ...(options.enabled === undefined ? {} : { enabled: options.enabled }),
+ ...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),
placeholderData: (previousData, previousQuery) => {
const previousKey = previousQuery?.queryKey;
- const resource = `modules/${moduleId}/${part}`;
- return previousKey?.[0] === "channel" && previousKey[1] === channelId && previousKey[2] === resource
+ return previousKey?.[0] === "channel" && previousKey[1] === channelId &&
+ previousKey[2] === "module" && previousKey[3] === moduleId && previousKey[4] === part
? keepPreviousData(previousData)
: undefined;
},
diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts
new file mode 100644
index 00000000..e66e0bf5
--- /dev/null
+++ b/src/dashboard/data/realtime.ts
@@ -0,0 +1,96 @@
+import { useCallback, useSyncExternalStore } from "react";
+import type { QueryClient } from "@tanstack/react-query";
+
+import type { RealtimeMessage } from "../../realtime-contract";
+import { dashboardDataKeys, queryKeys } from "./keys";
+import { refreshQuery } from "./refresh";
+
+export type DashboardRealtimeStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew";
+
+const statuses = new Map();
+const subscribers = new Map void>>();
+
+export const getDashboardRealtimeStatus = (channelId: string): DashboardRealtimeStatus =>
+ statuses.get(channelId) ?? "offline";
+
+export const setDashboardRealtimeStatus = (channelId: string, status: DashboardRealtimeStatus): void => {
+ if (statuses.get(channelId) === status) return;
+ statuses.set(channelId, status);
+ for (const subscriber of subscribers.get(channelId) ?? []) subscriber();
+};
+
+export const useDashboardRealtimeStatus = (channelId: string): DashboardRealtimeStatus => {
+ const subscribe = useCallback((subscriber: () => void) => {
+ let channelSubscribers = subscribers.get(channelId);
+ if (channelSubscribers === undefined) {
+ channelSubscribers = new Set();
+ subscribers.set(channelId, channelSubscribers);
+ }
+ channelSubscribers.add(subscriber);
+ return () => {
+ channelSubscribers.delete(subscriber);
+ if (channelSubscribers.size === 0) subscribers.delete(channelId);
+ };
+ }, [channelId]);
+ const getSnapshot = useCallback(() => getDashboardRealtimeStatus(channelId), [channelId]);
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+};
+
+const invalidate = (queryClient: QueryClient, queryKey: readonly unknown[]): void => {
+ const matchingQueries = queryClient.getQueryCache().findAll({ queryKey });
+ void queryClient.invalidateQueries({ queryKey, refetchType: "none" });
+ for (const query of matchingQueries) {
+ if (query.isActive()) void refreshQuery(queryClient, query.queryKey);
+ }
+};
+
+/** Invalidates the channel caches affected by a realtime hint; API responses remain authoritative. */
+export const invalidateDashboardRealtimeMessage = (
+ queryClient: QueryClient,
+ message: RealtimeMessage,
+): void => {
+ const { channelId } = message;
+ switch (message.type) {
+ case "stream.state.changed":
+ invalidate(queryClient, queryKeys.channels());
+ invalidate(queryClient, ["channel", channelId]);
+ break;
+ case "event_log.new": {
+ invalidate(queryClient, queryKeys.channel(channelId, "events"));
+ const moduleIds = new Set(message.payload.entries.map((entry) => entry.moduleId));
+ for (const moduleId of moduleIds) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId));
+ break;
+ }
+ case "variables.changed":
+ invalidate(queryClient, dashboardDataKeys.variables(channelId));
+ invalidate(queryClient, dashboardDataKeys.overlays(channelId));
+ invalidate(queryClient, ["channel", channelId, "module"]);
+ break;
+ case "overlay.changed":
+ invalidate(queryClient, dashboardDataKeys.overlays(channelId));
+ invalidate(queryClient, dashboardDataKeys.overlay(channelId, message.payload.overlayId));
+ invalidate(queryClient, dashboardDataKeys.variables(channelId));
+ break;
+ case "ads.schedule.updated":
+ invalidate(queryClient, dashboardDataKeys.module(channelId, "ads"));
+ break;
+ default: {
+ if (message.type.startsWith("modul.")) {
+ const moduleId = message.type.split(".")[1];
+ if (moduleId !== undefined) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId));
+ const votekickOpened = message.type === "modul.votekick.opened";
+ const votekickClosed = message.type === "modul.votekick.tally" && message.payload.status !== "running";
+ if (votekickOpened || votekickClosed) {
+ // Chat voting also reads channel-wide ballot availability from the shared ballot.
+ invalidate(queryClient, dashboardDataKeys.module(channelId, "chat_voting"));
+ }
+ }
+ }
+ }
+};
+
+/** A successful reconnect makes every cached resource for that channel stale. */
+export const invalidateDashboardChannelQueries = (queryClient: QueryClient, channelId: string): void => {
+ invalidate(queryClient, queryKeys.channels());
+ invalidate(queryClient, ["channel", channelId]);
+};
diff --git a/src/dashboard/events/EventsPage.tsx b/src/dashboard/events/EventsPage.tsx
index 4bc04054..e1ef47c4 100644
--- a/src/dashboard/events/EventsPage.tsx
+++ b/src/dashboard/events/EventsPage.tsx
@@ -374,8 +374,6 @@ const EventsPageContent = (properties: EventsPageContentProperties): ReactElemen
}, []);
const realtime = useRealtimeEventFeed({
channelId,
- filters,
- refresh: onRefresh,
refreshOnReturn: onRefreshOnReturn,
scrollToBeginning,
});
diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx
index 95b0408f..37345c53 100644
--- a/src/dashboard/main.tsx
+++ b/src/dashboard/main.tsx
@@ -382,10 +382,74 @@ const mergeAndRememberChannelStreamVersion = (
return merged;
};
-const ChannelControlActions = ({ channelId, controls, now, onRefresh }: {
+const useSecondClock = (): number => {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = window.setInterval(() => { setNow(Date.now()); }, 1000);
+ return () => { window.clearInterval(id); };
+ }, []);
+ return now;
+};
+
+const ChannelControlExpiryWatcher = ({ controls, onRefresh }: {
+ controls: PanelChannelControls | undefined;
+ onRefresh: () => Promise;
+}): null => {
+ const refreshedExpiryKey = useRef("");
+ useEffect(() => {
+ let timer: number | null = null;
+ const checkExpiry = (): void => {
+ const now = Date.now();
+ const mute = controls?.mute ?? CONTROL_OFF;
+ const pause = controls?.pause ?? CONTROL_OFF;
+ const staleExpiryKey = [mute, pause]
+ .filter((control) => control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) <= now)
+ .map((control) => control.until)
+ .join("|");
+ if (staleExpiryKey.length === 0) refreshedExpiryKey.current = "";
+ else if (refreshedExpiryKey.current !== staleExpiryKey) {
+ refreshedExpiryKey.current = staleExpiryKey;
+ void onRefresh();
+ }
+ const nextExpiry = [mute, pause]
+ .filter((control) => control.active && control.mode === "timed" && control.until !== null)
+ .map((control) => Date.parse(control.until ?? ""))
+ .filter((expiresAt) => Number.isFinite(expiresAt) && expiresAt > now)
+ .sort((left, right) => left - right)[0];
+ if (nextExpiry !== undefined) timer = window.setTimeout(checkExpiry, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextExpiry - now)));
+ };
+ checkExpiry();
+ return () => { if (timer !== null) window.clearTimeout(timer); };
+ }, [controls, onRefresh]);
+ return null;
+};
+
+const ChannelControlStatus = ({ control, kind }: {
+ control: PanelChannelControl;
+ kind: "mute" | "pause";
+}): ReactElement => {
+ const texts = dashboardTexts();
+ const labels = texts.channelControls;
+ const now = useSecondClock();
+ const configured = control.active || control.pending === true;
+ let label: string | null = null;
+ if (control.pending === true) label = labels.pendingUntilStreamStart;
+ else if (control.active && control.mode === "timed" && control.until !== null) {
+ const remaining = Math.ceil((Date.parse(control.until) - now) / 60_000);
+ label = remaining <= 0 ? texts.status.refreshing
+ : kind === "mute" ? labels.muteRemaining(String(remaining)) : labels.pauseRemaining(String(remaining));
+ } else if (control.active && control.mode === "until_stream_end") {
+ label = `${kind === "mute" ? labels.muteActive : labels.pauseActive} · ${labels.untilStreamEnd}`;
+ } else if (control.active) label = kind === "mute" ? labels.muteActive : labels.pauseActive;
+ const timed = control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) > now;
+ return
+ {configured && label !== null ? {timed ? : null}{label} : null}
+ ;
+};
+
+const ChannelControlActions = ({ channelId, controls, onRefresh }: {
channelId: string;
controls: PanelChannelControls | undefined;
- now: number;
onRefresh: () => Promise;
}): ReactElement => {
const texts = dashboardTexts();
@@ -393,27 +457,11 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: {
const [dialogControl, setDialogControl] = useState<"mute" | "pause" | null>(null);
const [duration, setDuration] = useState("unlimited");
const [busy, setBusy] = useState<"mute" | "pause" | null>(null);
- const refreshRef = useRef(onRefresh);
- useEffect(() => { refreshRef.current = onRefresh; }, [onRefresh]);
const mute = controls?.mute ?? CONTROL_OFF;
const pause = controls?.pause ?? CONTROL_OFF;
const muteConfigured = mute.active || mute.pending === true;
const pauseConfigured = pause.active || pause.pending === true;
- const staleExpiryKey = [mute, pause]
- .filter((control) => control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) <= now)
- .map((control) => control.until)
- .join("|");
- const refreshedExpiryKey = useRef("");
-
- useEffect(() => {
- if (staleExpiryKey.length === 0) {
- refreshedExpiryKey.current = "";
- return;
- }
- if (refreshedExpiryKey.current === staleExpiryKey) return;
- refreshedExpiryKey.current = staleExpiryKey;
- void refreshRef.current();
- }, [staleExpiryKey]);
+ const activeChannelControls = controls ?? { mute: CONTROL_OFF, pause: CONTROL_OFF };
const apply = async (kind: "mute" | "pause", next: ChannelControlDuration | null): Promise => {
if (busy !== null) return;
@@ -434,29 +482,6 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: {
}
};
- const activeText = (control: typeof mute, kind: "mute" | "pause"): string | null => {
- if (control.pending === true) return labels.pendingUntilStreamStart;
- if (!control.active) return null;
- if (control.mode === "timed" && control.until !== null) {
- const remaining = Math.ceil((Date.parse(control.until) - now) / 60_000);
- if (remaining <= 0) return texts.status.refreshing;
- return kind === "mute" ? labels.muteRemaining(String(remaining)) : labels.pauseRemaining(String(remaining));
- }
- if (control.mode === "until_stream_end") return `${kind === "mute" ? labels.muteActive : labels.pauseActive} · ${labels.untilStreamEnd}`;
- return kind === "mute" ? labels.muteActive : labels.pauseActive;
- };
-
- const controlState = (control: typeof mute, kind: "mute" | "pause", configured: boolean): ReactElement | null => {
- const label = configured ? activeText(control, kind) : null;
- if (label === null) return null;
- const timed = control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) > now;
- return {timed ? : null}{label};
- };
-
- const controlStateSlot = (control: typeof mute, kind: "mute" | "pause", configured: boolean): ReactElement => (
- {controlState(control, kind, configured)}
- );
-
const durationOptions = CHANNEL_CONTROL_DURATIONS.map((choice) => ({
value: choice,
label: choice === "15m" ? labels.duration15m
@@ -480,7 +505,7 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: {
disabled={busy !== null}
onClick={() => { if (muteConfigured) void apply("mute", null); else setDialogControl("mute"); }}
/>
- {controlStateSlot(mute, "mute", muteConfigured)}
+