@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.
- package/CHANGELOG.md +7 -0
- package/dist/index.browser.es.js +495 -105
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +495 -105
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +495 -105
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +23 -3
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/package.json +1 -1
- package/src/Call.ts +93 -61
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +23 -0
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import { reconcileRingState } from '../reconcileRingState';
|
|
4
|
+
import { CallingState, StreamVideoWriteableStateStore } from '../../store';
|
|
5
|
+
import {
|
|
6
|
+
CallResponse,
|
|
7
|
+
CallSessionResponse,
|
|
8
|
+
MemberResponse,
|
|
9
|
+
} from '../../gen/coordinator';
|
|
10
|
+
import { Call } from '../../Call';
|
|
11
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
12
|
+
import type { JoinSource } from '../../reporting';
|
|
13
|
+
import { ClientEventReporter } from '../../reporting';
|
|
14
|
+
import { settled } from '../../helpers/concurrency';
|
|
15
|
+
|
|
16
|
+
describe('reconcileRingState', () => {
|
|
17
|
+
describe('acceptance', () => {
|
|
18
|
+
it('ignores an acceptance by the current user', async () => {
|
|
19
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
20
|
+
setSession(call, { accepted_by: { m1: timestamp() } });
|
|
21
|
+
|
|
22
|
+
expect(await reconcile(call)).toBe(false);
|
|
23
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('joins the call for the caller once a callee has accepted', async () => {
|
|
27
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
28
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
29
|
+
|
|
30
|
+
expect(await reconcile(call)).toBe(true);
|
|
31
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-ws' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reports the poller as the source when the poll found the acceptance', async () => {
|
|
35
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
36
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
37
|
+
|
|
38
|
+
expect(await reconcile(call, 'ring-poll-api')).toBe(true);
|
|
39
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is not terminal when the join fails, so a retry can follow', async () => {
|
|
43
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
44
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
45
|
+
vi.mocked(call.join).mockRejectedValueOnce(new Error('transient'));
|
|
46
|
+
|
|
47
|
+
expect(await reconcile(call)).toBe(false);
|
|
48
|
+
expect(call.join).toHaveBeenCalled();
|
|
49
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not join another callee when someone else accepts', async () => {
|
|
53
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm0' });
|
|
54
|
+
setSession(call, { accepted_by: { m1: timestamp() } });
|
|
55
|
+
|
|
56
|
+
expect(await reconcile(call)).toBe(false);
|
|
57
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('rejection', () => {
|
|
62
|
+
it('cancels the call once every callee has rejected', async () => {
|
|
63
|
+
const call = ringingCall({
|
|
64
|
+
currentUserId: 'm1',
|
|
65
|
+
createdById: 'm1',
|
|
66
|
+
members: ['m1', 'm2', 'm3'],
|
|
67
|
+
});
|
|
68
|
+
setSession(call, {
|
|
69
|
+
rejected_by: { m2: timestamp(), m3: timestamp() },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(await reconcile(call)).toBe(true);
|
|
73
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
74
|
+
reject: true,
|
|
75
|
+
reason: 'cancel',
|
|
76
|
+
message: 'ring: everyone rejected',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is not terminal when leaving fails, so a retry can follow', async () => {
|
|
81
|
+
const call = ringingCall({
|
|
82
|
+
currentUserId: 'm1',
|
|
83
|
+
createdById: 'm1',
|
|
84
|
+
members: ['m1', 'm2'],
|
|
85
|
+
});
|
|
86
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
87
|
+
vi.mocked(call.leave).mockRejectedValueOnce(new Error('transient'));
|
|
88
|
+
|
|
89
|
+
expect(await reconcile(call)).toBe(false);
|
|
90
|
+
expect(call.leave).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps ringing while only one callee has rejected', async () => {
|
|
94
|
+
const call = ringingCall({
|
|
95
|
+
currentUserId: 'm0',
|
|
96
|
+
createdById: 'm0',
|
|
97
|
+
members: ['m0', 'm1', 'm2'],
|
|
98
|
+
});
|
|
99
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
100
|
+
|
|
101
|
+
expect(await reconcile(call)).toBe(false);
|
|
102
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('leaves a callee once the caller has cancelled', async () => {
|
|
106
|
+
const call = ringingCall({
|
|
107
|
+
currentUserId: 'm1',
|
|
108
|
+
createdById: 'm0',
|
|
109
|
+
members: ['m0', 'm1', 'm2'],
|
|
110
|
+
});
|
|
111
|
+
setSession(call, { rejected_by: { m0: timestamp() } });
|
|
112
|
+
|
|
113
|
+
expect(await reconcile(call)).toBe(true);
|
|
114
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
115
|
+
reason: 'ended',
|
|
116
|
+
message: 'ring: creator rejected',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps a callee ringing while another callee rejects', async () => {
|
|
121
|
+
const call = ringingCall({
|
|
122
|
+
currentUserId: 'm1',
|
|
123
|
+
createdById: 'm0',
|
|
124
|
+
members: ['m0', 'm1', 'm2'],
|
|
125
|
+
});
|
|
126
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
127
|
+
|
|
128
|
+
expect(await reconcile(call)).toBe(false);
|
|
129
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('missed', () => {
|
|
134
|
+
it('keeps ringing when every callee is marked missed before auto-cancel', async () => {
|
|
135
|
+
const call = ringingCall({
|
|
136
|
+
currentUserId: 'm1',
|
|
137
|
+
createdById: 'm1',
|
|
138
|
+
members: ['m1', 'm2', 'm3'],
|
|
139
|
+
});
|
|
140
|
+
setSession(call, {
|
|
141
|
+
missed_by: { m2: timestamp(), m3: timestamp() },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(await reconcile(call)).toBe(false);
|
|
145
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps ringing when callees are split between rejected and missed', async () => {
|
|
149
|
+
const call = ringingCall({
|
|
150
|
+
currentUserId: 'm1',
|
|
151
|
+
createdById: 'm1',
|
|
152
|
+
members: ['m1', 'm2', 'm3'],
|
|
153
|
+
});
|
|
154
|
+
setSession(call, {
|
|
155
|
+
rejected_by: { m2: timestamp() },
|
|
156
|
+
missed_by: { m3: timestamp() },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(await reconcile(call)).toBe(false);
|
|
160
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps ringing while one callee can still accept', async () => {
|
|
164
|
+
const call = ringingCall({
|
|
165
|
+
currentUserId: 'm1',
|
|
166
|
+
createdById: 'm1',
|
|
167
|
+
members: ['m1', 'm2', 'm3'],
|
|
168
|
+
});
|
|
169
|
+
setSession(call, { missed_by: { m2: timestamp() } });
|
|
170
|
+
|
|
171
|
+
expect(await reconcile(call)).toBe(false);
|
|
172
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('ended call', () => {
|
|
177
|
+
it('leaves without rejecting, even when the call was accepted', async () => {
|
|
178
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
179
|
+
setSession(call, {
|
|
180
|
+
accepted_by: { m2: timestamp() },
|
|
181
|
+
ended_at: timestamp(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(await reconcile(call)).toBe(true);
|
|
185
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
186
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
187
|
+
reject: false,
|
|
188
|
+
reason: 'ended',
|
|
189
|
+
message: 'ring: call ended',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('leaves when the call itself has ended', async () => {
|
|
194
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
195
|
+
setSession(call, {});
|
|
196
|
+
call.state.setEndedAt(new Date());
|
|
197
|
+
|
|
198
|
+
expect(await reconcile(call)).toBe(true);
|
|
199
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
200
|
+
reject: false,
|
|
201
|
+
reason: 'ended',
|
|
202
|
+
message: 'ring: call ended',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('is terminal once the call is no longer ringing', async () => {
|
|
208
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
209
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
210
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
211
|
+
|
|
212
|
+
expect(await reconcile(call)).toBe(true);
|
|
213
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
214
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Pins the ordering the state-driven reconciler depends on: `Call.setup`
|
|
218
|
+
// registers `updateFromEvent` as an `all` listener, and `dispatchEvent`
|
|
219
|
+
// drains those before the typed ring handlers. Reconciling from state would
|
|
220
|
+
// silently read the previous session if either side changed.
|
|
221
|
+
it('sees the event data on the state by the time a ring handler runs', async () => {
|
|
222
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
223
|
+
await call.setup();
|
|
224
|
+
call.state.setCallingState(CallingState.RINGING);
|
|
225
|
+
|
|
226
|
+
call.streamClient.dispatchEvent(
|
|
227
|
+
fromPartial({
|
|
228
|
+
type: 'call.accepted',
|
|
229
|
+
call_cid: call.cid,
|
|
230
|
+
created_at: new Date().toISOString(),
|
|
231
|
+
user: { id: 'm2' },
|
|
232
|
+
call: {
|
|
233
|
+
...callResponse('m1'),
|
|
234
|
+
session: {
|
|
235
|
+
id: 'session-1',
|
|
236
|
+
accepted_by: { m2: timestamp() },
|
|
237
|
+
rejected_by: {},
|
|
238
|
+
missed_by: {},
|
|
239
|
+
participants: [],
|
|
240
|
+
participants_count_by_role: {},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
await settled(call['joinLeaveConcurrencyTag']);
|
|
246
|
+
|
|
247
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-ws' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('keeps ringing when the call has no members yet', async () => {
|
|
251
|
+
const call = ringingCall({
|
|
252
|
+
currentUserId: 'm1',
|
|
253
|
+
createdById: 'm1',
|
|
254
|
+
members: [],
|
|
255
|
+
});
|
|
256
|
+
setSession(call, {});
|
|
257
|
+
|
|
258
|
+
expect(await reconcile(call)).toBe(false);
|
|
259
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// the source only matters for the caller's join, so default it and let the
|
|
264
|
+
// tests that assert on it pass their own
|
|
265
|
+
const reconcile = (call: Call, joinSource: JoinSource = 'ring-ws') =>
|
|
266
|
+
reconcileRingState(call, joinSource);
|
|
267
|
+
|
|
268
|
+
const timestamp = () => new Date().toISOString();
|
|
269
|
+
|
|
270
|
+
const callResponse = (createdById: string) =>
|
|
271
|
+
fromPartial<CallResponse>({
|
|
272
|
+
id: '12345',
|
|
273
|
+
type: 'development',
|
|
274
|
+
cid: 'development:12345',
|
|
275
|
+
created_by: { id: createdById },
|
|
276
|
+
blocked_user_ids: [],
|
|
277
|
+
egress: {},
|
|
278
|
+
settings: {
|
|
279
|
+
ring: {
|
|
280
|
+
auto_cancel_timeout_ms: 30_000,
|
|
281
|
+
incoming_call_timeout_ms: 30_000,
|
|
282
|
+
missed_call_timeout_ms: 30_000,
|
|
283
|
+
},
|
|
284
|
+
screensharing: { target_resolution: undefined },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const setSession = (call: Call, session: Partial<CallSessionResponse>) => {
|
|
289
|
+
call.state['sessionSubject'].next(
|
|
290
|
+
fromPartial({
|
|
291
|
+
accepted_by: {},
|
|
292
|
+
rejected_by: {},
|
|
293
|
+
missed_by: {},
|
|
294
|
+
...session,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const ringingCall = ({
|
|
300
|
+
currentUserId,
|
|
301
|
+
createdById,
|
|
302
|
+
members = [currentUserId, 'm2'],
|
|
303
|
+
}: {
|
|
304
|
+
currentUserId: string;
|
|
305
|
+
createdById: string;
|
|
306
|
+
members?: string[];
|
|
307
|
+
}) => {
|
|
308
|
+
const store = new StreamVideoWriteableStateStore();
|
|
309
|
+
store.setConnectedUser(fromPartial({ id: currentUserId }));
|
|
310
|
+
const streamClient = new StreamClient('api-key');
|
|
311
|
+
const call = new Call({
|
|
312
|
+
type: 'development',
|
|
313
|
+
id: '12345',
|
|
314
|
+
clientStore: store,
|
|
315
|
+
streamClient,
|
|
316
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
317
|
+
ringing: true,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
call.state.updateFromCallResponse(callResponse(createdById));
|
|
321
|
+
call.state.setMembers(
|
|
322
|
+
members.map((userId) => fromPartial<MemberResponse>({ user_id: userId })),
|
|
323
|
+
);
|
|
324
|
+
call.state.setCallingState(CallingState.RINGING);
|
|
325
|
+
|
|
326
|
+
vi.spyOn(call, 'join').mockResolvedValue(undefined);
|
|
327
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
328
|
+
|
|
329
|
+
return call;
|
|
330
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import {
|
|
4
|
+
type OwnRingOutcomeInput,
|
|
5
|
+
resolveOwnRingOutcome,
|
|
6
|
+
} from '../resolveOwnRingOutcome';
|
|
7
|
+
import { CallingState } from '../../store';
|
|
8
|
+
import { CallSessionResponse } from '../../gen/coordinator';
|
|
9
|
+
|
|
10
|
+
const ME = 'm1';
|
|
11
|
+
const timestamp = () => new Date().toISOString();
|
|
12
|
+
|
|
13
|
+
describe('resolveOwnRingOutcome', () => {
|
|
14
|
+
const resolve = (overrides: Partial<OwnRingOutcomeInput> = {}) =>
|
|
15
|
+
resolveOwnRingOutcome({
|
|
16
|
+
currentUserId: ME,
|
|
17
|
+
callingState: CallingState.RINGING,
|
|
18
|
+
...overrides,
|
|
19
|
+
session: fromPartial<CallSessionResponse>({
|
|
20
|
+
accepted_by: {},
|
|
21
|
+
rejected_by: {},
|
|
22
|
+
missed_by: {},
|
|
23
|
+
...overrides.session,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports nothing while nobody has acted', () => {
|
|
28
|
+
expect(resolve()).toEqual({ settledByMe: false });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports nothing for another user accepting or rejecting', () => {
|
|
32
|
+
const outcome = resolve({
|
|
33
|
+
session: fromPartial({
|
|
34
|
+
accepted_by: { m2: timestamp() },
|
|
35
|
+
rejected_by: { m3: timestamp() },
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(outcome).toEqual({ settledByMe: false });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('leaves when the ring was answered on another device', () => {
|
|
43
|
+
const outcome = resolve({
|
|
44
|
+
session: fromPartial({ accepted_by: { [ME]: timestamp() } }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(outcome).toEqual({
|
|
48
|
+
settledByMe: true,
|
|
49
|
+
leaveReason: 'answeredElsewhere',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not leave when this device is the one that accepted', () => {
|
|
54
|
+
const outcome = resolve({
|
|
55
|
+
callingState: CallingState.JOINING,
|
|
56
|
+
session: fromPartial({ accepted_by: { [ME]: timestamp() } }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// the drop no longer has to fire, but this device is joining, not leaving
|
|
60
|
+
expect(outcome).toEqual({ settledByMe: true, leaveReason: undefined });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('leaves when the current user rejected', () => {
|
|
64
|
+
const outcome = resolve({
|
|
65
|
+
session: fromPartial({ rejected_by: { [ME]: timestamp() } }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(outcome).toEqual({ settledByMe: true, leaveReason: 'rejected' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('prefers the answered-elsewhere reason over a rejection', () => {
|
|
72
|
+
const outcome = resolve({
|
|
73
|
+
session: fromPartial({
|
|
74
|
+
accepted_by: { [ME]: timestamp() },
|
|
75
|
+
rejected_by: { [ME]: timestamp() },
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(outcome).toEqual({
|
|
80
|
+
settledByMe: true,
|
|
81
|
+
leaveReason: 'answeredElsewhere',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reports nothing without a connected user', () => {
|
|
86
|
+
const outcome = resolve({
|
|
87
|
+
currentUserId: undefined,
|
|
88
|
+
session: fromPartial({ rejected_by: { [ME]: timestamp() } }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(outcome).toEqual({ settledByMe: false });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports nothing without a session', () => {
|
|
95
|
+
expect(
|
|
96
|
+
resolveOwnRingOutcome({
|
|
97
|
+
session: undefined,
|
|
98
|
+
currentUserId: ME,
|
|
99
|
+
callingState: CallingState.RINGING,
|
|
100
|
+
}),
|
|
101
|
+
).toEqual({ settledByMe: false });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Call } from '../Call';
|
|
2
|
+
import type { JoinSource } from '../reporting';
|
|
3
|
+
import { CallingState } from '../store';
|
|
4
|
+
import type { CallLeaveOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decides what a ringing call should do next, based on the current call state.
|
|
8
|
+
*
|
|
9
|
+
* The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
|
|
10
|
+
* state poller both run this. They differ only in how the state got there: the
|
|
11
|
+
* handlers rely on `CallState.updateFromEvent`, which runs before them, and the
|
|
12
|
+
* poller applies the polled ring state itself.
|
|
13
|
+
*
|
|
14
|
+
* @param call the call to reconcile.
|
|
15
|
+
* @param joinSource which of the two triggered this run, reported on the
|
|
16
|
+
* caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
|
|
17
|
+
* poller.
|
|
18
|
+
* @returns whether the ring reached a terminal state. A failed join is not
|
|
19
|
+
* terminal: the caller should keep trying while the ring is open.
|
|
20
|
+
*/
|
|
21
|
+
export const reconcileRingState = async (
|
|
22
|
+
call: Call,
|
|
23
|
+
joinSource: JoinSource,
|
|
24
|
+
): Promise<boolean> => {
|
|
25
|
+
if (call.state.callingState !== CallingState.RINGING) return true;
|
|
26
|
+
return call.isCreatedByMe
|
|
27
|
+
? reconcileAsCaller(call, joinSource)
|
|
28
|
+
: reconcileAsCallee(call);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const reconcileAsCaller = async (
|
|
32
|
+
call: Call,
|
|
33
|
+
joinSource: JoinSource,
|
|
34
|
+
): Promise<boolean> => {
|
|
35
|
+
const { session, members, endedAt } = call.state;
|
|
36
|
+
const currentUserId = call.currentUserId;
|
|
37
|
+
|
|
38
|
+
// checked before `accepted_by`: an ended session cannot be joined
|
|
39
|
+
if (endedAt || session?.ended_at) {
|
|
40
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
41
|
+
// `leave` reports the remote end to callingx off `reason: 'ended'`
|
|
42
|
+
return leave(call, {
|
|
43
|
+
reject: false,
|
|
44
|
+
reason: 'ended',
|
|
45
|
+
message: 'ring: call ended',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const acceptedBy = session?.accepted_by ?? {};
|
|
50
|
+
if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
|
|
51
|
+
call.logger.info('ring: the call was accepted, joining');
|
|
52
|
+
try {
|
|
53
|
+
await call.join({ joinSource });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// `doJoin` restores the ringing state when a join fails, so the ring is
|
|
56
|
+
// still open. Report it unsettled and let the next poll retry.
|
|
57
|
+
call.logger.error('Failed to join an accepted call', err);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const otherMembers = members
|
|
64
|
+
.filter((member) => member.user_id !== currentUserId)
|
|
65
|
+
.map((member) => member.user_id);
|
|
66
|
+
if (otherMembers.length === 0) return false;
|
|
67
|
+
|
|
68
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
69
|
+
if (otherMembers.every((userId) => rejectedBy[userId])) {
|
|
70
|
+
call.logger.info('ring: everyone rejected, leaving');
|
|
71
|
+
return leave(call, {
|
|
72
|
+
reject: true,
|
|
73
|
+
reason: 'cancel',
|
|
74
|
+
message: 'ring: everyone rejected',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// the current user's own accept or reject, on this or another device, is
|
|
82
|
+
// handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
|
|
83
|
+
const reconcileAsCallee = async (call: Call): Promise<boolean> => {
|
|
84
|
+
const createdById = call.state.createdBy?.id;
|
|
85
|
+
const rejectedBy = call.state.session?.rejected_by ?? {};
|
|
86
|
+
if (createdById && rejectedBy[createdById]) {
|
|
87
|
+
call.logger.info('ring: the caller cancelled, leaving');
|
|
88
|
+
return leave(call, {
|
|
89
|
+
reason: 'ended',
|
|
90
|
+
message: 'ring: creator rejected',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// `false` when the call could not be left, so the ring stays open for a retry.
|
|
97
|
+
const leave = async (
|
|
98
|
+
call: Call,
|
|
99
|
+
options: CallLeaveOptions,
|
|
100
|
+
): Promise<boolean> => {
|
|
101
|
+
try {
|
|
102
|
+
await call.leave(options);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
call.logger.error('Failed to leave a ringing call', err);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CallingState } from '../store';
|
|
2
|
+
import type { CallSessionResponse } from '../gen/coordinator';
|
|
3
|
+
|
|
4
|
+
export type OwnRingOutcome = {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the current user accepted or rejected the ring, on this device or
|
|
7
|
+
* on another one. Either way the ring no longer needs to time out.
|
|
8
|
+
*/
|
|
9
|
+
settledByMe: boolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set when this device should stop ringing and leave, carrying the reason to
|
|
13
|
+
* report to the native call UI.
|
|
14
|
+
*/
|
|
15
|
+
leaveReason?: 'answeredElsewhere' | 'rejected';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OwnRingOutcomeInput = {
|
|
19
|
+
/** The call session to read the accept and reject maps from. */
|
|
20
|
+
session: CallSessionResponse | undefined;
|
|
21
|
+
/** The connected user, or `undefined` when there is none. */
|
|
22
|
+
currentUserId: string | undefined;
|
|
23
|
+
/** The current calling state. */
|
|
24
|
+
callingState: CallingState;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decides what a ringing call should do about the current user's own accept or
|
|
29
|
+
* reject, which may have happened on another device.
|
|
30
|
+
*
|
|
31
|
+
* Accepting on *this* device also lands in `accepted_by`, so an acceptance only
|
|
32
|
+
* means another device took the call when this one is still ringing.
|
|
33
|
+
*
|
|
34
|
+
* Only meaningful for a ringing call; the caller checks that.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveOwnRingOutcome = ({
|
|
37
|
+
session,
|
|
38
|
+
currentUserId,
|
|
39
|
+
callingState,
|
|
40
|
+
}: OwnRingOutcomeInput): OwnRingOutcome => {
|
|
41
|
+
if (!currentUserId) return { settledByMe: false };
|
|
42
|
+
|
|
43
|
+
const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
|
|
44
|
+
const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
|
|
45
|
+
if (!acceptedByMe && !rejectedByMe) return { settledByMe: false };
|
|
46
|
+
|
|
47
|
+
const answeredElsewhere =
|
|
48
|
+
acceptedByMe && callingState === CallingState.RINGING;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
settledByMe: true,
|
|
52
|
+
leaveReason: answeredElsewhere
|
|
53
|
+
? 'answeredElsewhere'
|
|
54
|
+
: rejectedByMe
|
|
55
|
+
? 'rejected'
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getStreamClientId } from '../utils';
|
|
3
|
+
import { SdkType } from '../../gen/video/sfu/models/models';
|
|
4
|
+
|
|
5
|
+
describe('getStreamClientId', () => {
|
|
6
|
+
const sdk = (type: SdkType) => ({
|
|
7
|
+
type,
|
|
8
|
+
major: '1',
|
|
9
|
+
minor: '43',
|
|
10
|
+
patch: '1',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('names the React SDK', () => {
|
|
14
|
+
expect(getStreamClientId(sdk(SdkType.REACT))).toBe(
|
|
15
|
+
'stream-video-react-v1.43.1',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('names the React Native SDK', () => {
|
|
20
|
+
expect(getStreamClientId(sdk(SdkType.REACT_NATIVE))).toBe(
|
|
21
|
+
'stream-video-react-native-v1.43.1',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to the plain JS client for every other SDK type', () => {
|
|
26
|
+
expect(getStreamClientId(sdk(SdkType.PLAIN_JAVASCRIPT))).toBe(
|
|
27
|
+
'stream-video-js-v1.43.1',
|
|
28
|
+
);
|
|
29
|
+
expect(getStreamClientId(undefined)).toBe(
|
|
30
|
+
'stream-video-js-v0.0.0-development',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/stats/utils.ts
CHANGED
|
@@ -51,3 +51,18 @@ export const getSdkName = (sdk: Sdk | undefined) => {
|
|
|
51
51
|
export const getSdkVersion = (sdk: Sdk | undefined) => {
|
|
52
52
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
53
53
|
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the identifier the SFU receives in the `X-Stream-Client` header,
|
|
57
|
+
* e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
|
|
58
|
+
* already gets through the client's user agent.
|
|
59
|
+
*/
|
|
60
|
+
export const getStreamClientId = (sdk: Sdk | undefined) => {
|
|
61
|
+
const sdkPlatforms: Partial<Record<SdkType, string>> = {
|
|
62
|
+
[SdkType.REACT]: 'react',
|
|
63
|
+
[SdkType.REACT_NATIVE]: 'react-native',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
|
|
67
|
+
return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
|
|
68
|
+
};
|
package/src/store/CallState.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
CallSessionParticipantLeftEvent,
|
|
38
38
|
CallSessionResponse,
|
|
39
39
|
CallSettingsResponse,
|
|
40
|
+
GetCallRingStateResponse,
|
|
40
41
|
ClosedCaptionEvent,
|
|
41
42
|
EgressResponse,
|
|
42
43
|
MemberResponse,
|
|
@@ -1352,6 +1353,30 @@ export class CallState {
|
|
|
1352
1353
|
this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
|
|
1353
1354
|
};
|
|
1354
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* Merges a polled ring state into the current session. Only the ring fields
|
|
1358
|
+
* are touched: the endpoint does not return the session roster.
|
|
1359
|
+
*
|
|
1360
|
+
* @internal
|
|
1361
|
+
*
|
|
1362
|
+
* @param ringState the ring state as returned by the coordinator.
|
|
1363
|
+
*/
|
|
1364
|
+
updateFromRingState = (ringState: GetCallRingStateResponse) => {
|
|
1365
|
+
this.setCurrentValue(this.sessionSubject, (session) => {
|
|
1366
|
+
if (!session || session.id !== ringState.session_id) return session;
|
|
1367
|
+
return {
|
|
1368
|
+
...session,
|
|
1369
|
+
accepted_by: ringState.accepted_by,
|
|
1370
|
+
rejected_by: ringState.rejected_by,
|
|
1371
|
+
missed_by: ringState.missed_by,
|
|
1372
|
+
ended_at: ringState.session_ended_at ?? session.ended_at,
|
|
1373
|
+
};
|
|
1374
|
+
});
|
|
1375
|
+
if (ringState.call_ended_at) {
|
|
1376
|
+
this.setEndedAt(new Date(ringState.call_ended_at));
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1355
1380
|
/**
|
|
1356
1381
|
* Updates the call state with the data received from the SFU server.
|
|
1357
1382
|
*
|