@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
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The in-call chat drawer, beside the call like the participants drawer (`_ParticipantsDrawer.tsx`) - `_CallView.tsx`
8
+ * shows one or the other. The messages come from `CollabSession` (`apps/shared/collab/CollabSession.ts`) and are
9
+ * rendered as Markdown by `ChatMarkdown` (bare links become links, image links become images).
10
+ *
11
+ * The composer is a textarea: Enter sends, Shift+Enter starts a new line. Its emoji button opens the web client's own
12
+ * emoji picker and inserts at the cursor; its GIF button opens the web client's own Giphy picker, and picking a GIF
13
+ * sends it at once as a message holding just its address - which every participant's chat then shows as the GIF
14
+ * itself. Both pickers are the very components the mail composer uses (`@rapidmx/web-client`'s `EmojiPicker` and
15
+ * `GifPicker`); GIF search goes through the server's own Giphy proxy, which a guest's token authenticates like a
16
+ * signed-in user's session.
17
+ *
18
+ * The list follows new messages only while it is already scrolled to the bottom, so reading back through the history
19
+ * isn't interrupted by someone else's message.
20
+ */
21
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
+ import EmojiPicker from "@rapidmx/web-client/shared/components/mail/compose/EmojiPicker.js";
23
+ import GifPicker from "@rapidmx/web-client/shared/components/mail/compose/GifPicker.js";
24
+ import ChatMarkdown from "../shared/collab/chatMarkdown.js";
25
+ import { MAX_CHAT_LENGTH } from "../shared/collab/protocol.js";
26
+ import { EmojiIcon, GifIcon, SendIcon } from "./_icons.js";
27
+ /** How close to the bottom (px) still counts as "at the bottom" for following new messages. */
28
+ const STICK_THRESHOLD_PX = 48;
29
+ /** Within this many characters of `MAX_CHAT_LENGTH`, the composer shows how many are left. */
30
+ const COUNTER_THRESHOLD = 200;
31
+ /** Two messages from the same person this close together (ms) share one name heading. */
32
+ const GROUP_WINDOW_MS = 2 * 60000;
33
+ const ICON_BUTTON = "w-9 h-9 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-40 disabled:cursor-not-allowed";
34
+ function formatTime(ms) {
35
+ return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
36
+ }
37
+ export default function ChatPanel({ entries, onSend, onClose }) {
38
+ const [draft, setDraft] = useState("");
39
+ const [picker, setPicker] = useState(null);
40
+ const panelRef = useRef(null);
41
+ const listRef = useRef(null);
42
+ const inputRef = useRef(null);
43
+ const emojiButtonRef = useRef(null);
44
+ const gifButtonRef = useRef(null);
45
+ const stickRef = useRef(true);
46
+ useEffect(() => {
47
+ inputRef.current?.focus();
48
+ }, []);
49
+ useEffect(() => {
50
+ // Only an Escape meant for the chat - with focus in it - closes it, not one closing a menu elsewhere.
51
+ const onKeyDown = (event) => {
52
+ if (event.key === "Escape" && !picker && panelRef.current?.contains(document.activeElement)) {
53
+ onClose();
54
+ }
55
+ };
56
+ document.addEventListener("keydown", onKeyDown);
57
+ return () => document.removeEventListener("keydown", onKeyDown);
58
+ }, [onClose, picker]);
59
+ useLayoutEffect(() => {
60
+ const list = listRef.current;
61
+ if (list && stickRef.current) {
62
+ list.scrollTop = list.scrollHeight;
63
+ }
64
+ }, [entries]);
65
+ function handleScroll() {
66
+ const list = listRef.current;
67
+ stickRef.current = list.scrollHeight - list.scrollTop - list.clientHeight <= STICK_THRESHOLD_PX;
68
+ }
69
+ function send(text) {
70
+ if (onSend(text)) {
71
+ stickRef.current = true;
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ function handleSubmit(event) {
77
+ event?.preventDefault();
78
+ if (send(draft)) {
79
+ setDraft("");
80
+ }
81
+ }
82
+ function handleKeyDown(event) {
83
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
84
+ event.preventDefault();
85
+ handleSubmit();
86
+ }
87
+ }
88
+ function insertEmoji(native) {
89
+ const input = inputRef.current;
90
+ const start = input.selectionStart ?? draft.length;
91
+ const end = input.selectionEnd ?? draft.length;
92
+ setPicker(null);
93
+ const next = draft.slice(0, start) + native + draft.slice(end);
94
+ // Past the limit the emoji is left out whole, never cut in half.
95
+ if (next.length > MAX_CHAT_LENGTH) {
96
+ input.focus();
97
+ return;
98
+ }
99
+ setDraft(next);
100
+ requestAnimationFrame(() => {
101
+ input.focus();
102
+ input.setSelectionRange(start + native.length, start + native.length);
103
+ });
104
+ }
105
+ function sendGif(url) {
106
+ setPicker(null);
107
+ send(url);
108
+ inputRef.current?.focus();
109
+ }
110
+ const remaining = MAX_CHAT_LENGTH - draft.length;
111
+ return (_jsxs("aside", { ref: panelRef, role: "dialog", "aria-label": "Chat", className: "relative z-10 shrink-0 w-80 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsx("h2", { className: "text-sm font-medium", children: "Chat" }), _jsx("button", { type: "button", className: ICON_BUTTON, "aria-label": "Close chat", onClick: onClose, children: "\u2715" })] }), _jsxs("div", { ref: listRef, onScroll: handleScroll, className: "flex-1 min-h-0 overflow-y-auto px-3 py-2", role: "log", "aria-label": "Messages", "aria-live": "polite", children: [entries.length === 0 && _jsx("p", { className: "mt-6 text-center text-sm text-white/60", children: "Messages are visible to everyone in the call and disappear when it ends." }), entries.map((entry, index) => {
112
+ const previous = entries[index - 1];
113
+ const grouped = !!previous && previous.peer === entry.peer && entry.sentAt - previous.sentAt < GROUP_WINDOW_MS && entry.sentAt >= previous.sentAt;
114
+ return (_jsxs("div", { className: grouped ? "mt-1" : "mt-3", "data-testid": "chat-message", children: [!grouped && (_jsxs("p", { className: "flex items-baseline gap-2 text-xs", children: [_jsx("span", { className: "font-semibold text-white", children: entry.self ? "You" : entry.name }), _jsx("span", { className: "text-white/50", children: formatTime(entry.sentAt) })] })), _jsx("div", { className: "mt-0.5 text-white/90", children: _jsx(ChatMarkdown, { text: entry.text }) })] }, entry.key));
115
+ })] }), _jsxs("form", { onSubmit: handleSubmit, className: "shrink-0 border-t border-white/10 p-2", children: [_jsxs("div", { className: "flex items-end gap-1 rounded-2xl bg-[#3c4043] px-1 py-1", children: [_jsx("button", { ref: emojiButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Insert emoji", "aria-expanded": picker === "emoji", onClick: () => setPicker((prev) => (prev === "emoji" ? null : "emoji")), children: _jsx(EmojiIcon, {}) }), _jsx("button", { ref: gifButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Send a GIF", "aria-expanded": picker === "gif", onClick: () => setPicker((prev) => (prev === "gif" ? null : "gif")), children: _jsx(GifIcon, {}) }), _jsx("textarea", { ref: inputRef, rows: 1, value: draft, maxLength: MAX_CHAT_LENGTH, "aria-label": "Message", placeholder: "Send a message", className: "flex-1 min-w-0 max-h-32 resize-none bg-transparent py-2 px-1 text-sm placeholder:text-white/50 focus:outline-none [field-sizing:content]", onChange: (event) => setDraft(event.target.value), onKeyDown: handleKeyDown }), _jsx("button", { type: "submit", className: ICON_BUTTON, "aria-label": "Send message", disabled: !draft.trim(), children: _jsx(SendIcon, {}) })] }), _jsxs("p", { className: "mt-1 px-2 flex justify-between gap-2 text-[11px] text-white/50", children: [_jsx("span", { children: "**bold** *italic* `code` - Shift+Enter for a new line" }), remaining <= COUNTER_THRESHOLD && _jsxs("span", { "aria-live": "polite", children: [remaining, " left"] })] })] }), picker === "emoji" && _jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: insertEmoji, onClose: () => setPicker(null) }), picker === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: sendGif, onClose: () => setPicker(null) })] }));
116
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
3
+ * brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
4
+ * on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
5
+ * `#rrggbb`.
6
+ */
7
+ import React from "react";
8
+ export interface ColorWheelProps {
9
+ color: string;
10
+ onChange: (color: string) => void;
11
+ /** The wheel's diameter, in CSS pixels. */
12
+ size?: number;
13
+ }
14
+ export default function ColorWheel({ color, onChange, size }: ColorWheelProps): React.JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
8
+ * brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
9
+ * on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
10
+ * `#rrggbb`.
11
+ */
12
+ import { useEffect, useRef, useState } from "react";
13
+ import { hexToHsv, hsvToHex, hsvToRgb, wheelPoint } from "../shared/collab/color.js";
14
+ /** How far one arrow-key press turns the hue (degrees) or moves the saturation. */
15
+ const HUE_STEP = 5;
16
+ const SATURATION_STEP = 0.05;
17
+ export default function ColorWheel({ color, onChange, size = 168 }) {
18
+ const canvasRef = useRef(null);
19
+ const [hsv, setHsv] = useState(() => hexToHsv(color));
20
+ const radius = size / 2;
21
+ // Follows a color chosen elsewhere (a swatch) - unless it is the one this wheel just produced, which would
22
+ // otherwise snap the hue of a gray (where hue is meaningless) back to red.
23
+ useEffect(() => {
24
+ setHsv((prev) => (hsvToHex(prev) === color ? prev : hexToHsv(color)));
25
+ }, [color]);
26
+ useEffect(() => {
27
+ const ctx = canvasRef.current?.getContext("2d");
28
+ if (!ctx) {
29
+ return;
30
+ }
31
+ const image = ctx.createImageData(size, size);
32
+ for (let y = 0; y < size; y++) {
33
+ for (let x = 0; x < size; x++) {
34
+ const offset = (y * size + x) * 4;
35
+ if (Math.hypot(x - radius, y - radius) > radius) {
36
+ continue;
37
+ }
38
+ const { h, s } = wheelPoint(x, y, radius);
39
+ const [r, g, b] = hsvToRgb({ h, s, v: hsv.v });
40
+ image.data[offset] = r;
41
+ image.data[offset + 1] = g;
42
+ image.data[offset + 2] = b;
43
+ image.data[offset + 3] = 255;
44
+ }
45
+ }
46
+ ctx.putImageData(image, 0, 0);
47
+ }, [hsv.v, size, radius]);
48
+ function pick(next) {
49
+ setHsv(next);
50
+ onChange(hsvToHex(next));
51
+ }
52
+ function pickAt(event) {
53
+ const rect = event.currentTarget.getBoundingClientRect();
54
+ const scale = rect.width > 0 ? size / rect.width : 1;
55
+ const { h, s } = wheelPoint((event.clientX - rect.left) * scale, (event.clientY - rect.top) * scale, radius);
56
+ pick({ ...hsv, h, s });
57
+ }
58
+ function handleKeyDown(event) {
59
+ const moves = {
60
+ ArrowLeft: { h: (hsv.h - HUE_STEP + 360) % 360 },
61
+ ArrowRight: { h: (hsv.h + HUE_STEP) % 360 },
62
+ ArrowUp: { s: Math.min(1, hsv.s + SATURATION_STEP) },
63
+ ArrowDown: { s: Math.max(0, hsv.s - SATURATION_STEP) },
64
+ };
65
+ const move = moves[event.key];
66
+ if (move) {
67
+ event.preventDefault();
68
+ pick({ ...hsv, ...move });
69
+ }
70
+ }
71
+ const angle = (hsv.h * Math.PI) / 180;
72
+ const markerX = radius + Math.cos(angle) * hsv.s * radius;
73
+ const markerY = radius + Math.sin(angle) * hsv.s * radius;
74
+ return (_jsxs("div", { className: "flex flex-col items-center gap-3", "data-testid": "color-wheel", children: [_jsxs("div", { className: "relative", style: { width: size, height: size }, children: [_jsx("canvas", { ref: canvasRef, width: size, height: size, tabIndex: 0, role: "slider", "aria-label": "Hue and saturation", "aria-valuemin": 0, "aria-valuemax": 359, "aria-valuenow": Math.round(hsv.h), "aria-valuetext": `Hue ${Math.round(hsv.h)} degrees, saturation ${Math.round(hsv.s * 100)}%`, className: "rounded-full cursor-crosshair touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onPointerDown: (event) => {
75
+ event.currentTarget.setPointerCapture?.(event.pointerId);
76
+ pickAt(event);
77
+ }, onPointerMove: (event) => {
78
+ if (event.buttons & 1) {
79
+ pickAt(event);
80
+ }
81
+ }, onKeyDown: handleKeyDown }), _jsx("span", { className: "pointer-events-none absolute w-4 h-4 -ml-2 -mt-2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)]", style: { left: markerX, top: markerY, backgroundColor: hsvToHex(hsv) }, "aria-hidden": "true" })] }), _jsxs("label", { className: "w-full flex items-center gap-2 text-xs text-white/70", children: [_jsx("span", { className: "shrink-0", children: "Brightness" }), _jsx("input", { type: "range", min: 0, max: 100, value: Math.round(hsv.v * 100), "aria-label": "Brightness", className: "flex-1", style: { accentColor: hsvToHex({ ...hsv, v: Math.max(hsv.v, 0.3) }) }, onChange: (event) => pick({ ...hsv, v: Number(event.target.value) / 100 }) })] })] }));
82
+ }
@@ -6,9 +6,11 @@
6
6
  * (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
7
7
  * comment). Purely informational and entirely local: nothing here is sent anywhere.
