@stream-io/video-client 2.0.0-beta.0 → 2.0.0-beta.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +1126 -791
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1126 -791
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +1126 -791
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +37 -11
  10. package/dist/src/StreamVideoClient.d.ts +15 -21
  11. package/dist/src/coordinator/connection/types.d.ts +16 -0
  12. package/dist/src/events/call.d.ts +0 -11
  13. package/dist/src/gen/coordinator/index.d.ts +73 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  15. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  16. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  17. package/dist/src/ringing/index.d.ts +4 -0
  18. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  19. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  20. package/dist/src/rtc/Publisher.d.ts +18 -0
  21. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  22. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  23. package/dist/src/stats/utils.d.ts +6 -0
  24. package/dist/src/store/CallState.d.ts +118 -267
  25. package/dist/src/store/ClientState.d.ts +62 -0
  26. package/dist/src/store/index.d.ts +1 -1
  27. package/dist/src/store/subjects.d.ts +15 -0
  28. package/dist/src/store/types.d.ts +22 -0
  29. package/dist/src/types.d.ts +33 -4
  30. package/index.ts +1 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +285 -125
  33. package/src/StreamSfuClient.ts +6 -2
  34. package/src/StreamVideoClient.ts +40 -47
  35. package/src/__tests__/Call.autodrop.test.ts +118 -66
  36. package/src/__tests__/Call.getRingState.test.ts +87 -0
  37. package/src/__tests__/Call.lifecycle.test.ts +499 -6
  38. package/src/__tests__/Call.publishing.test.ts +2 -2
  39. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  40. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  42. package/src/__tests__/StreamVideoClient.disconnect.test.ts +62 -0
  43. package/src/coordinator/connection/types.ts +19 -0
  44. package/src/devices/__tests__/CameraManager.test.ts +2 -2
  45. package/src/devices/__tests__/DeviceManager.test.ts +2 -2
  46. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -2
  47. package/src/devices/__tests__/MicrophoneManager.test.ts +2 -2
  48. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -2
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -3
  50. package/src/devices/__tests__/SpeakerManager.test.ts +4 -4
  51. package/src/events/__tests__/call.test.ts +5 -220
  52. package/src/events/call.ts +1 -73
  53. package/src/events/callEventHandlers.ts +12 -23
  54. package/src/gen/coordinator/index.ts +67 -0
  55. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +2 -2
  56. package/src/helpers/__tests__/DynascaleManager.test.ts +2 -2
  57. package/src/helpers/__tests__/ViewportTracker.test.ts +2 -2
  58. package/src/reporting/ClientEventReporter.ts +30 -4
  59. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  60. package/src/ringing/RingStatePoller.ts +177 -0
  61. package/src/ringing/RingTimeout.ts +90 -0
  62. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  63. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  64. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  65. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  66. package/src/ringing/index.ts +4 -0
  67. package/src/ringing/reconcileRingState.ts +108 -0
  68. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  69. package/src/rtc/Publisher.ts +66 -12
  70. package/src/rtc/TransceiverCache.ts +17 -0
  71. package/src/rtc/__tests__/Call.reconnect.test.ts +5 -5
  72. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  73. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  74. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  75. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  76. package/src/rtc/e2ee/SPEC.md +1 -1
  77. package/src/stats/__tests__/utils.test.ts +33 -0
  78. package/src/stats/utils.ts +15 -0
  79. package/src/store/CallState.ts +305 -550
  80. package/src/store/ClientState.ts +101 -0
  81. package/src/store/__tests__/CallState.test.ts +60 -1
  82. package/src/store/__tests__/ClientState.test.ts +35 -0
  83. package/src/store/__tests__/rxUtils.test.ts +67 -1
  84. package/src/store/index.ts +1 -1
  85. package/src/store/rxUtils.ts +8 -4
  86. package/src/store/subjects.ts +30 -0
  87. package/src/store/types.ts +45 -0
  88. package/src/types.ts +40 -4
  89. package/dist/src/store/stateStore.d.ts +0 -84
  90. package/src/store/stateStore.ts +0 -156
package/src/Call.ts CHANGED
@@ -22,11 +22,7 @@ import {
22
22
  registerEventHandlers,
23
23
  registerRingingCallEventHandlers,
24
24
  } from './events/callEventHandlers';
