@stream-io/video-client 1.59.1 → 1.60.0

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 (47) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/index.browser.es.js +495 -105
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +495 -105
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +495 -105
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +23 -3
  9. package/dist/src/coordinator/connection/types.d.ts +16 -0
  10. package/dist/src/events/call.d.ts +0 -11
  11. package/dist/src/gen/coordinator/index.d.ts +73 -0
  12. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  13. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  14. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  15. package/dist/src/ringing/index.d.ts +4 -0
  16. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  17. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  18. package/dist/src/stats/utils.d.ts +6 -0
  19. package/dist/src/store/CallState.d.ts +10 -1
  20. package/package.json +1 -1
  21. package/src/Call.ts +93 -61
  22. package/src/StreamSfuClient.ts +6 -2
  23. package/src/__tests__/Call.autodrop.test.ts +116 -64
  24. package/src/__tests__/Call.getRingState.test.ts +87 -0
  25. package/src/__tests__/Call.lifecycle.test.ts +23 -0
  26. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  27. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  28. package/src/coordinator/connection/types.ts +19 -0
  29. package/src/events/__tests__/call.test.ts +2 -217
  30. package/src/events/call.ts +1 -73
  31. package/src/events/callEventHandlers.ts +12 -23
  32. package/src/gen/coordinator/index.ts +67 -0
  33. package/src/reporting/ClientEventReporter.ts +30 -4
  34. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  35. package/src/ringing/RingStatePoller.ts +177 -0
  36. package/src/ringing/RingTimeout.ts +90 -0
  37. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  38. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  39. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  40. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  41. package/src/ringing/index.ts +4 -0
  42. package/src/ringing/reconcileRingState.ts +108 -0
  43. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  44. package/src/stats/__tests__/utils.test.ts +33 -0
  45. package/src/stats/utils.ts +15 -0
  46. package/src/store/CallState.ts +25 -0
  47. package/src/store/__tests__/CallState.test.ts +59 -0
package/dist/index.es.js CHANGED
@@ -5082,7 +5082,7 @@ const toIceCandidate = (iceTrickle) => {
5082
5082
  }
5083
5083
  };
5084
5084
 
5085
- const version = "1.59.1";
5085
+ const version = "1.60.0";
5086
5086
  const [major, minor, patch] = version.split('.');