8
8
  *
9
- * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
10
- * absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
11
- * "Not available" rather than a blank space, so it never looks like the panel forgot to load.
9
+ * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up, or the pair moves
10
+ * to the server relay), shown as "Not available yet" rather than a blank space, so it never looks like the panel
11
+ * forgot to load. For a `"websocket"`-relayed participant the round trip is this browser's own leg to the relay
12
+ * server (the relay has no direct path to the peer), and the relay's own counters follow (`RelayRows`): how much of this browser's microphone audio the capture path
13
+ * delivered and how much of it was digital silence, and how this peer's audio is playing back here.
12
14
  */
13
15
  import React from "react";
14
16
  import type { MeshParticipant } from "../shared/webrtc/types.js";
@@ -24,9 +24,25 @@ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }) {
24
24
  return (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: kind }), _jsxs("span", { children: [packetsLost !== undefined && `${packetsLost} lost`, jitter !== undefined && ` · ${formatMs(jitter)} jitter`, (bytesSent !== undefined || bytesReceived !== undefined) &&
25
25
  ` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`] })] }));
26
26
  }
27
+ /** `part` as a whole-number percentage of `whole`, or "—" when there is no whole yet. */
28
+ function percent(part, whole) {
29
+ return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
30
+ }
31
+ function plural(count, noun) {
32
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
33
+ }
34
+ /** The server relay's own counters (see `RelayDiagnostics`). The capture row is this browser's own microphone, the
35
+ * playback row this peer's audio as heard here - so a mostly silent capture points at the sender, silence arriving in
36
+ * playback with no capture problem points at the other end's capture, and gaps point at the network. */
37
+ function RelayRows({ relay }) {
38
+ const { send, receive } = relay;
39
+ const notSent = send.audio.framesDropped + send.video.framesDropped;
40
+ return (_jsxs(_Fragment, { children: [send.audioCaptureWallMs > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Your mic capture" }), _jsxs("span", { children: [percent(send.audioCapturedMs, send.audioCaptureWallMs), " of real time \u00B7 ", percent(send.audioSilentMs, send.audioCapturedMs), " silent", send.audioEncoderSkippedMs > 0 && ` · ${formatMs(send.audioEncoderSkippedMs / 1000)} skipped`] })] })), notSent > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Not sent" }), _jsxs("span", { children: [send.audio.framesDropped, " audio \u00B7 ", send.video.framesDropped, " video frames"] })] })), receive && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Playback" }), _jsx("span", { children: `${percent(receive.audio.silentMs, receive.audio.playedMs)} silent · ${plural(receive.audio.gaps, "gap")} ` +
41
+ `(${formatMs(receive.audio.gapMs / 1000)}) · ${formatMs(receive.audio.bufferedMs / 1000)} buffered` })] })), receive && receive.audio.droppedLate > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Dropped late" }), _jsx("span", { children: plural(receive.audio.droppedLate, "packet") })] }))] }));
42
+ }
27
43
  export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }) {
28
44
  return (_jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "This browser" }), browserCapabilities().map(({ label, supported }) => (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: label }), _jsx("span", { className: supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]", children: supported ? "Supported" : "Not supported" })] }, label))), _jsxs("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: [selfName, " ", _jsx("span", { className: "text-white/60", children: "(you)" })] }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Sending" }), _jsx("span", { children: [micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing" })] }), participants.length === 0 && _jsx("p", { className: "text-white/60 mt-3", children: "Nobody else is in the call yet." }), participants.map((p) => {
29
45
  const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
30
- return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), p.transport === "websocket" || !p.diagnostics ? (_jsxs("p", { className: `${LABEL} py-0.5`, children: ["Not available", p.transport !== "websocket" && " yet", "."] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video })] }))] }, p.uid));
46
+ return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), !p.diagnostics ? (_jsx("p", { className: `${LABEL} py-0.5`, children: "Not available yet." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, title: p.diagnostics.relay ? "From this browser to the relay server and back" : undefined, children: p.diagnostics.relay ? "Round-trip to server" : "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video }), p.diagnostics.relay && _jsx(RelayRows, { relay: p.diagnostics.relay })] }))] }, p.uid));
31
47
  })] }));
