@rapidmx/meet-plugin 0.9.3 → 0.10.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 (52) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  10. package/apps/shared/media/useLocalMedia.ts +7 -3
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  12. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  13. package/apps/shared/webrtc/types.ts +55 -7
  14. package/dist/apps/meet/[token].d.ts +4 -1
  15. package/dist/apps/meet/[token].js +121 -9
  16. package/dist/apps/meet/_CallControls.d.ts +9 -1
  17. package/dist/apps/meet/_CallControls.js +6 -5
  18. package/dist/apps/meet/_CallView.d.ts +28 -2
  19. package/dist/apps/meet/_CallView.js +155 -6
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  21. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  22. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  23. package/dist/apps/meet/_ParticipantTile.js +9 -2
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  25. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  26. package/dist/apps/meet/_icons.d.ts +3 -0
  27. package/dist/apps/meet/_icons.js +3 -0
  28. package/dist/apps/meet/_meetApi.d.ts +93 -1
  29. package/dist/apps/meet/_meetApi.js +82 -0
  30. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  31. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  32. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  33. package/dist/apps/shared/media/useLocalMedia.js +3 -3
  34. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  35. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  36. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  37. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  38. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  39. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  40. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  41. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  42. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  43. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  44. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  45. package/dist/lib/util/PasswordUtils.js +52 -0
  46. package/dist/lib/util/PasswordUtils.js.map +1 -0
  47. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  48. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  49. package/dist/types/models/types.d.ts +23 -0
  50. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  51. package/dist/types/util/PasswordUtils.d.ts +17 -0
  52. package/package.json +1 -1