25
- import {
26
- CallingState,
27
- CallState,
28
- StreamVideoWriteableStateStore,
29
- } from './store';
25
+ import { CallingState, CallState, ClientState } from './store';
30
26
  import {
31
27
  createSafeAsyncSubscription,
32
28
  createSubscription,
@@ -50,6 +46,7 @@ import {
50
46
  EndCallResponse,
51
47
  GetCallReportResponse,
52
48
  GetCallResponse,
49
+ GetCallRingStateResponse,
53
50
  GetCallSessionParticipantStatsDetailsResponse,
54
51
  GetOrCreateCallRequest,
55
52
  GetOrCreateCallResponse,
@@ -152,7 +149,7 @@ import {
152
149
  StatsReporter,
153
150
  Tracer,
154
151
  } from './stats';
155
- import type { ClientEventReporter, JoinReason } from './reporting';
152
+ import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
156
153
  import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
157
154
  import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
158
155
  import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
@@ -160,6 +157,7 @@ import { DynascaleManager } from './helpers/DynascaleManager';
160
157
  import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
161
158
  import { ViewportTracker } from './helpers/ViewportTracker';
162
159
  import { PermissionsContext } from './permissions';
160
+ import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
163
161
  import { CallTypes } from './CallType';
164
162
  import { StreamClient } from './coordinator/connection/client';
165
163
  import { retryInterval, sleep } from './coordinator/connection/utils';
@@ -302,9 +300,10 @@ export class Call {
302
300
  private statsReporter?: StatsReporter;
303
301
  private sfuStatsReporter?: SfuStatsReporter;
304
302
  private lastStatsOptions?: StatsOptions;
305
- private dropTimeout: ReturnType<typeof setTimeout> | undefined;
303
+ private ringTimeout: RingTimeout | undefined;
304
+ private ringStatePoller: RingStatePoller | undefined;
306
305
 
307
- private readonly clientStore: StreamVideoWriteableStateStore;
306
+ private readonly clientState: ClientState;
308
307
  public readonly streamClient: StreamClient;
309
308
  public readonly clientEventReporter: ClientEventReporter;
310
309
  private sfuClient?: StreamSfuClient;
@@ -387,7 +386,7 @@ export class Call {
387
386
  members,
388
387
  ownCapabilities,
389
388
  sortParticipantsBy,
390
- clientStore,
389
+ clientState,
391
390
  ringing = false,
392
391
  watching = false,
393
392
  }: CallConstructor) {
@@ -398,7 +397,7 @@ export class Call {
398
397
  this.watching = watching;
399
398
  this.streamClient = streamClient;
400
399
  this.clientEventReporter = clientEventReporter;
401
- this.clientStore = clientStore;
400
+ this.clientState = clientState;
402
401
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
403
402
  this.logger = videoLoggerSystem.getLogger('Call');
404
403
 
@@ -537,40 +536,27 @@ export class Call {
537
536
  createSubscription(this.state.session$, (session) => {
538
537
  if (!this.ringing) return;
539
538
 
540
- const receiverId = this.clientStore.connectedUser?.id;
541
- if (!receiverId) return;
542
-
543
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
544
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
545
-
546
- if (isAcceptedByMe || isRejectedByMe) {
547
- this.cancelAutoDrop();
548
- }
549
-
550
- const isAcceptedElsewhere =
551
- isAcceptedByMe && this.state.callingState === CallingState.RINGING;
539
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
540
+ session,
541
+ currentUserId: this.currentUserId,
542
+ callingState: this.state.callingState,
543
+ });
544
+ if (settledByMe) this.cancelAutoDrop();
545
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;
552
546
 
553
- if (
554
- (isAcceptedElsewhere || isRejectedByMe) &&
555
- !hasPending(this.joinLeaveConcurrencyTag)
556
- ) {
557
- globalThis.streamRNVideoSDK?.callingX?.endCall(
558
- this,
559
- isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
547
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
548
+ this.leave().catch(() => {
549
+ this.logger.error(
550
+ 'Could not leave a call that was accepted or rejected elsewhere',
560
551
  );
561
- this.leave().catch(() => {
562
- this.logger.error(
563
- 'Could not leave a call that was accepted or rejected elsewhere',
564
- );
565
- });
566
- }
552
+ });
567
553
  }),
568
554
  );
569
555
  };
570
556
 
571
557
  private handleRingingCall = () => {
572
558
  const callSession = this.state.session;
573
- const receiver_id = this.clientStore.connectedUser?.id;
559
+ const receiver_id = this.clientState.connectedUser?.id;
574
560
  const ended_at = callSession?.ended_at;
575
561
  const created_by_id = this.state.createdBy?.id;
576
562
 
@@ -608,6 +594,7 @@ export class Call {
608
594
  this.state.setCallingState(CallingState.RINGING);
609
595
  }
610
596
  this.scheduleAutoDrop();
597
+ this.scheduleRingStatePolling();
611
598
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
612
599
  }
613
600
  };
@@ -713,6 +700,13 @@ export class Call {
713
700
  throw new Error('Cannot leave call that has already been left.');
714
701
  }
715
702
 
703
+ // before the first await: the calling state stays RINGING well into the
704
+ // teardown, so pause both watchdogs before they can race this leave. They
705
+ // keep their deadlines, so a failed leave resumes them without handing the
706
+ // ring another window.
707
+ this.ringTimeout?.pause();
708
+ this.ringStatePoller?.pause();
709
+
716
710
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
717
711
  const callingState = this.state.callingState;
718
712
 
@@ -796,6 +790,11 @@ export class Call {
796
790
  await this.dynascaleManager?.dispose();
797
791
 
798
792
  this.state.setCallingState(CallingState.LEFT);
793
+ // `ringingSubject` is cleared further down, so this still reads true for
794
+ // a call that was ringing.
795
+ if (this.ringing) {
796
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
797
+ }
799
798
  this.state.setParticipants([]);
800
799
  this.state.dispose();
801
800
 
@@ -821,7 +820,8 @@ export class Call {
821
820
  this.unifiedSessionId = undefined;
822
821
  this.ringingSubject.next(false);
823
822
  this.cancelAutoDrop();
824
- this.clientStore.unregisterCall(this);
823
+ this.cancelRingStatePolling();
824
+ this.clientState.unregisterCall(this);
825
825
 
826
826
  globalThis.streamRNVideoSDK?.callManager.stop({
827
827
  isRingingTypeCall: this.ringing,
@@ -863,6 +863,17 @@ export class Call {
863
863
  this.logger.warn('Failed to dispose media engine', err);
864
864
  });
865
865
  }
866
+ }).catch((err) => {
867
+ if (
868
+ !hasPending(this.joinLeaveConcurrencyTag) &&
869
+ this.state.callingState === CallingState.RINGING
870
+ ) {
871
+ // resume, never re-arm: a fresh watchdog would restart the ring
872
+ // window this leave was already most of the way through
873
+ this.ringTimeout?.start();
874
+ this.ringStatePoller?.resume();
875
+ }
876
+ throw err;
866
877
  });
867
878
  };