32
48
  }
@@ -10,7 +10,10 @@
10
10
  * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
11
11
  * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
12
12
  * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
13
- * reflection, and mirroring it would show it backwards to no one but themselves. */
13
+ * reflection, and mirroring it would show it backwards to no one but themselves.
14
+ *
15
+ * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
16
+ * hiding the local tile from grid view. */
14
17
  import React from "react";
15
18
  import type { MediaTransport } from "../shared/webrtc/types.js";
16
19
  /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
@@ -36,12 +39,17 @@ export interface ParticipantTileProps {
36
39
  status?: string;
37
40
  /** Highlights this tile as the current presenter/focus. */
38
41
  isFocused?: boolean;
39
- /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
42
+ /** Marks this tile as showing a screen share rather than a camera - a screen share is never mirrored
43
+ * (regardless of `mirrored`/`isLocal`), since mirroring someone else's shared content would be disorienting. */
40
44
  contain?: boolean;
41
45
  /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
42
46
  * mirrored. Default `true`; see this module's doc comment. */
43
47
  mirrored?: boolean;
48
+ /** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
49
+ * doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
50
+ * local tile while alone in the call, where hiding it would just blank the only thing on screen. */
51
+ onHideSelf?: () => void;
44
52
  onClick?: () => void;
45
53
  className?: string;
46
54
  }
