@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.
Files changed (46) hide show
  1. package/apps/meet/[token].tsx +51 -46
  2. package/apps/meet/_CallControls.tsx +255 -32
  3. package/apps/meet/_CallView.tsx +275 -115
  4. package/apps/meet/_MeetLobby.tsx +111 -142
  5. package/apps/meet/_ParticipantTile.tsx +51 -19
  6. package/apps/meet/_icons.tsx +54 -0
  7. package/apps/shared/media/chime.ts +74 -0
  8. package/apps/shared/media/deviceMedia.ts +3 -13
  9. package/apps/shared/media/useLocalMedia.ts +337 -0
  10. package/apps/shared/push/GuestSignalingClient.ts +2 -0
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +52 -15
  14. package/dist/apps/meet/[token].js +31 -19
  15. package/dist/apps/meet/_CallControls.d.ts +20 -7
  16. package/dist/apps/meet/_CallControls.js +72 -3
  17. package/dist/apps/meet/_CallView.d.ts +23 -4
  18. package/dist/apps/meet/_CallView.js +141 -58
  19. package/dist/apps/meet/_MeetLobby.d.ts +16 -19
  20. package/dist/apps/meet/_MeetLobby.js +49 -99
  21. package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
  22. package/dist/apps/meet/_ParticipantTile.js +12 -9
  23. package/dist/apps/meet/_icons.d.ts +15 -0
  24. package/dist/apps/meet/_icons.js +16 -0
  25. package/dist/apps/shared/media/chime.d.ts +31 -0
  26. package/dist/apps/shared/media/chime.js +49 -0
  27. package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
  28. package/dist/apps/shared/media/deviceMedia.js +3 -12
  29. package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
  30. package/dist/apps/shared/media/useLocalMedia.js +259 -0
  31. package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +52 -15
  36. package/dist/apps/shared/webrtc/types.js +3 -1
  37. package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
  38. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  39. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
  40. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
  41. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
  42. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
  43. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
  44. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
  45. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
  46. 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) => ({ uid: peer.uid, name: peer.name }));
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 `localStream`'s
34
- * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
35
- * stream's lifecycle, only attaches it. */
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
- /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
53
- * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
54
- * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
55
- * it). */
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
- const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
59
- void sender?.replaceTrack(track);
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
- if (this.peers.has(message.from)) {
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 name = message.name ?? message.from;
130
- const peer = this.createPeer(message.from, name);
131
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
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 (isOfferer(this.options.selfUid, message.from)) {
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 = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
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
- const stream = event.streams[0];
166
- if (stream) {
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: { uid: peer.uid, name: peer.name } });
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
- // No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
215
- // nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
216
- // connection (there are normally several). See this module's known-limitations note in the Phase 2
217
- // report.
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
- addTrack: (track, stream) => pc.addTrack(track, stream),
5
- getSenders: () => pc.getSenders(),
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?.({ streams: event.streams });
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 participant is
13
- * relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
14
- * that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
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 uid - the real, already-authenticated caller's own uid when `join()` returned
21
- * `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
22
- * `VideoMeetingJoinResult.selfUid`). */
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.replaceLocalVideoTrack()` uses (presentation mode - see
44
- * this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
45
- * second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
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
- addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
56
- getSenders(): RTCRtpSenderLike[];
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
- streams: readonly MediaStream[];
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
- export {};
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
- return await this.meetingRepo.update(patch, meeting, { user: stripTrustedRoles(user, this.trustedRoles), ignoreACL: true });
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.inviteeRepo.find({ meetingUid: ModelUtils.literal(meeting.uid) }, { ignoreACL: true });
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 and every one of its invitees. Deleting the meeting also removes its own " +
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."),