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