From c9cd6e84383b56356129c1aaa8703ee96ba72c3c Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Tue, 7 Apr 2026 15:17:22 +0530 Subject: [PATCH 01/18] set view mode when in live mode --- .../src/components/whiteboard/WhiteboardConfigure.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 229c7525a..0d1765bb6 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -350,7 +350,13 @@ const WhiteboardConfigure: React.FC = props => { logger.log(LogSource.Internals, 'WHITEBOARD', 'Join room successful'); whiteboardRoom.current = room; cursorAdapter.setRoom(room); - whiteboardRoom.current?.setViewMode(ViewMode.Freedom); + whiteboardRoom.current?.setViewMode( + $config.EVENT_MODE + ? isHost + ? ViewMode.Broadcaster + : ViewMode.Follower + : ViewMode.Freedom, + ); whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); if (isHost && !isMobileUA()) { whiteboardRoom.current?.setMemberState({ From 3b525a602dcb923eccdaf6bf09ce14753d4f0f38 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Tue, 7 Apr 2026 17:55:37 +0530 Subject: [PATCH 02/18] multiple host in livestream --- .../whiteboard/WhiteboardConfigure.tsx | 86 ++++++++++++++----- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 0d1765bb6..ae4cce9e5 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -326,37 +326,79 @@ const WhiteboardConfigure: React.FC = props => { }; }, []); - const join = () => { + const join = (isStartedFirst: boolean = false) => { const InitState = whiteboardRoomState; try { const index = randomIntFromInterval(0, 9); setWhiteboardRoomState(RoomPhase.Connecting); logger.log(LogSource.Internals, 'WHITEBOARD', 'Trying to join room'); whiteWebSdkClient.current - .joinRoom({ - cursorAdapter: cursorAdapter, - uid: `${whiteboardUidRef.current}`, - uuid: room_uuid, - roomToken: room_token, - floatBar: true, - isWritable: isHost && !isMobileUA(), - userPayload: { - cursorName: name, - cursorColor: CursorColor[index].cursorColor, - textColor: CursorColor[index].textColor, + .joinRoom( + { + cursorAdapter: cursorAdapter, + uid: `${whiteboardUidRef.current}`, + uuid: room_uuid, + roomToken: room_token, + floatBar: true, + isWritable: isHost && !isMobileUA(), + userPayload: { + cursorName: name, + cursorColor: CursorColor[index].cursorColor, + textColor: CursorColor[index].textColor, + }, }, - }) + { + // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. + onRoomStateChanged: + $config.EVENT_MODE && isHost + ? modifyState => { + console.log( + '[Whiteboard-LiveStream] onRoomStateChanged', + modifyState.broadcastState, + ); + if ( + modifyState.broadcastState !== undefined && + modifyState.broadcastState.broadcasterId === undefined + ) { + console.log( + '[Whiteboard-LiveStream] Broadcaster dropped, claiming Broadcaster role', + ); + whiteboardRoom.current?.setViewMode(ViewMode.Broadcaster); + } + } + : undefined, + }, + ) .then(room => { - logger.log(LogSource.Internals, 'WHITEBOARD', 'Join room successful'); + logger.log( + LogSource.Internals, + 'WHITEBOARD', + 'Join room successful', + isHost, + $config.EVENT_MODE, + ); whiteboardRoom.current = room; cursorAdapter.setRoom(room); - whiteboardRoom.current?.setViewMode( - $config.EVENT_MODE - ? isHost + // In livestream: host who starts the whiteboard is Broadcaster (attendees follow their viewport), + // co-hosts get Freedom (can draw independently), attendees are Followers. + // If no Broadcaster exists in the room (e.g. all hosts dropped and rejoined), first host to join claims it. + // In meeting: everyone gets Freedom (independent viewport). + const noBroadcasterInRoom = + room.state.broadcastState.broadcasterId === undefined; + const viewMode = $config.EVENT_MODE + ? isHost + ? isStartedFirst || noBroadcasterInRoom ? ViewMode.Broadcaster - : ViewMode.Follower - : ViewMode.Freedom, - ); + : ViewMode.Freedom + : ViewMode.Follower + : ViewMode.Freedom; + console.log('[Whiteboard]-LiveStream Setting ViewMode:', viewMode, { + isHost, + isStartedFirst, + noBroadcasterInRoom, + EVENT_MODE: $config.EVENT_MODE, + }); + whiteboardRoom.current?.setViewMode(viewMode); whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); if (isHost && !isMobileUA()) { whiteboardRoom.current?.setMemberState({ @@ -421,10 +463,10 @@ const WhiteboardConfigure: React.FC = props => { appIdentifier: appIdentifier, region: $config.WHITEBOARD_REGION, }); - join(); + join(true); setWhiteboardStartedFirst(true); } else if (whiteboardActive) { - join(); + join(false); } else { if ( whiteboardRoom.current && From 135f94d8afb88516eb09108ef0aa836ec4439b5b Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Tue, 7 Apr 2026 18:10:22 +0530 Subject: [PATCH 03/18] make co host follower --- template/src/components/whiteboard/WhiteboardConfigure.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index ae4cce9e5..df0dcc268 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -380,7 +380,8 @@ const WhiteboardConfigure: React.FC = props => { whiteboardRoom.current = room; cursorAdapter.setRoom(room); // In livestream: host who starts the whiteboard is Broadcaster (attendees follow their viewport), - // co-hosts get Freedom (can draw independently), attendees are Followers. + // co-hosts are Followers (follow Broadcaster, auto-switch to Freedom when they interact with the board), + // attendees are Followers (no whiteboard controls). // If no Broadcaster exists in the room (e.g. all hosts dropped and rejoined), first host to join claims it. // In meeting: everyone gets Freedom (independent viewport). const noBroadcasterInRoom = @@ -389,10 +390,10 @@ const WhiteboardConfigure: React.FC = props => { ? isHost ? isStartedFirst || noBroadcasterInRoom ? ViewMode.Broadcaster - : ViewMode.Freedom + : ViewMode.Follower : ViewMode.Follower : ViewMode.Freedom; - console.log('[Whiteboard]-LiveStream Setting ViewMode:', viewMode, { + console.log('[Whiteboard-LiveStream] Setting ViewMode:', viewMode, { isHost, isStartedFirst, noBroadcasterInRoom, From e783dbebb5634b96842d49701b2b032d4d260a6c Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Tue, 7 Apr 2026 18:31:18 +0530 Subject: [PATCH 04/18] remove istarted flag --- .../src/components/whiteboard/WhiteboardConfigure.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index df0dcc268..0014121c0 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -326,7 +326,7 @@ const WhiteboardConfigure: React.FC = props => { }; }, []); - const join = (isStartedFirst: boolean = false) => { + const join = () => { const InitState = whiteboardRoomState; try { const index = randomIntFromInterval(0, 9); @@ -388,14 +388,13 @@ const WhiteboardConfigure: React.FC = props => { room.state.broadcastState.broadcasterId === undefined; const viewMode = $config.EVENT_MODE ? isHost - ? isStartedFirst || noBroadcasterInRoom + ? noBroadcasterInRoom ? ViewMode.Broadcaster : ViewMode.Follower : ViewMode.Follower : ViewMode.Freedom; console.log('[Whiteboard-LiveStream] Setting ViewMode:', viewMode, { isHost, - isStartedFirst, noBroadcasterInRoom, EVENT_MODE: $config.EVENT_MODE, }); @@ -464,10 +463,10 @@ const WhiteboardConfigure: React.FC = props => { appIdentifier: appIdentifier, region: $config.WHITEBOARD_REGION, }); - join(true); + join(); setWhiteboardStartedFirst(true); } else if (whiteboardActive) { - join(false); + join(); } else { if ( whiteboardRoom.current && From 495552aa785f3517dc29479c4eb3340077d22c8d Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 10:54:05 +0530 Subject: [PATCH 05/18] attendees cannot access scale controls for whiteboard in livestream --- .../components/whiteboard/WhiteboardWidget.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardWidget.tsx b/template/src/components/whiteboard/WhiteboardWidget.tsx index e98ef73ee..e8daf5a53 100644 --- a/template/src/components/whiteboard/WhiteboardWidget.tsx +++ b/template/src/components/whiteboard/WhiteboardWidget.tsx @@ -84,7 +84,7 @@ const WhiteboardWidget = ({whiteboardRoom}) => { isWhiteboardOnFullScreen, } = useContext(whiteboardContext); const { - data: {whiteboard: {room_uuid} = {}}, + data: {isHost, whiteboard: {room_uuid} = {}}, } = useRoomInfo(); const {store} = useContext(StorageContext); @@ -312,12 +312,16 @@ const WhiteboardWidget = ({whiteboardRoom}) => { ) : ( <> )} - + {/* In livestream, hide zoom/fit controls for attendees to prevent them from + breaking out of Follower mode by triggering a camera move */} + {$config.EVENT_MODE && !isHost ? null : ( + + )} {whiteboardRoom.current?.isWritable && $config.ENABLE_WHITEBOARD_FILE_UPLOAD ? ( <> From 85a11adde416421ef003f410452eb64bec51ef7c Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 11:37:05 +0530 Subject: [PATCH 06/18] move the condition to outer div --- .../whiteboard/WhiteboardWidget.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardWidget.tsx b/template/src/components/whiteboard/WhiteboardWidget.tsx index e8daf5a53..d1cf6cd86 100644 --- a/template/src/components/whiteboard/WhiteboardWidget.tsx +++ b/template/src/components/whiteboard/WhiteboardWidget.tsx @@ -267,7 +267,11 @@ const WhiteboardWidget = ({whiteboardRoom}) => { ) : ( <> )} - {isWebInternal() && !isMobileUA() ? ( + {/* In livestream, hide all whiteboard controls for attendees — they are Followers + and interacting with the board (zoom/pan) would break viewport sync with the host */} + {isWebInternal() && + !isMobileUA() && + !($config.EVENT_MODE && !isHost) ? ( {whiteboardRoom.current?.isWritable ? ( <> @@ -312,16 +316,12 @@ const WhiteboardWidget = ({whiteboardRoom}) => { ) : ( <> )} - {/* In livestream, hide zoom/fit controls for attendees to prevent them from - breaking out of Follower mode by triggering a camera move */} - {$config.EVENT_MODE && !isHost ? null : ( - - )} + {whiteboardRoom.current?.isWritable && $config.ENABLE_WHITEBOARD_FILE_UPLOAD ? ( <> From 91797857fe661ae3ef335faabb55a5e9c767effc Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 17:59:43 +0530 Subject: [PATCH 07/18] fix the reset camera state and permission --- template/src/components/Controls.tsx | 2 +- .../whiteboard/WhiteboardCanvas.tsx | 4 +- .../whiteboard/WhiteboardConfigure.tsx | 141 ++++++++++++------ 3 files changed, 98 insertions(+), 49 deletions(-) diff --git a/template/src/components/Controls.tsx b/template/src/components/Controls.tsx index 98f1cf400..9ae6cdd05 100644 --- a/template/src/components/Controls.tsx +++ b/template/src/components/Controls.tsx @@ -201,7 +201,7 @@ export const WhiteboardListener = () => { ); return () => { - LocalEventEmitter.on( + LocalEventEmitter.off( LocalEventsEnum.WHITEBOARD_ACTIVE_LOCAL, WhiteboardCallBack, ); diff --git a/template/src/components/whiteboard/WhiteboardCanvas.tsx b/template/src/components/whiteboard/WhiteboardCanvas.tsx index 704d13b9b..54ed8ffae 100644 --- a/template/src/components/whiteboard/WhiteboardCanvas.tsx +++ b/template/src/components/whiteboard/WhiteboardCanvas.tsx @@ -37,7 +37,9 @@ const WhiteboardCanvas: React.FC = ({ } return () => { - // unBindRoom(); + if (whiteboardPaper?.parentElement === wbSurfaceRef?.current) { + wbSurfaceRef?.current?.removeChild(whiteboardPaper); + } }; }, []); diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 0014121c0..f5ff90a42 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -123,6 +123,9 @@ const WhiteboardConfigure: React.FC = props => { useEffect(() => { if ( whiteboardRoomState === RoomPhase.Connected && + // In livestream, don't recenter the camera locally when whiteboard gets pinned. + // Followers must inherit the broadcaster's current viewport instead. + !$config.EVENT_MODE && pinnedUid && pinnedUid == whiteboardUidRef.current ) { @@ -141,10 +144,20 @@ const WhiteboardConfigure: React.FC = props => { boardColor: boardColorRemote, whiteboardLastImageUploadPosition: whiteboardLastImageUploadPositionRemote, } = useRoomInfo(); + const shouldUseCursorAdapter = !($config.EVENT_MODE && !isHost); const {currentLayout} = useLayout(); useEffect(() => { try { + const setWritable = + typeof whiteboardRoom?.current?.setWritable === 'function' + ? whiteboardRoom.current.setWritable.bind(whiteboardRoom.current) + : undefined; + + if (!setWritable) { + return; + } + if ( whiteboardRoomState === RoomPhase.Connected && isHost && @@ -157,9 +170,9 @@ const WhiteboardConfigure: React.FC = props => { (activeUids[0] === getWhiteboardUid() || pinnedUid === getWhiteboardUid()) ) { - whiteboardRoom?.current?.setWritable(true); + setWritable(true); } else { - whiteboardRoom?.current?.setWritable(false); + setWritable(false); } } } catch (error) { @@ -172,6 +185,14 @@ const WhiteboardConfigure: React.FC = props => { } }, [currentLayout, isHost, whiteboardRoomState, activeUids, pinnedUid]); + useEffect(() => { + if (whiteboardRoomState === RoomPhase.Connected) { + // Netless reads the bound element size for viewport math. Refresh after + // layout/pin/participant changes so late joiners and pinned mode use the current size. + whiteboardRoom.current?.refreshViewSize?.(); + } + }, [whiteboardRoomState, currentLayout, pinnedUid, activeUids?.length]); + const BoardColorChangedCallBack = ({boardColor}) => { setBoardColor(boardColor); }; @@ -333,42 +354,19 @@ const WhiteboardConfigure: React.FC = props => { setWhiteboardRoomState(RoomPhase.Connecting); logger.log(LogSource.Internals, 'WHITEBOARD', 'Trying to join room'); whiteWebSdkClient.current - .joinRoom( - { - cursorAdapter: cursorAdapter, - uid: `${whiteboardUidRef.current}`, - uuid: room_uuid, - roomToken: room_token, - floatBar: true, - isWritable: isHost && !isMobileUA(), - userPayload: { - cursorName: name, - cursorColor: CursorColor[index].cursorColor, - textColor: CursorColor[index].textColor, - }, - }, - { - // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. - onRoomStateChanged: - $config.EVENT_MODE && isHost - ? modifyState => { - console.log( - '[Whiteboard-LiveStream] onRoomStateChanged', - modifyState.broadcastState, - ); - if ( - modifyState.broadcastState !== undefined && - modifyState.broadcastState.broadcasterId === undefined - ) { - console.log( - '[Whiteboard-LiveStream] Broadcaster dropped, claiming Broadcaster role', - ); - whiteboardRoom.current?.setViewMode(ViewMode.Broadcaster); - } - } - : undefined, + .joinRoom({ + cursorAdapter: shouldUseCursorAdapter ? cursorAdapter : undefined, + uid: `${whiteboardUidRef.current}`, + uuid: room_uuid, + roomToken: room_token, + floatBar: true, + isWritable: isHost && !isMobileUA(), + userPayload: { + cursorName: name, + cursorColor: CursorColor[index].cursorColor, + textColor: CursorColor[index].textColor, }, - ) + }) .then(room => { logger.log( LogSource.Internals, @@ -378,14 +376,16 @@ const WhiteboardConfigure: React.FC = props => { $config.EVENT_MODE, ); whiteboardRoom.current = room; - cursorAdapter.setRoom(room); + if (shouldUseCursorAdapter) { + cursorAdapter.setRoom(room); + } // In livestream: host who starts the whiteboard is Broadcaster (attendees follow their viewport), // co-hosts are Followers (follow Broadcaster, auto-switch to Freedom when they interact with the board), - // attendees are Followers (no whiteboard controls). // If no Broadcaster exists in the room (e.g. all hosts dropped and rejoined), first host to join claims it. // In meeting: everyone gets Freedom (independent viewport). const noBroadcasterInRoom = room.state.broadcastState.broadcasterId === undefined; + console.log('supriya noBroadcasterInRoom', noBroadcasterInRoom); const viewMode = $config.EVENT_MODE ? isHost ? noBroadcasterInRoom @@ -393,13 +393,44 @@ const WhiteboardConfigure: React.FC = props => { : ViewMode.Follower : ViewMode.Follower : ViewMode.Freedom; - console.log('[Whiteboard-LiveStream] Setting ViewMode:', viewMode, { - isHost, - noBroadcasterInRoom, - EVENT_MODE: $config.EVENT_MODE, - }); - whiteboardRoom.current?.setViewMode(viewMode); + room.setViewMode(viewMode); + // In livestream, lock camera gestures for followers so touchpad pan/zoom + // cannot kick them out of follower mode into freedom. + room.disableCameraTransform = + $config.EVENT_MODE && viewMode === ViewMode.Follower; + console.log('supriya viewMode', viewMode); + + // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. + // hasSeenBroadcaster ensures we only react to an actual drop (not the transient + // undefined state during initial room sync before the Broadcaster is propagated). + if ($config.EVENT_MODE && isHost) { + let hasSeenBroadcaster = false; + room.callbacks.on('onRoomStateChanged', modifyState => { + const currentBroadcastState = room.state?.broadcastState; + console.log( + 'supriya currentBroadcastState: ', + currentBroadcastState, + ); + if (currentBroadcastState?.broadcasterId !== undefined) { + hasSeenBroadcaster = true; + } + console.log(' supriya hasSeenBroadcaster: ', hasSeenBroadcaster); + + // broadcasterId becomes undefined only after a clean disconnect (unmount cleanup + // guarantees this), so this is a reliable signal that the Broadcaster dropped. + if ( + hasSeenBroadcaster && + currentBroadcastState?.broadcasterId === undefined + ) { + console.log(' supriya setViewMode: ', ViewMode.Broadcaster); + + room.setViewMode(ViewMode.Broadcaster); + room.disableCameraTransform = false; + } + }); + } whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); + whiteboardRoom.current?.refreshViewSize?.(); if (isHost && !isMobileUA()) { whiteboardRoom.current?.setMemberState({ strokeColor: [0, 0, 0], @@ -426,11 +457,13 @@ const WhiteboardConfigure: React.FC = props => { const InitState = whiteboardRoomState; try { setWhiteboardRoomState(RoomPhase.Disconnecting); - whiteboardRoom.current + const room = whiteboardRoom.current; + room ?.disconnect() .then(() => { + room?.bindHtmlElement(null); + whiteboardRoom.current = {} as Room; whiteboardUidRef.current = Date.now(); - whiteboardRoom.current?.bindHtmlElement(null); setWhiteboardRoomState(RoomPhase.Disconnected); }) .catch(err => { @@ -477,6 +510,20 @@ const WhiteboardConfigure: React.FC = props => { } }, [whiteboardActive]); + // Disconnect from whiteboard room when component unmounts (e.g. user leaves the call abruptly) + useEffect(() => { + return () => { + if ( + whiteboardRoom.current && + Object.keys(whiteboardRoom.current)?.length + ) { + whiteboardRoom.current?.bindHtmlElement(null); + whiteboardRoom.current?.disconnect(); + whiteboardRoom.current = {} as Room; + } + }; + }, []); + const getWhiteboardUid = () => { return whiteboardUidRef?.current; }; From 7408723a0117b5f9d4295fbdd56b7b1cf2600c17 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 18:08:38 +0530 Subject: [PATCH 08/18] refresh view --- .../src/components/whiteboard/WhiteboardConfigure.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index f5ff90a42..da3da3340 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -431,6 +431,15 @@ const WhiteboardConfigure: React.FC = props => { } whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); whiteboardRoom.current?.refreshViewSize?.(); + if ($config.EVENT_MODE && viewMode === ViewMode.Follower) { + // Late followers can occasionally mount before the broadcaster viewport + // is fully applied. Re-applying follower mode after the first bind/size + // refresh nudges Netless to sync the current broadcaster view immediately. + requestAnimationFrame(() => { + room.refreshViewSize?.(); + room.setViewMode(ViewMode.Follower); + }); + } if (isHost && !isMobileUA()) { whiteboardRoom.current?.setMemberState({ strokeColor: [0, 0, 0], From a4697491efbeb97bdf1a7b69f425eaab8c8f6e2e Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 18:12:02 +0530 Subject: [PATCH 09/18] add refs to clean up state --- .../whiteboard/WhiteboardToolBox.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardToolBox.tsx b/template/src/components/whiteboard/WhiteboardToolBox.tsx index b0d065d9a..f56e1721d 100644 --- a/template/src/components/whiteboard/WhiteboardToolBox.tsx +++ b/template/src/components/whiteboard/WhiteboardToolBox.tsx @@ -261,6 +261,8 @@ const WhiteboardToolBox = ({whiteboardRoom}) => { const [isColorContainerHovered, setColorContainerHovered] = useState(false); const [isPencilBtnHovered, setPencilBtnHovered] = useState(false); const [isPencilContainerHovered, setPencilContainerHovered] = useState(false); + const roomStateChangedRef = React.useRef(null); + const clearWhiteboardRef = React.useRef(null); const handleSelect = (applicanceName: ApplianceNames) => { if (applicanceName !== ApplianceNames.selector) { setCursorColor(ColorPickerValues[selectedColor].rgb); @@ -272,17 +274,41 @@ const WhiteboardToolBox = ({whiteboardRoom}) => { }; useEffect(() => { - whiteboardRoom?.current?.callbacks.on('onRoomStateChanged', modifyState => { + roomStateChangedRef.current = modifyState => { setRoomState({ ...whiteboardRoom?.current?.state, ...modifyState, }); - }); - LocalEventEmitter.on(LocalEventsEnum.CLEAR_WHITEBOARD, () => { + }; + clearWhiteboardRef.current = () => { whiteboardRoom.current?.cleanCurrentScene(); setShowWhiteboardClearAllPopup(false); clearAllCallback(); - }); + }; + + whiteboardRoom?.current?.callbacks?.on( + 'onRoomStateChanged', + roomStateChangedRef.current, + ); + LocalEventEmitter.on( + LocalEventsEnum.CLEAR_WHITEBOARD, + clearWhiteboardRef.current, + ); + + return () => { + if (roomStateChangedRef.current) { + whiteboardRoom?.current?.callbacks?.off( + 'onRoomStateChanged', + roomStateChangedRef.current, + ); + } + if (clearWhiteboardRef.current) { + LocalEventEmitter.off( + LocalEventsEnum.CLEAR_WHITEBOARD, + clearWhiteboardRef.current, + ); + } + }; }, []); useEffect(() => { From 4765c42f7c56bcc457d5cc33c20dd0e9c27f7b20 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 20:33:34 +0530 Subject: [PATCH 10/18] add log for lag --- .../whiteboard/WhiteboardConfigure.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index da3da3340..6dc6111c0 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -351,7 +351,14 @@ const WhiteboardConfigure: React.FC = props => { const InitState = whiteboardRoomState; try { const index = randomIntFromInterval(0, 9); + const joinStartTs = Date.now(); setWhiteboardRoomState(RoomPhase.Connecting); + console.log('[whiteboard-lag] join:start', { + ts: joinStartTs, + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + }); logger.log(LogSource.Internals, 'WHITEBOARD', 'Trying to join room'); whiteWebSdkClient.current .joinRoom({ @@ -368,6 +375,7 @@ const WhiteboardConfigure: React.FC = props => { }, }) .then(room => { + const joinSuccessTs = Date.now(); logger.log( LogSource.Internals, 'WHITEBOARD', @@ -375,6 +383,13 @@ const WhiteboardConfigure: React.FC = props => { isHost, $config.EVENT_MODE, ); + console.log('[whiteboard-lag] join:success', { + ts: joinSuccessTs, + latencyMs: joinSuccessTs - joinStartTs, + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + }); whiteboardRoom.current = room; if (shouldUseCursorAdapter) { cursorAdapter.setRoom(room); @@ -398,6 +413,13 @@ const WhiteboardConfigure: React.FC = props => { // cannot kick them out of follower mode into freedom. room.disableCameraTransform = $config.EVENT_MODE && viewMode === ViewMode.Follower; + console.log('[whiteboard-lag] viewmode:applied', { + ts: Date.now(), + isHost, + viewMode, + disableCameraTransform: room.disableCameraTransform, + broadcasterId: room.state.broadcastState.broadcasterId, + }); console.log('supriya viewMode', viewMode); // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. @@ -430,14 +452,32 @@ const WhiteboardConfigure: React.FC = props => { }); } whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); + console.log('[whiteboard-lag] bindHtmlElement', { + ts: Date.now(), + isHost, + viewMode, + }); whiteboardRoom.current?.refreshViewSize?.(); + console.log('[whiteboard-lag] refreshViewSize:after-bind', { + ts: Date.now(), + isHost, + viewMode, + }); if ($config.EVENT_MODE && viewMode === ViewMode.Follower) { // Late followers can occasionally mount before the broadcaster viewport // is fully applied. Re-applying follower mode after the first bind/size // refresh nudges Netless to sync the current broadcaster view immediately. requestAnimationFrame(() => { + console.log('[whiteboard-lag] follower-resync:start', { + ts: Date.now(), + isHost, + }); room.refreshViewSize?.(); room.setViewMode(ViewMode.Follower); + console.log('[whiteboard-lag] follower-resync:done', { + ts: Date.now(), + isHost, + }); }); } if (isHost && !isMobileUA()) { @@ -446,6 +486,12 @@ const WhiteboardConfigure: React.FC = props => { }); } setWhiteboardRoomState(RoomPhase.Connected); + console.log('[whiteboard-lag] roomPhase:connected', { + ts: Date.now(), + isHost, + viewMode, + totalJoinLatencyMs: Date.now() - joinStartTs, + }); }) .catch(err => { setWhiteboardRoomState(InitState); From d0da8a7d3d619de26d31549b64687d5a1ec8bb9a Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 20:43:55 +0530 Subject: [PATCH 11/18] whiteboard not in pinned mode when attendee joins --- template/src/components/Controls.tsx | 18 ++++++++++++++---- .../whiteboard/WhiteboardConfigure.tsx | 12 ++++++++++++ .../src/pages/video-call/VideoComponent.tsx | 11 +++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/template/src/components/Controls.tsx b/template/src/components/Controls.tsx index 9ae6cdd05..6faa6d02d 100644 --- a/template/src/components/Controls.tsx +++ b/template/src/components/Controls.tsx @@ -227,8 +227,13 @@ export const WhiteboardListener = () => { }; useEffect(() => { - whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned'); - }, []); + if (whiteboardActive) { + dispatch({type: 'UserPin', value: [getWhiteboardUid()]}); + if (currentLayout !== 'pinned') { + setLayout('pinned'); + } + } + }, [whiteboardActive]); const toggleWhiteboard = ( whiteboardActive: boolean, @@ -420,8 +425,13 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { }; useEffect(() => { - whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned'); - }, []); + if (whiteboardActive) { + dispatch({type: 'UserPin', value: [getWhiteboardUid()]}); + if (currentLayout !== 'pinned') { + setLayout('pinned'); + } + } + }, [whiteboardActive]); const WhiteboardCallBack = ({status}) => { if (status) { diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 6dc6111c0..ddddb3dad 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -408,6 +408,14 @@ const WhiteboardConfigure: React.FC = props => { : ViewMode.Follower : ViewMode.Follower : ViewMode.Freedom; + console.log('[whiteboard-view-mode] initial', { + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + broadcasterId: room.state.broadcastState.broadcasterId, + noBroadcasterInRoom, + viewMode, + }); room.setViewMode(viewMode); // In livestream, lock camera gestures for followers so touchpad pan/zoom // cannot kick them out of follower mode into freedom. @@ -444,6 +452,10 @@ const WhiteboardConfigure: React.FC = props => { hasSeenBroadcaster && currentBroadcastState?.broadcasterId === undefined ) { + console.log('[whiteboard-view-mode] promote-to-broadcaster', { + isHost, + whiteboardUid: `${whiteboardUidRef.current}`, + }); console.log(' supriya setViewMode: ', ViewMode.Broadcaster); room.setViewMode(ViewMode.Broadcaster); diff --git a/template/src/pages/video-call/VideoComponent.tsx b/template/src/pages/video-call/VideoComponent.tsx index 10ccbbe41..56f199017 100644 --- a/template/src/pages/video-call/VideoComponent.tsx +++ b/template/src/pages/video-call/VideoComponent.tsx @@ -12,6 +12,7 @@ import Spacer from '../../atoms/Spacer'; import {useLiveStreamDataContext} from '../../components/contexts/LiveStreamDataContext'; import {useCustomization} from 'customization-implementation'; import useMount from '../../components/useMount'; +import {whiteboardContext} from '../../components/whiteboard/WhiteboardConfigure'; const VideoComponent = () => { const {dispatch} = useContext(DispatchContext); @@ -19,6 +20,7 @@ const VideoComponent = () => { const layoutsData = useLayoutsData(); const {currentLayout, setLayout} = useLayout(); const {activeUids, pinnedUid} = useContent(); + const {whiteboardActive} = useContext(whiteboardContext); const {rtcProps} = useContext(PropsContext); const isDesktop = useIsDesktop(); const {audienceUids, hostUids} = useLiveStreamDataContext(); @@ -58,7 +60,12 @@ const VideoComponent = () => { const currentLayoutRef = useRef(currentLayout); const gridLayoutName = getGridLayoutName(); useEffect(() => { - if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed) { + // When only one participant is visible, reset pinning and revert to grid layout. + // Skip this reset when whiteboard is active: in EVENT_MODE the audience's own uid is + // filtered from activeUids, so activeUids.length === 1 (host only) even while the + // whiteboard uid is being added. Without this guard the effect would clear pinnedUid + // and switch to grid, preventing the whiteboard from appearing in the max/pinned slot. + if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed && !whiteboardActive) { if (pinnedUid) { dispatch({type: 'UserPin', value: [0]}); dispatch({type: 'UserSecondaryPin', value: [0]}); @@ -67,7 +74,7 @@ const VideoComponent = () => { setLayout(gridLayoutName); } } - }, [activeUids, isCustomLayoutUsed]); + }, [activeUids, isCustomLayoutUsed, whiteboardActive]); useEffect(() => { currentLayoutRef.current = currentLayout; From eac36923550143be845717ac15bd8c6b78b785d0 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 20:51:24 +0530 Subject: [PATCH 12/18] fix the dependency --- .../components/whiteboard/WhiteboardConfigure.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index ddddb3dad..2eafa091b 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -183,15 +183,20 @@ const WhiteboardConfigure: React.FC = props => { error, ); } - }, [currentLayout, isHost, whiteboardRoomState, activeUids, pinnedUid]); + // activeUids[0] (the max-slot uid) is the only element checked in the condition above — + // using the full activeUids array would re-run setWritable on every participant join/leave, + // briefly stalling the SDK draw queue and causing cumulative lag for attendees. + }, [currentLayout, isHost, whiteboardRoomState, activeUids?.[0], pinnedUid]); useEffect(() => { if (whiteboardRoomState === RoomPhase.Connected) { - // Netless reads the bound element size for viewport math. Refresh after - // layout/pin/participant changes so late joiners and pinned mode use the current size. + // Netless reads the bound element size for viewport math. Refresh when layout or + // pin state changes (those affect the whiteboard container size). Participant + // count changes do not affect container size in pinned layout, so activeUids.length + // is intentionally excluded to avoid redundant refreshes on every join. whiteboardRoom.current?.refreshViewSize?.(); } - }, [whiteboardRoomState, currentLayout, pinnedUid, activeUids?.length]); + }, [whiteboardRoomState, currentLayout, pinnedUid]); const BoardColorChangedCallBack = ({boardColor}) => { setBoardColor(boardColor); From e0982ad133c089a4d8df069f68618042cd438b32 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 8 Apr 2026 20:54:16 +0530 Subject: [PATCH 13/18] add connecting loader --- .../whiteboard/WhiteboardCanvas.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardCanvas.tsx b/template/src/components/whiteboard/WhiteboardCanvas.tsx index 54ed8ffae..38f30bd44 100644 --- a/template/src/components/whiteboard/WhiteboardCanvas.tsx +++ b/template/src/components/whiteboard/WhiteboardCanvas.tsx @@ -16,7 +16,7 @@ import { whiteboardContext, whiteboardPaper, } from './WhiteboardConfigure'; -import {StyleSheet, View, Text} from 'react-native'; +import {StyleSheet, View, Text, ActivityIndicator} from 'react-native'; import {RoomPhase, ApplianceNames} from 'white-web-sdk'; import WhiteboardToolBox from './WhiteboardToolBox'; import WhiteboardWidget from './WhiteboardWidget'; @@ -28,7 +28,8 @@ const WhiteboardCanvas: React.FC = ({ showToolbox, }) => { const wbSurfaceRef = useRef(); - const {whiteboardRoom, boardColor} = useContext(whiteboardContext); + const {whiteboardRoom, boardColor, whiteboardRoomState} = + useContext(whiteboardContext); useEffect(function () { if (whiteboardPaper) { @@ -43,8 +44,16 @@ const WhiteboardCanvas: React.FC = ({ }; }, []); + const isSyncing = whiteboardRoomState === RoomPhase.Connecting; + return ( <> + {isSyncing && ( + + + Syncing whiteboard... + + )} {showToolbox && //@ts-ignore @@ -96,6 +105,22 @@ const style = StyleSheet.create({ paddingTop: 50, paddingLeft: 20, }, + syncingOverlay: { + position: 'absolute', + width: '100%', + height: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 20, + borderRadius: 4, + }, + syncingText: { + color: '#fff', + marginTop: 12, + fontSize: 14, + }, }); export default WhiteboardCanvas; From dc8cdf70aef2e5213624ddbdebea6934ad4bd93d Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Thu, 9 Apr 2026 11:50:28 +0530 Subject: [PATCH 14/18] remove debug logs --- .../whiteboard/WhiteboardConfigure.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 2eafa091b..d6a12fc12 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -183,9 +183,9 @@ const WhiteboardConfigure: React.FC = props => { error, ); } - // activeUids[0] (the max-slot uid) is the only element checked in the condition above — - // using the full activeUids array would re-run setWritable on every participant join/leave, - // briefly stalling the SDK draw queue and causing cumulative lag for attendees. + // activeUids[0] (the max-slot uid) is the only element checked in the condition above — + // using the full activeUids array would re-run setWritable on every participant join/leave, + // briefly stalling the SDK draw queue and causing cumulative lag for attendees. }, [currentLayout, isHost, whiteboardRoomState, activeUids?.[0], pinnedUid]); useEffect(() => { @@ -405,7 +405,6 @@ const WhiteboardConfigure: React.FC = props => { // In meeting: everyone gets Freedom (independent viewport). const noBroadcasterInRoom = room.state.broadcastState.broadcasterId === undefined; - console.log('supriya noBroadcasterInRoom', noBroadcasterInRoom); const viewMode = $config.EVENT_MODE ? isHost ? noBroadcasterInRoom @@ -433,7 +432,6 @@ const WhiteboardConfigure: React.FC = props => { disableCameraTransform: room.disableCameraTransform, broadcasterId: room.state.broadcastState.broadcasterId, }); - console.log('supriya viewMode', viewMode); // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. // hasSeenBroadcaster ensures we only react to an actual drop (not the transient @@ -442,15 +440,9 @@ const WhiteboardConfigure: React.FC = props => { let hasSeenBroadcaster = false; room.callbacks.on('onRoomStateChanged', modifyState => { const currentBroadcastState = room.state?.broadcastState; - console.log( - 'supriya currentBroadcastState: ', - currentBroadcastState, - ); if (currentBroadcastState?.broadcasterId !== undefined) { hasSeenBroadcaster = true; } - console.log(' supriya hasSeenBroadcaster: ', hasSeenBroadcaster); - // broadcasterId becomes undefined only after a clean disconnect (unmount cleanup // guarantees this), so this is a reliable signal that the Broadcaster dropped. if ( @@ -461,8 +453,6 @@ const WhiteboardConfigure: React.FC = props => { isHost, whiteboardUid: `${whiteboardUidRef.current}`, }); - console.log(' supriya setViewMode: ', ViewMode.Broadcaster); - room.setViewMode(ViewMode.Broadcaster); room.disableCameraTransform = false; } From d1a2a574e01071b52aabe20c2780072d736a0f2c Mon Sep 17 00:00:00 2001 From: HariharanIT Date: Thu, 9 Apr 2026 14:17:05 +0530 Subject: [PATCH 15/18] Updated the version number --- template/defaultConfig.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/defaultConfig.js b/template/defaultConfig.js index c7469d385..441d61ff2 100644 --- a/template/defaultConfig.js +++ b/template/defaultConfig.js @@ -77,8 +77,8 @@ const DefaultConfig = { CHAT_ORG_NAME: '', CHAT_APP_NAME: '', CHAT_URL: '', - CLI_VERSION: '3.1.15', - CORE_VERSION: '4.1.15', + CLI_VERSION: '3.1.16-beta.1', + CORE_VERSION: '4.1.16-beta.1', DISABLE_LANDSCAPE_MODE: false, STT_AUTO_START: false, CLOUD_RECORDING_AUTO_START: false, From 913c808a7505e45ec53a47296428119671faf161 Mon Sep 17 00:00:00 2001 From: HariharanIT Date: Thu, 9 Apr 2026 14:17:53 +0530 Subject: [PATCH 16/18] 4.1.16-beta.1 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5a883ede..f3bfc60ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { "name": "agora-appbuilder-core", - "version": "4.1.15", + "version": "4.1.16-beta.1", "lockfileVersion": 1 } diff --git a/package.json b/package.json index af39698fd..072df362f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agora-appbuilder-core", - "version": "4.1.15", + "version": "4.1.16-beta.1", "description": "React Native template for RTE app builder", "main": "index.js", "files": [ From f0b723f85be20fd6dc2518a16b5209f51204df66 Mon Sep 17 00:00:00 2001 From: HariharanIT Date: Thu, 9 Apr 2026 16:26:53 +0530 Subject: [PATCH 17/18] Updated prod version --- template/defaultConfig.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/defaultConfig.js b/template/defaultConfig.js index 441d61ff2..333d7af91 100644 --- a/template/defaultConfig.js +++ b/template/defaultConfig.js @@ -77,8 +77,8 @@ const DefaultConfig = { CHAT_ORG_NAME: '', CHAT_APP_NAME: '', CHAT_URL: '', - CLI_VERSION: '3.1.16-beta.1', - CORE_VERSION: '4.1.16-beta.1', + CLI_VERSION: '3.1.16', + CORE_VERSION: '4.1.16', DISABLE_LANDSCAPE_MODE: false, STT_AUTO_START: false, CLOUD_RECORDING_AUTO_START: false, From b2535bd5c3500c6b4cd78a55d422fcb2ad739ca1 Mon Sep 17 00:00:00 2001 From: HariharanIT Date: Thu, 9 Apr 2026 16:27:03 +0530 Subject: [PATCH 18/18] 4.1.16 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3bfc60ff..c9fe1ba22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { "name": "agora-appbuilder-core", - "version": "4.1.16-beta.1", + "version": "4.1.16", "lockfileVersion": 1 } diff --git a/package.json b/package.json index 072df362f..16fd94c72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agora-appbuilder-core", - "version": "4.1.16-beta.1", + "version": "4.1.16", "description": "React Native template for RTE app builder", "main": "index.js", "files": [