868
879
 
@@ -877,7 +888,7 @@ export class Call {
877
888
  * Retrieves the current user ID.
878
889
  */
879
890
  get currentUserId() {
880
- return this.clientStore.connectedUser?.id;
891
+ return this.clientState.connectedUser?.id;
881
892
  }
882
893
 
883
894
  /**
@@ -942,8 +953,8 @@ export class Call {
942
953
  this.ringingSubject.next(true);
943
954
  // we remove the instance from the calls list to enable the following filter in useCalls hook
944
955
  // const calls = useCalls().filter((c) => c.ringing);
945
- const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
946
- this.clientStore.setCalls([this, ...calls]);
956
+ const calls = this.clientState.calls.filter((c) => c.cid !== this.cid);
957
+ this.clientState.setCalls([this, ...calls]);
947
958
  await this.applyDeviceConfig(settings, { publish: false });
948
959
  };
949
960
 
@@ -973,12 +984,14 @@ export class Call {
973
984
  members_limit?: number;
974
985
  video?: boolean;
975
986
  }): Promise<GetCallResponse> => {
987
+ const getLeaveGeneration = this.leaveGeneration;
976
988
  await this.setup();
977
989
 
978
990
  const response = await this.streamClient.get<GetCallResponse>(
979
991
  this.streamClientBasePath,
980
992
  params,
981
993
  );
994
+ if (this.leaveGeneration !== getLeaveGeneration) return response;
982
995
 
983
996
  this.updateFromCallStateResponse(response);
984
997
 
@@ -988,7 +1001,7 @@ export class Call {
988
1001
 
989
1002
  if (this.streamClient._hasConnectionID()) {
990
1003
  this.watching = true;
991
- this.clientStore.registerOrUpdateCall(this);
1004
+ this.clientState.registerOrUpdateCall(this);
992
1005
  }
993
1006
  await this.applyDeviceConfig(response.call.settings, { publish: false });
994
1007
 
@@ -1016,7 +1029,7 @@ export class Call {
1016
1029
 
1017
1030
  if (this.streamClient._hasConnectionID()) {
1018
1031
  this.watching = true;
1019
- this.clientStore.registerOrUpdateCall(this);
1032
+ this.clientState.registerOrUpdateCall(this);
1020
1033
  }
1021
1034
 
1022
1035
  await this.applyDeviceConfig(response.call.settings, { publish: false });
@@ -1056,6 +1069,27 @@ export class Call {
1056
1069
  );
1057
1070
  };
1058
1071
 
1072
+ /**
1073
+ * Returns who accepted, rejected or missed the ring for a call session.
1074
+ * Safe to poll: it performs no writes and emits no events.
1075
+ *
1076
+ * @param callSessionId the call session to read. Defaults to the current one.
1077
+ * Pass it explicitly to read a session that has already ended, as ending a
1078
+ * call clears its current session.
1079
+ */
1080
+ getRingState = async (
1081
+ callSessionId?: string,
1082
+ ): Promise<GetCallRingStateResponse> => {
1083
+ const sessionId = callSessionId ?? this.state.session?.id;
1084
+ if (!sessionId) {
1085
+ throw new Error('Cannot read the ring state: the call has no session');
1086
+ }
1087
+ return this.streamClient.get<GetCallRingStateResponse>(
1088
+ `${this.streamClientBasePath}/ring_state`,
1089
+ { call_session_id: sessionId },
1090
+ );
1091
+ };
1092
+
1059
1093
  /**
1060
1094
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
1061
1095
  * Will send a `call.notification` event to the call members.
@@ -1104,6 +1138,10 @@ export class Call {
1104
1138
  /**
1105
1139
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
1106
1140
  *
1141
+ * One instance is one call flow: discard it after leaving, cancelling, or a
1142
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
1143
+ * live call is handled here and needs no new instance.
1144
+ *
1107
1145
  * @returns a promise which resolves once the call join-flow has finished.
1108
1146
  */
1109
1147
  join = singleFlight(
@@ -1112,19 +1150,30 @@ export class Call {
1112
1150
  joinResponseTimeout,
1113
1151
  rpcRequestTimeout,
1114
1152
  allowOwnTracksLoopback = false,
1153
+ joinSource,
1115
1154
  ...data
1116
1155
  }: JoinCallData & {
1117
1156
  maxJoinRetries?: number;
1118
1157
  joinResponseTimeout?: number;
1119
1158
  rpcRequestTimeout?: number;
1120
1159
  allowOwnTracksLoopback?: boolean;
1160
+ joinSource?: JoinSource;
1121
1161
  } = {}): Promise<void> => {
1122
1162
  const callingState = this.state.callingState;
1123
1163
 
1164
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
1165
+ // live call is refused without tearing that call down.
1124
1166
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1125
1167
  throw new Error(`Illegal State: call.join() shall be called only once`);
1126
1168
  }
1127
1169
 
1170
+ // snapshot before the first await: a leave() landing at any point from
1171
+ // here on supersedes this join, and the retry loop must bail out instead
1172
+ // of resurrecting a call that leave() already tore down.
1173
+ const joinLeaveGeneration = this.leaveGeneration;
1174
+ const supersededByLeave = () =>
1175
+ this.leaveGeneration !== joinLeaveGeneration;
1176
+
1128
1177
  // we need this to be set before the callingx.joinCall() is
1129
1178
  // called to avoid registering the test call in the CallKit/Telecom
1130
1179
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
@@ -1133,35 +1182,67 @@ export class Call {
1133
1182
  this.ringingSubject.next(true);
1134
1183
  }
1135
1184
 
1185
+ // A ringing call is joined by the SDK rather than by app code, so React
1186
+ // Native prepares it from in here - there is no earlier point at which the
1187
+ // app holds the call. Read after `data.ring` above, which is what makes an
1188
+ // outgoing call ringing in the first place.
1189
+ const ringingLifecycle = this.ringing
1190
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
1191
+ : undefined;
1136
1192
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1137
- if (callingX) {
1138
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
1139
- await callingX.joinCall(this, this.clientStore.calls);
1140
- }
1193
+ try {
1194
+ if (ringingLifecycle) {
1195
+ await ringingLifecycle.beforeJoin(this);
1196
+ if (supersededByLeave()) {
1197
+ this.logger.debug('Join superseded by leave; not joining');
1198
+ return;
1199
+ }
1200
+ }
1141
1201
 
1142
- await this.setup();
1202
+ if (callingX) {
1203
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
1204
+ await callingX.joinCall(
1205
+ this,
1206
+ this.clientState.calls,
1207
+ supersededByLeave,
1208
+ );
1209
+ if (supersededByLeave()) {
1210
+ this.logger.debug('Join superseded by leave; not setting up');
1211
+ return;
1212
+ }
1213
+ }
1143
1214
 
1144
- this.clientEventReporter.registerCall(this.cid, {
1145
- callType: this.type,
1146
- callId: this.id,
1147
- getCallSessionId: () => this.state.session?.id ?? '',
1148
- getSfuId: () => this.credentials?.server.edge_name ?? '',
1149
- });
1215
+ await this.setup();
1216
+ if (supersededByLeave()) {
1217
+ this.logger.debug('Join superseded by leave; not registering');
1218
+ return;
1219
+ }
1150
1220
 
1151
- this.joinResponseTimeout = joinResponseTimeout;
1152
- this.rpcRequestTimeout = rpcRequestTimeout;
1153
- // we will count the number of join failures per SFU.
1154
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1155
- // field to force the coordinator to provide us another SFU
1156
- const sfuJoinFailures = new Map<string, number>();
1157
- const joinData: JoinCallData = data;
1158
- maxJoinRetries = Math.max(maxJoinRetries, 1);
1159
- try {
1221
+ this.clientEventReporter.registerCall(this.cid, {
1222
+ callType: this.type,
1223
+ callId: this.id,
1224
+ getCallSessionId: () => this.state.session?.id ?? '',
1225
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
1226
+ });
1227
+
1228
+ this.joinResponseTimeout = joinResponseTimeout;
1229
+ this.rpcRequestTimeout = rpcRequestTimeout;
1230
+ // we will count the number of join failures per SFU.
1231
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1232
+ // field to force the coordinator to provide us another SFU
1233
+ const sfuJoinFailures = new Map<string, number>();
1234
+ const joinData: JoinCallData = data;
1235
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
1160
1236
  await this.clientEventReporter.withJoinLifecycle(
1161
1237
  this.cid,
1162
- 'first-attempt',
1238
+ { joinReason: 'first-attempt', joinSource },
1163
1239
  async () => {
1164
1240
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1241
+ // Also covers a leave that lands during the backoff below.
1242
+ if (supersededByLeave()) {
1243
+ this.logger.debug('Join superseded by leave; not attempting');
1244
+ return;
1245
+ }
1165
1246
  try {
1166
1247
  this.logger.trace(`Joining call (${attempt})`, this.cid);
1167
1248
  await this.doJoin(data);
@@ -1169,6 +1250,10 @@ export class Call {
1169
1250
  delete joinData.migrating_from_list;
1170
1251
  return;
1171
1252
  } catch (err) {
1253
+ if (supersededByLeave()) {
1254
+ this.logger.debug('Join superseded by leave; not retrying');
1255
+ return;
1256
+ }
1172
1257
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1173
1258
  if (
1174
1259
  (err instanceof ErrorFromResponse && err.unrecoverable) ||
@@ -1209,6 +1294,9 @@ export class Call {
1209
1294
  );
1210
1295
  } catch (error) {
1211
1296
  callingX?.endCall(this, 'error');
1297
+ // Ends the failed ringing flow and releases what its setup installed.
1298
+ // Never rejects, so `error` is what the caller sees.
1299
+ await ringingLifecycle?.onJoinFailed(this);
1212
1300
  throw error;
1213
1301
  }
1214
1302
  },
@@ -1237,6 +1325,10 @@ export class Call {
1237
1325
  // globals resolve to the call's factory. Idempotent across
1238
1326
  // reconnect/migration attempts.
1239
1327
  await this.ensureMediaFactory();
1328
+ if (supersededByLeave()) {
1329
+ this.logger.debug('Join superseded by leave; not wiring media');
1330
+ return;
1331
+ }
1240
1332
 
1241
1333
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1242
1334
  if (callingX) {
@@ -1264,10 +1356,17 @@ export class Call {
1264
1356
  'CoordinatorJoin',
1265
1357
  () => this.doJoinRequest(data),
1266
1358
  );
1359
+ if (!joinResponse || supersededByLeave()) {
1360
+ this.logger.debug(
1361
+ 'Join superseded by leave; not creating SFU client',
1362
+ );
1363
+ return;
1364
+ }
1267
1365
  this.credentials = joinResponse.credentials;
1268
1366
  statsOptions = joinResponse.stats_options;
1269
1367
  this.lastStatsOptions = statsOptions;
1270
1368
  } catch (error) {
1369
+ if (supersededByLeave()) return;
1271
1370
  // prevent triggering reconnect flow if the state is OFFLINE
1272
1371
  const avoidRestoreState =
1273
1372
  this.state.callingState === CallingState.OFFLINE;
@@ -1322,6 +1421,10 @@ export class Call {
1322
1421
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
1323
1422
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
1324
1423
  ]);
1424
+ if (supersededByLeave()) {
1425
+ this.logger.debug('Join superseded by leave; not joining SFU');
1426
+ return;
1427
+ }
1325
1428
  const isReconnecting =
1326
1429
  this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
1327
1430
  const reconnectDetails = isReconnecting
@@ -1355,6 +1458,12 @@ export class Call {
1355
1458
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
1356
1459
  }),
1357
1460
  );
1461
+ if (supersededByLeave()) {
1462
+ this.logger.debug(
1463
+ 'Join superseded by leave; not applying SFU response',
1464
+ );
1465
+ return;
1466
+ }
1358
1467
 
1359
1468
  this.currentPublishOptions = publishOptions;
1360
1469
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
@@ -1366,6 +1475,10 @@ export class Call {
1366
1475
  );
1367
1476
  }
1368
1477
  } catch (error) {
1478
+ if (supersededByLeave()) {
1479
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
1480
+ return;
1481
+ }
1369
1482
  this.logger.warn('Join SFU request failed', error);
1370
1483
  sfuClient.close(
1371
1484
  StreamSfuClient.JOIN_FAILED,
@@ -1377,8 +1490,8 @@ export class Call {
1377
1490
  }
1378
1491
  }
1379
1492
 
1380
- // If the user left while this join was in flight, bail before re-setting JOINED and before
1381
- // peer-connection setup below (both run synchronously after this, so one check covers them).
1493
+ // If the user left while this join was in flight, bail before re-setting JOINED
1494
+ // or starting peer-connection setup below.
1382
1495
  if (supersededByLeave()) {
1383
1496
  this.logger.debug('Join superseded by leave; aborting join flow');
1384
1497
  return;
@@ -1408,6 +1521,10 @@ export class Call {
1408
1521
  unifiedSessionId: this.unifiedSessionId,
1409
1522
  });
1410
1523
  }
1524
+ if (supersededByLeave()) {
1525
+ this.logger.debug('Join superseded by leave; not completing join flow');
1526
+ return;
1527
+ }
1411
1528
 
1412
1529
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
1413
1530
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
@@ -1593,6 +1710,7 @@ export class Call {
1593
1710
  closePreviousInstances: boolean;
1594
1711
  unifiedSessionId: string;
1595
1712
  }) => {
1713
+ const joinLeaveGeneration = this.leaveGeneration;
1596
1714
  const {
1597
1715
  sfuClient,
1598
1716
  connectionConfig,
@@ -1609,10 +1727,12 @@ export class Call {
1609
1727
  // Flush the previous reporter's final sample while its peer connections are
1610
1728
  // still alive, before we dispose them below. Awaits only the sampling step.
1611
1729
  await this.sfuStatsReporter?.flush();
1730
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1612
1731
  this.sfuStatsReporter?.stop();
1613
1732
  this.sfuStatsReporter = undefined;
1614
1733
  if (closePreviousInstances && this.subscriber) {
1615
1734
  await this.subscriber.dispose();
1735
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1616
1736
  this.state.removeAllOrphanedTracks();
1617
1737
  }
1618
1738
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
@@ -1659,6 +1779,7 @@ export class Call {
1659
1779
  if (!isAnonymous) {
1660
1780
  if (closePreviousInstances && this.publisher) {
1661
1781
  await this.publisher.dispose();
1782
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1662
1783
  }
1663
1784
  this.publisher = new Publisher(
1664
1785
  basePeerConnectionOptions,
@@ -1702,9 +1823,19 @@ export class Call {
1702
1823
  *
1703
1824
  * @internal
1704
1825
  * @param data the join call data.
1826
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
1705
1827
  */
1706
- doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1828
+ doJoinRequest = async (
1829
+ data?: JoinCallData,
1830
+ ): Promise<JoinCallResponse | undefined> => {
1831
+ const joinLeaveGeneration = this.leaveGeneration;
1707
1832
  const location = await this.streamClient.getLocationHint();
1833
+ if (this.leaveGeneration !== joinLeaveGeneration) {
1834
+ this.logger.debug(
1835
+ 'Join superseded by leave; not sending coordinator request',
1836
+ );
1837
+ return;
1838
+ }
1708
1839
  const e2ee = !!this.e2eeManager;
1709
1840
  const request: JoinCallRequest = { ...data, location, e2ee };
1710
1841
  const joinResponse = await this.streamClient.post<
@@ -1712,6 +1843,13 @@ export class Call {
1712
1843
  JoinCallRequest
1713
1844
  >(`${this.streamClientBasePath}/join`, request);
1714
1845
 
1846
+ if (this.leaveGeneration !== joinLeaveGeneration) {
1847
+ this.logger.debug(
1848
+ 'Join superseded by leave; not applying coordinator response',
1849
+ );
1850
+ return joinResponse;
1851
+ }
1852
+
1715
1853
  this.state.updateFromCallResponse(joinResponse.call);
1716
1854
  this.state.setMembers(joinResponse.members);
1717
1855
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -1726,11 +1864,12 @@ export class Call {
1726
1864
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
1727
1865
  // signals other users that I have accepted the incoming call.
1728
1866
  await this.accept();
1867
+ if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse;
1729
1868
  }
1730
1869
 
1731
1870
  if (this.streamClient._hasConnectionID()) {
1732
1871
  this.watching = true;
1733
- this.clientStore.registerOrUpdateCall(this);
1872
+ this.clientState.registerOrUpdateCall(this);
1734
1873
  }
1735
1874
 
1736
1875
  return joinResponse;
@@ -1830,6 +1969,7 @@ export class Call {
1830
1969
  callingState === CallingState.JOINING ||
1831
1970
  callingState === CallingState.RECONNECTING ||
1832
1971
  callingState === CallingState.MIGRATING ||
1972
+ callingState === CallingState.LEFT ||
1833
1973
  callingState === CallingState.RECONNECTING_FAILED
1834
1974
  )
1835
1975
  return;
@@ -1840,19 +1980,26 @@ export class Call {
1840
1980
  // `POST /join` per entry) once the call is already healthy again.
1841
1981
  if (hasPending(this.reconnectConcurrencyTag)) return;
1842
1982
 
1983
+ const reconnectLeaveGeneration = this.leaveGeneration;
1984
+ const supersededByLeave = () =>
1985
+ this.leaveGeneration !== reconnectLeaveGeneration;
1843
1986
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1987
+ if (supersededByLeave()) return;
1844
1988
  const reconnectStartTime = Date.now();
1845
1989
  this.reconnectStrategy = strategy;
1846
1990
  this.reconnectReason = reason;
1847
1991
  const sfuRejoinFailures = new Map<string, number>();
1848
1992
 
1849
1993
  const markAsReconnectingFailed = async () => {
1994
+ if (supersededByLeave()) return;
1850
1995
  try {
1851
1996
  // attempt to fetch the call data from the server, as the call
1852
1997
  // state might have changed while we were reconnecting or were offline
1853
1998
  await this.get();
1854
1999
  } finally {
1855
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2000
+ if (!supersededByLeave()) {
2001
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2002
+ }
1856
2003
  }
1857
2004
  };
1858
2005
 
@@ -1922,6 +2069,7 @@ export class Call {
1922
2069
  try {
1923
2070
  // wait until the network is available
1924
2071
  await this.networkAvailableTask?.promise;
2072
+ if (supersededByLeave()) return;
1925
2073
 
1926
2074
  this.logger.info(
1927
2075
  `[Reconnect] Reconnecting with strategy ${
@@ -1972,6 +2120,7 @@ export class Call {
1972
2120
  this.consecutiveNegotiationFailures = 0;
1973
2121
  break; // do-while loop, reconnection worked, exit the loop
1974
2122
  } catch (error) {
2123
+ if (supersededByLeave()) return;
1975
2124
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
1976
2125
  const failedSfu = this.credentials?.server.edge_name;
1977
2126
  if (failedSfu) {
@@ -2015,6 +2164,7 @@ export class Call {
2015
2164
 
2016
2165
  // exponential backoff with jitter, capped at 5 s
2017
2166
  await sleep(retryInterval(attempt));
2167
+ if (supersededByLeave()) return;
2018
2168
 
2019
2169
  const wasMigrating =
2020
2170
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
@@ -2045,6 +2195,7 @@ export class Call {
2045
2195
  );
2046
2196
  }
2047
2197
  } while (
2198
+ !supersededByLeave() &&
2048
2199
  this.state.callingState !== CallingState.JOINED &&
2049
2200
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
2050
2201
  this.state.callingState !== CallingState.LEFT
@@ -2058,10 +2209,12 @@ export class Call {
2058
2209
  * @internal
2059
2210
  */
2060
2211
  private reconnectFast = async () => {
2212
+ const reconnectLeaveGeneration = this.leaveGeneration;
2061
2213
  const reconnectStartTime = Date.now();
2062
2214
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
2063
2215
  this.state.setCallingState(CallingState.RECONNECTING);
2064
2216
  await this.doJoin(this.joinCallData);
2217
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2065
2218
  await this.get(); // fetch the latest call state, as it might have changed
2066
2219
  this.sfuStatsReporter?.sendReconnectionTime(
2067
2220
  WebsocketReconnectStrategy.FAST,
@@ -2074,6 +2227,7 @@ export class Call {
2074
2227
  * @internal
2075
2228
  */
2076
2229
  private reconnectRejoin = async () => {
2230
+ const reconnectLeaveGeneration = this.leaveGeneration;
2077
2231
  const reconnectStartTime = Date.now();
2078
2232
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
2079
2233
  this.state.setCallingState(CallingState.RECONNECTING);
@@ -2081,10 +2235,14 @@ export class Call {
2081
2235
  this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
2082
2236
  ? 'network-available'
2083
2237
  : 'full-rejoin';
2084
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
2085
- this.doJoin(this.joinCallData),
2238
+ await this.clientEventReporter.withJoinLifecycle(
2239
+ this.cid,
2240
+ { joinReason },
2241
+ () => this.doJoin(this.joinCallData),
2086
2242
  );
2243
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2087
2244
  await this.restorePublishedTracks();
2245
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2088
2246
  this.restoreSubscribedTracks();
2089
2247
  this.sfuStatsReporter?.sendReconnectionTime(
2090
2248
  WebsocketReconnectStrategy.REJOIN,
@@ -2097,6 +2255,7 @@ export class Call {
2097
2255
  * @internal
2098
2256
  */
2099
2257
  private reconnectMigrate = async () => {
2258
+ const reconnectLeaveGeneration = this.leaveGeneration;
2100
2259
  const reconnectStartTime = Date.now();
2101
2260
  const currentSfuClient = this.sfuClient;
2102
2261
  if (!currentSfuClient) {
@@ -2114,32 +2273,35 @@ export class Call {
2114
2273
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
2115
2274
 
2116
2275
  try {
2117
- const currentSfu = currentSfuClient.edgeName;
2118
- await this.clientEventReporter.withJoinLifecycle(
2119
- this.cid,
2120
- 'migration',
2121
- () =>
2122
- this.doJoin({
2123
- ...this.joinCallData,
2124
- migrating_from: currentSfu,
2125
- migrating_from_list: [currentSfu],
2126
- }),
2127
- );
2128
- } finally {
2129
- // cleanup the migration_from field after the migration is complete or failed
2130
- // as we don't want to keep dirty data in the join call data
2131
- delete this.joinCallData?.migrating_from;
2132
- delete this.joinCallData?.migrating_from_list;
2133
- }
2276
+ try {
2277
+ const currentSfu = currentSfuClient.edgeName;
2278
+ await this.clientEventReporter.withJoinLifecycle(
2279
+ this.cid,
2280
+ { joinReason: 'migration' },
2281
+ () =>
2282
+ this.doJoin({
2283
+ ...this.joinCallData,
2284
+ migrating_from: currentSfu,
2285
+ migrating_from_list: [currentSfu],
2286
+ }),
2287
+ );
2288
+ } finally {
2289
+ // cleanup the migration_from field after the migration is complete or failed
2290
+ // as we don't want to keep dirty data in the join call data
2291
+ delete this.joinCallData?.migrating_from;
2292
+ delete this.joinCallData?.migrating_from_list;
2293
+ }
2134
2294
 
2135
- await this.restorePublishedTracks();
2136
- this.restoreSubscribedTracks();
2295
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2296
+ await this.restorePublishedTracks();
2297
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2298
+ this.restoreSubscribedTracks();
2137
2299
 
2138
- try {
2139
2300
  // Wait for the migration to complete, then close the previous SFU client
2140
2301
  // and the peer connection instances. In case of failure, the migration
2141
2302
  // task would throw an error and REJOIN would be attempted.
2142
2303
  await migrationTask();
2304
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2143
2305
 
2144
2306
  // in MIGRATE, we can consider the call as joined only after
2145
2307
  // `participantMigrationComplete` event is received, signaled by
@@ -2473,10 +2635,11 @@ export class Call {
2473
2635
  * Must be called before {@link join} so the RTCPeerConnection can be
2474
2636
  * configured for E2EE.
2475
2637
  *
2476
- * The manager is kept across {@link leave} so a rejoin of this same instance
2477
- * stays encrypted: do not dispose it while this call may be joined again.
2478
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
2479
- * publishing nothing, so re-attach a fresh one instead of reusing it.
2638
+ * The application owns the manager's lifetime - the SDK never disposes it, and
2639
+ * closing the peer connections does not release it. Discard the manager
2640
+ * together with this call instance once its flow has ended, and create a fresh
2641
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
2642
+ * rather than silently publishing nothing.
2480
2643
  *
2481
2644
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
2482
2645
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -2529,7 +2692,7 @@ export class Call {
2529
2692
  };
2530
2693
 
2531
2694
  /**
2532
- * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
2695
+ * Will enhance the reported stats with additional participant-specific information (the `callStatsReport$` state variable).
2533
2696
  * This is usually helpful when detailed stats for a specific participant are needed.
2534
2697
  *
2535
2698
  * @param sessionId the sessionId to start reporting for.
@@ -3114,42 +3277,39 @@ export class Call {
3114
3277
  */
3115
3278
  private scheduleAutoDrop = () => {
3116
3279
  this.cancelAutoDrop();
3117
-
3118
- const settings = this.state.settings;
3119
- if (!settings) return;
3120
- // ignore if the call is not ringing
3121
- if (this.state.callingState !== CallingState.RINGING) return;
3122
-
3123
- const timeoutInMs = this.isCreatedByMe
3124
- ? settings.ring.auto_cancel_timeout_ms
3125
- : settings.ring.incoming_call_timeout_ms;
3126
-
3127
- // 0 means no auto-drop
3128
- if (timeoutInMs <= 0) return;
3129
- this.dropTimeout = setTimeout(() => {
3130
- // the call might have stopped ringing by this point,
3131
- // e.g. it was already accepted and joined
3132
- if (this.state.callingState !== CallingState.RINGING) return;
3133
- this.leave({
3134
- reject: true,
3135
- reason: 'timeout',
3136
- message: `ringing timeout - ${
3137
- this.isCreatedByMe
3138
- ? 'no one accepted'
3139
- : `user didn't interact with incoming call screen`
3140
- }`,
3141
- }).catch((err) => {
3142
- this.logger.error('Failed to drop call', err);
3143
- });
3144
- }, timeoutInMs);
3280
+ this.ringTimeout = new RingTimeout(this);
3281
+ this.ringTimeout.start();
3145
3282
  };
3146
3283
 
3147
3284
  /**
3148
3285
  * Cancels a scheduled auto-drop timeout.
3149
3286
  */
3150
3287
  private cancelAutoDrop = () => {
3151
- clearTimeout(this.dropTimeout);
3152
- this.dropTimeout = undefined;
3288
+ this.ringTimeout?.stop();
3289
+ this.ringTimeout = undefined;
3290
+ };
3291
+
3292
+ /**
3293
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
3294
+ * current user created.
3295
+ */
3296
+ private scheduleRingStatePolling = () => {
3297
+ this.cancelRingStatePolling();
3298
+
3299
+ if (!this.isCreatedByMe) return;
3300
+ const options = this.streamClient.options.ringStatePolling;
3301
+ if (options === false) return;
3302
+
3303
+ this.ringStatePoller = new RingStatePoller(this, options);
3304
+ this.ringStatePoller.start();
3305
+ };
3306
+
3307
+ /**
3308
+ * Cancels the ring state polling.
3309
+ */
3310
+ private cancelRingStatePolling = () => {
3311
+ this.ringStatePoller?.stop();
3312
+ this.ringStatePoller = undefined;
3153
3313
  };
3154
3314
 
3155
3315
  /**