47
- export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onClick, className, }: ParticipantTileProps): React.JSX.Element;
55
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onHideSelf, onClick, className, }: ParticipantTileProps): React.JSX.Element;
@@ -15,9 +15,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
16
16
  * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
17
17
  * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
18
- * reflection, and mirroring it would show it backwards to no one but themselves. */
19
- import { useEffect, useRef } from "react";
20
- import { MicOffIcon } from "./_icons.js";
18
+ * reflection, and mirroring it would show it backwards to no one but themselves.
19
+ *
20
+ * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
21
+ * hiding the local tile from grid view. */
22
+ import { useEffect, useRef, useState } from "react";
23
+ import { MicOffIcon, OverflowIcon } from "./_icons.js";
21
24
  /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
22
25
  * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
23
26
  * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
@@ -44,15 +47,43 @@ function StatusPill({ status, className }) {
44
47
  function initialOf(name) {
45
48
  return (Array.from(name.trim())[0] ?? "?").toUpperCase();
46
49
  }
47
- export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onClick, className, }) {
50
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onHideSelf, onClick, className, }) {
48
51
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
49
52
  const videoRef = useRef(null);
50
53
  const showVideo = !!stream && !cameraOff;
54
+ const [hideMenuOpen, setHideMenuOpen] = useState(false);
55
+ const hideMenuRef = useRef(null);
51
56
  // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
52
57
  useEffect(() => {
53
58
  if (videoRef.current) {
54
59
  videoRef.current.srcObject = stream ?? null;
55
60
  }
56
61
  }, [stream, showVideo]);
57
- return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("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", children: "\u270B" })), micMuted && (_jsx("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", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("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", children: [name, isLocal ? " (you)" : ""] })] }));
62
+ // The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
63
+ // this app.
64
+ useEffect(() => {
65
+ if (!hideMenuOpen) {
66
+ return;
67
+ }
68
+ const onPointerDown = (event) => {
69
+ if (!hideMenuRef.current?.contains(event.target)) {
70
+ setHideMenuOpen(false);
71
+ }
72
+ };
73
+ const onKeyDown = (event) => {
74
+ if (event.key === "Escape") {
75
+ setHideMenuOpen(false);
76
+ }
77
+ };
78
+ document.addEventListener("pointerdown", onPointerDown);
79
+ document.addEventListener("keydown", onKeyDown);
80
+ return () => {
81
+ document.removeEventListener("pointerdown", onPointerDown);
82
+ document.removeEventListener("keydown", onKeyDown);
83
+ };
84
+ }, [hideMenuOpen]);
85
+ return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full object-contain bg-black ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("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", children: "\u270B" })), (micMuted || onHideSelf) && (_jsxs("div", { className: "absolute top-2 right-2 flex items-center gap-1", children: [micMuted && (_jsx("span", { className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white", role: "img", "aria-label": "Muted", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), onHideSelf && (_jsxs("div", { className: "relative", ref: hideMenuRef, children: [_jsx("button", { type: "button", 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", "aria-label": "Local video options", "aria-haspopup": "menu", "aria-expanded": hideMenuOpen, onClick: () => setHideMenuOpen((prev) => !prev), children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(OverflowIcon, {}) }) }), hideMenuOpen && (_jsx("div", { role: "menu", "aria-label": "Local video options", className: "absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: _jsx("button", { type: "button", role: "menuitem", 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", onClick: () => {
86
+ setHideMenuOpen(false);
87
+ onHideSelf();
88
+ }, children: "Hide self" }) }))] }))] })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("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", children: [name, isLocal ? " (you)" : ""] })] }));
58
89
  }
