agora-appbuilder-core 4.1.15 → 4.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agora-appbuilder-core",
3
- "version": "4.1.15",
3
+ "version": "4.1.16",
4
4
  "description": "React Native template for RTE app builder",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -77,8 +77,8 @@ const DefaultConfig = {
77
77
  CHAT_ORG_NAME: '',
78
78
  CHAT_APP_NAME: '',
79
79
  CHAT_URL: '',
80
- CLI_VERSION: '3.1.15',
81
- CORE_VERSION: '4.1.15',
80
+ CLI_VERSION: '3.1.16',
81
+ CORE_VERSION: '4.1.16',
82
82
  DISABLE_LANDSCAPE_MODE: false,
83
83
  STT_AUTO_START: false,
84
84
  CLOUD_RECORDING_AUTO_START: false,
@@ -201,7 +201,7 @@ export const WhiteboardListener = () => {
201
201
  );
202
202
 
203
203
  return () => {
204
- LocalEventEmitter.on(
204
+ LocalEventEmitter.off(
205
205
  LocalEventsEnum.WHITEBOARD_ACTIVE_LOCAL,
206
206
  WhiteboardCallBack,
207
207
  );
@@ -227,8 +227,13 @@ export const WhiteboardListener = () => {
227
227
  };
228
228
 
229
229
  useEffect(() => {
230
- whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned');
231
- }, []);
230
+ if (whiteboardActive) {
231
+ dispatch({type: 'UserPin', value: [getWhiteboardUid()]});
232
+ if (currentLayout !== 'pinned') {
233
+ setLayout('pinned');
234
+ }
235
+ }
236
+ }, [whiteboardActive]);
232
237
 
233
238
  const toggleWhiteboard = (
234
239
  whiteboardActive: boolean,
@@ -420,8 +425,13 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => {
420
425
  };
421
426
 
422
427
  useEffect(() => {
423
- whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned');
424
- }, []);
428
+ if (whiteboardActive) {
429
+ dispatch({type: 'UserPin', value: [getWhiteboardUid()]});
430
+ if (currentLayout !== 'pinned') {
431
+ setLayout('pinned');
432
+ }
433
+ }
434
+ }, [whiteboardActive]);
425
435
 
