@stream-io/video-client 1.59.0 → 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 +14 -0
- package/dist/index.browser.es.js +544 -111
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +544 -110
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +544 -111
- 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/coordinator/connection/utils.d.ts +10 -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/sorting/participants.d.ts +13 -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/__tests__/StreamVideoClient.ringing.test.ts +32 -20
- package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
- package/src/coordinator/connection/client.ts +3 -2
- package/src/coordinator/connection/types.ts +19 -0
- package/src/coordinator/connection/utils.ts +28 -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/sorting/__tests__/presets.test.ts +188 -3
- package/src/sorting/__tests__/sorting.test.ts +22 -1
- package/src/sorting/participants.ts +20 -0
- package/src/sorting/presets.ts +4 -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 +61 -2
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import * as TestData from './participant-data';
|
|
3
|
-
import { VisibilityState } from '../../types';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { StreamVideoParticipant, VisibilityState } from '../../types';
|
|
4
|
+
import { Comparator } from '../comparator';
|
|
5
|
+
import {
|
|
6
|
+
defaultSortPreset,
|
|
7
|
+
livestreamOrAudioRoomSortPreset,
|
|
8
|
+
paginatedLayoutSortPreset,
|
|
9
|
+
speakerLayoutSortPreset,
|
|
10
|
+
} from '../presets';
|
|
11
|
+
import {
|
|
12
|
+
ParticipantSource,
|
|
13
|
+
TrackType,
|
|
14
|
+
} from '../../gen/video/sfu/models/models';
|
|
15
|
+
|
|
16
|
+
const participant = (
|
|
17
|
+
name: string,
|
|
18
|
+
publishedTracks: TrackType[],
|
|
19
|
+
visibility: VisibilityState = VisibilityState.VISIBLE,
|
|
20
|
+
overrides: Partial<StreamVideoParticipant> = {},
|
|
21
|
+
): StreamVideoParticipant =>
|
|
22
|
+
({
|
|
23
|
+
name,
|
|
24
|
+
userId: name,
|
|
25
|
+
sessionId: name,
|
|
26
|
+
roles: ['user'],
|
|
27
|
+
publishedTracks,
|
|
28
|
+
trackLookupPrefix: name,
|
|
29
|
+
isSpeaking: false,
|
|
30
|
+
isDominantSpeaker: false,
|
|
31
|
+
audioLevel: 0,
|
|
32
|
+
image: '',
|
|
33
|
+
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
34
|
+
viewportVisibilityState: {
|
|
35
|
+
videoTrack: visibility,
|
|
36
|
+
screenShareTrack: visibility,
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
}) as StreamVideoParticipant;
|
|
40
|
+
|
|
41
|
+
const names = (ps: StreamVideoParticipant[]) => ps.map((p) => p.name);
|
|
42
|
+
|
|
43
|
+
// every preset that prioritizes publishing participants over passive ones
|
|
44
|
+
const presets: [string, Comparator<StreamVideoParticipant>][] = [
|
|
45
|
+
['defaultSortPreset', defaultSortPreset],
|
|
46
|
+
['speakerLayoutSortPreset', speakerLayoutSortPreset],
|
|
47
|
+
['livestreamOrAudioRoomSortPreset', livestreamOrAudioRoomSortPreset],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const { UNKNOWN, VISIBLE, INVISIBLE } = VisibilityState;
|
|
51
|
+
const { AUDIO, VIDEO, SCREEN_SHARE } = TrackType;
|
|
6
52
|
|
|
7
53
|
describe('presets', () => {
|
|
8
54
|
it('paginatedLayoutSortPreset', () => {
|
|
@@ -60,4 +106,143 @@ describe('presets', () => {
|
|
|
60
106
|
]
|
|
61
107
|
`);
|
|
62
108
|
});
|
|
109
|
+
|
|
110
|
+
// In SpeakerLayout the viewport is the participants bar, so the spotlight
|
|
111
|
+
// tile is never observed and stays UNKNOWN. Regression test for a passive
|
|
112
|
+
// participant holding the spotlight over a participant publishing video.
|
|
113
|
+
it.each(presets)(
|
|
114
|
+
'%s keeps passive participants out of the spotlight',
|
|
115
|
+
(_, preset) => {
|
|
116
|
+
const ps = [
|
|
117
|
+
participant('passive-viewer', [], UNKNOWN),
|
|
118
|
+
participant('host', [VIDEO]),
|
|
119
|
+
participant('local-viewer', [], VISIBLE, { isLocalParticipant: true }),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
expect(names(ps.sort(preset))).toEqual([
|
|
123
|
+
'host',
|
|
124
|
+
'passive-viewer',
|
|
125
|
+
'local-viewer',
|
|
126
|
+
]);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
it.each(presets)(
|
|
131
|
+
'%s promotes a rejoined publisher appended last',
|
|
132
|
+
(_, preset) => {
|
|
133
|
+
const ps = [
|
|
134
|
+
participant('v1', [], UNKNOWN),
|
|
135
|
+
participant('v2', []),
|
|
136
|
+
participant('v3', []),
|
|
137
|
+
].sort(preset);
|
|
138
|
+
// a rejoining participant is appended to the end of the list
|
|
139
|
+
ps.push(participant('host', [AUDIO, VIDEO], UNKNOWN));
|
|
140
|
+
|
|
141
|
+
expect(names(ps.sort(preset))).toEqual(['host', 'v1', 'v2', 'v3']);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
it.each(presets)(
|
|
146
|
+
'%s ranks passive participants below publishing ones regardless of visibility',
|
|
147
|
+
(_, preset) => {
|
|
148
|
+
const passive = [
|
|
149
|
+
participant('p1', [], UNKNOWN),
|
|
150
|
+
participant('p2', [], VISIBLE),
|
|
151
|
+
participant('p3', [], INVISIBLE),
|
|
152
|
+
];
|
|
153
|
+
const publishing = [
|
|
154
|
+
participant('a1', [VIDEO], UNKNOWN),
|
|
155
|
+
participant('a2', [AUDIO], VISIBLE),
|
|
156
|
+
participant('a3', [AUDIO, VIDEO], INVISIBLE),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
for (const a of passive) {
|
|
160
|
+
for (const b of publishing) {
|
|
161
|
+
expect(preset(a, b)).toBe(1);
|
|
162
|
+
expect(preset(b, a)).toBe(-1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// `publishing` ignores the track type, so a participant keeps its rank while
|
|
169
|
+
// it still publishes something. In speakerLayoutSortPreset the gated
|
|
170
|
+
// comparators cannot override this, because the gate only opens on INVISIBLE.
|
|
171
|
+
it.each<[string, TrackType[]]>([
|
|
172
|
+
['camera and microphone on', [AUDIO, VIDEO]],
|
|
173
|
+
['the microphone muted', [VIDEO]],
|
|
174
|
+
['the camera turned off', [AUDIO]],
|
|
175
|
+
])(
|
|
176
|
+
'speakerLayoutSortPreset keeps the spotlight with %s',
|
|
177
|
+
(_, spotlightTracks) => {
|
|
178
|
+
const ps = [
|
|
179
|
+
participant('spotlight', spotlightTracks, UNKNOWN),
|
|
180
|
+
participant('bar', [AUDIO, VIDEO]),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
|
|
184
|
+
'spotlight',
|
|
185
|
+
'bar',
|
|
186
|
+
]);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
it('speakerLayoutSortPreset drops a spotlight that stops publishing', () => {
|
|
191
|
+
const ps = [
|
|
192
|
+
participant('spotlight', [], UNKNOWN),
|
|
193
|
+
participant('bar', [AUDIO, VIDEO]),
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
|
|
197
|
+
'bar',
|
|
198
|
+
'spotlight',
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// livestreamOrAudioRoomSortPreset gates on INVISIBLE *or* UNKNOWN, so an
|
|
203
|
+
// unobserved spotlight tile stays comparable by audio and video state. This
|
|
204
|
+
// predates the `publishing` comparator, which only breaks ties between a
|
|
205
|
+
// publishing and a non-publishing participant.
|
|
206
|
+
it('livestreamOrAudioRoomSortPreset still re-ranks publishers by track state', () => {
|
|
207
|
+
const ps = [
|
|
208
|
+
participant('spotlight', [VIDEO], UNKNOWN),
|
|
209
|
+
participant('bar', [AUDIO, VIDEO]),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
expect(names(ps.sort(livestreamOrAudioRoomSortPreset))).toEqual([
|
|
213
|
+
'bar',
|
|
214
|
+
'spotlight',
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each<[string, TrackType[], Partial<StreamVideoParticipant>]>([
|
|
219
|
+
['a pinned participant', [], { pin: { isLocalPin: true, pinnedAt: 1 } }],
|
|
220
|
+
['a presenter', [SCREEN_SHARE], {}],
|
|
221
|
+
['the dominant speaker', [AUDIO], { isDominantSpeaker: true }],
|
|
222
|
+
])(
|
|
223
|
+
'speakerLayoutSortPreset ranks %s above a publishing participant',
|
|
224
|
+
(_, tracks, overrides) => {
|
|
225
|
+
const ps = [
|
|
226
|
+
participant('publisher', [AUDIO, VIDEO], UNKNOWN),
|
|
227
|
+
participant('contender', tracks, VISIBLE, overrides),
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
|
|
231
|
+
'contender',
|
|
232
|
+
'publisher',
|
|
233
|
+
]);
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it('livestreamOrAudioRoomSortPreset still uses role as a tie breaker between publishers', () => {
|
|
238
|
+
const ps = [
|
|
239
|
+
participant('viewer', [AUDIO, VIDEO]),
|
|
240
|
+
participant('host', [AUDIO, VIDEO], UNKNOWN, { roles: ['host'] }),
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
expect(names(ps.sort(livestreamOrAudioRoomSortPreset))).toEqual([
|
|
244
|
+
'host',
|
|
245
|
+
'viewer',
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
63
248
|
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ParticipantSource,
|
|
4
|
+
TrackType,
|
|
5
|
+
} from '../../gen/video/sfu/models/models';
|
|
6
|
+
import { StreamVideoParticipant } from '../../types';
|
|
3
7
|
import {
|
|
4
8
|
combineComparators,
|
|
5
9
|
Comparator,
|
|
6
10
|
conditional,
|
|
7
11
|
dominantSpeaker,
|
|
8
12
|
pinned,
|
|
13
|
+
publishing,
|
|
9
14
|
publishingAudio,
|
|
10
15
|
publishingVideo,
|
|
11
16
|
screenSharing,
|
|
@@ -37,6 +42,22 @@ describe('Sorting', () => {
|
|
|
37
42
|
expect(sorted.map((p) => p.name)).toEqual(['F', 'D', 'B', 'A', 'E', 'C']);
|
|
38
43
|
});
|
|
39
44
|
|
|
45
|
+
it('publishing', () => {
|
|
46
|
+
const { AUDIO, VIDEO, SCREEN_SHARE } = TrackType;
|
|
47
|
+
const p = (publishedTracks: TrackType[]) =>
|
|
48
|
+
({ publishedTracks }) as StreamVideoParticipant;
|
|
49
|
+
|
|
50
|
+
// publishing anything ranks above publishing nothing
|
|
51
|
+
expect(publishing(p([AUDIO]), p([]))).toEqual(-1);
|
|
52
|
+
expect(publishing(p([SCREEN_SHARE]), p([]))).toEqual(-1);
|
|
53
|
+
expect(publishing(p([]), p([VIDEO]))).toEqual(1);
|
|
54
|
+
|
|
55
|
+
// the track type is irrelevant, so device toggles do not change the rank
|
|
56
|
+
expect(publishing(p([AUDIO, VIDEO]), p([AUDIO]))).toEqual(0);
|
|
57
|
+
expect(publishing(p([VIDEO]), p([AUDIO]))).toEqual(0);
|
|
58
|
+
expect(publishing(p([]), p([]))).toEqual(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
40
61
|
it('conditional comparator', () => {
|
|
41
62
|
const byValue: Comparator<number> = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
42
63
|
const input = [2, 3, 1];
|
|
@@ -73,6 +73,26 @@ export const publishingAudio: Comparator<StreamVideoParticipant> = (a, b) => {
|
|
|
73
73
|
return 0;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A comparator which prioritizes participants who are publishing any track
|
|
78
|
+
* over participants who are not publishing at all.
|
|
79
|
+
*
|
|
80
|
+
* Unlike `publishingVideo` and `publishingAudio`, this comparator does not
|
|
81
|
+
* distinguish between track types. Muting the microphone or turning off the
|
|
82
|
+
* camera does not change a participant's rank as long as they still publish
|
|
83
|
+
* something, which keeps the ordering stable across ordinary device toggles.
|
|
84
|
+
*
|
|
85
|
+
* @param a the first participant.
|
|
86
|
+
* @param b the second participant.
|
|
87
|
+
*/
|
|
88
|
+
export const publishing: Comparator<StreamVideoParticipant> = (a, b) => {
|
|
89
|
+
const hasA = a.publishedTracks.length > 0;
|
|
90
|
+
const hasB = b.publishedTracks.length > 0;
|
|
91
|
+
if (hasA && !hasB) return -1;
|
|
92
|
+
if (!hasA && hasB) return 1;
|
|
93
|
+
return 0;
|
|
94
|
+
};
|
|
95
|
+
|
|
76
96
|
/**
|
|
77
97
|
* A comparator which prioritizes participants who are pinned.
|
|
78
98
|
*
|
package/src/sorting/presets.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ParticipantSource } from '../gen/video/sfu/models/models';
|
|
|
4
4
|
import {
|
|
5
5
|
dominantSpeaker,
|
|
6
6
|
pinned,
|
|
7
|
+
publishing,
|
|
7
8
|
publishingAudio,
|
|
8
9
|
publishingVideo,
|
|
9
10
|
reactionType,
|
|
@@ -51,6 +52,7 @@ const withVideoIngressSource = withParticipantSource(
|
|
|
51
52
|
export const defaultSortPreset = combineComparators(
|
|
52
53
|
screenSharing,
|
|
53
54
|
pinned,
|
|
55
|
+
publishing,
|
|
54
56
|
ifInvisibleBy(
|
|
55
57
|
combineComparators(
|
|
56
58
|
dominantSpeaker,
|
|
@@ -69,6 +71,7 @@ export const speakerLayoutSortPreset = combineComparators(
|
|
|
69
71
|
screenSharing,
|
|
70
72
|
pinned,
|
|
71
73
|
dominantSpeaker,
|
|
74
|
+
publishing,
|
|
72
75
|
ifInvisibleBy(
|
|
73
76
|
combineComparators(
|
|
74
77
|
speaking,
|
|
@@ -102,6 +105,7 @@ export const paginatedLayoutSortPreset = combineComparators(
|
|
|
102
105
|
* The sorting preset for livestreams and audio rooms.
|
|
103
106
|
*/
|
|
104
107
|
export const livestreamOrAudioRoomSortPreset = combineComparators(
|
|
108
|
+
publishing,
|
|
105
109
|
ifInvisibleOrUnknownBy(
|
|
106
110
|
combineComparators(
|
|
107
111
|
dominantSpeaker,
|
|
@@ -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
|
*
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
CallAcceptedEvent,
|
|
25
25
|
CallEndedEvent,
|
|
26
26
|
CallUpdatedEvent,
|
|
27
|
+
type GetCallRingStateResponse,
|
|
27
28
|
MemberResponse,
|
|
28
29
|
OwnCapability,
|
|
29
30
|
} from '../../gen/coordinator';
|
|
@@ -266,8 +267,8 @@ describe('CallState', () => {
|
|
|
266
267
|
"E",
|
|
267
268
|
"F",
|
|
268
269
|
"A",
|
|
269
|
-
"C",
|
|
270
270
|
"D",
|
|
271
|
+
"C",
|
|
271
272
|
]
|
|
272
273
|
`);
|
|
273
274
|
|
|
@@ -288,8 +289,8 @@ describe('CallState', () => {
|
|
|
288
289
|
"E",
|
|
289
290
|
"F",
|
|
290
291
|
"A",
|
|
291
|
-
"C",
|
|
292
292
|
"D",
|
|
293
|
+
"C",
|
|
293
294
|
]
|
|
294
295
|
`);
|
|
295
296
|
});
|
|
@@ -1474,4 +1475,62 @@ describe('CallState', () => {
|
|
|
1474
1475
|
expect(state['closedCaptionsTasks'].size).toBe(0);
|
|
1475
1476
|
});
|
|
1476
1477
|
});
|
|
1478
|
+
|
|
1479
|
+
describe('updateFromRingState', () => {
|
|
1480
|
+
const ringState = fromPartial<GetCallRingStateResponse>({
|
|
1481
|
+
session_id: 'session-1',
|
|
1482
|
+
accepted_by: { bob: '2026-08-24T10:00:04Z' },
|
|
1483
|
+
rejected_by: { carol: '2026-08-24T10:00:09Z' },
|
|
1484
|
+
missed_by: { dave: '2026-08-24T10:00:35Z' },
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
const withSession = (id: string) => {
|
|
1488
|
+
const state = new CallState();
|
|
1489
|
+
state['sessionSubject'].next(
|
|
1490
|
+
fromPartial({
|
|
1491
|
+
id,
|
|
1492
|
+
accepted_by: {},
|
|
1493
|
+
rejected_by: {},
|
|
1494
|
+
missed_by: {},
|
|
1495
|
+
participants: [fromPartial({ user_session_id: 'p1' })],
|
|
1496
|
+
}),
|
|
1497
|
+
);
|
|
1498
|
+
return state;
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
it('merges the ring maps into the current session', () => {
|
|
1502
|
+
const state = withSession('session-1');
|
|
1503
|
+
state.updateFromRingState(ringState);
|
|
1504
|
+
|
|
1505
|
+
expect(state.session?.accepted_by).toEqual(ringState.accepted_by);
|
|
1506
|
+
expect(state.session?.rejected_by).toEqual(ringState.rejected_by);
|
|
1507
|
+
expect(state.session?.missed_by).toEqual(ringState.missed_by);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
it('leaves the session roster untouched', () => {
|
|
1511
|
+
const state = withSession('session-1');
|
|
1512
|
+
state.updateFromRingState(ringState);
|
|
1513
|
+
|
|
1514
|
+
expect(state.session?.participants).toHaveLength(1);
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
it('ignores a ring state that belongs to another session', () => {
|
|
1518
|
+
const state = withSession('session-2');
|
|
1519
|
+
state.updateFromRingState(ringState);
|
|
1520
|
+
|
|
1521
|
+
expect(state.session?.accepted_by).toEqual({});
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it('applies the end timestamps', () => {
|
|
1525
|
+
const state = withSession('session-1');
|
|
1526
|
+
state.updateFromRingState({
|
|
1527
|
+
...ringState,
|
|
1528
|
+
session_ended_at: '2026-08-24T10:01:00Z',
|
|
1529
|
+
call_ended_at: '2026-08-24T10:01:00Z',
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
expect(state.session?.ended_at).toBe('2026-08-24T10:01:00Z');
|
|
1533
|
+
expect(state.endedAt).toEqual(new Date('2026-08-24T10:01:00Z'));
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1477
1536
|
});
|