@@ -34,7 +34,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
34
34
  */
35
35
  import { useEffect } from "react";
36
36
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
37
- import { MicOffIcon } from "./_icons.js";
37
+ import { BatonIcon, MicOffIcon, RemoveIcon } from "./_icons.js";
38
38
  /** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
39
39
  * anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
40
40
  * indistinguishable from the list just not having loaded yet). */
@@ -43,11 +43,11 @@ function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
43
43
  }
44
44
  function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
45
45
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
46
- return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", 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", "aria-label": `Give the talking stick to ${name}`, onClick: onGiveStick, children: "Give stick" })), canModerate && !micMuted && (_jsx("button", { type: "button", 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", "aria-label": `Mute ${name}`, onClick: onMute, children: "Mute" })), canModerate && (_jsx("button", { type: "button", 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", "aria-label": `Remove ${name}`, onClick: () => {
46
+ return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", 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", "aria-label": `Give the talking stick to ${name}`, title: `Give the talking stick to ${name}`, onClick: onGiveStick, children: _jsx(BatonIcon, {}) })), canModerate && !micMuted && (_jsx("button", { type: "button", 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", "aria-label": `Mute ${name}`, title: `Mute ${name}`, onClick: onMute, children: _jsx(MicOffIcon, {}) })), canModerate && (_jsx("button", { type: "button", 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", "aria-label": `Remove ${name}`, title: `Remove ${name}`, onClick: () => {
47
47
  if (window.confirm(`Remove ${name} from the call?`)) {
48
48
  onKick?.();
49
49
  }
50
- }, children: "Remove" }))] }));
50
+ }, children: _jsx(RemoveIcon, {}) }))] }));
51
51
  }