@@ -138,6 +138,10 @@ function combineErrors(audio: MediaAccessError | null, video: MediaAccessError |
138
138
  export interface LocalMediaOptions {
139
139
  /** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
140
140
  effectsAssetsUrl?: string;
141
+ /** The host's `VideoMeeting.forceMuteOnJoin` setting - when `true`, this call's microphone starts muted
142
+ * regardless of what was remembered from a previous call, overriding (but never overwriting) that preference -
143
+ * see `ensurePrefs()`. `undefined`/`false` changes nothing. */
144
+ forceMuteOnJoin?: boolean;
141
145
  }
142
146
 
143
147
  /** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
@@ -145,7 +149,7 @@ function preferring(base: MediaTrackConstraints, preferredId?: string): MediaTra
145
149
  return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
146
150
  }
147
151
 
148
- export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): LocalMedia {
152
+ export function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin }: LocalMediaOptions = {}): LocalMedia {
149
153
  const [supported, setSupported] = useState(true);
150
154
  const [requesting, setRequesting] = useState(false);
151
155
  const [error, setError] = useState<MediaAccessError | null>(null);
@@ -186,7 +190,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
186
190
  }
187
191
  const prefs = loadPreferences();
188
192
  prefsRef.current = prefs;
189
- if (prefs.micEnabled === false) {
193
+ if (prefs.micEnabled === false || forceMuteOnJoin) {
190
194
  micEnabledRef.current = false;
191
195
  setMicEnabled(false);
192
196
  }
@@ -204,7 +208,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
204
208
  );
205
209
  }
206
210
  return prefs;
207
- }, [applyBackground, applyFilters]);
211
+ }, [applyBackground, applyFilters, forceMuteOnJoin]);
208
212
 
209
213
  /** Saves a change to the remembered settings. */
210
214
  const remember = useCallback(
@@ -62,6 +62,15 @@
62
62
  *
63
63
  * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
64
  *
65
+ * ## Host moderation (`mute-request`, `kicked`)
66
+ *
67
+ * `sendMuteRequest()`/`sendKick()` send a point-to-point signal to one peer; the manager itself does not decide who
68
+ * may send one - a "host" identity exists only in `_CallView.tsx`, which shows the controls that call these
69
+ * methods to nobody but the host (see `types.ts`'s `SignalMessage` doc comment for why this is cooperative, not
70
+ * enforced, at the signaling layer). Receiving one emits `mute-requested`/`kicked` for the caller to act on; this
71
+ * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
+ * participant's actual departure is `stop()`, which the caller decides to call.
73
+ *
65
74
  * ## Three media paths, tried in order
66
75
  *
67
76
  * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
@@ -115,6 +124,11 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
115
124
  /** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
116
125
  export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
117
126
 
127
+ /** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
128
+ * this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
129
+ * free to call every frame. */
130
+ export const DEFAULT_DIAGNOSTICS_POLL_MS = 3_000;
131
+
118
132
  type MediaKind = "audio" | "video";
119
133
 
120
134
  interface PeerState extends MeshParticipant {
@@ -128,6 +142,9 @@ interface PeerState extends MeshParticipant {
128
142
  connectTimer: ReturnType<typeof setTimeout> | undefined;
129
143
  /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
130
144
  disconnectTimer: ReturnType<typeof setTimeout> | undefined;
145
+ /** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
146
+ * it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
147
+ diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
131
148
  }
132
149
 
133
150
  export interface MeshConnectionManagerOptions {
@@ -156,6 +173,8 @@ export interface MeshConnectionManagerOptions {
156
173
  connectTimeoutMs?: number;
157
174
  /** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
158
175
  disconnectedGraceMs?: number;
176
+ /** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
177
+ diagnosticsPollMs?: number;
159
178
  }
160
179
 
161
180
  export class MeshConnectionManager {
@@ -222,6 +241,7 @@ export class MeshConnectionManager {
222
241
  this.unsubscribe = undefined;
223
242
  for (const peer of this.peers.values()) {
224
243
  this.clearTimers(peer);
244
+ this.stopDiagnosticsPolling(peer);
225
245
  peer.pc.close();
226
246
  }
227
247
  this.peers.clear();
@@ -285,6 +305,20 @@ export class MeshConnectionManager {
285
305
  this.emit({ type: "presenter-changed", uid: undefined });
286
306
  }
287
307
 
308
+ /** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
309
+ * moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
310
+ * is responsible for only offering this to one. */
311
+ sendMuteRequest(peerUid: string): void {
312
+ this.send({ kind: "mute-request", to: peerUid });
313
+ }
314
+
315
+ /** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
316
+ * does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
317
+ * which the caller is expected to call alongside this for an enforced removal). */
318
+ sendKick(peerUid: string): void {
319
+ this.send({ kind: "kicked", to: peerUid });
320
+ }
321
+
288
322
  private sendHello(): void {
289
323
  this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
290
324
  }
@@ -345,6 +379,12 @@ export class MeshConnectionManager {
345
379
  case "relay-fallback":
346
380
  this.handleRelayFallback(message.from);
347
381
  return;
382
+ case "mute-request":
383
+ this.emit({ type: "mute-requested" });
384
+ return;
385
+ case "kicked":
386
+ this.emit({ type: "kicked" });
387
+ return;
348
388
  }
349
389
  }
350
390
 
@@ -408,6 +448,7 @@ export class MeshConnectionManager {
408
448
  return;
409
449
  }
410
450
  this.clearTimers(peer);
451
+ this.stopDiagnosticsPolling(peer);
411
452
  peer.pc.close();
412
453
  this.peers.delete(uid);
413
454
  if (peer.transport === "websocket") {
@@ -440,6 +481,7 @@ export class MeshConnectionManager {
440
481
  transport: "connecting",
441
482
  connectTimer: undefined,
442
483
  disconnectTimer: undefined,
484
+ diagnosticsTimer: undefined,
443
485
  };
444
486
  this.orphanCandidates.delete(uid);
445
487
  this.peers.set(uid, peer);
@@ -484,9 +526,10 @@ export class MeshConnectionManager {
484
526
  }
485
527
  }
486
528
 
487
- /** Records whether a connected pair is direct or relayed through TURN, from the candidate pair ICE selected. */
529
+ /** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
530
+ * `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
488
531
  private async detectTransport(peer: PeerState): Promise<void> {
489
- let type: "p2p" | "turn" | "unknown";
532
+ let type: "p2p" | "turn" | "turn-tcp" | "unknown";
490
533
  try {
491
534
  type = await peer.pc.connectionType();
492
535
  } catch {
@@ -494,7 +537,39 @@ export class MeshConnectionManager {
494
537
  }
495
538
  // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
496
539
  if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
497
- this.setTransport(peer, type === "turn" ? "turn" : "p2p");
540
+ this.setTransport(peer, type === "turn" || type === "turn-tcp" ? type : "p2p");
541
+ this.startDiagnosticsPolling(peer);
542
+ }
543
+ }
544
+
545
+ /** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
546
+ * again) does not get a second timer. */
547
+ private startDiagnosticsPolling(peer: PeerState): void {
548
+ if (peer.diagnosticsTimer !== undefined) {
549
+ return;
550
+ }
551
+ const pollMs = this.options.diagnosticsPollMs ?? DEFAULT_DIAGNOSTICS_POLL_MS;
552
+ peer.diagnosticsTimer = setInterval(() => void this.pollDiagnostics(peer), pollMs);
553
+ void this.pollDiagnostics(peer); // the first sample doesn't wait a full interval
554
+ }
555
+
556
+ private stopDiagnosticsPolling(peer: PeerState): void {
557
+ clearInterval(peer.diagnosticsTimer);
558
+ peer.diagnosticsTimer = undefined;
559
+ }
560
+
561
+ private async pollDiagnostics(peer: PeerState): Promise<void> {
562
+ let diagnostics: MeshParticipant["diagnostics"];
563
+ try {
564
+ diagnostics = await peer.pc.collectDiagnostics();
565
+ } catch {
566
+ // Leaves whatever was last polled in place rather than blanking a momentary failure.
567
+ return;
568
+ }
569
+ // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
570
+ if (this.peers.get(peer.uid) === peer && peer.diagnosticsTimer !== undefined) {
571
+ peer.diagnostics = diagnostics;
572
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
498
573
  }
499
574
  }
500
575
 
@@ -522,6 +597,8 @@ export class MeshConnectionManager {
522
597
  return;
523
598
  }
524
599
  this.clearTimers(peer);
600
+ this.stopDiagnosticsPolling(peer);
601
+ peer.diagnostics = undefined;
525
602
  const relay = this.options.relay;
526
603
  if (!relay?.supported) {
527
604
  this.setTransport(peer, "failed");
@@ -667,7 +744,15 @@ export class MeshConnectionManager {
667
744
  }
668
745
 
669
746
  function toParticipant(peer: PeerState): MeshParticipant {
670
- return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised, transport: peer.transport };
747
+ return {
748
+ uid: peer.uid,
749
+ name: peer.name,
750
+ audioOn: peer.audioOn,
751
+ videoOn: peer.videoOn,
752
+ handRaised: peer.handRaised,
753
+ transport: peer.transport,
754
+ diagnostics: peer.diagnostics,
755
+ };
671
756
  }
672
757
 
673
758
  function sameState(a: ParticipantState, b: ParticipantState): boolean {
@@ -7,15 +7,23 @@
7
7
  * this factory's only real caller. `createBrowserPeerConnection` is a plain function value, never invoked at
8
8
  * module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
9
9
  * exist at all - it would only throw if actually *called* outside a browser. */
10
- import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
10
+ import type { ConnectionDiagnostics, RTCPeerConnectionFactory, RTCPeerConnectionLike, RtpStreamDiagnostics } from "./types.js";
11
11
 
12
12
  /**
13
- * Whether the pair ICE settled on goes through a TURN relay. The selected pair is found the standard way (the
14
- * `transport` report's `selectedCandidatePairId`) and, for a browser that does not fill that in, as the succeeded and
15
- * nominated `candidate-pair`. A relay on *either* end means the media passes through a TURN server, so it counts as
16
- * `"turn"`; `"unknown"` when no pair is reported at all.
13
+ * Whether the pair ICE settled on goes through a TURN relay, and - when it's our own end that's relayed - whether
14
+ * that relay is TCP (`turn-tcp`, meaning `turns:` too, TLS being TCP-based as well) rather than UDP. The selected
15
+ * pair is found the standard way (the `transport` report's `selectedCandidatePairId`) and, for a browser that does
16
+ * not fill that in, as the succeeded and nominated `candidate-pair`. A relay on *either* end means the media
17
+ * passes through a TURN server, so it counts as at least `"turn"`; `"unknown"` when no pair is reported at all.
18
+ *
19
+ * The TCP/UDP distinction matters because they fail very differently under loss: UDP just drops the lost packet
20
+ * (a click), while TCP retransmits it and blocks everything queued behind it until that arrives (a stall) - exactly
21
+ * the "gaps that feel like latency, not packet loss" a participant on a TCP-relayed connection reports. `relayProtocol`
22
+ * (`"udp"`/`"tcp"`/`"tls"`) is only ever reported on a *local* relay candidate - there's no equivalent visibility into
23
+ * how the *remote* peer reaches its own TURN allocation, so a relay on the remote end alone is reported as plain
24
+ * `"turn"` rather than guessed at.
17
25
  */
18
- export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "unknown"> {
26
+ export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "turn-tcp" | "unknown"> {
19
27
  const reports = new Map<string, Record<string, unknown>>();
20
28
  (await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
21
29
  const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
@@ -27,7 +35,38 @@ export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getSta
27
35
  }
28
36
  const local = reports.get(String(pair.localCandidateId));
29
37
  const remote = reports.get(String(pair.remoteCandidateId));
30
- return local?.candidateType === "relay" || remote?.candidateType === "relay" ? "turn" : "p2p";
38
+ if (local?.candidateType !== "relay" && remote?.candidateType !== "relay") {
39
+ return "p2p";
40
+ }
41
+ const relayProtocol = local?.candidateType === "relay" ? String(local.relayProtocol ?? "") : "";
42
+ return relayProtocol === "tcp" || relayProtocol === "tls" ? "turn-tcp" : "turn";
43
+ }
44
+
45
+ function streamDiagnostics(reports: Map<string, Record<string, unknown>>, kind: "audio" | "video"): RtpStreamDiagnostics {
46
+ const inbound = [...reports.values()].find((report) => report.type === "inbound-rtp" && report.kind === kind);
47
+ const outbound = [...reports.values()].find((report) => report.type === "outbound-rtp" && report.kind === kind);
48
+ const num = (value: unknown): number | undefined => (typeof value === "number" ? value : undefined);
49
+ return {
50
+ packetsLost: num(inbound?.packetsLost),
51
+ jitter: num(inbound?.jitter),
52
+ bytesSent: num(outbound?.bytesSent),
53
+ bytesReceived: num(inbound?.bytesReceived),
54
+ };
55
+ }
56
+
57
+ /** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - a single
58
+ * `getStats()` walk, independent of (and in addition to) `selectedConnectionType()`'s own call, since this is
59
+ * polled periodically at a much lower priority than the one-time transport check and there's no value in coupling
60
+ * the two together. */
61
+ export async function collectDiagnostics(pc: Pick<RTCPeerConnection, "getStats">): Promise<ConnectionDiagnostics> {
62
+ const reports = new Map<string, Record<string, unknown>>();
63
+ (await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
64
+ const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
65
+ const pair =
66
+ (transport ? reports.get(String(transport.selectedCandidatePairId)) : undefined) ??
67
+ [...reports.values()].find((report) => report.type === "candidate-pair" && (report.selected || (report.nominated && report.state === "succeeded")));
68
+ const roundTripTimeSeconds = typeof pair?.currentRoundTripTime === "number" ? pair.currentRoundTripTime : undefined;
69
+ return { roundTripTimeSeconds, audio: streamDiagnostics(reports, "audio"), video: streamDiagnostics(reports, "video") };
31
70
  }
32
71
 
33
72
  export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
@@ -55,6 +94,7 @@ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) =>
55
94
  addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
56
95
  close: () => pc.close(),
57
96
  connectionType: () => selectedConnectionType(pc),
97
+ collectDiagnostics: () => collectDiagnostics(pc),
58
98
  onicecandidate: null,
59
99
  ontrack: null,
60
100
  onconnectionstatechange: null,
@@ -29,9 +29,16 @@ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮"
29
29
  * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
30
30
  *
31
31
  * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
32
- * participant is relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) -
33
- * a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to
34
- * every subscriber, sender included.
32
+ * participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are point-to-point (`to` is the
33
+ * intended peer's uid) - a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans
34
+ * every message out to every subscriber, sender included.
35
+ *
36
+ * `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) - purely
37
+ * cooperative, like every other message here: the recipient's own client decides whether to honor one, and nothing
38
+ * on the wire distinguishes a genuine host's message from any other participant's, since this client protocol has
39
+ * no host identity of its own to check against server-side. `kicked` is paired with the sender also revoking the
40
+ * recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is enforced and does
41
+ * not depend on the recipient's client cooperating.
35
42
  */
36
43
  export interface SignalMessage {
37
44
  type: "video-meeting-signal";
@@ -45,7 +52,9 @@ export interface SignalMessage {
45
52
  | "presenter-release"
46
53
  | "state"
47
54
  | "reaction"
48
- | "relay-fallback";
55
+ | "relay-fallback"
56
+ | "mute-request"
57
+ | "kicked";
49
58
  /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
50
59
  * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
51
60
  * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
@@ -81,6 +90,28 @@ export interface SignalingChannel {
81
90
  onMessage(handler: (message: SignalMessage) => void): () => void;
82
91
  }
83
92
 
93
+ /** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
94
+ * since a single `getStats()` snapshot has no "since when" to divide by; a caller wanting a bitrate keeps the
95
+ * previous sample itself and divides the difference by the elapsed time (see `MeshConnectionManager`'s poller).
96
+ * `undefined` for anything this browser (or this stream's direction - an audio-only connection reports nothing for
97
+ * video, and vice versa) doesn't report. */
98
+ export interface RtpStreamDiagnostics {
99
+ packetsLost?: number;
100
+ /** Seconds. */
101
+ jitter?: number;
102
+ bytesSent?: number;
103
+ bytesReceived?: number;
104
+ }
105
+
106
+ /** One point-in-time sample of a connection's own quality stats, for the diagnostics panel. */
107
+ export interface ConnectionDiagnostics {
108
+ /** Seconds - the selected candidate pair's own current round-trip time. `undefined` when this browser doesn't
109
+ * report one (a connection still connecting, or simply not supported). */
110
+ roundTripTimeSeconds?: number;
111
+ audio: RtpStreamDiagnostics;
112
+ video: RtpStreamDiagnostics;
113
+ }
114
+
84
115
  /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
85
116
  * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
86
117
  * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
@@ -115,17 +146,24 @@ export interface RTCPeerConnectionLike {
115
146
  connectionState: string;
116
147
  /** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
117
148
  * `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
118
- connectionType(): Promise<"p2p" | "turn" | "unknown">;
149
+ connectionType(): Promise<"p2p" | "turn" | "turn-tcp" | "unknown">;
150
+ /** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - polled
151
+ * periodically and independently of `connectionType()` (see `MeshConnectionManager`'s doc comment). */
152
+ collectDiagnostics(): Promise<ConnectionDiagnostics>;
119
153
  }
120
154
 
121
155
  /** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
122
156
  *
123
157
  * - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
124
158
  * - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
159
+ * - `"turn-tcp"`: the same, but the relay hop itself is TCP (or `turns:`, TLS being TCP-based too) rather than UDP -
160
+ * a network that blocks UDP outright. TCP's reliable, ordered delivery means one lost or delayed packet stalls
161
+ * everything queued behind it instead of just being dropped, so this can sound like pauses rather than the clicks
162
+ * plain packet loss would - worth telling apart from ordinary `"turn"` for exactly that reason.
125
163
  * - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
126
164
  * `apps/shared/relay/`), the last resort when neither of the above connects.
127
165
  * - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
128
- export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
166
+ export type MediaTransport = "connecting" | "p2p" | "turn" | "turn-tcp" | "websocket" | "failed";
129
167
 
130
168
  /** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
131
169
  * it) - declared here so the mesh, and its tests, depend on the shape alone. */
@@ -151,6 +189,10 @@ export interface MeshParticipant extends ParticipantState {
151
189
  name: string;
152
190
  /** How this participant's media currently reaches the local one - see `MediaTransport`. */
153
191
  transport: MediaTransport;
192
+ /** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
193
+ * first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
194
+ * a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
195
+ diagnostics?: ConnectionDiagnostics;
154
196
  }
155
197
 
156
198
  export type MeshEvent =
@@ -160,4 +202,10 @@ export type MeshEvent =
160
202
  | { type: "remote-stream"; uid: string; stream: MediaStream }
161
203
  | { type: "hand-raised"; uid: string; name: string }
162
204
  | { type: "reaction"; uid: string; name: string; emoji: string }
163
- | { type: "presenter-changed"; uid: string | undefined };
205
+ | { type: "presenter-changed"; uid: string | undefined }
206
+ /** Someone (implicitly the host, on the client's own say-so - see `SignalMessage`'s doc comment) asked the local
207
+ * participant to mute. The caller (`_CallView.tsx`) mutes only if currently unmuted; this never unmutes anyone. */
208
+ | { type: "mute-requested" }
209
+ /** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
210
+ * "removed by the host" state rather than the ordinary "you left" one. */
211
+ | { type: "kicked" };
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
3
3
  * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
4
- * `loading` -> `lobby` -> `in-call` -> `ended`.
4
+ * `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
5
+ * the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
6
+ * waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
7
+ * after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
5
8
  *
6
9
  * ## Session-based name prefill - NOT implemented, and why
7
10
  *
@@ -6,7 +6,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  /**
7
7
  * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
8
8
  * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
9
- * `loading` -> `lobby` -> `in-call` -> `ended`.
9
+ * `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
10
+ * the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
11
+ * waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
12
+ * after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
10
13
  *
11
14
  * ## Session-based name prefill - NOT implemented, and why
12
15
  *
@@ -35,11 +38,15 @@ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
35
38
  import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
36
39
  import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
37
40
  import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
41
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
38
42
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
39
43
  import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
44
  import MeetLobby from "./_MeetLobby.js";
41
45
  import CallView from "./_CallView.js";
42
- import { joinMeeting } from "./_meetApi.js";
46
+ import { joinMeeting, pollAdmission, requestAdmission, verifyMeetingPassword, } from "./_meetApi.js";
47
+ const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
48
+ /** How often `MeetJoinContent` polls while in the `"waiting"` phase. */
49
+ const ADMISSION_POLL_MS = 3000;
43
50
  export default function MeetJoinPage({ params }) {
44
51
  const { branding } = useBranding();
45
52
  return _jsx(MeetJoinContent, { token: params.token, branding: branding });
@@ -52,16 +59,48 @@ function MeetJoinContent({ token, branding }) {
52
59
  const [name, setName] = useState("");
53
60
  const [loadError, setLoadError] = useState(null);
54
61
  const [joinResult, setJoinResult] = useState(null);
55
- const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
62
+ /** The restricted `PublicVideoMeeting` `joinMeeting()` returned while this meeting required a password - just
63
+ * enough to show the password prompt's own title/host, since `joinResult` itself stays unset until one is
64
+ * verified (see `handleSubmitPassword()`). Only read while `phase === "password"`. */
65
+ const [passwordMeeting, setPasswordMeeting] = useState(null);
66
+ const [passwordInput, setPasswordInput] = useState("");
67
+ const [passwordError, setPasswordError] = useState(null);
68
+ /** The restricted `PublicVideoMeeting` `joinMeeting()`/`requestAdmission()` returned while this meeting has a
69
+ * waiting room - read on both the `"admission-request"` and `"waiting"` screens. */
70
+ const [admissionMeeting, setAdmissionMeeting] = useState(null);
71
+ const [admissionName, setAdmissionName] = useState("");
72
+ const [admissionPassword, setAdmissionPassword] = useState("");
73
+ const [admissionError, setAdmissionError] = useState(null);
74
+ /** The guest token `requestAdmission()` minted, to poll with (`Authorization` header - a guest has no session
75
+ * cookie of their own). `undefined` for an already-authenticated real caller, whose existing session cookie
76
+ * already identifies them to `pollAdmission()` with no header needed. */
77
+ const [pendingGuestToken, setPendingGuestToken] = useState(undefined);
78
+ /** Set only when the call ended without the participant's own action (currently: kicked by the host) - see
79
+ * `CallView`'s `onLeave` doc comment. Shown instead of the ordinary "you left" message; also hides "Rejoin
80
+ * meeting", since a kicked participant's server-side channel grant has just been revoked and a rejoin attempt
81
+ * would only fail. */
82
+ const [endedReason, setEndedReason] = useState(undefined);
83
+ const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl, forceMuteOnJoin: joinResult?.meeting.forceMuteOnJoin });
56
84
  const { release } = media;
57
85
  React.useEffect(() => {
58
86
  let cancelled = false;
59
87
  joinMeeting(token)
60
88
  .then((result) => {
61
- if (!cancelled) {
62
- setJoinResult(result);
63
- setPhase("lobby");
89
+ if (cancelled) {
90
+ return;
91
+ }
92
+ if ("requiresAdmission" in result) {
93
+ setAdmissionMeeting(result.meeting);
94
+ setPhase("admission-request");
95
+ return;
64
96
  }
97
+ if ("requiresPassword" in result) {
98
+ setPasswordMeeting(result.meeting);
99
+ setPhase("password");
100
+ return;
101
+ }
102
+ setJoinResult(result);
103
+ setPhase("lobby");
65
104
  })
66
105
  .catch((err) => {
67
106
  if (cancelled) {
@@ -82,8 +121,69 @@ function MeetJoinContent({ token, branding }) {
82
121
  setName(joinName);
83
122
  setPhase("in-call");
84
123
  }
85
- function handleLeave() {
124
+ async function handleSubmitPassword(e) {
125
+ e.preventDefault();
126
+ setPasswordError(null);
127
+ try {
128
+ const result = await verifyMeetingPassword(token, passwordInput);
129
+ setJoinResult(result);
130
+ setPhase("lobby");
131
+ }
132
+ catch (err) {
133
+ setPasswordError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
134
+ }
135
+ }
136
+ async function handleRequestAdmission(e) {
137
+ e.preventDefault();
138
+ setAdmissionError(null);
139
+ try {
140
+ const result = await requestAdmission(token, {
141
+ name: admissionName.trim(),
142
+ ...(admissionMeeting?.hasPassword && { password: admissionPassword }),
143
+ });
144
+ setName(admissionName.trim());
145
+ setPendingGuestToken(result.token);
146
+ setPhase("waiting");
147
+ }
148
+ catch (err) {
149
+ setAdmissionError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
150
+ }
151
+ }
152
+ // Polls while waiting for the host to respond - stops as soon as this phase is left, one way or the other.
153
+ React.useEffect(() => {
154
+ if (phase !== "waiting") {
155
+ return;
156
+ }
157
+ let cancelled = false;
158
+ const poll = async () => {
159
+ try {
160
+ const result = await pollAdmission(token, pendingGuestToken);
161
+ if (cancelled || "requiresAdmission" in result) {
162
+ return;
163
+ }
164
+ setJoinResult(result);
165
+ setPhase("lobby");
166
+ }
167
+ catch (err) {
168
+ if (cancelled || !(err instanceof ApiRequestError) || err.status !== 403) {
169
+ // A transient failure (network blip, server hiccup) - stay on this screen and try again next
170
+ // tick, rather than giving up on what might just be one bad request.
171
+ return;
172
+ }
173
+ setAdmissionError(err.message || "The host did not admit you to this meeting.");
174
+ setPhase("denied");
175
+ }
176
+ };
177
+ void poll();
178
+ const interval = setInterval(() => void poll(), ADMISSION_POLL_MS);
179
+ return () => {
180
+ cancelled = true;
181
+ clearInterval(interval);
182
+ };
183
+ }, [phase, token, pendingGuestToken]);
184
+ function handleLeave(reason) {
86
185
  release();
186
+ setEndedReason(reason);
87
187
  setPhase("ended");
88
188
  }
89
189
  if (phase === "in-call" && joinResult) {
@@ -93,7 +193,7 @@ function MeetJoinContent({ token, branding }) {
93
193
  // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
94
194
  // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
95
195
  // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
96
- token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, media: media, onLeave: handleLeave }));
196
+ token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, hostUid: joinResult.meeting.hostUid, initialForceMuteOnJoin: !!joinResult.meeting.forceMuteOnJoin, initialHasPassword: !!joinResult.meeting.hasPassword, initialWaitingRoomEnabled: !!joinResult.meeting.waitingRoomEnabled, media: media, onLeave: handleLeave }));
97
197
  }
98
198
  let content;
99
199
  if (phase === "loading") {
@@ -102,8 +202,20 @@ function MeetJoinContent({ token, branding }) {
102
202
  else if (phase === "not-found") {
103
203
  content = (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
104
204
  }
205
+ else if (phase === "password") {
206
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: passwordMeeting?.title }), passwordMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", passwordMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "This meeting requires a password to join." }), _jsxs("form", { onSubmit: (e) => void handleSubmitPassword(e), children: [_jsx(FormField, { label: "Password", htmlFor: "meet-password", children: _jsx("input", { id: "meet-password", type: "password", autoFocus: true, className: INPUT_CLASS, value: passwordInput, onChange: (e) => setPasswordInput(e.target.value) }) }), passwordError && _jsx(Alert, { children: passwordError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !passwordInput, children: "Join meeting" })] })] }));
207
+ }
208
+ else if (phase === "admission-request") {
209
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), admissionMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", admissionMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "The host must let you in before you can join." }), _jsxs("form", { onSubmit: (e) => void handleRequestAdmission(e), children: [_jsx(FormField, { label: "Your name", htmlFor: "admission-name", children: _jsx("input", { id: "admission-name", type: "text", autoFocus: true, className: INPUT_CLASS, value: admissionName, onChange: (e) => setAdmissionName(e.target.value) }) }), admissionMeeting?.hasPassword && (_jsx(FormField, { label: "Password", htmlFor: "admission-password", children: _jsx("input", { id: "admission-password", type: "password", className: `${INPUT_CLASS} mt-3`, value: admissionPassword, onChange: (e) => setAdmissionPassword(e.target.value) }) })), admissionError && _jsx(Alert, { children: admissionError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !admissionName.trim() || (!!admissionMeeting?.hasPassword && !admissionPassword), children: "Ask to join" })] })] }));
210
+ }
211
+ else if (phase === "waiting") {
212
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), _jsx("p", { className: "text-base text-text-muted mt-4", children: "Waiting for the host to let you in\u2026" })] }));
213
+ }
214
+ else if (phase === "denied") {
215
+ content = (_jsx(MeetCard, { children: _jsx(Alert, { children: admissionError ?? "The host did not admit you to this meeting." }) }));
216
+ }
105
217
  else if (phase === "ended") {
106
- content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: "You can close this page now, or rejoin." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" })] }));
218
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: endedReason ? "Removed from the meeting" : "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: endedReason ?? "You can close this page now, or rejoin." }), !endedReason && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" }))] }));
107
219
  }
108
220
  else {
109
221
  // phase === "lobby" (joinResult is always set by then - see the effect above).
@@ -10,14 +10,22 @@
10
10
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
11
11
  */
12
12
  import React from "react";
13
+ import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
13
14
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
15
+ import { type MeshParticipant } from "../shared/webrtc/types.js";
14
16
  export type CallViewMode = "grid" | "focus";
15
17
  export interface CallControlsProps {
16
18
  media: LocalMedia;
19
+ selfName: string;
20
+ participants: MeshParticipant[];
17
21
  isPresenting: boolean;
18
22
  /** Someone else is presenting - the share button is disabled and explains why. */
19
23
  presentingElsewhereName?: string;
20
24
  onToggleShare: () => void;
25
+ /** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
26
+ screenTransform: ScreenTransformState;
27
+ onRotateScreen: () => void;
28
+ onFlipScreen: () => void;
21
29
  handRaised: boolean;
22
30
  onToggleHand: () => void;
23
31
  onReaction: (emoji: string) => void;
@@ -29,4 +37,4 @@ export interface CallControlsProps {
29
37
  export declare function LevelBars({ level }: {
30
38
  level: number;
31
39
  }): React.JSX.Element;
32
- export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
40
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;