426
436
  const WhiteboardCallBack = ({status}) => {
427
437
  if (status) {
@@ -16,7 +16,7 @@ import {
16
16
  whiteboardContext,
17
17
  whiteboardPaper,
18
18
  } from './WhiteboardConfigure';
19
- import {StyleSheet, View, Text} from 'react-native';
19
+ import {StyleSheet, View, Text, ActivityIndicator} from 'react-native';
20
20
  import {RoomPhase, ApplianceNames} from 'white-web-sdk';
21
21
  import WhiteboardToolBox from './WhiteboardToolBox';
22
22
  import WhiteboardWidget from './WhiteboardWidget';
@@ -28,7 +28,8 @@ const WhiteboardCanvas: React.FC<WhiteboardCanvasInterface> = ({
28
28
  showToolbox,
29
29
  }) => {
30
30
  const wbSurfaceRef = useRef();
31
- const {whiteboardRoom, boardColor} = useContext(whiteboardContext);
31
+ const {whiteboardRoom, boardColor, whiteboardRoomState} =
32
+ useContext(whiteboardContext);
32
33
 
33
34
  useEffect(function () {
34
35
  if (whiteboardPaper) {
@@ -37,12 +38,22 @@ const WhiteboardCanvas: React.FC<WhiteboardCanvasInterface> = ({
37
38
  }
38
39
 
39
40
  return () => {
40
- // unBindRoom();
41
+ if (whiteboardPaper?.parentElement === wbSurfaceRef?.current) {
42
+ wbSurfaceRef?.current?.removeChild(whiteboardPaper);
43
+ }
41
44
  };
42
45
  }, []);
43
46
 
47
+ const isSyncing = whiteboardRoomState === RoomPhase.Connecting;
48
+
44
49
  return (
45
50
  <>
51
+ {isSyncing && (
52
+ <View style={style.syncingOverlay}>
53
+ <ActivityIndicator size="large" color="#fff" />
54
+ <Text style={style.syncingText}>Syncing whiteboard...</Text>
55
+ </View>
56
+ )}
46
57
  <WhiteboardWidget whiteboardRoom={whiteboardRoom} />
47
58
  {showToolbox &&
48
59
  //@ts-ignore
@@ -94,6 +105,22 @@ const style = StyleSheet.create({
94
105
  paddingTop: 50,
95
106
  paddingLeft: 20,
96
107
  },
108
+ syncingOverlay: {
109
+ position: 'absolute',
110
+ width: '100%',
111
+ height: '100%',
112
+ backgroundColor: 'rgba(0,0,0,0.5)',
113
+ display: 'flex',
114
+ justifyContent: 'center',
115
+ alignItems: 'center',
116
+ zIndex: 20,
117
+ borderRadius: 4,
118
+ },
119
+ syncingText: {
120
+ color: '#fff',
121
+ marginTop: 12,
122
+ fontSize: 14,
123
+ },
97
124
  });
98
125
 
99
126
  export default WhiteboardCanvas;
@@ -123,6 +123,9 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
123
123
  useEffect(() => {
124
124
  if (
125
125
  whiteboardRoomState === RoomPhase.Connected &&
126
+ // In livestream, don't recenter the camera locally when whiteboard gets pinned.
127
+ // Followers must inherit the broadcaster's current viewport instead.
128
+ !$config.EVENT_MODE &&
126
129
  pinnedUid &&
127
130
  pinnedUid == whiteboardUidRef.current
128
131
  ) {
@@ -141,10 +144,20 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
141
144
  boardColor: boardColorRemote,
142
145
  whiteboardLastImageUploadPosition: whiteboardLastImageUploadPositionRemote,
143
146
  } = useRoomInfo();
147
+ const shouldUseCursorAdapter = !($config.EVENT_MODE && !isHost);
144
148
  const {currentLayout} = useLayout();
145
149
 
146
150
  useEffect(() => {
147
151
  try {
152
+ const setWritable =
153
+ typeof whiteboardRoom?.current?.setWritable === 'function'
154
+ ? whiteboardRoom.current.setWritable.bind(whiteboardRoom.current)
155
+ : undefined;
156
+
157
+ if (!setWritable) {
158
+ return;
159
+ }
160
+
148
161
  if (
149
162
  whiteboardRoomState === RoomPhase.Connected &&
150
163
  isHost &&
@@ -157,9 +170,9 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
157
170
  (activeUids[0] === getWhiteboardUid() ||
158
171
  pinnedUid === getWhiteboardUid())
159
172
  ) {
160
- whiteboardRoom?.current?.setWritable(true);
173
+ setWritable(true);
161
174
  } else {
162
- whiteboardRoom?.current?.setWritable(false);
175
+ setWritable(false);
163
176
  }
164
177
  }
165
178
  } catch (error) {
@@ -170,7 +183,20 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
170
183
  error,
171
184
  );
172
185
  }
173
- }, [currentLayout, isHost, whiteboardRoomState, activeUids, pinnedUid]);
186
+ // activeUids[0] (the max-slot uid) is the only element checked in the condition above —
187
+ // using the full activeUids array would re-run setWritable on every participant join/leave,
188
+ // briefly stalling the SDK draw queue and causing cumulative lag for attendees.
189
+ }, [currentLayout, isHost, whiteboardRoomState, activeUids?.[0], pinnedUid]);
190
+
191
+ useEffect(() => {
192
+ if (whiteboardRoomState === RoomPhase.Connected) {
193
+ // Netless reads the bound element size for viewport math. Refresh when layout or
194
+ // pin state changes (those affect the whiteboard container size). Participant
195
+ // count changes do not affect container size in pinned layout, so activeUids.length
196
+ // is intentionally excluded to avoid redundant refreshes on every join.
197
+ whiteboardRoom.current?.refreshViewSize?.();
198
+ }
199
+ }, [whiteboardRoomState, currentLayout, pinnedUid]);
174
200
 
175
201
  const BoardColorChangedCallBack = ({boardColor}) => {
176
202
  setBoardColor(boardColor);
@@ -330,11 +356,18 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
330
356
  const InitState = whiteboardRoomState;
331
357
  try {
332
358
  const index = randomIntFromInterval(0, 9);
359
+ const joinStartTs = Date.now();
333
360
  setWhiteboardRoomState(RoomPhase.Connecting);
361
+ console.log('[whiteboard-lag] join:start', {
362
+ ts: joinStartTs,
363
+ isHost,
364
+ eventMode: $config.EVENT_MODE,
365
+ whiteboardUid: `${whiteboardUidRef.current}`,
366
+ });
334
367
  logger.log(LogSource.Internals, 'WHITEBOARD', 'Trying to join room');
335
368
  whiteWebSdkClient.current
336
369
  .joinRoom({
337
- cursorAdapter: cursorAdapter,
370
+ cursorAdapter: shouldUseCursorAdapter ? cursorAdapter : undefined,
338
371
  uid: `${whiteboardUidRef.current}`,
339
372
  uuid: room_uuid,
340
373
  roomToken: room_token,
@@ -347,17 +380,125 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
347
380
  },
348
381
  })
349
382
  .then(room => {
350
- logger.log(LogSource.Internals, 'WHITEBOARD', 'Join room successful');
383
+ const joinSuccessTs = Date.now();
384
+ logger.log(
385
+ LogSource.Internals,
386
+ 'WHITEBOARD',
387
+ 'Join room successful',
388
+ isHost,
389
+ $config.EVENT_MODE,
390
+ );
391
+ console.log('[whiteboard-lag] join:success', {
392
+ ts: joinSuccessTs,
393
+ latencyMs: joinSuccessTs - joinStartTs,
394
+ isHost,
395
+ eventMode: $config.EVENT_MODE,
396
+ whiteboardUid: `${whiteboardUidRef.current}`,
397
+ });
351
398
  whiteboardRoom.current = room;
352
- cursorAdapter.setRoom(room);
353
- whiteboardRoom.current?.setViewMode(ViewMode.Freedom);
399
+ if (shouldUseCursorAdapter) {
400
+ cursorAdapter.setRoom(room);
401
+ }
402
+ // In livestream: host who starts the whiteboard is Broadcaster (attendees follow their viewport),
403
+ // co-hosts are Followers (follow Broadcaster, auto-switch to Freedom when they interact with the board),
404
+ // If no Broadcaster exists in the room (e.g. all hosts dropped and rejoined), first host to join claims it.
405
+ // In meeting: everyone gets Freedom (independent viewport).
406
+ const noBroadcasterInRoom =
407
+ room.state.broadcastState.broadcasterId === undefined;
408
+ const viewMode = $config.EVENT_MODE
409
+ ? isHost
410
+ ? noBroadcasterInRoom
411
+ ? ViewMode.Broadcaster
412
+ : ViewMode.Follower
413
+ : ViewMode.Follower
414
+ : ViewMode.Freedom;
415
+ console.log('[whiteboard-view-mode] initial', {
416
+ isHost,
417
+ eventMode: $config.EVENT_MODE,
418
+ whiteboardUid: `${whiteboardUidRef.current}`,
419
+ broadcasterId: room.state.broadcastState.broadcasterId,
420
+ noBroadcasterInRoom,
421
+ viewMode,
422
+ });
423
+ room.setViewMode(viewMode);
424
+ // In livestream, lock camera gestures for followers so touchpad pan/zoom
425
+ // cannot kick them out of follower mode into freedom.
426
+ room.disableCameraTransform =
427
+ $config.EVENT_MODE && viewMode === ViewMode.Follower;
428
+ console.log('[whiteboard-lag] viewmode:applied', {
429
+ ts: Date.now(),
430
+ isHost,
431
+ viewMode,
432
+ disableCameraTransform: room.disableCameraTransform,
433
+ broadcasterId: room.state.broadcastState.broadcasterId,
434
+ });
435
+
436
+ // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster.
437
+ // hasSeenBroadcaster ensures we only react to an actual drop (not the transient
438
+ // undefined state during initial room sync before the Broadcaster is propagated).
439
+ if ($config.EVENT_MODE && isHost) {
440
+ let hasSeenBroadcaster = false;
441
+ room.callbacks.on('onRoomStateChanged', modifyState => {
442
+ const currentBroadcastState = room.state?.broadcastState;
443
+ if (currentBroadcastState?.broadcasterId !== undefined) {
444
+ hasSeenBroadcaster = true;
445
+ }
446
+ // broadcasterId becomes undefined only after a clean disconnect (unmount cleanup
447
+ // guarantees this), so this is a reliable signal that the Broadcaster dropped.
448
+ if (
449
+ hasSeenBroadcaster &&
450
+ currentBroadcastState?.broadcasterId === undefined
451
+ ) {
452
+ console.log('[whiteboard-view-mode] promote-to-broadcaster', {
453
+ isHost,
454
+ whiteboardUid: `${whiteboardUidRef.current}`,
455
+ });
456
+ room.setViewMode(ViewMode.Broadcaster);
457
+ room.disableCameraTransform = false;
458
+ }
459
+ });
460
+ }
354
461
  whiteboardRoom.current?.bindHtmlElement(whiteboardPaper);
462
+ console.log('[whiteboard-lag] bindHtmlElement', {
463
+ ts: Date.now(),
464
+ isHost,
465
+ viewMode,
466
+ });
467
+ whiteboardRoom.current?.refreshViewSize?.();
468
+ console.log('[whiteboard-lag] refreshViewSize:after-bind', {
469
+ ts: Date.now(),
470
+ isHost,
471
+ viewMode,
472
+ });
473
+ if ($config.EVENT_MODE && viewMode === ViewMode.Follower) {
474
+ // Late followers can occasionally mount before the broadcaster viewport
475
+ // is fully applied. Re-applying follower mode after the first bind/size
476
+ // refresh nudges Netless to sync the current broadcaster view immediately.
477
+ requestAnimationFrame(() => {
478
+ console.log('[whiteboard-lag] follower-resync:start', {
479
+ ts: Date.now(),
480
+ isHost,
481
+ });
482
+ room.refreshViewSize?.();
483
+ room.setViewMode(ViewMode.Follower);
484
+ console.log('[whiteboard-lag] follower-resync:done', {
485
+ ts: Date.now(),
486
+ isHost,
487
+ });
488
+ });
489
+ }
355
490
  if (isHost && !isMobileUA()) {
356
491
  whiteboardRoom.current?.setMemberState({
357
492
  strokeColor: [0, 0, 0],
358
493
  });
359
494
  }
360
495
  setWhiteboardRoomState(RoomPhase.Connected);
496
+ console.log('[whiteboard-lag] roomPhase:connected', {
497
+ ts: Date.now(),
498
+ isHost,
499
+ viewMode,
500
+ totalJoinLatencyMs: Date.now() - joinStartTs,
501
+ });
361
502
  })
362
503
  .catch(err => {
363
504
  setWhiteboardRoomState(InitState);
@@ -378,11 +519,13 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
378
519
  const InitState = whiteboardRoomState;
379
520
  try {
380
521
  setWhiteboardRoomState(RoomPhase.Disconnecting);
381
- whiteboardRoom.current
522
+ const room = whiteboardRoom.current;
523
+ room
382
524
  ?.disconnect()
383
525
  .then(() => {
526
+ room?.bindHtmlElement(null);
527
+ whiteboardRoom.current = {} as Room;
384
528
  whiteboardUidRef.current = Date.now();
385
- whiteboardRoom.current?.bindHtmlElement(null);
386
529
  setWhiteboardRoomState(RoomPhase.Disconnected);
387
530
  })
388
531
  .catch(err => {
@@ -429,6 +572,20 @@ const WhiteboardConfigure: React.FC<WhiteboardPropsInterface> = props => {
429
572
  }
430
573
  }, [whiteboardActive]);
431
574
 
575
+ // Disconnect from whiteboard room when component unmounts (e.g. user leaves the call abruptly)
576
+ useEffect(() => {
577
+ return () => {
578
+ if (
579
+ whiteboardRoom.current &&
580
+ Object.keys(whiteboardRoom.current)?.length
581
+ ) {
582
+ whiteboardRoom.current?.bindHtmlElement(null);
583
+ whiteboardRoom.current?.disconnect();
584
+ whiteboardRoom.current = {} as Room;
585
+ }
586
+ };
587
+ }, []);
588
+
432
589
  const getWhiteboardUid = () => {
433
590
  return whiteboardUidRef?.current;
434
591
  };
@@ -261,6 +261,8 @@ const WhiteboardToolBox = ({whiteboardRoom}) => {
261
261
  const [isColorContainerHovered, setColorContainerHovered] = useState(false);
262
262
  const [isPencilBtnHovered, setPencilBtnHovered] = useState(false);
263
263
  const [isPencilContainerHovered, setPencilContainerHovered] = useState(false);
264
+ const roomStateChangedRef = React.useRef(null);
265
+ const clearWhiteboardRef = React.useRef(null);
264
266
  const handleSelect = (applicanceName: ApplianceNames) => {
265
267
  if (applicanceName !== ApplianceNames.selector) {
266
268
  setCursorColor(ColorPickerValues[selectedColor].rgb);
@@ -272,17 +274,41 @@ const WhiteboardToolBox = ({whiteboardRoom}) => {
272
274
  };
273
275
 
274
276
  useEffect(() => {
275
- whiteboardRoom?.current?.callbacks.on('onRoomStateChanged', modifyState => {
277
+ roomStateChangedRef.current = modifyState => {
276
278
  setRoomState({
277
279
  ...whiteboardRoom?.current?.state,
278
280
  ...modifyState,
279
281
  });
280
- });
281
- LocalEventEmitter.on(LocalEventsEnum.CLEAR_WHITEBOARD, () => {
282
+ };
283
+ clearWhiteboardRef.current = () => {
282
284
  whiteboardRoom.current?.cleanCurrentScene();
283
285
  setShowWhiteboardClearAllPopup(false);
284
286
  clearAllCallback();
285
- });
287
+ };
288
+
289
+ whiteboardRoom?.current?.callbacks?.on(
290
+ 'onRoomStateChanged',
291
+ roomStateChangedRef.current,
292
+ );
293
+ LocalEventEmitter.on(
294
+ LocalEventsEnum.CLEAR_WHITEBOARD,
295
+ clearWhiteboardRef.current,
296
+ );
297
+
298
+ return () => {
299
+ if (roomStateChangedRef.current) {
300
+ whiteboardRoom?.current?.callbacks?.off(
301
+ 'onRoomStateChanged',
302
+ roomStateChangedRef.current,
303
+ );
304
+ }
305
+ if (clearWhiteboardRef.current) {
306
+ LocalEventEmitter.off(
307
+ LocalEventsEnum.CLEAR_WHITEBOARD,
308
+ clearWhiteboardRef.current,
309
+ );
310
+ }
311
+ };
286
312
  }, []);
287
313
 
288
314
  useEffect(() => {
@@ -84,7 +84,7 @@ const WhiteboardWidget = ({whiteboardRoom}) => {
84
84
  isWhiteboardOnFullScreen,
85
85
  } = useContext(whiteboardContext);
86
86
  const {
87
- data: {whiteboard: {room_uuid} = {}},
87
+ data: {isHost, whiteboard: {room_uuid} = {}},
88
88
  } = useRoomInfo();
89
89
  const {store} = useContext(StorageContext);
90
90
 
@@ -267,7 +267,11 @@ const WhiteboardWidget = ({whiteboardRoom}) => {
267
267
  ) : (
268
268
  <></>
269
269
  )}
270
- {isWebInternal() && !isMobileUA() ? (
270
+ {/* In livestream, hide all whiteboard controls for attendees — they are Followers
271
+ and interacting with the board (zoom/pan) would break viewport sync with the host */}
272
+ {isWebInternal() &&
273
+ !isMobileUA() &&
274
+ !($config.EVENT_MODE && !isHost) ? (
271
275
  <View style={style.widgetContainer}>
272
276
  {whiteboardRoom.current?.isWritable ? (
273
277
  <>
@@ -12,6 +12,7 @@ import Spacer from '../../atoms/Spacer';
12
12
  import {useLiveStreamDataContext} from '../../components/contexts/LiveStreamDataContext';
13
13
  import {useCustomization} from 'customization-implementation';
14
14
  import useMount from '../../components/useMount';
15
+ import {whiteboardContext} from '../../components/whiteboard/WhiteboardConfigure';
15
16
 
16
17
  const VideoComponent = () => {
17
18
  const {dispatch} = useContext(DispatchContext);
@@ -19,6 +20,7 @@ const VideoComponent = () => {
19
20
  const layoutsData = useLayoutsData();
20
21
  const {currentLayout, setLayout} = useLayout();
21
22
  const {activeUids, pinnedUid} = useContent();
23
+ const {whiteboardActive} = useContext(whiteboardContext);
22
24
  const {rtcProps} = useContext(PropsContext);
23
25
  const isDesktop = useIsDesktop();
24
26
  const {audienceUids, hostUids} = useLiveStreamDataContext();
@@ -58,7 +60,12 @@ const VideoComponent = () => {
58
60
  const currentLayoutRef = useRef(currentLayout);
59
61
  const gridLayoutName = getGridLayoutName();
60
62
  useEffect(() => {
61
- if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed) {
63
+ // When only one participant is visible, reset pinning and revert to grid layout.
64
+ // Skip this reset when whiteboard is active: in EVENT_MODE the audience's own uid is
65
+ // filtered from activeUids, so activeUids.length === 1 (host only) even while the
66
+ // whiteboard uid is being added. Without this guard the effect would clear pinnedUid
67
+ // and switch to grid, preventing the whiteboard from appearing in the max/pinned slot.
68
+ if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed && !whiteboardActive) {
62
69
  if (pinnedUid) {
63
70
  dispatch({type: 'UserPin', value: [0]});
64
71
  dispatch({type: 'UserSecondaryPin', value: [0]});
@@ -67,7 +74,7 @@ const VideoComponent = () => {
67
74
  setLayout(gridLayoutName);
68
75
  }
69
76
  }
70
- }, [activeUids, isCustomLayoutUsed]);
77
+ }, [activeUids, isCustomLayoutUsed, whiteboardActive]);
71
78
 
72
79
  useEffect(() => {
73
80
  currentLayoutRef.current = currentLayout;