52
52
  export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
53
53
  useEffect(() => {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
3
+ * participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
4
+ * to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
5
+ *
6
+ * ## Tools
7
+ *
8
+ * - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
9
+ * - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
10
+ * - **Stamps** - places one of `STAMPS` where clicked.
11
+ * - **Eraser** - erases whatever it passes over, anyone's drawing.
12
+ *
13
+ * Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
14
+ * Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
15
+ *
16
+ * ## Everyone's pointers
17
+ *
18
+ * While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
19
+ * have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
20
+ * their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
21
+ * canvas rather than part of the drawing, so a pointer moving never redraws the board.
22
+ *
23
+ * ## Talking stick
24
+ *
25
+ * While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
26
+ * undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
27
+ * stick comes round. The pointer is still shared.
28
+ *
29
+ * ## The canvas
30
+ *
31
+ * The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
32
+ * stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
33
+ * the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
34
+ * changes (`revision`).
35
+ */
36
+ import React from "react";
37
+ import type { CollabSession, RemoteCursor } from "../shared/collab/CollabSession.js";
38
+ import { type PenType, type StencilShape } from "../shared/collab/protocol.js";
39
+ export interface WhiteboardProps {
40
+ session: CollabSession;
41
+ /** `session.board.revision` - a new value redraws the board. */
42
+ revision: number;
43
+ /** Everyone else's pointers on the board - `CollabSession.cursors`. */
44
+ cursors?: readonly RemoteCursor[];
45
+ /** Whether this participant may draw now - see this module's doc comment on talking stick. Defaults to `true`. */
46
+ canDraw?: boolean;
47
+ /** Who may draw while `canDraw` is off, for the note saying so; absent while nobody holds the stick. */
48
+ drawLockedTo?: string;
49
+ onClose: () => void;
50
+ }
51
+ export declare const PEN_LABELS: Record<PenType, string>;
52
+ export declare const SHAPE_LABELS: Record<StencilShape, string>;
53
+ /** The color swatches, in display order. */
54
+ export declare const SWATCHES: readonly ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"];
55
+ /** Black or white, whichever reads better on `color` (`#rrggbb`). */
56
+ export declare function labelTextColor(color: string): string;
57
+ /** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
58
+ * their name beside it. */
59
+ export declare function CursorMarker({ cursor, left, top }: {
60
+ cursor: Pick<RemoteCursor, "name" | "tool" | "color" | "stamp">;
61
+ left: number;
62
+ top: number;
63
+ }): React.JSX.Element;
64
+ export default function Whiteboard({ session, revision, cursors, canDraw, drawLockedTo, onClose }: WhiteboardProps): React.JSX.Element;