Skip to content

Commit 8f79025

Browse files
authored
fix: DCL Cast admin panel — auto-open, slide state, and reliable late-joiner replication (#1358)
* fix: auto-open the admin DCL Cast panel on presentation start and receive slide state Two bugs in the admin toolkit's DCL Cast section: 1. The panel never auto-opened when a presentation started, for admins who were not already sitting on the DCL Cast sub-tab. Presentation detection lived inside the DclCast React component, so it only ran once the panel was already open — a passive admin detected nothing. 2. Slide state (page count, current slide, video) never populated. The runtime's `consumeMessages` resolves to a bare array of { sender, data }, but the code destructured `const { messages }`, which yields undefined, throws on `.length`, and is silently swallowed by the surrounding try/catch — dropping every reply. Fixes (all scene-side; no explorer or cast-presenter-server changes needed): - Move presentation detection into a boot-time, admin-gated background service (presentation-detector.ts) that auto-opens the panel to Video Control when the presentation bot's LiveKit track appears, independent of the active tab. Data flows one way: detector -> state.videoControl.presentationState -> views. - Read consumeMessages as an array (tolerating a { messages } wrapper too). - ensurePresenterRole: parse { presenters: string[] } correctly and compare addresses case-insensitively (it previously always re-promoted); sequence getDclCastInfo -> ensurePresenterRole so the promotion targets an existing room. - Extract showcaseState/sharePresentationState into a pure DclCast/state.ts; add findActiveCastScreenIndex helper so auto-open selects the casting screen. * docs: note that ~system/CommsApi consumeMessages returns a bare array Records the gotcha behind this fix: the runtime returns [{ sender, data }], not the { messages } wrapper the type implies, so `const { messages } = await consumeMessages(...)` throws and is swallowed by the surrounding try/catch. * fix: harden DCL Cast presentation detector per review Address PR review feedback on the presentation detector's lifecycle and error handling: - Split the conflated `presentationSubscribed` flag into an edge tracker (`presentationActive`, drives the one-shot auto-open) and a liveness flag (`presentationSystem`, only assigned on successful setup). A failed setup no longer wedges detection permanently — the next poll tick retries. (P1) - Await `startPresentationSystem` inside the poll and wrap its setup in try/catch/finally so it never leaves an unhandled rejection or a dangling in-flight guard; bail if the presentation ended mid-setup. (P1) - Add `stopPresentationDetection` — removes the poll interval and consume systems and resets module state, giving the detector a real reset path. (P1) - Log (console.error) instead of silently swallowing consume failures. (P1) - Replace the unsafe double `as` casts in `consumePresentationMessages` with a validating `extractMessages` helper that narrows each entry to a string `data` before JSON.parse. (P2) - Use `CAST_SRC_PREFIX` instead of the inline 'livekit-video://' literal, and remove the leftover debug track-dump console.log. (P2) - Retry button now calls `fetchDclCastInfo` so loading/error state updates. (P2) * perf: tighten DCL Cast detection to 1s and throttle status consume to 250ms - Detection poll 5s -> 1s so the panel auto-opens ~1s after a presentation starts. The poll stays continuous by design (it's the authoritative teardown/re-arm path and the only backstop for an ungraceful bot exit); documented on the constant so it isn't "optimized" into a stop-on-detect. - Status consume: drain the presentation comms topic every ~250ms (4Hz) via the setInterval helper instead of every frame (~30Hz). Visually instant for a slide counter/video state and the dominant cost, so net RPC during a presentation drops ~6x even with the faster detection poll. No logic change beyond cadence; each drain still returns the latest state, so throttling never drops the final message (including presentation:stopped). * refactor: make Admin Toolkit UI event-driven (single view-state owner + transitions) Consolidate all panel/tab/sub-view state into a single store, mutated only through named transitions, with the render a pure function of that state. This removes the local-useState shadow and the imperative "blink" remount tricks that made the DCL Cast auto-open unreliable, and gives the presentation lifecycle a deterministic path. - New store.ts (single state owner) + actions.ts (named transitions); repointing the state importers breaks the detector -> VideoControl/utils -> index import cycle. - DCL Cast sub-view now reads state.videoControl.selectedTab, so a presentation auto-opens the panel to Video Control -> DCL Cast regardless of remount or whether a screen is casting yet. - Presentation start -> showPresentation(); end (bot 'stopped' or track gone) -> dismissPresentation() returns to the full DCL Cast panel. Manual Stop Sharing uses the same end transition. - Fold the showcase/share/moderation singletons and the announcement banner into the store behind transitions; the announcement banner now resets on new input. - Remove dead code: the unrendered RewardsControl tab and the now-unused nextTick blink array + drain system. * fix: reliably replicate cast stream to late joiners A client joining after an admin enabled the cast intermittently showed the default video (and, for admins, a stale "Activate" panel) instead of the cast. The late-joiner handshake in the admin message bus was single-shot: the client sent REQUEST_STATE once at init and applied the first SYNC_STATE reply. Both are best-effort comms; if either was dropped the client never learned the trusted src, and the per-frame revert system then pinned the screen to defaultURL even though the cast src had already arrived over CRDT. Retry REQUEST_STATE (1s interval, up to 10 attempts) until a remote SYNC_STATE reply is applied, then stop. Keys strictly on receivedRemoteState (a full reply from another participant) rather than adminHasActed, so a live SET_ANNOUNCEMENT cannot stop the retry before the missing video state arrives. Only recovers state that predates the join; live changes after joining still arrive via SET_VIDEO. * refactor: address review feedback on the Admin Toolkit event-driven refactor Follow-up to the event-driven refactor tightening the "actions are the only writers" invariant and hardening the presentation detector. - Route the remaining direct state writes through actions: the video-player dropdown (selectVideoPlayer), the stream toggles in DclCastInfo/ShowStreamKey (setStream), the minimize/expand chevrons (minimizeCast/expandCast), and the detector's dclCast/presentationState writes (setDclCastInfo / setPresentationState / clearPresentationState). - Wire up the now-used minimize/expand transitions and drop the dead closePanel. - Clear the stored modal handlers in closeShowcase/closeSharePresentation so their captured render closures are not retained while the modal is hidden. - Detector: add a poll reentrancy guard + top-level try/catch, remove the unused stopPresentationDetection export and the vestigial injected state param (it is now purely action-driven), and drop the discarded interval handle. * chore: clear pre-existing lint in admin-toolkit-ui Fix the 11 pre-existing ESLint errors in files the event-driven refactor did not touch, so the directory lints clean end to end. - consistent-type-imports: Active, Card, Loading, AddUserInput. Card keeps ReactEcs as a value import (it is the JSX factory) with a targeted rule disable. - no-unused-vars: drop the unused UiTransform import (Loading), add the JSX-factory disable for ReactEcs (BanUserDescription), and remove three dead params from the SmartItemsControl helper components. * style: prettier-format the late-joiner REQUEST_STATE retry block * refactor: address follow-up review on admin toolkit UI - Complete the "actions are the only writers" migration: route the remaining direct state writes in SmartItemsControl, TextAnnouncementsControl and the UI-entity bootstrap through named actions, so the actions.ts invariant holds. - Make unbanMessage `string | undefined` to match every other store optional. - Drop the unnecessary export on handleGetDclCastInfo (used only in its module). - Dedupe the presentation end callback so dismissPresentation fires once per presentation (the comms 'stopped' message and the track-gone poll edge both signal the end; latch resets on the start edge). * fix: close review findings on the admin toolkit DCL Cast fixes - P1: guard the consume .then() with presentationActive so a late-resolving consumePresentationMessages can't repopulate stale slide state after the falling-edge teardown cleared it (there was no self-healing path once the consume system was stopped). - ensurePresenterRole now returns whether the player is/became a presenter; startPresentationSystem bails (leaving presentationSystem null) on failure so the 1s poll retries, instead of running a consume loop that never receives presenter-only state. - setSmartItemAction drops the unsafe `as SelectedSmartItem` cast (guards on presence); setSmartItemVisibility uses copy-on-write to match its siblings. - receivedRemoteState only latches on a SYNC_STATE that carries video state, so an announcement-only reply no longer stops the late-joiner video retry early.
1 parent 0ee9d8f commit 8f79025

29 files changed

Lines changed: 778 additions & 629 deletions

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ post-`Composite.instance` in `add-child.ts` (`remapSyncComponentIds`, beside the
173173
`{self}` id/trigger remap). When adding a placeholder-bearing field — or debugging
174174
a `Cannot convert … to a BigInt` serialize crash — ensure both paths resolve it.
175175

176+
### `~system/CommsApi` `consumeMessages` returns a bare array
177+
178+
`consumeMessages({ topic })` resolves to a **bare array** of `{ sender, data }`, not
179+
the `{ messages: [...] }` wrapper its TypeScript type implies (the explorer's
180+
`CommsApiWrap.ConsumeMessages` serializes a raw JSON array). Destructuring
181+
`const { messages } = await consumeMessages(...)` yields `undefined` and throws on
182+
`.length` — and inside a `try/catch` that silently drops every message with no error.
183+
Read it as an array, tolerating both shapes:
184+
`Array.isArray(res) ? res : (res?.messages ?? [])`.
185+
176186
## Skills
177187

178188
Skills live in `.ai/skills/*/SKILL.md`. Read the relevant `SKILL.md` when a task matches a skill's domain.

packages/asset-packs/src/admin-toolkit-ui/Active.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { IEngine } from '@dcl/ecs';
1+
import type { IEngine } from '@dcl/ecs';
22
import { Color4 } from '@dcl/ecs-math';
3-
import ReactEcs, { Label, UiEntity, UiTransformProps } from '@dcl/react-ecs';
3+
import type { UiTransformProps } from '@dcl/react-ecs';
4+
import ReactEcs, { Label, UiEntity } from '@dcl/react-ecs';
45
import { clearInterval, setInterval } from './utils';
56
import { COLORS } from './VideoControl';
67

packages/asset-packs/src/admin-toolkit-ui/Card.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import ReactEcs, { UiEntity, UiTransformProps } from '@dcl/react-ecs';
1+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- ReactEcs is the JSX factory (runtime value), not just the ReactEcs.JSX.Element type
2+
import ReactEcs, { UiEntity } from '@dcl/react-ecs';
3+
import type { UiTransformProps } from '@dcl/react-ecs';
24
import { containerBackgroundColor } from '.';
35

46
export function Card({

packages/asset-packs/src/admin-toolkit-ui/FeedbackButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import ReactEcs, { UiEntity, Label } from '@dcl/react-ecs';
22
import { Color4 } from '@dcl/sdk/math';
33
import { startTimeout, stopTimeout } from '../timer';
4-
import { Button, ButtonVariant, type CompositeButtonProps } from './Button';
5-
import { state } from '.';
4+
import { Button, type ButtonVariant, type CompositeButtonProps } from './Button';
5+
import { state } from './store';
66

77
const FEEDBACK_TIMEOUT_ACTION_PREFIX = 'feedback_button_';
88

packages/asset-packs/src/admin-toolkit-ui/Loading.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { IEngine, UiTransform } from '@dcl/ecs';
1+
import type { IEngine } from '@dcl/ecs';
22
import { Color4 } from '@dcl/ecs-math';
3-
import ReactEcs, { UiEntity, UiTransformProps } from '@dcl/react-ecs';
3+
import type { UiTransformProps } from '@dcl/react-ecs';
4+
import ReactEcs, { UiEntity } from '@dcl/react-ecs';
45
import { clearInterval, setInterval } from './utils';
56

67
interface LoadingProps {

packages/asset-packs/src/admin-toolkit-ui/ModerationControl/AddUserInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { handleAddAdmin, handleBanUser } from './utils';
99
import { BanUserDescription } from './BanUserDescription';
1010

11-
import { SceneAdmin } from '.';
11+
import type { SceneAdmin } from '.';
1212

1313
export enum PermissionType {
1414
ADMIN = 'admin',

packages/asset-packs/src/admin-toolkit-ui/ModerationControl/BanUserDescription.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ReactEcs is required for JSX factory
12
import ReactEcs, { UiEntity } from '@dcl/react-ecs';
23
import { getAddUserInputStyles, getBanUserTextStyles } from './styles/AddUserInputStyles';
34

packages/asset-packs/src/admin-toolkit-ui/ModerationControl/RemoveAdminConfirmation.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { Button } from '../Button';
66
import { LoadingDots } from '../Loading';
77
import { Error } from '../Error';
88
import { fetchAndSyncSceneAdmins } from '..';
9+
import { cancelRemoveAdmin } from '../actions';
910
import { deleteSceneAdmin } from './api';
10-
import { moderationControlState } from '.';
1111
import type { SceneAdmin } from '.';
1212

1313
export function RemoveAdminConfirmation({ admin, engine }: { admin: SceneAdmin; engine: IEngine }) {
@@ -89,7 +89,7 @@ export function RemoveAdminConfirmation({ admin, engine }: { admin: SceneAdmin;
8989
margin: { right: 30, left: 30 },
9090
}}
9191
onMouseDown={() => {
92-
moderationControlState.adminToRemove = undefined;
92+
cancelRemoveAdmin();
9393
}}
9494
/>
9595
)}
@@ -114,7 +114,7 @@ export function RemoveAdminConfirmation({ admin, engine }: { admin: SceneAdmin;
114114
if (error) {
115115
setError(error);
116116
} else {
117-
moderationControlState.adminToRemove = undefined;
117+
cancelRemoveAdmin();
118118
await fetchAndSyncSceneAdmins();
119119
}
120120
setIsLoading(false);

packages/asset-packs/src/admin-toolkit-ui/ModerationControl/UsersList.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import ReactEcs, { UiEntity, Label } from '@dcl/react-ecs';
55
import { getContentUrl } from '../constants';
66
import { Button } from '../Button';
77
import { clearInterval, setInterval } from '../utils';
8+
import { state } from '../store';
9+
import { closeAdminList, closeBanList, confirmRemoveAdmin, setUnbanMessage } from '../actions';
810
import { RemoveAdminConfirmation } from './RemoveAdminConfirmation';
911
import { type SceneBanUser } from './api';
1012
import { handleUnbanUser } from './utils';
11-
import { moderationControlState, type SceneAdmin } from './.';
13+
import { type SceneAdmin } from './.';
1214
import {
1315
getModalStyles,
1416
getModalBackgrounds,
@@ -74,9 +76,9 @@ const getActionButtonText = (type: UserListType) => {
7476

7577
const closeModal = (type: UserListType) => {
7678
if (type === UserListType.ADMIN) {
77-
moderationControlState.showModalAdminList = false;
79+
closeAdminList();
7880
} else {
79-
moderationControlState.showModalBanList = false;
81+
closeBanList();
8082
}
8183
};
8284

@@ -88,42 +90,43 @@ export function ModalUserList({ users, engine, type }: ModalUserListProps) {
8890

8991
const handleRemoveUser = async (user: SceneAdmin | SceneBanUser) => {
9092
if (type === UserListType.ADMIN) {
91-
moderationControlState.adminToRemove = user as SceneAdmin;
93+
confirmRemoveAdmin(user as SceneAdmin);
9294
} else {
9395
const bannedUser = user as SceneBanUser;
9496

9597
const success = await handleUnbanUser(bannedUser.bannedAddress);
9698
if (success) {
9799
const username = bannedUser.name || bannedUser.bannedAddress;
98-
moderationControlState.unbanMessage = `${username} has been unbanned from your scene`;
100+
setUnbanMessage(`${username} has been unbanned from your scene`);
99101
} else {
100-
moderationControlState.unbanMessage = 'We were unable to unban this user';
102+
setUnbanMessage('We were unable to unban this user');
101103
}
102104
}
103105
};
104106

105107
ReactEcs.useEffect(() => {
106-
if (moderationControlState.unbanMessage) {
108+
if (state.moderationControl.unbanMessage) {
107109
let counter = 0;
108110
const interval = setInterval(
109111
engine,
110112
() => {
111113
counter += 100;
112114
if (counter >= 3000) {
113-
moderationControlState.unbanMessage = null;
115+
setUnbanMessage(undefined);
114116
}
115117
},
116118
100,
117119
);
118120

119121
return () => clearInterval(engine, interval);
120122
}
121-
}, [moderationControlState.unbanMessage]);
123+
}, [state.moderationControl.unbanMessage]);
122124

123-
if (moderationControlState.adminToRemove) {
125+
const adminToRemove = state.moderationControl.adminToRemove;
126+
if (adminToRemove) {
124127
return (
125128
<RemoveAdminConfirmation
126-
admin={moderationControlState.adminToRemove}
129+
admin={adminToRemove}
127130
engine={engine}
128131
/>
129132
);
@@ -288,10 +291,10 @@ export function ModalUserList({ users, engine, type }: ModalUserListProps) {
288291
</UiEntity>
289292
)}
290293

291-
{type === UserListType.BAN && moderationControlState.unbanMessage && (
294+
{type === UserListType.BAN && state.moderationControl.unbanMessage && (
292295
<UiEntity uiTransform={styles.messageContainer}>
293296
<Label
294-
value={moderationControlState.unbanMessage}
297+
value={state.moderationControl.unbanMessage}
295298
fontSize={14}
296299
color={Color4.White()}
297300
uiTransform={styles.messageLabel}

packages/asset-packs/src/admin-toolkit-ui/ModerationControl/index.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Header } from '../Header';
88
import { Button } from '../Button';
99
import { Card } from '../Card';
1010
import { fetchSceneBans } from '..';
11+
import { openAdminList, openBanList } from '../actions';
1112
import { AddUserInput, PermissionType } from './AddUserInput';
1213
import {
1314
getModerationControlStyles,
@@ -42,19 +43,6 @@ export type SceneAdmin = {
4243
canBeRemoved: boolean;
4344
};
4445

45-
type State = {
46-
showModalAdminList?: boolean;
47-
adminToRemove?: SceneAdmin;
48-
showModalBanList?: boolean;
49-
unbanMessage?: string | null;
50-
};
51-
export const moderationControlState: State = {
52-
showModalAdminList: false,
53-
showModalBanList: false,
54-
adminToRemove: undefined,
55-
unbanMessage: null as string | null,
56-
};
57-
5846
export function ModerationControl({ engine: _engine, player: _player, sceneAdmins }: Props) {
5947
const styles = getModerationControlStyles();
6048
const colors = getModerationControlColors();
@@ -79,7 +67,7 @@ export function ModerationControl({ engine: _engine, player: _player, sceneAdmin
7967
uiTransform={styles.viewListButton}
8068
icon={MODERATION_ICONS.VERIFIED_USER_ICON}
8169
iconTransform={styles.viewListIcon}
82-
onMouseDown={() => (moderationControlState.showModalAdminList = true)}
70+
onMouseDown={() => openAdminList()}
8371
/>
8472
<UiEntity uiTransform={styles.divider} />
8573
<AddUserInput
@@ -97,7 +85,7 @@ export function ModerationControl({ engine: _engine, player: _player, sceneAdmin
9785
iconTransform={styles.viewListIcon}
9886
onMouseDown={async () => {
9987
await fetchSceneBans();
100-
moderationControlState.showModalBanList = true;
88+
openBanList();
10189
}}
10290
/>
10391
</UiEntity>

0 commit comments

Comments
 (0)