agora-appbuilder-core 4.1.23 → 4.1.24-beta.2

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.23",
3
+ "version": "4.1.24-beta.2",
4
4
  "description": "React Native template for RTE app builder",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -209,6 +209,14 @@ interface ActiveScreenshareLifecycle {
209
209
  cleanupPromise?: Promise<void>;
210
210
  journeyData: Record<string, unknown>;
211
211
  }
212
+ interface ScreenshareJourneyContext {
213
+ screenshareAttemptId?: string;
214
+ screenshareSessionId?: string;
215
+ recordingActive?: boolean;
216
+ screenShareUid?: UID;
217
+ stopOrigin?: string;
218
+ stopActorUid?: UID;
219
+ }
212
220
  interface RemoteStream {
213
221
  audio?: IRemoteAudioTrack;
214
222
  video?: IRemoteVideoTrack;
@@ -883,24 +891,28 @@ export default class RtcEngine {
883
891
  user,
884
892
  mediaType,
885
893
  );
886
- if (this.inScreenshare && user.uid === this.screenClient.uid) {
887
- (this.eventsMap.get('onRemoteVideoStateChanged') as callbackType)(
888
- {},
889
- user.uid,
890
- 2,
891
- 0,
892
- 0,
893
- );
894
- } else {
895
- await this.client.subscribe(user, mediaType);
896
- logger.log(
897
- LogSource.AgoraSDK,
898
- 'API',
899
- 'RTC [subscribe] to track successfully done',
900
- user,
901
- mediaType,
902
- );
894
+ const isOwnScreenshare =
895
+ this.inScreenshare && user.uid === this.screenClient?.uid;
896
+ if (isOwnScreenshare) {
897
+ if (mediaType === 'video') {
898
+ (this.eventsMap.get('onRemoteVideoStateChanged') as callbackType)(
899
+ {},
900
+ user.uid,
901
+ 2,
902
+ 0,
903
+ 0,
904
+ );
905
+ }
906
+ return;
903
907
  }
908
+ await this.client.subscribe(user, mediaType);
909
+ logger.log(
910
+ LogSource.AgoraSDK,
911
+ 'API',
912
+ 'RTC [subscribe] to track successfully done',
913
+ user,
914
+ mediaType,
915
+ );
904
916
  // If the subscribed track is an audio track
905
917
  if (mediaType === 'audio') {
906
918
  const audioTrack = user.audioTrack;
@@ -948,6 +960,20 @@ export default class RtcEngine {
948
960
  user,
949
961
  mediaType,
950
962
  );
963
+ const isOwnScreenshare =
964
+ this.inScreenshare && user.uid === this.screenClient?.uid;
965
+ if (isOwnScreenshare) {
966
+ if (mediaType === 'video') {
967
+ (this.eventsMap.get('onRemoteVideoStateChanged') as callbackType)(
968
+ {},
969
+ user.uid,
970
+ 0,
971
+ 0,
972
+ 0,
973
+ );
974
+ }
975
+ return;
976
+ }
951
977
  if (mediaType === 'video') {
952
978
  this.logRemoteScreenshareTermination(
953
979
  user.uid,
@@ -1713,6 +1739,9 @@ export default class RtcEngine {
1713
1739
  return;
1714
1740
  }
1715
1741
 
1742
+ // The track-ended callback passes the exact lifecycle object it captured.
1743
+ // A different reference means a newer screen share is now active, so this
1744
+ // older callback must not clean up the current share.
1716
1745
  if (
1717
1746
  expectedLifecycle &&
1718
1747
  this.activeScreenshareLifecycle !== expectedLifecycle
@@ -1814,6 +1843,9 @@ export default class RtcEngine {
1814
1843
  throw error;
1815
1844
  } finally {
1816
1845
  lifecycle.cleaned = true;
1846
+ // Only the lifecycle that is still active may clear shared state and
1847
+ // notify the app. Object identity prevents stale cleanup from ending a
1848
+ // newer screen-share session.
1817
1849
  if (this.activeScreenshareLifecycle === lifecycle) {
1818
1850
  this.activeScreenshareLifecycle = null;
1819
1851
  this.screenStream = {};
@@ -1970,6 +2002,41 @@ export default class RtcEngine {
1970
2002
  }
1971
2003
  }
1972
2004
 
2005
+ async stopScreenshare(
2006
+ journeyContext: ScreenshareJourneyContext = {},
2007
+ ): Promise<void> {
2008
+ const journeyData = {
2009
+ action: 'stop' as const,
2010
+ screenshareAttemptId:
2011
+ journeyContext.screenshareAttemptId || 'rtc-unknown-attempt',
2012
+ screenshareSessionId:
2013
+ journeyContext.screenshareSessionId || 'unknown-session',
2014
+ recordingActive: journeyContext.recordingActive || false,
2015
+ screenShareUid:
2016
+ journeyContext.screenShareUid || this.screenClient?.uid,
2017
+ stopOrigin: journeyContext.stopOrigin || 'unknown',
2018
+ stopActorUid: journeyContext.stopActorUid,
2019
+ };
2020
+ logger.log(
2021
+ LogSource.AgoraSDK,
2022
+ 'API',
2023
+ `[SCREENSHARE_JOURNEY] screen share stop entered RTC engine from ${journeyData.stopOrigin}`,
2024
+ {
2025
+ ...journeyData,
2026
+ stage: 'rtc_stop',
2027
+ outcome: 'started',
2028
+ requestedAction: 'stop',
2029
+ executedAction: 'stop',
2030
+ operationState: this.screenshareOperationState,
2031
+ },
2032
+ );
2033
+ await this.cleanupActiveScreenshare(
2034
+ journeyData.stopOrigin,
2035
+ true,
2036
+ journeyData.screenshareAttemptId,
2037
+ );
2038
+ }
2039
+
1973
2040
  async startScreenshare(
1974
2041
  token: string,
1975
2042
  channelName: string,
@@ -1986,18 +2053,10 @@ export default class RtcEngine {
1986
2053
  encoderConfig: this.screenShareProfile,
1987
2054
  },
1988
2055
  audio: 'enable' | 'disable' | 'auto' = 'auto',
1989
- journeyContext: {
1990
- action?: 'start' | 'stop';
1991
- screenshareAttemptId?: string;
1992
- screenshareSessionId?: string;
1993
- recordingActive?: boolean;
1994
- screenShareUid?: UID;
1995
- stopOrigin?: string;
1996
- stopActorUid?: UID;
1997
- } = {},
2056
+ journeyContext: ScreenshareJourneyContext = {},
1998
2057
  ): Promise<void> {
1999
2058
  const journeyData = {
2000
- action: journeyContext.action || (this.inScreenshare ? 'stop' : 'start'),
2059
+ action: 'start' as const,
2001
2060
  screenshareAttemptId:
2002
2061
  journeyContext.screenshareAttemptId || 'rtc-unknown-attempt',
2003
2062
  screenshareSessionId:
@@ -2011,28 +2070,6 @@ export default class RtcEngine {
2011
2070
  ...screenShareConfig,
2012
2071
  encoderConfig: this.screenShareProfile,
2013
2072
  };
2014
- if (journeyData.action === 'stop') {
2015
- logger.log(
2016
- LogSource.AgoraSDK,
2017
- 'API',
2018
- `[SCREENSHARE_JOURNEY] screen share stop entered RTC engine from ${journeyData.stopOrigin}`,
2019
- {
2020
- ...journeyData,
2021
- stage: 'rtc_stop',
2022
- outcome: 'started',
2023
- requestedAction: 'stop',
2024
- executedAction: 'stop',
2025
- operationState: this.screenshareOperationState,
2026
- },
2027
- );
2028
- await this.cleanupActiveScreenshare(
2029
- journeyData.stopOrigin,
2030
- true,
2031
- journeyData.screenshareAttemptId,
2032
- );
2033
- return;
2034
- }
2035
-
2036
2073
  if (this.screenshareOperationState !== 'inactive' || this.inScreenshare) {
2037
2074
  const duplicateStartError = Object.assign(
2038
2075
  new Error(
@@ -75,7 +75,7 @@ describe('web RTC screen-share lifecycle', () => {
75
75
  let engine: any;
76
76
  let screenClient: any;
77
77
 
78
- const callScreenshare = (action: 'start' | 'stop', sessionId = 'session-1') =>
78
+ const callStartScreenshare = (sessionId = 'session-1') =>
79
79
  engine.startScreenshare(
80
80
  'token',
81
81
  'channel',
@@ -87,14 +87,21 @@ describe('web RTC screen-share lifecycle', () => {
87
87
  {encoderConfig: '1080p_2'},
88
88
  'auto',
89
89
  {
90
- action,
91
- screenshareAttemptId: `${action}-request`,
90
+ screenshareAttemptId: 'start-request',
92
91
  screenshareSessionId: sessionId,
93
92
  screenShareUid: 101,
94
93
  stopOrigin: 'toolbar',
95
94
  },
96
95
  );
97
96
 
97
+ const callStopScreenshare = (sessionId = 'session-1') =>
98
+ engine.stopScreenshare({
99
+ screenshareAttemptId: 'stop-request',
100
+ screenshareSessionId: sessionId,
101
+ screenShareUid: 101,
102
+ stopOrigin: 'toolbar',
103
+ });
104
+
98
105
  beforeAll(() => {
99
106
  (global as any).window = {};
100
107
  RtcEngine = require('../RtcEngine').default;
@@ -120,9 +127,9 @@ describe('web RTC screen-share lifecycle', () => {
120
127
  const {track} = createTrack();
121
128
  mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise);
122
129
 
123
- const firstStart = callScreenshare('start');
130
+ const firstStart = callStartScreenshare();
124
131
  await expect(
125
- callScreenshare('start', 'duplicate-session'),
132
+ callStartScreenshare('duplicate-session'),
126
133
  ).rejects.toMatchObject({code: 'SCREENSHARE_OPERATION_IN_PROGRESS'});
127
134
 
128
135
  expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(1);
@@ -136,7 +143,7 @@ describe('web RTC screen-share lifecycle', () => {
136
143
  const stopped = jest.fn();
137
144
  engine.eventsMap.set('onScreenshareStopped', stopped);
138
145
 
139
- await callScreenshare('stop');
146
+ await callStopScreenshare();
140
147
 
141
148
  expect(mockCreateScreenVideoTrack).not.toHaveBeenCalled();
142
149
  expect(screenClient.join).not.toHaveBeenCalled();
@@ -153,7 +160,7 @@ describe('web RTC screen-share lifecycle', () => {
153
160
  }),
154
161
  );
155
162
 
156
- await expect(callScreenshare('start')).rejects.toMatchObject({
163
+ await expect(callStartScreenshare()).rejects.toMatchObject({
157
164
  code: 'INVALID_OPERATION',
158
165
  });
159
166
 
@@ -168,7 +175,7 @@ describe('web RTC screen-share lifecycle', () => {
168
175
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
169
176
  screenClient.publish.mockRejectedValueOnce(new Error('publish failed'));
170
177
 
171
- await expect(callScreenshare('start')).rejects.toThrow('publish failed');
178
+ await expect(callStartScreenshare()).rejects.toThrow('publish failed');
172
179
 
173
180
  expect(track.stop).toHaveBeenCalledTimes(1);
174
181
  expect(track.close).toHaveBeenCalledTimes(1);
@@ -185,7 +192,7 @@ describe('web RTC screen-share lifecycle', () => {
185
192
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
186
193
  screenClient.publish.mockRejectedValueOnce(publishError);
187
194
 
188
- await expect(callScreenshare('start')).rejects.toBe(publishError);
195
+ await expect(callStartScreenshare()).rejects.toBe(publishError);
189
196
 
190
197
  expect(track.stop).toHaveBeenCalledTimes(1);
191
198
  expect(track.close).toHaveBeenCalledTimes(1);
@@ -201,13 +208,13 @@ describe('web RTC screen-share lifecycle', () => {
201
208
  }),
202
209
  );
203
210
 
204
- await expect(callScreenshare('start')).rejects.toMatchObject({
211
+ await expect(callStartScreenshare()).rejects.toMatchObject({
205
212
  code: 'PERMISSION_DENIED',
206
213
  });
207
214
 
208
215
  const {track} = createTrack();
209
216
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
210
- await callScreenshare('start', 'session-2');
217
+ await callStartScreenshare('session-2');
211
218
 
212
219
  expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(2);
213
220
  expect(screenClient.join).toHaveBeenCalledTimes(1);
@@ -220,7 +227,7 @@ describe('web RTC screen-share lifecycle', () => {
220
227
  const {track} = createTrack();
221
228
  mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise);
222
229
 
223
- const pendingStart = callScreenshare('start');
230
+ const pendingStart = callStartScreenshare();
224
231
  await engine.release('end_call_cleanup');
225
232
  picker.resolve(track);
226
233
 
@@ -239,8 +246,8 @@ describe('web RTC screen-share lifecycle', () => {
239
246
  engine.eventsMap.set('onScreenshareStopped', stopped);
240
247
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
241
248
 
242
- await callScreenshare('start');
243
- await callScreenshare('stop');
249
+ await callStartScreenshare();
250
+ await callStopScreenshare();
244
251
  await handlers['track-ended']();
245
252
 
246
253
  expect(screenClient.leave).toHaveBeenCalledTimes(1);
@@ -249,6 +256,28 @@ describe('web RTC screen-share lifecycle', () => {
249
256
  expect(stopped).toHaveBeenCalledTimes(1);
250
257
  });
251
258
 
259
+ it('does not let an older lifecycle clean up a newer screen share', async () => {
260
+ const first = createTrack();
261
+ const second = createTrack();
262
+ const stopped = jest.fn();
263
+ engine.eventsMap.set('onScreenshareStopped', stopped);
264
+ mockCreateScreenVideoTrack
265
+ .mockResolvedValueOnce(first.track)
266
+ .mockResolvedValueOnce(second.track);
267
+
268
+ await callStartScreenshare();
269
+ await callStopScreenshare();
270
+ await callStartScreenshare('session-2');
271
+ await first.handlers['track-ended']();
272
+
273
+ expect(engine.activeScreenshareLifecycle.video).toBe(second.track);
274
+ expect(engine.inScreenshare).toBe(true);
275
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
276
+ expect(second.track.stop).not.toHaveBeenCalled();
277
+ expect(second.track.close).not.toHaveBeenCalled();
278
+ expect(stopped).toHaveBeenCalledTimes(1);
279
+ });
280
+
252
281
  it('joins an existing cleanup when Stop is requested twice', async () => {
253
282
  const {track} = createTrack();
254
283
  const leave = deferred<void>();
@@ -257,9 +286,9 @@ describe('web RTC screen-share lifecycle', () => {
257
286
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
258
287
  screenClient.leave.mockReturnValueOnce(leave.promise);
259
288
 
260
- await callScreenshare('start');
261
- const firstStop = callScreenshare('stop');
262
- const secondStop = callScreenshare('stop');
289
+ await callStartScreenshare();
290
+ const firstStop = callStopScreenshare();
291
+ const secondStop = callStopScreenshare();
263
292
  leave.resolve(undefined);
264
293
  await Promise.all([firstStop, secondStop]);
265
294
 
@@ -278,8 +307,8 @@ describe('web RTC screen-share lifecycle', () => {
278
307
  throw new Error('track stop failed');
279
308
  });
280
309
 
281
- await callScreenshare('start');
282
- await expect(callScreenshare('stop')).resolves.toBeUndefined();
310
+ await callStartScreenshare();
311
+ await expect(callStopScreenshare()).resolves.toBeUndefined();
283
312
 
284
313
  expect(track.close).toHaveBeenCalledTimes(1);
285
314
  expect(screenClient.leave).toHaveBeenCalledTimes(1);
@@ -294,7 +323,7 @@ describe('web RTC screen-share lifecycle', () => {
294
323
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
295
324
  screenClient.leave.mockRejectedValueOnce(new Error('leave failed'));
296
325
 
297
- await callScreenshare('start');
326
+ await callStartScreenshare();
298
327
  await expect(handlers['track-ended']()).resolves.toBeUndefined();
299
328
 
300
329
  expect(screenClient.leave).toHaveBeenCalledTimes(1);
@@ -317,7 +346,7 @@ describe('web RTC screen-share lifecycle', () => {
317
346
  engine.eventsMap.set('onScreenshareStopped', stopped);
318
347
  mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
319
348
 
320
- await callScreenshare('start');
349
+ await callStartScreenshare();
321
350
  await engine.release('end_call_cleanup');
322
351
 
323
352
  const {logger} = require('../../../../src/logger/AppBuilderLogger');
@@ -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.23",
81
- CORE_VERSION: "4.1.23",
80
+ CLI_VERSION: "3.1.24-beta.2",
81
+ CORE_VERSION: "4.1.24-beta.2",
82
82
  DISABLE_LANDSCAPE_MODE: false,
83
83
  STT_AUTO_START: false,
84
84
  CLOUD_RECORDING_AUTO_START: false,
@@ -37,6 +37,8 @@ import {
37
37
  videoRoomGoToActiveSpeakerText,
38
38
  } from '../language/default-labels/videoCallScreenLabels';
39
39
  import {useFullScreen} from '..//utils/useFullScreen';
40
+ import {LogSource, logger} from '../logger/AppBuilderLogger';
41
+ import {getPinnedLayoutState} from './pinnedLayoutState';
40
42
  const {topPinned} = layoutProps;
41
43
 
42
44
  const PinnedVideo = ({renderData}) => {
@@ -50,12 +52,38 @@ const PinnedVideo = ({renderData}) => {
50
52
  const {width} = useWindowDimensions();
51
53
  const isDesktop = width > BREAKPOINTS.lg;
52
54
  const isSidePinnedlayout = topPinned === true ? false : isDesktop; // if either explicity set to false or auto evaluation
53
- const [maxUid, ...minUids] = renderData;
55
+ const {maximizedUid, sidebarUids} = getPinnedLayoutState(
56
+ renderData,
57
+ pinnedUid,
58
+ secondaryPinnedUid,
59
+ );
60
+ const renderDataSnapshot = JSON.stringify(renderData);
61
+ const sidebarUidsSnapshot = JSON.stringify(sidebarUids);
54
62
  const activeSpeaker = useActiveSpeaker();
55
63
  const {dispatch} = useContext(DispatchContext);
56
64
  const {videoTileInViewPortState} = useVideoCall();
57
65
  const {getWhiteboardUid = () => 0} = useWhiteboard();
58
66
  const {defaultContent, customContent} = useContent();
67
+ useEffect(() => {
68
+ logger.log(
69
+ LogSource.Internals,
70
+ 'LAYOUT',
71
+ '[PINNED_LAYOUT] pinned layout UIDs resolved',
72
+ {
73
+ renderData: JSON.parse(renderDataSnapshot),
74
+ pinnedUid: pinnedUid || null,
75
+ secondaryPinnedUid: secondaryPinnedUid || null,
76
+ maximizedUid,
77
+ sidebarUids: JSON.parse(sidebarUidsSnapshot),
78
+ },
79
+ );
80
+ }, [
81
+ renderDataSnapshot,
82
+ pinnedUid,
83
+ secondaryPinnedUid,
84
+ maximizedUid,
85
+ sidebarUidsSnapshot,
86
+ ]);
59
87
  useEffect(() => {
60
88
  if (activeSpeaker && !videoTileInViewPortState[activeSpeaker] && isOnTop) {
61
89
  dispatch({
@@ -85,21 +113,20 @@ const PinnedVideo = ({renderData}) => {
85
113
  }
86
114
  };
87
115
 
88
- const isWhiteboard = customContent && customContent[pinnedUid || maxUid];
116
+ const isWhiteboard = customContent && customContent[maximizedUid];
89
117
  const isRemoteScreenshare =
90
- (pinnedUid || maxUid) != screenUid &&
118
+ maximizedUid != screenUid &&
91
119
  defaultContent &&
92
- defaultContent[pinnedUid || maxUid]?.video &&
93
- defaultContent[pinnedUid || maxUid]?.type === 'screenshare';
120
+ defaultContent[maximizedUid]?.video &&
121
+ defaultContent[maximizedUid]?.type === 'screenshare';
94
122
 
95
123
  //fixed issue on expand/collapse for screenshare
96
124
  //screen will have the fullscreen functionality
97
125
  useEffect(() => {
98
- const maximizedUid = pinnedUid || maxUid;
99
126
  if (getWhiteboardUid() != maximizedUid && collapse) {
100
127
  setCollapse(false);
101
128
  }
102
- }, [isWhiteboard, collapse, pinnedUid, maxUid, getWhiteboardUid]);
129
+ }, [isWhiteboard, collapse, maximizedUid, getWhiteboardUid]);
103
130
 
104
131
  return (
105
132
  <View
@@ -129,9 +156,7 @@ const PinnedVideo = ({renderData}) => {
129
156
  marginBottom: 8,
130
157
  }
131
158
  }>
132
- {secondaryPinnedUid &&
133
- secondaryPinnedUid !== pinnedUid &&
134
- secondaryPinnedUid !== maxUid ? (
159
+ {secondaryPinnedUid && sidebarUids.includes(secondaryPinnedUid) ? (
135
160
  <Pressable
136
161
  disabled={true}
137
162
  style={
@@ -158,10 +183,8 @@ const PinnedVideo = ({renderData}) => {
158
183
  <></>
159
184
  )}
160
185
  {/* Pinned Video Top View(Desktop minimized and Mobile native and Mobile web) / Side View(Desktop maximized)*/}
161
- {minUids?.map((minUid, i) => {
186
+ {sidebarUids?.map(minUid => {
162
187
  if (minUid === secondaryPinnedUid) return null;
163
- if (minUid === pinnedUid) return null;
164
- if (minUid === maxUid) return null;
165
188
  //rendering minimized view
166
189
  return (
167
190
  <Pressable
@@ -194,7 +217,7 @@ const PinnedVideo = ({renderData}) => {
194
217
  marginRight: 8,
195
218
  }
196
219
  }
197
- key={'minVideo' + i}
220
+ key={'minVideo' + minUid}
198
221
  onPress={() => {}}>
199
222
  <RenderComponent uid={minUid} />
200
223
  </Pressable>
@@ -259,7 +282,7 @@ const PinnedVideo = ({renderData}) => {
259
282
  )}
260
283
  </>
261
284
  )}
262
- {(pinnedUid || maxUid) && (
285
+ {maximizedUid && (
263
286
  <View
264
287
  style={
265
288
  isSidePinnedlayout
@@ -268,7 +291,7 @@ const PinnedVideo = ({renderData}) => {
268
291
  : style.width80
269
292
  : style.flex8
270
293
  }>
271
- <View style={style.flex1} key={'maxVideo' + (pinnedUid || maxUid)}>
294
+ <View style={style.flex1} key={'maxVideo' + maximizedUid}>
272
295
  {isSidePinnedlayout && (isWhiteboard || isRemoteScreenshare) ? (
273
296
  <IconButton
274
297
  containerStyle={{
@@ -279,12 +302,9 @@ const PinnedVideo = ({renderData}) => {
279
302
  elevation: 999,
280
303
  }}
281
304
  onPress={() => {
282
- if (
283
- defaultContent &&
284
- defaultContent[pinnedUid || maxUid]?.video
285
- ) {
286
- requestFullscreen(pinnedUid || maxUid);
287
- } else if (customContent[pinnedUid || maxUid]) {
305
+ if (defaultContent && defaultContent[maximizedUid]?.video) {
306
+ requestFullscreen(maximizedUid);
307
+ } else if (customContent[maximizedUid]) {
288
308
  setCollapse(!collapse);
289
309
  }
290
310
  }}
@@ -348,7 +368,7 @@ const PinnedVideo = ({renderData}) => {
348
368
  <></>
349
369
  )} */}
350
370
  {/** Render the maximized view */}
351
- <RenderComponent uid={pinnedUid || maxUid} isMax={true} />
371
+ <RenderComponent uid={maximizedUid} isMax={true} />
352
372
  </View>
353
373
  </View>
354
374
  )}
@@ -0,0 +1,47 @@
1
+ import {
2
+ canViewInLarge,
3
+ getPinnedLayoutState,
4
+ isUidMaximized,
5
+ } from '../pinnedLayoutState';
6
+
7
+ describe('pinned layout state', () => {
8
+ it('keeps every non-maximized UID visible when active-speaker ordering moves another UID to index zero', () => {
9
+ expect(getPinnedLayoutState([200, 101, 100, 201], 101)).toEqual({
10
+ maximizedUid: 101,
11
+ sidebarUids: [200, 100, 201],
12
+ });
13
+ });
14
+
15
+ it('keeps the secondary pin first without removing any other sidebar UID', () => {
16
+ expect(getPinnedLayoutState([200, 100, 101, 201], 101, 100)).toEqual({
17
+ maximizedUid: 101,
18
+ sidebarUids: [100, 200, 201],
19
+ });
20
+ });
21
+
22
+ it('falls back to the first render UID when there is no valid primary pin', () => {
23
+ expect(getPinnedLayoutState([200, 100, 101], 999)).toEqual({
24
+ maximizedUid: 200,
25
+ sidebarUids: [100, 101],
26
+ });
27
+ });
28
+
29
+ it('does not offer View in large for either pinned position', () => {
30
+ expect(canViewInLarge(101, 101, 100)).toBe(false);
31
+ expect(canViewInLarge(100, 101, 100)).toBe(false);
32
+ expect(canViewInLarge(200, 101, 100)).toBe(true);
33
+ });
34
+
35
+ it('does not offer View in large for the fallback maximized UID', () => {
36
+ expect(canViewInLarge(101, undefined, 100, 101)).toBe(false);
37
+ expect(canViewInLarge(100, undefined, 100, 101)).toBe(false);
38
+ expect(canViewInLarge(200, undefined, 100, 101)).toBe(true);
39
+ });
40
+
41
+ it('identifies explicit and fallback maximized UIDs for tile actions', () => {
42
+ expect(isUidMaximized(101, 101, 200)).toBe(true);
43
+ expect(isUidMaximized(200, 101, 200)).toBe(false);
44
+ expect(isUidMaximized(200, undefined, 200)).toBe(true);
45
+ expect(isUidMaximized(100, undefined, 200)).toBe(false);
46
+ });
47
+ });
@@ -77,6 +77,7 @@ import {
77
77
  DEFAULT_ACTION_KEYS,
78
78
  UserActionMenuItemsConfig,
79
79
  } from '../../atoms/UserActionMenuPreset';
80
+ import {canViewInLarge, isUidMaximized} from '../pinnedLayoutState';
80
81
 
81
82
  interface UserActionMenuOptionsOptionsProps {
82
83
  user: ContentInterface;
@@ -207,7 +208,14 @@ export default function UserActionMenuOptionsOptions(
207
208
  )
208
209
  ) {
209
210
  if (enablePinForMe) {
210
- if (pinnedUid !== user.uid) {
211
+ if (
212
+ canViewInLarge(
213
+ user.uid,
214
+ pinnedUid,
215
+ secondaryPinnedUid,
216
+ activeUids?.[0],
217
+ )
218
+ ) {
211
219
  const viewInLargeKey = ActionMenuKeys.VIEW_IN_LARGE;
212
220
  const viewInLargeConfig = userActionMenuItems?.[viewInLargeKey] ?? {};
213
221
  const isPinned = pinnedUid === user.uid;
@@ -274,9 +282,14 @@ export default function UserActionMenuOptionsOptions(
274
282
  const pinToTopKey = ActionMenuKeys.PIN_TO_TOP;
275
283
  const pinToTopConfig = userActionMenuItems?.[pinToTopKey] ?? {};
276
284
  const isPinnedToTop = user.uid === secondaryPinnedUid;
285
+ const isMaximized = isUidMaximized(
286
+ user.uid,
287
+ pinnedUid,
288
+ activeUids?.[0],
289
+ );
277
290
  const isOnlyOneActive = activeUids?.length === 1;
278
291
 
279
- if (!pinToTopConfig.hide) {
292
+ if (!pinToTopConfig.hide && !isMaximized) {
280
293
  items.push({
281
294
  key: pinToTopKey,
282
295
  disabled: pinToTopConfig.disabled ?? isOnlyOneActive,
@@ -0,0 +1,43 @@
1
+ import {UidType} from '../../agora-rn-uikit';
2
+
3
+ export const getPinnedLayoutState = (
4
+ renderData: UidType[],
5
+ pinnedUid?: UidType,
6
+ secondaryPinnedUid?: UidType,
7
+ ) => {
8
+ const maximizedUid: UidType =
9
+ (pinnedUid && renderData.includes(pinnedUid) ? pinnedUid : renderData[0]) ||
10
+ 0;
11
+ const sidebarUids = renderData.filter(uid => uid !== maximizedUid);
12
+
13
+ if (
14
+ secondaryPinnedUid &&
15
+ sidebarUids.includes(secondaryPinnedUid) &&
16
+ sidebarUids[0] !== secondaryPinnedUid
17
+ ) {
18
+ return {
19
+ maximizedUid,
20
+ sidebarUids: [
21
+ secondaryPinnedUid,
22
+ ...sidebarUids.filter(uid => uid !== secondaryPinnedUid),
23
+ ],
24
+ };
25
+ }
26
+
27
+ return {maximizedUid, sidebarUids};
28
+ };
29
+
30
+ export const isUidMaximized = (
31
+ uid: UidType,
32
+ pinnedUid?: UidType,
33
+ fallbackMaximizedUid?: UidType,
34
+ ) => uid === (pinnedUid || fallbackMaximizedUid);
35
+
36
+ export const canViewInLarge = (
37
+ uid: UidType,
38
+ pinnedUid?: UidType,
39
+ secondaryPinnedUid?: UidType,
40
+ fallbackMaximizedUid?: UidType,
41
+ ) =>
42
+ !isUidMaximized(uid, pinnedUid, fallbackMaximizedUid) &&
43
+ uid !== secondaryPinnedUid;