5087
5087
  let sdkInfo = {
5088
5088
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7115,6 +7115,30 @@ class CallState {
7115
7115
  this.setCurrentValue(this.captioningSubject, call.captioning);
7116
7116
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
7117
7117
  };
7118
+ /**
7119
+ * Merges a polled ring state into the current session. Only the ring fields
7120
+ * are touched: the endpoint does not return the session roster.
7121
+ *
7122
+ * @internal
7123
+ *
7124
+ * @param ringState the ring state as returned by the coordinator.
7125
+ */
7126
+ updateFromRingState = (ringState) => {
7127
+ this.setCurrentValue(this.sessionSubject, (session) => {
7128
+ if (!session || session.id !== ringState.session_id)
7129
+ return session;
7130
+ return {
7131
+ ...session,
7132
+ accepted_by: ringState.accepted_by,
7133
+ rejected_by: ringState.rejected_by,
7134
+ missed_by: ringState.missed_by,
7135
+ ended_at: ringState.session_ended_at ?? session.ended_at,
7136
+ };
7137
+ });
7138
+ if (ringState.call_ended_at) {
7139
+ this.setEndedAt(new Date(ringState.call_ended_at));
7140
+ }
7141
+ };
7118
7142
  /**
7119
7143
  * Updates the call state with the data received from the SFU server.
7120
7144
  *
@@ -7426,6 +7450,19 @@ const getSdkName = (sdk) => {
7426
7450
  const getSdkVersion = (sdk) => {
7427
7451
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7428
7452
  };
7453
+ /**
7454
+ * Returns the identifier the SFU receives in the `X-Stream-Client` header,
7455
+ * e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
7456
+ * already gets through the client's user agent.
7457
+ */
7458
+ const getStreamClientId = (sdk) => {
7459
+ const sdkPlatforms = {
7460
+ [SdkType.REACT]: 'react',
7461
+ [SdkType.REACT_NATIVE]: 'react-native',
7462
+ };
7463
+ const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
7464
+ return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
7465
+ };
7429
7466
 
7430
7467
  /**
7431
7468
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
@@ -10307,7 +10344,10 @@ class StreamSfuClient {
10307
10344
  this.rpc = createSignalClient({
10308
10345
  baseUrl: server.url,
10309
10346
  interceptors: [
10310
- withHeaders({ Authorization: `Bearer ${token}` }),
10347
+ withHeaders({
10348
+ Authorization: `Bearer ${token}`,
10349
+ 'X-Stream-Client': getStreamClientId(getSdkInfo()),
10350
+ }),
10311
10351
  this.tracer && withRequestTracer(this.tracer.trace),
10312
10352
  this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
10313
10353
  withTimeout(rpcRequestTimeout, this.tracer?.trace),
@@ -10644,64 +10684,370 @@ class StreamSfuClient {
10644
10684
  }
10645
10685
 
10646
10686
  /**
10647
- * Event handler that watched the delivery of `call.accepted`.
10648
- * Once the event is received, the call is joined.
10649
- */
10650
- const watchCallAccepted = (call) => {
10651
- return async function onCallAccepted(event) {
10652
- // We want to discard the event if it's from the current user
10653
- if (event.user.id === call.currentUserId)
10654
- return;
10655
- const { state } = call;
10656
- if (event.call.created_by.id === call.currentUserId &&
10657
- state.callingState === CallingState.RINGING) {
10658
- await call.join();
10687
+ * Decides what a ringing call should do next, based on the current call state.
10688
+ *
10689
+ * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10690
+ * state poller both run this. They differ only in how the state got there: the
10691
+ * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10692
+ * poller applies the polled ring state itself.
10693
+ *
10694
+ * @param call the call to reconcile.
10695
+ * @param joinSource which of the two triggered this run, reported on the
10696
+ * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10697
+ * poller.
10698
+ * @returns whether the ring reached a terminal state. A failed join is not
10699
+ * terminal: the caller should keep trying while the ring is open.
10700
+ */
10701
+ const reconcileRingState = async (call, joinSource) => {
10702
+ if (call.state.callingState !== CallingState.RINGING)
10703
+ return true;
10704
+ return call.isCreatedByMe
10705
+ ? reconcileAsCaller(call, joinSource)
10706
+ : reconcileAsCallee(call);
10707
+ };
10708
+ const reconcileAsCaller = async (call, joinSource) => {
10709
+ const { session, members, endedAt } = call.state;
10710
+ const currentUserId = call.currentUserId;
10711
+ // checked before `accepted_by`: an ended session cannot be joined
10712
+ if (endedAt || session?.ended_at) {
10713
+ call.logger.info('ring: the call has ended, leaving');
10714
+ // `leave` reports the remote end to callingx off `reason: 'ended'`
10715
+ return leave(call, {
10716
+ reject: false,
10717
+ reason: 'ended',
10718
+ message: 'ring: call ended',
10719
+ });
10720
+ }
10721
+ const acceptedBy = session?.accepted_by ?? {};
10722
+ if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
10723
+ call.logger.info('ring: the call was accepted, joining');
10724
+ try {
10725
+ await call.join({ joinSource });
10659
10726
  }
10660
- };
10727
+ catch (err) {
10728
+ // `doJoin` restores the ringing state when a join fails, so the ring is
10729
+ // still open. Report it unsettled and let the next poll retry.
10730
+ call.logger.error('Failed to join an accepted call', err);
10731
+ return false;
10732
+ }
10733
+ return true;
10734
+ }
10735
+ const otherMembers = members
10736
+ .filter((member) => member.user_id !== currentUserId)
10737
+ .map((member) => member.user_id);
10738
+ if (otherMembers.length === 0)
10739
+ return false;
10740
+ const rejectedBy = session?.rejected_by ?? {};
10741
+ if (otherMembers.every((userId) => rejectedBy[userId])) {
10742
+ call.logger.info('ring: everyone rejected, leaving');
10743
+ return leave(call, {
10744
+ reject: true,
10745
+ reason: 'cancel',
10746
+ message: 'ring: everyone rejected',
10747
+ });
10748
+ }
10749
+ return false;
10661
10750
  };
10751
+ // the current user's own accept or reject, on this or another device, is
10752
+ // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10753
+ const reconcileAsCallee = async (call) => {
10754
+ const createdById = call.state.createdBy?.id;
10755
+ const rejectedBy = call.state.session?.rejected_by ?? {};
10756
+ if (createdById && rejectedBy[createdById]) {
10757
+ call.logger.info('ring: the caller cancelled, leaving');
10758
+ return leave(call, {
10759
+ reason: 'ended',
10760
+ message: 'ring: creator rejected',
10761
+ });
10762
+ }
10763
+ return false;
10764
+ };
10765
+ // `false` when the call could not be left, so the ring stays open for a retry.
10766
+ const leave = async (call, options) => {
10767
+ try {
10768
+ await call.leave(options);
10769
+ return true;
10770
+ }
10771
+ catch (err) {
10772
+ call.logger.error('Failed to leave a ringing call', err);
10773
+ return false;
10774
+ }
10775
+ };
10776
+
10777
+ // `doJoin` restores the previous calling state when a join fails, so JOINING can
10778
+ // go back to RINGING. Only a state the ring cannot return from ends the poller.
10779
+ const ringIsOver = (callingState) => callingState !== CallingState.RINGING &&
10780
+ callingState !== CallingState.JOINING;
10662
10781
  /**
10663
- * Event handler that watches delivery of `call.rejected` Websocket event.
10664
- * Once the event is received, the call is left.
10782
+ * Polls the coordinator for the outcome of a ring the current user started.
10783
+ *
10784
+ * `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
10785
+ * with no store-and-forward, so a caller that drops one is left on a ringing
10786
+ * screen while the callee is already in the call. After a quiet period this
10787
+ * reads the ring state until a terminal outcome or the end of the ring window.
10665
10788
  */
10666
- const watchCallRejected = (call) => {
10667
- return async function onCallRejected(event) {
10668
- // We want to discard the event if it's from the current user
10669
- if (event.user.id === call.currentUserId)
10789
+ class RingStatePoller {
10790
+ logger = videoLoggerSystem.getLogger('RingStatePoller');
10791
+ call;
10792
+ startAfterMs;
10793
+ intervalMs;
10794
+ sessionId;
10795
+ deadlineAt = 0;
10796
+ idleTimeoutId;
10797
+ intervalId;
10798
+ stopped = false;
10799
+ wasPolling = false;
10800
+ inFlight = false;
10801
+ unsubscribe = [];
10802
+ constructor(call, options = {}) {
10803
+ this.call = call;
10804
+ this.startAfterMs = options.startAfterMs ?? 15_000;
10805
+ this.intervalMs = options.intervalMs ?? 5_000;
10806
+ }
10807
+ /**
10808
+ * Starts polling. Does nothing when the call has no session yet.
10809
+ */
10810
+ start = () => {
10811
+ if (this.stopped || this.sessionId)
10670
10812
  return;
10671
- const { call: eventCall } = event;
10672
- const { session: callSession } = eventCall;
10673
- if (!callSession) {
10674
- call.logger.warn('No call session provided. Ignoring call.rejected event.', event);
10813
+ if (this.call.state.callingState !== CallingState.RINGING)
10814
+ return;
10815
+ // captured once: `call.ended` clears the call's current session
10816
+ const sessionId = this.call.state.session?.id;
10817
+ if (!sessionId) {
10818
+ this.logger.warn('the call has no session');
10675
10819
  return;
10676
10820
  }
10677
- const rejectedBy = callSession.rejected_by;
10678
- const { members, callingState } = call.state;
10679
- if (callingState !== CallingState.RINGING) {
10680
- call.logger.info('Call is not in ringing mode (it is either accepted or rejected already). Ignoring call.rejected event.', event);
10821
+ this.sessionId = sessionId;
10822
+ const ring = this.call.state.settings?.ring;
10823
+ const maxDurationMs = ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
10824
+ this.deadlineAt = Date.now() + maxDurationMs;
10825
+ // an incoming event proves the socket is alive, so the quiet period starts
10826
+ // over: in a group ring a single rejection does not settle the ring.
10827
+ this.unsubscribe.push(this.call.on('call.accepted', () => this.armIdleWindow()), this.call.on('call.rejected', () => this.armIdleWindow()), this.call.on('call.missed', () => this.armIdleWindow()),
10828
+ // the ring is over, whichever way it went. Joining an accepted call
10829
+ // never goes through `leave`, so this is the only signal for it.
10830
+ createSubscription(this.call.state.callingState$, (callingState) => {
10831
+ if (ringIsOver(callingState))
10832
+ this.stop();
10833
+ }));
10834
+ this.armIdleWindow();
10835
+ };
10836
+ /**
10837
+ * Pauses polling, keeping the captured session, the deadline and the event
10838
+ * subscriptions. Resuming does not extend the ring window.
10839
+ */
10840
+ pause = () => {
10841
+ if (this.stopped)
10842
+ return;
10843
+ this.wasPolling = this.intervalId !== undefined;
10844
+ const timers = getTimers();
10845
+ timers.clearTimeout(this.idleTimeoutId);
10846
+ timers.clearInterval(this.intervalId);
10847
+ this.idleTimeoutId = undefined;
10848
+ this.intervalId = undefined;
10849
+ };
10850
+ /**
10851
+ * Resumes a paused poller. Goes straight back to polling if it was already
10852
+ * past the quiet period, so a pause does not buy the ring another one.
10853
+ */
10854
+ resume = () => {
10855
+ if (this.stopped || !this.sessionId)
10856
+ return;
10857
+ if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
10681
10858
  return;
10682
10859
  }
10683
- if (call.isCreatedByMe) {
10684
- const everyoneElseRejected = members
10685
- .filter((m) => m.user_id !== call.currentUserId)
10686
- .every((m) => rejectedBy[m.user_id]);
10687
- if (everyoneElseRejected) {
10688
- call.logger.info('everyone rejected, leaving the call');
10689
- await call.leave({
10690
- reject: true,
10691
- reason: 'cancel',
10692
- message: 'ring: everyone rejected',
10693
- });
10694
- }
10860
+ if (!this.wasPolling) {
10861
+ this.armIdleWindow();
10862
+ return;
10695
10863
  }
10696
- else {
10697
- if (rejectedBy[eventCall.created_by.id]) {
10698
- call.logger.info('call creator rejected, leaving call');
10699
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10700
- await call.leave({ message: 'ring: creator rejected' });
10864
+ this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
10865
+ this.runTick();
10866
+ };
10867
+ /**
10868
+ * Stops polling. The poller cannot be restarted.
10869
+ */
10870
+ stop = () => {
10871
+ if (this.stopped)
10872
+ return;
10873
+ this.stopped = true;
10874
+ const timers = getTimers();
10875
+ timers.clearTimeout(this.idleTimeoutId);
10876
+ timers.clearInterval(this.intervalId);
10877
+ this.idleTimeoutId = undefined;
10878
+ this.intervalId = undefined;
10879
+ this.unsubscribe.forEach((off) => off());
10880
+ this.unsubscribe = [];
10881
+ };
10882
+ armIdleWindow = () => {
10883
+ if (this.stopped)
10884
+ return;
10885
+ const timers = getTimers();
10886
+ timers.clearTimeout(this.idleTimeoutId);
10887
+ timers.clearInterval(this.intervalId);
10888
+ this.intervalId = undefined;
10889
+ this.idleTimeoutId = timers.setTimeout(() => {
10890
+ this.idleTimeoutId = undefined;
10891
+ if (this.stopped)
10892
+ return;
10893
+ this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
10894
+ this.runTick();
10895
+ }, this.startAfterMs);
10896
+ };
10897
+ runTick = () => {
10898
+ this.tick().catch((err) => {
10899
+ this.logger.warn('Failed to poll the ring state', err);
10900
+ });
10901
+ };
10902
+ tick = async () => {
10903
+ if (this.stopped || this.inFlight || !this.sessionId)
10904
+ return;
10905
+ const { callingState } = this.call.state;
10906
+ if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
10907
+ this.stop();
10908
+ return;
10909
+ }
10910
+ // mid-join: nothing to reconcile until we know whether it succeeded
10911
+ if (callingState !== CallingState.RINGING)
10912
+ return;
10913
+ this.inFlight = true;
10914
+ try {
10915
+ const ringState = await this.call.getRingState(this.sessionId);
10916
+ if (this.stopped)
10917
+ return;
10918
+ this.call.state.updateFromRingState(ringState);
10919
+ if (await reconcileRingState(this.call, 'ring-poll-api'))
10920
+ this.stop();
10921
+ }
10922
+ catch (err) {
10923
+ // a missing session, or one of another call, will never resolve
10924
+ const status = err instanceof ErrorFromResponse ? err.status : undefined;
10925
+ if (status === 400 || status === 404) {
10926
+ this.logger.warn('Stopped polling the ring state', err);
10927
+ this.stop();
10701
10928
  }
10929
+ else {
10930
+ this.logger.debug('Failed to poll the ring state', err);
10931
+ }
10932
+ }
10933
+ finally {
10934
+ this.inFlight = false;
10702
10935
  }
10703
10936
  };
10937
+ }
10938
+
10939
+ /**
10940
+ * Drops a call that has been ringing for too long: the caller cancels after
10941
+ * `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
10942
+ *
10943
+ * Either timeout being `0` means the call rings until something else settles it.
10944
+ */
10945
+ class RingTimeout {
10946
+ call;
10947
+ timeoutId;
10948
+ deadlineAt;
10949
+ stopped = false;
10950
+ constructor(call) {
10951
+ this.call = call;
10952
+ }
10953
+ /**
10954
+ * Schedules an auto-drop timeout based on the call settings.
10955
+ * Applicable only for ringing calls.
10956
+ */
10957
+ start = () => {
10958
+ if (this.stopped || this.timeoutId !== undefined)
10959
+ return;
10960
+ // ignore if the call is not ringing
10961
+ if (this.call.state.callingState !== CallingState.RINGING)
10962
+ return;
10963
+ const ring = this.call.state.settings?.ring;
10964
+ if (!ring)
10965
+ return;
10966
+ const isCaller = this.call.isCreatedByMe;
10967
+ const timeoutMs = isCaller
10968
+ ? ring.auto_cancel_timeout_ms
10969
+ : ring.incoming_call_timeout_ms;
10970
+ // 0 means no auto-drop
10971
+ if (timeoutMs <= 0)
10972
+ return;
10973
+ const now = Date.now();
10974
+ this.deadlineAt ??= now + timeoutMs;
10975
+ const delayMs = Math.max(0, this.deadlineAt - now);
10976
+ const timers = getTimers();
10977
+ this.timeoutId = timers.setTimeout(() => {
10978
+ this.timeoutId = undefined;
10979
+ // A failed timeout-triggered leave can arm a fresh retry window.
10980
+ this.deadlineAt = undefined;
10981
+ if (this.stopped)
10982
+ return;
10983
+ // the call might have stopped ringing by this point, e.g. it was already
10984
+ // accepted and joined
10985
+ if (this.call.state.callingState !== CallingState.RINGING)
10986
+ return;
10987
+ this.call
10988
+ .leave({
10989
+ reject: true,
10990
+ reason: 'timeout',
10991
+ message: `ringing timeout - ${isCaller
10992
+ ? 'no one accepted'
10993
+ : `user didn't interact with incoming call screen`}`,
10994
+ })
10995
+ .catch((err) => {
10996
+ videoLoggerSystem
10997
+ .getLogger('RingTimeout')
10998
+ .error('Failed to drop the call', err);
10999
+ });
11000
+ }, delayMs);
11001
+ };
11002
+ /**
11003
+ * Pauses the timeout while preserving its original deadline.
11004
+ */
11005
+ pause = () => {
11006
+ if (this.stopped || this.timeoutId === undefined)
11007
+ return;
11008
+ getTimers().clearTimeout(this.timeoutId);
11009
+ this.timeoutId = undefined;
11010
+ };
11011
+ /**
11012
+ * Cancels a scheduled auto-drop timeout. It cannot be armed again.
11013
+ */
11014
+ stop = () => {
11015
+ if (this.stopped)
11016
+ return;
11017
+ this.stopped = true;
11018
+ getTimers().clearTimeout(this.timeoutId);
11019
+ this.timeoutId = undefined;
11020
+ this.deadlineAt = undefined;
11021
+ };
11022
+ }
11023
+
11024
+ /**
11025
+ * Decides what a ringing call should do about the current user's own accept or
11026
+ * reject, which may have happened on another device.
11027
+ *
11028
+ * Accepting on *this* device also lands in `accepted_by`, so an acceptance only
11029
+ * means another device took the call when this one is still ringing.
11030
+ *
11031
+ * Only meaningful for a ringing call; the caller checks that.
11032
+ */
11033
+ const resolveOwnRingOutcome = ({ session, currentUserId, callingState, }) => {
11034
+ if (!currentUserId)
11035
+ return { settledByMe: false };
11036
+ const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
11037
+ const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
11038
+ if (!acceptedByMe && !rejectedByMe)
11039
+ return { settledByMe: false };
11040
+ const answeredElsewhere = acceptedByMe && callingState === CallingState.RINGING;
11041
+ return {
11042
+ settledByMe: true,
11043
+ leaveReason: answeredElsewhere
11044
+ ? 'answeredElsewhere'
11045
+ : rejectedByMe
11046
+ ? 'rejected'
11047
+ : undefined,
11048
+ };
10704
11049
  };
11050
+
10705
11051
  /**
10706
11052
  * Event handler that watches the delivery of `call.ended` Websocket event.
10707
11053
  */
@@ -11102,14 +11448,18 @@ const registerEventHandlers = (call, dispatcher) => {
11102
11448
  * @param call the call to register event handlers for.
11103
11449
  */
11104
11450
  const registerRingingCallEventHandlers = (call) => {
11105
- const coordinatorRingEvents = {
11106
- 'call.accepted': watchCallAccepted(call),
11107
- 'call.rejected': watchCallRejected(call),
11451
+ const reconcile = () => {
11452
+ reconcileRingState(call, 'ring-ws').catch((err) => {
11453
+ call.logger.error('Failed to reconcile the ring state', err);
11454
+ });
11108
11455
  };
11109
- const eventHandlers = Object.keys(coordinatorRingEvents).map((event) => {
11110
- const eventName = event;
11111
- return call.on(eventName, coordinatorRingEvents[eventName]);
11112
- });
11456
+ // each event needs its own closure. `call.missed` is deliberately absent:
11457
+ // nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
11458
+ // "nobody answered" case until the server-owned ring timeout lands.
11459
+ const eventHandlers = [
11460
+ call.on('call.accepted', () => reconcile()),
11461
+ call.on('call.rejected', () => reconcile()),
11462
+ ];
11113
11463
  return () => {
11114
11464
  eventHandlers.forEach((unsubscribe) => unsubscribe());
11115
11465
  };
@@ -15157,7 +15507,8 @@ class Call {
15157
15507
  statsReporter;
15158
15508
  sfuStatsReporter;
15159
15509
  lastStatsOptions;
15160
- dropTimeout;
15510
+ ringTimeout;
15511
+ ringStatePoller;
15161
15512
  clientStore;
15162
15513
  streamClient;
15163
15514
  clientEventReporter;
@@ -15335,22 +15686,19 @@ class Call {
15335
15686
  createSubscription(this.state.session$, (session) => {
15336
15687
  if (!this.ringing)
15337
15688
  return;
15338
- const receiverId = this.clientStore.connectedUser?.id;
15339
- if (!receiverId)
15340
- return;
15341
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
15342
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
15343
- if (isAcceptedByMe || isRejectedByMe) {
15689
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
15690
+ session,
15691
+ currentUserId: this.currentUserId,
15692
+ callingState: this.state.callingState,
15693
+ });
15694
+ if (settledByMe)
15344
15695
  this.cancelAutoDrop();
15345
- }
15346
- const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === CallingState.RINGING;
15347
- if ((isAcceptedElsewhere || isRejectedByMe) &&
15348
- !hasPending(this.joinLeaveConcurrencyTag)) {
15349
- globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
15350
- this.leave().catch(() => {
15351
- this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15352
- });
15353
- }
15696
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag))
15697
+ return;
15698
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
15699
+ this.leave().catch(() => {
15700
+ this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15701
+ });
15354
15702
  }));
15355
15703
  };
15356
15704
  handleRingingCall = () => {
@@ -15396,6 +15744,7 @@ class Call {
15396
15744
  this.state.setCallingState(CallingState.RINGING);
15397
15745
  }
15398
15746
  this.scheduleAutoDrop();
15747
+ this.scheduleRingStatePolling();
15399
15748
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
15400
15749
  }
15401
15750
  };
@@ -15482,6 +15831,12 @@ class Call {
15482
15831
  if (this.state.callingState === CallingState.LEFT) {
15483
15832
  throw new Error('Cannot leave call that has already been left.');
15484
15833
  }
15834
+ // before the first await: the calling state stays RINGING well into the
15835
+ // teardown, so pause both watchdogs before they can race this leave. They
15836
+ // keep their deadlines, so a failed leave resumes them without handing the
15837
+ // ring another window.
15838
+ this.ringTimeout?.pause();
15839
+ this.ringStatePoller?.pause();
15485
15840
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
15486
15841
  const callingState = this.state.callingState;
15487
15842
  if (callingState === CallingState.LEFT) {
@@ -15571,6 +15926,7 @@ class Call {
15571
15926
  this.unifiedSessionId = undefined;
15572
15927
  this.ringingSubject.next(false);
15573
15928
  this.cancelAutoDrop();
15929
+ this.cancelRingStatePolling();
15574
15930
  this.clientStore.unregisterCall(this);
15575
15931
  globalThis.streamRNVideoSDK?.callManager.stop({
15576
15932
  isRingingTypeCall: this.ringing,
@@ -15609,6 +15965,15 @@ class Call {
15609
15965
  this.logger.warn('Failed to dispose media engine', err);
15610
15966
  });
15611
15967
  }
15968
+ }).catch((err) => {
15969
+ if (!hasPending(this.joinLeaveConcurrencyTag) &&
15970
+ this.state.callingState === CallingState.RINGING) {
15971
+ // resume, never re-arm: a fresh watchdog would restart the ring
15972
+ // window this leave was already most of the way through
15973
+ this.ringTimeout?.start();
15974
+ this.ringStatePoller?.resume();
15975
+ }
15976
+ throw err;
15612
15977
  });
15613
15978
  };
15614
15979
  /**
@@ -15759,6 +16124,21 @@ class Call {
15759
16124
  ring = async (data = {}) => {
15760
16125
  return this.streamClient.post(`${this.streamClientBasePath}/ring`, data);
15761
16126
  };
16127
+ /**
16128
+ * Returns who accepted, rejected or missed the ring for a call session.
16129
+ * Safe to poll: it performs no writes and emits no events.
16130
+ *
16131
+ * @param callSessionId the call session to read. Defaults to the current one.
16132
+ * Pass it explicitly to read a session that has already ended, as ending a
16133
+ * call clears its current session.
16134
+ */
16135
+ getRingState = async (callSessionId) => {
16136
+ const sessionId = callSessionId ?? this.state.session?.id;
16137
+ if (!sessionId) {
16138
+ throw new Error('Cannot read the ring state: the call has no session');
16139
+ }
16140
+ return this.streamClient.get(`${this.streamClientBasePath}/ring_state`, { call_session_id: sessionId });
16141
+ };
15762
16142
  /**
15763
16143
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
15764
16144
  * Will send a `call.notification` event to the call members.
@@ -15799,7 +16179,7 @@ class Call {
15799
16179
  *
15800
16180
  * @returns a promise which resolves once the call join-flow has finished.
15801
16181
  */
15802
- join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
16182
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
15803
16183
  const callingState = this.state.callingState;
15804
16184
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
15805
16185
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -15831,7 +16211,7 @@ class Call {
15831
16211
  const joinData = data;
15832
16212
  maxJoinRetries = Math.max(maxJoinRetries, 1);
15833
16213
  try {
15834
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
16214
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
15835
16215
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
15836
16216
  try {
15837
16217
  this.logger.trace(`Joining call (${attempt})`, this.cid);
@@ -16557,7 +16937,7 @@ class Call {
16557
16937
  const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
16558
16938
  ? 'network-available'
16559
16939
  : 'full-rejoin';
16560
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
16940
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
16561
16941
  await this.restorePublishedTracks();
16562
16942
  this.restoreSubscribedTracks();
16563
16943
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
@@ -16581,7 +16961,7 @@ class Call {
16581
16961
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
16582
16962
  try {
16583
16963
  const currentSfu = currentSfuClient.edgeName;
16584
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
16964
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
16585
16965
  ...this.joinCallData,
16586
16966
  migrating_from: currentSfu,
16587
16967
  migrating_from_list: [currentSfu],
@@ -17354,40 +17734,36 @@ class Call {
17354
17734
  */
17355
17735
  scheduleAutoDrop = () => {
17356
17736
  this.cancelAutoDrop();
17357
- const settings = this.state.settings;
17358
- if (!settings)
17359
- return;
17360
- // ignore if the call is not ringing
17361
- if (this.state.callingState !== CallingState.RINGING)
17362
- return;
17363
- const timeoutInMs = this.isCreatedByMe
17364
- ? settings.ring.auto_cancel_timeout_ms
17365
- : settings.ring.incoming_call_timeout_ms;
17366
- // 0 means no auto-drop
17367
- if (timeoutInMs <= 0)
17368
- return;
17369
- this.dropTimeout = setTimeout(() => {
17370
- // the call might have stopped ringing by this point,
17371
- // e.g. it was already accepted and joined
17372
- if (this.state.callingState !== CallingState.RINGING)
17373
- return;
17374
- this.leave({
17375
- reject: true,
17376
- reason: 'timeout',
17377
- message: `ringing timeout - ${this.isCreatedByMe
17378
- ? 'no one accepted'
17379
- : `user didn't interact with incoming call screen`}`,
17380
- }).catch((err) => {
17381
- this.logger.error('Failed to drop call', err);
17382
- });
17383
- }, timeoutInMs);
17737
+ this.ringTimeout = new RingTimeout(this);
17738
+ this.ringTimeout.start();
17384
17739
  };
17385
17740
  /**
17386
17741
  * Cancels a scheduled auto-drop timeout.
17387
17742
  */
17388
17743
  cancelAutoDrop = () => {
17389
- clearTimeout(this.dropTimeout);
17390
- this.dropTimeout = undefined;
17744
+ this.ringTimeout?.stop();
17745
+ this.ringTimeout = undefined;
17746
+ };
17747
+ /**
17748
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
17749
+ * current user created.
17750
+ */
17751
+ scheduleRingStatePolling = () => {
17752
+ this.cancelRingStatePolling();
17753
+ if (!this.isCreatedByMe)
17754
+ return;
17755
+ const options = this.streamClient.options.ringStatePolling;
17756
+ if (options === false)
17757
+ return;
17758
+ this.ringStatePoller = new RingStatePoller(this, options);
17759
+ this.ringStatePoller.start();
17760
+ };
17761
+ /**
17762
+ * Cancels the ring state polling.
17763
+ */
17764
+ cancelRingStatePolling = () => {
17765
+ this.ringStatePoller?.stop();
17766
+ this.ringStatePoller = undefined;
17391
17767
  };
17392
17768
  /**
17393
17769
  * Retrieves the list of recordings for the current call or call session.
@@ -19058,11 +19434,11 @@ class StreamClient {
19058
19434
  return await this.wsConnection.connect(this.defaultWSTimeout);
19059
19435
  };
19060
19436
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19061
- "1.59.1";
19437
+ "1.60.0";
19062
19438
  getUserAgent = () => {
19063
19439
  if (!this.cachedUserAgent) {
19064
19440
  const { clientAppIdentifier = {} } = this.options;
19065
- const { sdkName = 'js', sdkVersion = "1.59.1", ...extras } = clientAppIdentifier;
19441
+ const { sdkName = 'js', sdkVersion = "1.60.0", ...extras } = clientAppIdentifier;
19066
19442
  this.cachedUserAgent = [
19067
19443
  `stream-video-${sdkName}-v${sdkVersion}`,
19068
19444
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19195,6 +19571,7 @@ class ClientEventReporter {
19195
19571
  callContexts = new Map();
19196
19572
  joinAttemptIds = new Map();
19197
19573
  joinReasons = new Map();
19574
+ joinSources = new Map();
19198
19575
  coordinatorPairs = new Map();
19199
19576
  wsPairs = new Map();
19200
19577
  peerConnectionPairs = new Map();
@@ -19309,6 +19686,7 @@ class ClientEventReporter {
19309
19686
  this.callContexts.delete(cid);
19310
19687
  this.joinAttemptIds.delete(cid);
19311
19688
  this.joinReasons.delete(cid);
19689
+ this.joinSources.delete(cid);
19312
19690
  this.coordinatorPairs.delete(cid);
19313
19691
  this.wsPairs.delete(cid);
19314
19692
  this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
@@ -19333,7 +19711,12 @@ class ClientEventReporter {
19333
19711
  this.logger.warn('Failed to start join correlation', err);
19334
19712
  }
19335
19713
  };
19336
- withJoinLifecycle = async (cid, joinReason, op) => {
19714
+ withJoinLifecycle = async (cid, options, op) => {
19715
+ const { joinReason, joinSource } = options;
19716
+ if (joinSource)
19717
+ this.joinSources.set(cid, joinSource);
19718
+ else
19719
+ this.joinSources.delete(cid);
19337
19720
  this.startCorrelation(cid, joinReason);
19338
19721
  try {
19339
19722
  return await op();
@@ -19342,6 +19725,9 @@ class ClientEventReporter {
19342
19725
  this.closeCallPairs(cid);
19343
19726
  throw err;
19344
19727
  }
19728
+ finally {
19729
+ this.joinSources.delete(cid);
19730
+ }
19345
19731
  };
19346
19732
  track = async (cid, stage, op) => {
19347
19733
  this.beginAttempt(cid, stage);
@@ -19465,6 +19851,7 @@ class ClientEventReporter {
19465
19851
  startedAt: Date.now(),
19466
19852
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19467
19853
  joinReasonSnapshot: this.joinReasons.get(cid),
19854
+ joinSourceSnapshot: this.joinSources.get(cid),
19468
19855
  };
19469
19856
  this.coordinatorPairs.set(cid, pair);
19470
19857
  this.sendForCall(cid, {
@@ -19472,6 +19859,7 @@ class ClientEventReporter {
19472
19859
  ...(pair.joinReasonSnapshot && {
19473
19860
  join_reason: pair.joinReasonSnapshot,
19474
19861
  }),
19862
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19475
19863
  event_type: 'initiated',
19476
19864
  });
19477
19865
  }
@@ -19486,6 +19874,7 @@ class ClientEventReporter {
19486
19874
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19487
19875
  ...this.sessionIdField(cid),
19488
19876
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
19877
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19489
19878
  event_type: 'completed',
19490
19879
  outcome: 'success',
19491
19880
  retry_count_attempt: pair.attempts - 1,
@@ -19504,6 +19893,7 @@ class ClientEventReporter {
19504
19893
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19505
19894
  ...this.sessionIdField(cid),
19506
19895
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
19896
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19507
19897
  event_type: 'completed',
19508
19898
  outcome: 'failure',
19509
19899
  retry_count_attempt: pair.attempts - 1,