@rapidmx/meet-plugin 0.2.0 → 0.3.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/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +275 -115
- package/apps/meet/_MeetLobby.tsx +111 -142
- package/apps/meet/_ParticipantTile.tsx +51 -19
- package/apps/meet/_icons.tsx +54 -0
- package/apps/shared/media/chime.ts +74 -0
- package/apps/shared/media/deviceMedia.ts +3 -13
- package/apps/shared/media/useLocalMedia.ts +337 -0
- package/apps/shared/push/GuestSignalingClient.ts +2 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +52 -15
- package/dist/apps/meet/[token].js +31 -19
- package/dist/apps/meet/_CallControls.d.ts +20 -7
- package/dist/apps/meet/_CallControls.js +72 -3
- package/dist/apps/meet/_CallView.d.ts +23 -4
- package/dist/apps/meet/_CallView.js +141 -58
- package/dist/apps/meet/_MeetLobby.d.ts +16 -19
- package/dist/apps/meet/_MeetLobby.js +49 -99
- package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
- package/dist/apps/meet/_ParticipantTile.js +12 -9
- package/dist/apps/meet/_icons.d.ts +15 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/shared/media/chime.d.ts +31 -0
- package/dist/apps/shared/media/chime.js +49 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
- package/dist/apps/shared/media/deviceMedia.js +3 -12
- package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
- package/dist/apps/shared/media/useLocalMedia.js +259 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +52 -15
- package/dist/apps/shared/webrtc/types.js +3 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
- package/package.json +181 -181
|
@@ -1,17 +1,94 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
|
|
7
|
+
* over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
|
|
8
|
+
* `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
|
|
9
|
+
* instance per call and mirrors its events into React state.
|
|
10
|
+
*
|
|
11
|
+
* ## Who calls whom
|
|
12
|
+
*
|
|
13
|
+
* On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
|
|
14
|
+
* has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
|
|
15
|
+
* both sides independently compute the same answer with no coordination round trip, and exactly one side ever
|
|
16
|
+
* calls `createOffer()` for a given pair.
|
|
17
|
+
*
|
|
18
|
+
* ## Media: two permanent transceivers, tracks swapped in and out
|
|
19
|
+
*
|
|
20
|
+
* Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
|
|
21
|
+
* whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
|
|
22
|
+
* offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
|
|
23
|
+
* m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
|
|
24
|
+
* sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
|
|
25
|
+
* through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
|
|
26
|
+
* `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
|
|
27
|
+
* as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
|
|
28
|
+
* direction, for the whole call.)
|
|
29
|
+
* The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
|
|
30
|
+
* read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
|
|
31
|
+
*
|
|
32
|
+
* ## Join announcement and roster discovery
|
|
33
|
+
*
|
|
34
|
+
* The channel has no history (a push event published while a socket was down is never replayed - see
|
|
35
|
+
* `@rapidmx/react-shared`'s `pushClient.ts` doc comment for the identical guarantee on the mail push channel this
|
|
36
|
+
* one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
|
|
37
|
+
* subscribed *and* who joined before them - never the other way around. Every participant who learns of a
|
|
38
|
+
* genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
|
|
39
|
+
* first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
|
|
40
|
+
* roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
|
|
41
|
+
* ever discovers) rather than a risk of runaway flooding.
|
|
42
|
+
*
|
|
43
|
+
* Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
|
|
44
|
+
* are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
|
|
45
|
+
* `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
|
|
46
|
+
* exists rather than dropped.
|
|
47
|
+
*
|
|
48
|
+
* ## Participant state, hands and reactions
|
|
49
|
+
*
|
|
50
|
+
* What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
|
|
51
|
+
* `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
|
|
52
|
+
* sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
|
|
53
|
+
*
|
|
54
|
+
* ## Presenter (single-writer screen share)
|
|
55
|
+
*
|
|
56
|
+
* `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
|
|
57
|
+
* A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
|
|
58
|
+
* *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
|
|
59
|
+
* participant converges on the same winner by resolving the collision deterministically: the lexicographically
|
|
60
|
+
* smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
|
|
61
|
+
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
62
|
+
*
|
|
63
|
+
* Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
|
|
64
|
+
*/
|
|
65
|
+
import { REACTION_EMOJIS, } from "./types.js";
|
|
1
66
|
/** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
|
|
2
67
|
export function isOfferer(selfUid, peerUid) {
|
|
3
68
|
return selfUid < peerUid;
|
|
4
69
|
}
|
|
70
|
+
/** How many peers' worth of early ICE candidates are held, and how many candidates per peer - bounds the memory a
|
|
71
|
+
* misbehaving participant could make this hold for peers that never materialize. */
|
|
72
|
+
const MAX_ORPHAN_PEERS = 32;
|
|
73
|
+
const MAX_ORPHAN_CANDIDATES = 64;
|
|
5
74
|
export class MeshConnectionManager {
|
|
6
75
|
constructor(options) {
|
|
7
76
|
this.options = options;
|
|
8
77
|
this.peers = new Map();
|
|
9
78
|
this.listeners = new Set();
|
|
79
|
+
this.orphanCandidates = new Map();
|
|
10
80
|
this.started = false;
|
|
11
81
|
this.stopped = false;
|
|
82
|
+
this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
|
|
83
|
+
this.localState = {
|
|
84
|
+
audioOn: !!options.localAudioTrack,
|
|
85
|
+
videoOn: !!options.localVideoTrack,
|
|
86
|
+
handRaised: false,
|
|
87
|
+
...options.localState,
|
|
88
|
+
};
|
|
12
89
|
}
|
|
13
90
|
get participants() {
|
|
14
|
-
return [...this.peers.values()].map((peer) => (
|
|
91
|
+
return [...this.peers.values()].map((peer) => toParticipant(peer));
|
|
15
92
|
}
|
|
16
93
|
get presenterUid() {
|
|
17
94
|
return this.currentPresenterUid;
|
|
@@ -30,9 +107,9 @@ export class MeshConnectionManager {
|
|
|
30
107
|
this.sendHello();
|
|
31
108
|
}
|
|
32
109
|
/** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
|
|
33
|
-
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping
|
|
34
|
-
*
|
|
35
|
-
*
|
|
110
|
+
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
|
|
111
|
+
* tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
|
|
112
|
+
* lifecycle, only sends them. */
|
|
36
113
|
stop() {
|
|
37
114
|
if (this.stopped) {
|
|
38
115
|
return;
|
|
@@ -47,17 +124,36 @@ export class MeshConnectionManager {
|
|
|
47
124
|
peer.pc.close();
|
|
48
125
|
}
|
|
49
126
|
this.peers.clear();
|
|
127
|
+
this.orphanCandidates.clear();
|
|
50
128
|
this.listeners.clear();
|
|
51
129
|
}
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
replaceLocalVideoTrack(track) {
|
|
130
|
+
/** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
|
|
131
|
+
* joins later - see this module's doc comment. */
|
|
132
|
+
setLocalTrack(kind, track) {
|
|
133
|
+
this.localTracks[kind] = track;
|
|
57
134
|
for (const peer of this.peers.values()) {
|
|
58
|
-
|
|
59
|
-
|
|
135
|
+
void peer.senders[kind]?.replaceTrack(track);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
|
|
139
|
+
* actually changed. Before `start()` it only records the state, which `hello` then carries. */
|
|
140
|
+
setLocalState(partial) {
|
|
141
|
+
const next = { ...this.localState, ...partial };
|
|
142
|
+
if (sameState(next, this.localState)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.localState = next;
|
|
146
|
+
if (this.started && !this.stopped) {
|
|
147
|
+
this.send({ kind: "state", state: next });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
|
|
151
|
+
sendReaction(emoji) {
|
|
152
|
+
if (!isReactionEmoji(emoji)) {
|
|
153
|
+
return false;
|
|
60
154
|
}
|
|
155
|
+
this.send({ kind: "reaction", emoji });
|
|
156
|
+
return true;
|
|
61
157
|
}
|
|
62
158
|
/** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
|
|
63
159
|
* else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
|
|
@@ -81,7 +177,7 @@ export class MeshConnectionManager {
|
|
|
81
177
|
this.emit({ type: "presenter-changed", uid: undefined });
|
|
82
178
|
}
|
|
83
179
|
sendHello() {
|
|
84
|
-
this.send({ kind: "hello", name: this.options.selfName });
|
|
180
|
+
this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
|
|
85
181
|
}
|
|
86
182
|
send(partial) {
|
|
87
183
|
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
|
|
@@ -120,23 +216,65 @@ export class MeshConnectionManager {
|
|
|
120
216
|
case "presenter-release":
|
|
121
217
|
this.handlePresenterRelease(message.from);
|
|
122
218
|
return;
|
|
219
|
+
case "state":
|
|
220
|
+
this.handleState(message);
|
|
221
|
+
return;
|
|
222
|
+
case "reaction":
|
|
223
|
+
this.handleReaction(message);
|
|
224
|
+
return;
|
|
123
225
|
}
|
|
124
226
|
}
|
|
125
227
|
handleHello(message) {
|
|
126
|
-
|
|
228
|
+
const known = this.peers.get(message.from);
|
|
229
|
+
if (known) {
|
|
230
|
+
// An offer that beat this hello created the peer under a placeholder name - fill in the real one.
|
|
231
|
+
this.updatePeer(known, message.name, message.state);
|
|
127
232
|
return;
|
|
128
233
|
}
|
|
129
|
-
const
|
|
130
|
-
const peer = this.createPeer(message.from, name);
|
|
131
|
-
this.emit({ type: "participant-joined", participant:
|
|
234
|
+
const offerer = isOfferer(this.options.selfUid, message.from);
|
|
235
|
+
const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
|
|
236
|
+
this.emit({ type: "participant-joined", participant: toParticipant(peer) });
|
|
132
237
|
// Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
|
|
133
238
|
// doc comment on roster discovery.
|
|
134
239
|
this.sendHello();
|
|
135
|
-
if (
|
|
240
|
+
if (offerer) {
|
|
136
241
|
void this.initiateOffer(peer);
|
|
137
242
|
}
|
|
138
243
|
}
|
|
244
|
+
handleState(message) {
|
|
245
|
+
const peer = this.peers.get(message.from);
|
|
246
|
+
if (peer) {
|
|
247
|
+
this.updatePeer(peer, undefined, message.state);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
handleReaction(message) {
|
|
251
|
+
const peer = this.peers.get(message.from);
|
|
252
|
+
if (peer && isReactionEmoji(message.emoji)) {
|
|
253
|
+
this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
|
|
257
|
+
* hand going up) only when something actually changed. */
|
|
258
|
+
updatePeer(peer, name, state) {
|
|
259
|
+
const nextName = name ?? peer.name;
|
|
260
|
+
const next = state ? sanitizeState(state) : undefined;
|
|
261
|
+
const handWentUp = !!next && next.handRaised && !peer.handRaised;
|
|
262
|
+
if (nextName === peer.name && (!next || sameState(next, peer))) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
peer.name = nextName;
|
|
266
|
+
if (next) {
|
|
267
|
+
peer.audioOn = next.audioOn;
|
|
268
|
+
peer.videoOn = next.videoOn;
|
|
269
|
+
peer.handRaised = next.handRaised;
|
|
270
|
+
}
|
|
271
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
272
|
+
if (handWentUp) {
|
|
273
|
+
this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
139
276
|
handleBye(uid) {
|
|
277
|
+
this.orphanCandidates.delete(uid);
|
|
140
278
|
const peer = this.peers.get(uid);
|
|
141
279
|
if (!peer) {
|
|
142
280
|
return;
|
|
@@ -149,23 +287,35 @@ export class MeshConnectionManager {
|
|
|
149
287
|
this.emit({ type: "presenter-changed", uid: undefined });
|
|
150
288
|
}
|
|
151
289
|
}
|
|
152
|
-
createPeer(uid, name) {
|
|
290
|
+
createPeer(uid, name, state, offerer) {
|
|
153
291
|
const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
|
|
154
|
-
const peer = {
|
|
292
|
+
const peer = {
|
|
293
|
+
uid,
|
|
294
|
+
name,
|
|
295
|
+
...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
|
|
296
|
+
pc,
|
|
297
|
+
senders: offerer
|
|
298
|
+
? {
|
|
299
|
+
audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
|
|
300
|
+
video: pc.addTransceiver("video", this.localTracks.video).sender,
|
|
301
|
+
}
|
|
302
|
+
: {},
|
|
303
|
+
remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
|
|
304
|
+
remoteDescriptionSet: false,
|
|
305
|
+
pendingCandidates: this.orphanCandidates.get(uid) ?? [],
|
|
306
|
+
};
|
|
307
|
+
this.orphanCandidates.delete(uid);
|
|
155
308
|
this.peers.set(uid, peer);
|
|
156
|
-
for (const track of this.options.localStream.getTracks()) {
|
|
157
|
-
pc.addTrack(track, this.options.localStream);
|
|
158
|
-
}
|
|
159
309
|
pc.onicecandidate = (event) => {
|
|
160
310
|
if (event.candidate) {
|
|
161
311
|
this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
|
|
162
312
|
}
|
|
163
313
|
};
|
|
164
314
|
pc.ontrack = (event) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.emit({ type: "remote-stream", uid, stream });
|
|
315
|
+
if (!peer.remoteStream.getTracks().includes(event.track)) {
|
|
316
|
+
peer.remoteStream.addTrack(event.track);
|
|
168
317
|
}
|
|
318
|
+
this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
|
|
169
319
|
};
|
|
170
320
|
pc.onconnectionstatechange = () => {
|
|
171
321
|
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
@@ -186,12 +336,23 @@ export class MeshConnectionManager {
|
|
|
186
336
|
let peer = this.peers.get(message.from);
|
|
187
337
|
const isNew = !peer;
|
|
188
338
|
if (!peer) {
|
|
189
|
-
peer = this.createPeer(message.from, message.from);
|
|
339
|
+
peer = this.createPeer(message.from, message.from, undefined, false);
|
|
190
340
|
}
|
|
191
341
|
await peer.pc.setRemoteDescription(message.sdp);
|
|
192
342
|
this.flushPendingCandidates(peer);
|
|
343
|
+
if (!peer.senders.audio && !peer.senders.video) {
|
|
344
|
+
// The answerer's half of the media setup - see this module's doc comment.
|
|
345
|
+
peer.senders = peer.pc.claimTransceivers();
|
|
346
|
+
for (const kind of ["audio", "video"]) {
|
|
347
|
+
const track = this.localTracks[kind];
|
|
348
|
+
if (track) {
|
|
349
|
+
await peer.senders[kind]?.replaceTrack(track);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
193
353
|
if (isNew) {
|
|
194
|
-
this.emit({ type: "participant-joined", participant:
|
|
354
|
+
this.emit({ type: "participant-joined", participant: toParticipant(peer) });
|
|
355
|
+
this.sendHello();
|
|
195
356
|
}
|
|
196
357
|
const answer = await peer.pc.createAnswer();
|
|
197
358
|
await peer.pc.setLocalDescription(answer);
|
|
@@ -211,10 +372,17 @@ export class MeshConnectionManager {
|
|
|
211
372
|
}
|
|
212
373
|
const peer = this.peers.get(message.from);
|
|
213
374
|
if (!peer) {
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
375
|
+
// A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
|
|
376
|
+
// the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
|
|
377
|
+
const held = this.orphanCandidates.get(message.from);
|
|
378
|
+
if (held) {
|
|
379
|
+
if (held.length < MAX_ORPHAN_CANDIDATES) {
|
|
380
|
+
held.push(message.candidate);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
|
|
384
|
+
this.orphanCandidates.set(message.from, [message.candidate]);
|
|
385
|
+
}
|
|
218
386
|
return;
|
|
219
387
|
}
|
|
220
388
|
if (!peer.remoteDescriptionSet) {
|
|
@@ -260,3 +428,16 @@ export class MeshConnectionManager {
|
|
|
260
428
|
}
|
|
261
429
|
}
|
|
262
430
|
}
|
|
431
|
+
function toParticipant(peer) {
|
|
432
|
+
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
|
|
433
|
+
}
|
|
434
|
+
function sameState(a, b) {
|
|
435
|
+
return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
|
|
436
|
+
}
|
|
437
|
+
/** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
|
|
438
|
+
function sanitizeState(state) {
|
|
439
|
+
return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
|
|
440
|
+
}
|
|
441
|
+
function isReactionEmoji(emoji) {
|
|
442
|
+
return REACTION_EMOJIS.includes(emoji);
|
|
443
|
+
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
export const createBrowserPeerConnection = (config) => {
|
|
2
2
|
const pc = new RTCPeerConnection(config);
|
|
3
3
|
const like = {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
addTransceiver: (kind, track) => {
|
|
5
|
+
const { sender } = pc.addTransceiver(track ?? kind, { direction: "sendrecv" });
|
|
6
|
+
return { sender };
|
|
7
|
+
},
|
|
8
|
+
claimTransceivers: () => {
|
|
9
|
+
const senders = {};
|
|
10
|
+
for (const transceiver of pc.getTransceivers()) {
|
|
11
|
+
const kind = transceiver.receiver.track.kind;
|
|
12
|
+
if (!senders[kind]) {
|
|
13
|
+
transceiver.direction = "sendrecv";
|
|
14
|
+
senders[kind] = transceiver.sender;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return senders;
|
|
18
|
+
},
|
|
6
19
|
createOffer: () => pc.createOffer(),
|
|
7
20
|
createAnswer: () => pc.createAnswer(),
|
|
8
21
|
setLocalDescription: (description) => pc.setLocalDescription(description),
|
|
@@ -20,7 +33,7 @@ export const createBrowserPeerConnection = (config) => {
|
|
|
20
33
|
// `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
|
|
21
34
|
// adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
|
|
22
35
|
pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
|
|
23
|
-
pc.ontrack = (event) => like.ontrack?.({
|
|
36
|
+
pc.ontrack = (event) => like.ontrack?.({ track: event.track });
|
|
24
37
|
pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
|
|
25
38
|
return like;
|
|
26
39
|
};
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
* kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
|
|
3
3
|
* an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
|
|
4
4
|
* its own doc comment. */
|
|
5
|
+
/** What a participant is sending, and whether their hand is up. Announced in `hello` and re-sent as a `state`
|
|
6
|
+
* message on every change, so a tile can show a muted microphone, an avatar for a camera that is off, and a raised
|
|
7
|
+
* hand without inspecting the media itself (the far end of a `replaceTrack(null)` gives no dependable signal that
|
|
8
|
+
* the track stopped). */
|
|
9
|
+
export interface ParticipantState {
|
|
10
|
+
audioOn: boolean;
|
|
11
|
+
videoOn: boolean;
|
|
12
|
+
handRaised: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
|
|
15
|
+
* arbitrary string from another participant is never rendered. */
|
|
16
|
+
export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];
|
|
5
17
|
/**
|
|
6
18
|
* One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
|
|
7
19
|
* and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
|
|
@@ -9,22 +21,26 @@
|
|
|
9
21
|
* literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
|
|
10
22
|
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
11
23
|
*
|
|
12
|
-
* `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every
|
|
13
|
-
* relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) -
|
|
14
|
-
* that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to
|
|
15
|
-
* subscriber, sender included.
|
|
24
|
+
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
|
|
25
|
+
* participant is relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) -
|
|
26
|
+
* a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to
|
|
27
|
+
* every subscriber, sender included.
|
|
16
28
|
*/
|
|
17
29
|
export interface SignalMessage {
|
|
18
30
|
type: "video-meeting-signal";
|
|
19
|
-
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
|
|
20
|
-
/** The sender's
|
|
21
|
-
*
|
|
22
|
-
*
|
|
31
|
+
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction";
|
|
32
|
+
/** The sender's per-call peer id - `CallView` derives it from `VideoMeetingJoinResult.selfUid` plus a random
|
|
33
|
+
* suffix, so the same account joining from two devices (or two tabs) is two participants, not one that ignores
|
|
34
|
+
* its own messages. */
|
|
23
35
|
from: string;
|
|
24
36
|
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
|
|
25
37
|
to?: string;
|
|
26
38
|
/** `hello` only - the display name the sender chose in the lobby. */
|
|
27
39
|
name?: string;
|
|
40
|
+
/** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
|
|
41
|
+
state?: ParticipantState;
|
|
42
|
+
/** `reaction` only - one of `REACTION_EMOJIS`. */
|
|
43
|
+
emoji?: string;
|
|
28
44
|
/** `offer`/`answer` only. */
|
|
29
45
|
sdp?: RTCSessionDescriptionInit;
|
|
30
46
|
/** `ice-candidate` only. */
|
|
@@ -40,9 +56,10 @@ export interface SignalingChannel {
|
|
|
40
56
|
* rather than relying on the transport to). Returns the function that stops listening. */
|
|
41
57
|
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
42
58
|
}
|
|
43
|
-
/** The subset of `RTCRtpSender` `MeshConnectionManager.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
59
|
+
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
60
|
+
* one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
|
|
61
|
+
* switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
|
|
62
|
+
* `RTCRtpSender` already satisfies this shape. */
|
|
46
63
|
export interface RTCRtpSenderLike {
|
|
47
64
|
track: MediaStreamTrack | null;
|
|
48
65
|
replaceTrack(track: MediaStreamTrack | null): Promise<void>;
|
|
@@ -52,8 +69,16 @@ export interface RTCRtpSenderLike {
|
|
|
52
69
|
* properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
|
|
53
70
|
* this stands in for and keeping a test fake to the bare minimum. */
|
|
54
71
|
export interface RTCPeerConnectionLike {
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
/** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
|
|
73
|
+
* nothing until `replaceTrack()` supplies it otherwise. */
|
|
74
|
+
addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): {
|
|
75
|
+
sender: RTCRtpSenderLike;
|
|
76
|
+
};
|
|
77
|
+
/** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
|
|
78
|
+
* send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
|
|
79
|
+
* transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
|
|
80
|
+
* unused, and everything the answerer sends is silently lost.) */
|
|
81
|
+
claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
|
|
57
82
|
createOffer(): Promise<RTCSessionDescriptionInit>;
|
|
58
83
|
createAnswer(): Promise<RTCSessionDescriptionInit>;
|
|
59
84
|
setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
|
@@ -64,7 +89,7 @@ export interface RTCPeerConnectionLike {
|
|
|
64
89
|
candidate: RTCIceCandidateInit | null;
|
|
65
90
|
}) => void) | null;
|
|
66
91
|
ontrack: ((event: {
|
|
67
|
-
|
|
92
|
+
track: MediaStreamTrack;
|
|
68
93
|
}) => void) | null;
|
|
69
94
|
onconnectionstatechange: (() => void) | null;
|
|
70
95
|
connectionState: string;
|
|
@@ -74,13 +99,16 @@ export type RTCPeerConnectionFactory = (config: {
|
|
|
74
99
|
}) => RTCPeerConnectionLike;
|
|
75
100
|
/** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
|
|
76
101
|
* the local participant themselves (the UI already knows its own name/uid without asking the manager). */
|
|
77
|
-
export interface MeshParticipant {
|
|
102
|
+
export interface MeshParticipant extends ParticipantState {
|
|
78
103
|
uid: string;
|
|
79
104
|
name: string;
|
|
80
105
|
}
|
|
81
106
|
export type MeshEvent = {
|
|
82
107
|
type: "participant-joined";
|
|
83
108
|
participant: MeshParticipant;
|
|
109
|
+
} | {
|
|
110
|
+
type: "participant-updated";
|
|
111
|
+
participant: MeshParticipant;
|
|
84
112
|
} | {
|
|
85
113
|
type: "participant-left";
|
|
86
114
|
uid: string;
|
|
@@ -88,6 +116,15 @@ export type MeshEvent = {
|
|
|
88
116
|
type: "remote-stream";
|
|
89
117
|
uid: string;
|
|
90
118
|
stream: MediaStream;
|
|
119
|
+
} | {
|
|
120
|
+
type: "hand-raised";
|
|
121
|
+
uid: string;
|
|
122
|
+
name: string;
|
|
123
|
+
} | {
|
|
124
|
+
type: "reaction";
|
|
125
|
+
uid: string;
|
|
126
|
+
name: string;
|
|
127
|
+
emoji: string;
|
|
91
128
|
} | {
|
|
92
129
|
type: "presenter-changed";
|
|
93
130
|
uid: string | undefined;
|
|
@@ -6,4 +6,6 @@
|
|
|
6
6
|
* kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
|
|
7
7
|
* an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
|
|
8
8
|
* its own doc comment. */
|
|
9
|
-
|
|
9
|
+
/** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
|
|
10
|
+
* arbitrary string from another participant is never rendered. */
|
|
11
|
+
export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];
|
|
@@ -50,6 +50,8 @@ export const GUEST_UID_PREFIX = "guest:";
|
|
|
50
50
|
/** How many times `ensureChannelGrant()` retries an optimistic-lock conflict on the meeting's own ACL before
|
|
51
51
|
* giving up - see that method's doc comment. */
|
|
52
52
|
const GUEST_GRANT_MAX_ATTEMPTS = 5;
|
|
53
|
+
/** The `label` of every `CalendarEventAttendeeLink` `persistMeeting()` writes. */
|
|
54
|
+
const ATTENDEE_LINK_LABEL = "Join video call";
|
|
53
55
|
/**
|
|
54
56
|
* The owner's management of their own `VideoMeeting`s (JWT-authenticated, mailbox-ACL-checked exactly like any
|
|
55
57
|
* other mailbox-scoped entity - see `booking-plugin`'s `BaseBookingTypeRoute` for the same "must own or hold a
|
|
@@ -218,6 +220,12 @@ export class BaseVideoMeetingRoute {
|
|
|
218
220
|
if (!this.mailboxRepo) {
|
|
219
221
|
this.mailboxRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.mailboxClass.name, args: [this.mailboxClass] });
|
|
220
222
|
}
|
|
223
|
+
if (!this.attendeeLinkRepo) {
|
|
224
|
+
this.attendeeLinkRepo = await this._objectFactory.newInstance(RepoUtils, {
|
|
225
|
+
name: this.attendeeLinkClass.name,
|
|
226
|
+
args: [this.attendeeLinkClass],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
221
229
|
}
|
|
222
230
|
/** Rejects a `403` unless `user` (with its trusted roles stripped - see `util/RouteAccessUtils.ts`) holds
|
|
223
231
|
* `action` on `mailboxUid`, by ownership or an explicit ACL grant. */
|
|
@@ -330,9 +338,50 @@ export class BaseVideoMeetingRoute {
|
|
|
330
338
|
joinToken: mintJoinToken(),
|
|
331
339
|
}), { user: strippedUser, ignoreACL: true }));
|
|
332
340
|
}
|
|
341
|
+
// The meeting's calendar event is invited by `MeetingSchedulingJob`, which gives each attendee their own
|
|
342
|
+
// `LOCATION`/body link from the `CalendarEventAttendeeLink` rows written for that event here - without
|
|
343
|
+
// them every invitee only receives the event's shared placeholder location, never a working join link.
|
|
344
|
+
// Written in this same transaction, so a meeting never exists without the links its invite needs.
|
|
345
|
+
if (meeting.calendarEventUid) {
|
|
346
|
+
for (const invitee of invitees) {
|
|
347
|
+
const url = this.joinUrl(invitee.joinToken);
|
|
348
|
+
if (url) {
|
|
349
|
+
await this.attendeeLinkRepo.create(new this.attendeeLinkClass({
|
|
350
|
+
mailboxUid,
|
|
351
|
+
calendarEventUid: meeting.calendarEventUid,
|
|
352
|
+
attendeeAddress: invitee.email,
|
|
353
|
+
url,
|
|
354
|
+
label: ATTENDEE_LINK_LABEL,
|
|
355
|
+
}), { user: strippedUser, ignoreACL: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
333
359
|
}
|
|
334
360
|
return { meeting, invitees };
|
|
335
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Deletes the `CalendarEventAttendeeLink` rows `persistMeeting()` wrote for `meeting`'s invitees, so a
|
|
364
|
+
* cancelled or deleted meeting's calendar event never keeps handing out join links to it. A row belongs to this
|
|
365
|
+
* meeting when it is for the meeting's own event and its `url` ends in one of `invitees`' own join tokens - the
|
|
366
|
+
* tokens (unlike the addresses, or the configured base URL, which may since have changed) name exactly this
|
|
367
|
+
* meeting's rows, so another meeting sharing the same event is never touched. A no-op for a meeting with no
|
|
368
|
+
* `calendarEventUid` (it never had any).
|
|
369
|
+
*/
|
|
370
|
+
async deleteAttendeeLinks(meeting, invitees, user) {
|
|
371
|
+
if (!meeting.calendarEventUid) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const links = await this.attendeeLinkRepo.find({ mailboxUid: ModelUtils.literal(meeting.mailboxUid), calendarEventUid: ModelUtils.literal(meeting.calendarEventUid) }, { ignoreACL: true, skipCache: true });
|
|
375
|
+
for (const link of links) {
|
|
376
|
+
if (invitees.some((invitee) => link.url.endsWith(`/${invitee.joinToken}`))) {
|
|
377
|
+
await this.attendeeLinkRepo.delete(link.uid, { user, ignoreACL: true });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** The meeting's own invitees. */
|
|
382
|
+
async findInvitees(meeting) {
|
|
383
|
+
return await this.inviteeRepo.find({ meetingUid: ModelUtils.literal(meeting.uid) }, { ignoreACL: true });
|
|
384
|
+
}
|
|
336
385
|
/** The join URL for a token/slug, or `undefined` when no public URL is configured - see this class's doc
|
|
337
386
|
* comment on `mail:videoconf:public_url`. */
|
|
338
387
|
joinUrl(tokenOrSlug) {
|
|
@@ -423,13 +472,19 @@ export class BaseVideoMeetingRoute {
|
|
|
423
472
|
if (patch.title === undefined && patch.status === undefined) {
|
|
424
473
|
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "Nothing to update: supply 'title' and/or 'status'.");
|
|
425
474
|
}
|
|
426
|
-
|
|
475
|
+
const strippedUser = stripTrustedRoles(user, this.trustedRoles);
|
|
476
|
+
const updated = await this.meetingRepo.update(patch, meeting, { user: strippedUser, ignoreACL: true });
|
|
477
|
+
if (patch.status === VideoMeetingStatus.CANCELLED) {
|
|
478
|
+
await this.deleteAttendeeLinks(meeting, await this.findInvitees(meeting), strippedUser);
|
|
479
|
+
}
|
|
480
|
+
return updated;
|
|
427
481
|
}
|
|
428
482
|
async delete(id, user) {
|
|
429
483
|
await this.init();
|
|
430
484
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.DELETE);
|
|
431
485
|
const strippedUser = stripTrustedRoles(user, this.trustedRoles);
|
|
432
|
-
const invitees = await this.
|
|
486
|
+
const invitees = await this.findInvitees(meeting);
|
|
487
|
+
await this.deleteAttendeeLinks(meeting, invitees, strippedUser);
|
|
433
488
|
for (const invitee of invitees) {
|
|
434
489
|
await this.inviteeRepo.delete(invitee.uid, { user: strippedUser, ignoreACL: true });
|
|
435
490
|
}
|
|
@@ -709,7 +764,7 @@ __decorate([
|
|
|
709
764
|
], BaseVideoMeetingRoute.prototype, "update", null);
|
|
710
765
|
__decorate([
|
|
711
766
|
Summary("Deletes a video meeting."),
|
|
712
|
-
Description("Deletes the meeting
|
|
767
|
+
Description("Deletes the meeting, every one of its invitees and the calendar invite links written for them. Deleting the meeting also removes its own " +
|
|
713
768
|
"per-record AccessControlList (RepoUtils.delete()'s standard recordACL cleanup - see the VideoMeeting " +
|
|
714
769
|
"interface's doc comment), so its uid stops working as a push channel immediately. Requires DELETE on " +
|
|
715
770
|
"the meeting's owning mailbox."),
|