@rapidmx/meet-plugin 0.14.1 → 0.16.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 (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -1,169 +1,243 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
6
- * corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
7
- * whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
8
- *
9
- * A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
10
- * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
11
- * in the current layout - can never silence anyone.
12
- *
13
- * The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
14
- * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
15
- * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
16
- * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
17
- * reflection, and mirroring it would show it backwards to no one but themselves. */
18
- import React, { useEffect, useRef } from "react";
19
- import type { MediaTransport } from "../shared/webrtc/types.js";
20
- import { MicOffIcon } from "./_icons.js";
21
-
22
- /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
23
- * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
24
- * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
25
- * slightly different words. */
26
- export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
27
- turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
28
- "turn-tcp": {
29
- label: "Relayed (TCP)",
30
- title:
31
- "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
32
- "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
33
- },
34
- websocket: {
35
- label: "Server relay",
36
- title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
37
- },
38
- failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
39
- };
40
-
41
- /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
42
- * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
43
- * why nothing is happening yet. */
44
- function StatusPill({ status, className }: { status: string; className?: string }) {
45
- return (
46
- <span
47
- aria-live="polite"
48
- data-testid="tile-status"
49
- className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
50
- >
51
- <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
52
- {status}
53
- </span>
54
- );
55
- }
56
-
57
- export interface ParticipantTileProps {
58
- name: string;
59
- stream?: MediaStream | null;
60
- isLocal?: boolean;
61
- /** Shows the initial instead of the video. */
62
- cameraOff?: boolean;
63
- micMuted?: boolean;
64
- handRaised?: boolean;
65
- /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
66
- transport?: MediaTransport;
67
- /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
68
- * participant's - shown with a spinner until the caller clears it. */
69
- status?: string;
70
- /** Highlights this tile as the current presenter/focus. */
71
- isFocused?: boolean;
72
- /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
73
- contain?: boolean;
74
- /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
75
- * mirrored. Default `true`; see this module's doc comment. */
76
- mirrored?: boolean;
77
- onClick?: () => void;
78
- className?: string;
79
- }
80
-
81
- function initialOf(name: string): string {
82
- return (Array.from(name.trim())[0] ?? "?").toUpperCase();
83
- }
84
-
85
- export default function ParticipantTile({
86
- name,
87
- stream,
88
- isLocal,
89
- cameraOff,
90
- micMuted,
91
- handRaised,
92
- transport,
93
- status,
94
- isFocused,
95
- contain,
96
- mirrored = true,
97
- onClick,
98
- className,
99
- }: ParticipantTileProps) {
100
- const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
101
- const videoRef = useRef<HTMLVideoElement>(null);
102
- const showVideo = !!stream && !cameraOff;
103
-
104
- // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
105
- useEffect(() => {
106
- if (videoRef.current) {
107
- videoRef.current.srcObject = stream ?? null;
108
- }
109
- }, [stream, showVideo]);
110
-
111
- return (
112
- <div
113
- className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
114
- isFocused ? "ring-2 ring-[#8ab4f8]" : ""
115
- } ${className ?? ""}`}
116
- onClick={onClick}
117
- role={onClick ? "button" : undefined}
118
- tabIndex={onClick ? 0 : undefined}
119
- aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
120
- >
121
- {showVideo ? (
122
- <>
123
- <video
124
- ref={videoRef}
125
- autoPlay
126
- playsInline
127
- muted
128
- className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
129
- />
130
- {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
131
- </>
132
- ) : (
133
- <div className="flex flex-col items-center gap-2 max-w-full px-1">
134
- <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
135
- {initialOf(name)}
136
- </div>
137
- {status && <StatusPill status={status} />}
138
- </div>
139
- )}
140
- {handRaised && (
141
- <span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
142
- ✋
143
- </span>
144
- )}
145
- {micMuted && (
146
- <span className="absolute top-2 right-2 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
147
- <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
148
- <MicOffIcon />
149
- </span>
150
- </span>
151
- )}
152
- {badge && (
153
- <span
154
- className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
155
- transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
156
- }`}
157
- title={badge.title}
158
- data-testid="transport-badge"
159
- >
160
- {badge.label}
161
- </span>
162
- )}
163
- <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
164
- {name}
165
- {isLocal ? " (you)" : ""}
166
- </div>
167
- </div>
168
- );
169
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
6
+ * corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
7
+ * whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
8
+ *
9
+ * A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
10
+ * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
11
+ * in the current layout - can never silence anyone.
12
+ *
13
+ * The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
14
+ * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
15
+ * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
16
+ * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
17
+ * reflection, and mirroring it would show it backwards to no one but themselves.
18
+ *
19
+ * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
20
+ * hiding the local tile from grid view. */
21
+ import React, { useEffect, useRef, useState } from "react";
22
+ import type { MediaTransport } from "../shared/webrtc/types.js";
23
+ import { MicOffIcon, OverflowIcon } from "./_icons.js";
24
+
25
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
26
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
27
+ * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
28
+ * slightly different words. */
29
+ export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
30
+ turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
31
+ "turn-tcp": {
32
+ label: "Relayed (TCP)",
33
+ title:
34
+ "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
35
+ "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
36
+ },
37
+ websocket: {
38
+ label: "Server relay",
39
+ title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
40
+ },
41
+ failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
42
+ };
43
+
44
+ /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
45
+ * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
46
+ * why nothing is happening yet. */
47
+ function StatusPill({ status, className }: { status: string; className?: string }) {
48
+ return (
49
+ <span
50
+ aria-live="polite"
51
+ data-testid="tile-status"
52
+ className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
53
+ >
54
+ <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
55
+ {status}
56
+ </span>
57
+ );
58
+ }
59
+
60
+ export interface ParticipantTileProps {
61
+ name: string;
62
+ stream?: MediaStream | null;
63
+ isLocal?: boolean;
64
+ /** Shows the initial instead of the video. */
65
+ cameraOff?: boolean;
66
+ micMuted?: boolean;
67
+ handRaised?: boolean;
68
+ /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
69
+ transport?: MediaTransport;
70
+ /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
71
+ * participant's - shown with a spinner until the caller clears it. */
72
+ status?: string;
73
+ /** Highlights this tile as the current presenter/focus. */
74
+ isFocused?: boolean;
75
+ /** Marks this tile as showing a screen share rather than a camera - a screen share is never mirrored
76
+ * (regardless of `mirrored`/`isLocal`), since mirroring someone else's shared content would be disorienting. */
77
+ contain?: boolean;
78
+ /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
79
+ * mirrored. Default `true`; see this module's doc comment. */
80
+ mirrored?: boolean;
81
+ /** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
82
+ * doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
83
+ * local tile while alone in the call, where hiding it would just blank the only thing on screen. */
84
+ onHideSelf?: () => void;
85
+ onClick?: () => void;
86
+ className?: string;
87
+ }
88
+
89
+ function initialOf(name: string): string {
90
+ return (Array.from(name.trim())[0] ?? "?").toUpperCase();
91
+ }
92
+
93
+ export default function ParticipantTile({
94
+ name,
95
+ stream,
96
+ isLocal,
97
+ cameraOff,
98
+ micMuted,
99
+ handRaised,
100
+ transport,
101
+ status,
102
+ isFocused,
103
+ contain,
104
+ mirrored = true,
105
+ onHideSelf,
106
+ onClick,
107
+ className,
108
+ }: ParticipantTileProps) {
109
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
110
+ const videoRef = useRef<HTMLVideoElement>(null);
111
+ const showVideo = !!stream && !cameraOff;
112
+ const [hideMenuOpen, setHideMenuOpen] = useState(false);
113
+ const hideMenuRef = useRef<HTMLDivElement>(null);
114
+
115
+ // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
116
+ useEffect(() => {
117
+ if (videoRef.current) {
118
+ videoRef.current.srcObject = stream ?? null;
119
+ }
120
+ }, [stream, showVideo]);
121
+
122
+ // The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
123
+ // this app.
124
+ useEffect(() => {
125
+ if (!hideMenuOpen) {
126
+ return;
127
+ }
128
+ const onPointerDown = (event: PointerEvent) => {
129
+ if (!hideMenuRef.current?.contains(event.target as Node)) {
130
+ setHideMenuOpen(false);
131
+ }
132
+ };
133
+ const onKeyDown = (event: KeyboardEvent) => {
134
+ if (event.key === "Escape") {
135
+ setHideMenuOpen(false);
136
+ }
137
+ };
138
+ document.addEventListener("pointerdown", onPointerDown);
139
+ document.addEventListener("keydown", onKeyDown);
140
+ return () => {
141
+ document.removeEventListener("pointerdown", onPointerDown);
142
+ document.removeEventListener("keydown", onKeyDown);
143
+ };
144
+ }, [hideMenuOpen]);
145
+
146
+ return (
147
+ <div
148
+ className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
149
+ isFocused ? "ring-2 ring-[#8ab4f8]" : ""
150
+ } ${className ?? ""}`}
151
+ onClick={onClick}
152
+ role={onClick ? "button" : undefined}
153
+ tabIndex={onClick ? 0 : undefined}
154
+ aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
155
+ >
156
+ {showVideo ? (
157
+ <>
158
+ <video
159
+ ref={videoRef}
160
+ autoPlay
161
+ playsInline
162
+ muted
163
+ className={`w-full h-full object-contain bg-black ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
164
+ />
165
+ {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
166
+ </>
167
+ ) : (
168
+ <div className="flex flex-col items-center gap-2 max-w-full px-1">
169
+ <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
170
+ {initialOf(name)}
171
+ </div>
172
+ {status && <StatusPill status={status} />}
173
+ </div>
174
+ )}
175
+ {handRaised && (
176
+ <span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
177
+ ✋
178
+ </span>
179
+ )}
180
+ {(micMuted || onHideSelf) && (
181
+ <div className="absolute top-2 right-2 flex items-center gap-1">
182
+ {micMuted && (
183
+ <span className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
184
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
185
+ <MicOffIcon />
186
+ </span>
187
+ </span>
188
+ )}
189
+ {onHideSelf && (
190
+ <div className="relative" ref={hideMenuRef}>
191
+ <button
192
+ type="button"
193
+ className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
194
+ aria-label="Local video options"
195
+ aria-haspopup="menu"
196
+ aria-expanded={hideMenuOpen}
197
+ onClick={() => setHideMenuOpen((prev) => !prev)}
198
+ >
199
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
200
+ <OverflowIcon />
201
+ </span>
202
+ </button>
203
+ {hideMenuOpen && (
204
+ <div
205
+ role="menu"
206
+ aria-label="Local video options"
207
+ className="absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
208
+ >
209
+ <button
210
+ type="button"
211
+ role="menuitem"
212
+ className="w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
213
+ onClick={() => {
214
+ setHideMenuOpen(false);
215
+ onHideSelf();
216
+ }}
217
+ >
218
+ Hide self
219
+ </button>
220
+ </div>
221
+ )}
222
+ </div>
223
+ )}
224
+ </div>
225
+ )}
226
+ {badge && (
227
+ <span
228
+ className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
229
+ transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
230
+ }`}
231
+ title={badge.title}
232
+ data-testid="transport-badge"
233
+ >
234
+ {badge.label}
235
+ </span>
236
+ )}
237
+ <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
238
+ {name}
239
+ {isLocal ? " (you)" : ""}
240
+ </div>
241
+ </div>
242
+ );
243
+ }
@@ -35,7 +35,7 @@ import React, { useEffect } from "react";
35
35
  import type { MeshParticipant } from "../shared/webrtc/types.js";
36
36
  import type { WaitingParticipant } from "./_meetApi.js";
37
37
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
38
- import { MicOffIcon } from "./_icons.js";
38
+ import { BatonIcon, MicOffIcon, RemoveIcon } from "./_icons.js";
39
39
 
40
40
  export interface ParticipantsDrawerProps {
41
41
  selfName: string;
@@ -184,35 +184,38 @@ function Row({
184
184
  {canGiveStick && !hasStick && (
185
185
  <button
186
186
  type="button"
187
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
187
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
188
188
  aria-label={`Give the talking stick to ${name}`}
189
+ title={`Give the talking stick to ${name}`}
189
190
  onClick={onGiveStick}
190
191
  >
191
- Give stick
192
+ <BatonIcon />
192
193
  </button>
193
194
  )}
194
195
  {canModerate && !micMuted && (
195
196
  <button
196
197
  type="button"
197
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
198
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
198
199
  aria-label={`Mute ${name}`}
200
+ title={`Mute ${name}`}
199
201
  onClick={onMute}
200
202
  >
201
- Mute
203
+ <MicOffIcon />
202
204
  </button>
203
205
  )}
204
206
  {canModerate && (
205
207
  <button
206
208
  type="button"
207
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
209
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
208
210
  aria-label={`Remove ${name}`}
211
+ title={`Remove ${name}`}
209
212
  onClick={() => {
210
213
  if (window.confirm(`Remove ${name} from the call?`)) {
211
214
  onKick?.();
212
215
  }
213
216
  }}
214
217
  >
215
- Remove
218
+ <RemoveIcon />
216
219
  </button>
217
220
  )}
218
221
  </li>