@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,312 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
8
+ * participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
9
+ * to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
10
+ *
11
+ * ## Tools
12
+ *
13
+ * - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
14
+ * - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
15
+ * - **Stamps** - places one of `STAMPS` where clicked.
16
+ * - **Eraser** - erases whatever it passes over, anyone's drawing.
17
+ *
18
+ * Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
19
+ * Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
20
+ *
21
+ * ## Everyone's pointers
22
+ *
23
+ * While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
24
+ * have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
25
+ * their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
26
+ * canvas rather than part of the drawing, so a pointer moving never redraws the board.
27
+ *
28
+ * ## Talking stick
29
+ *
30
+ * While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
31
+ * undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
32
+ * stick comes round. The pointer is still shared.
33
+ *
34
+ * ## The canvas
35
+ *
36
+ * The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
37
+ * stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
38
+ * the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
39
+ * changes (`revision`).
40
+ */
41
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
42
+ import { BOARD_HEIGHT, BOARD_WIDTH, PEN_TYPES, SIZES, STAMPS, STAMP_SIZES, STENCIL_SHAPES, } from "../shared/collab/protocol.js";
43
+ import { drawElement, hitTest, renderBoard } from "../shared/collab/whiteboardRender.js";
44
+ import ColorWheel from "./_ColorWheel.js";
45
+ import { ClearIcon, EraserIcon, UndoIcon } from "./_icons.js";
46
+ export const PEN_LABELS = {
47
+ ballpoint: "Ballpoint pen",
48
+ sharpie: "Sharpie",
49
+ marker: "Marker",
50
+ crayon: "Crayon",
51
+ pencil: "Coloring pencil",
52
+ };
53
+ export const SHAPE_LABELS = {
54
+ line: "Line",
55
+ arrow: "Arrow",
56
+ rect: "Rectangle",
57
+ ellipse: "Ellipse",
58
+ triangle: "Triangle",
59
+ star: "Star",
60
+ };
61
+ /** The color swatches, in display order. */
62
+ export const SWATCHES = ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"];
63
+ /** A freehand point closer than this (board units) to the previous one is skipped - it adds nothing visible. */
64
+ const MIN_POINT_DISTANCE = 2;
65
+ /** The eraser's reach around the pointer, in board units. */
66
+ const ERASER_RADIUS = 14;
67
+ /** A stencil drag shorter than this (board units) is a mis-click, not a shape. */
68
+ const MIN_SHAPE_SIZE = 4;
69
+ const TOOL_BUTTON = "w-9 h-9 shrink-0 flex items-center justify-center rounded-lg hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 aria-pressed:bg-[#a8c7fa] aria-pressed:text-[#062e6f]";
70
+ /** A short sample of each pen's line, for its toolbar button. */
71
+ function PenSample({ pen }) {
72
+ const look = {
73
+ ballpoint: { width: 1.5, opacity: 1, cap: "round" },
74
+ sharpie: { width: 3, opacity: 1, cap: "round" },
75
+ marker: { width: 6, opacity: 0.5, cap: "square" },
76
+ crayon: { width: 4, opacity: 0.8, dash: "2 1", cap: "round" },
77
+ pencil: { width: 1.5, opacity: 0.6, dash: "3 1", cap: "round" },
78
+ };
79
+ const { width, opacity, dash, cap } = look[pen];
80
+ return (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3 16c3-6 6-6 9 0s6 6 9 0", fill: "none", stroke: "currentColor", strokeWidth: width, strokeOpacity: opacity, strokeDasharray: dash, strokeLinecap: cap }) }));
81
+ }
82
+ /** Black or white, whichever reads better on `color` (`#rrggbb`). */
83
+ export function labelTextColor(color) {
84
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
85
+ return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#000000" : "#ffffff";
86
+ }
87
+ /** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
88
+ * their name beside it. */
89
+ export function CursorMarker({ cursor, left, top }) {
90
+ const { tool, color } = cursor;
91
+ let glyph;
92
+ if (tool === "stamp") {
93
+ glyph = _jsx("span", { className: "block -translate-x-1/2 -translate-y-1/2 text-2xl leading-none drop-shadow", children: cursor.stamp ?? "⭐" });
94
+ }
95
+ else if (tool === "eraser") {
96
+ glyph = (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6 -translate-x-1/2 -translate-y-1/2", "aria-hidden": "true", children: _jsx("circle", { cx: "12", cy: "12", r: "9", fill: "#ffffff", fillOpacity: "0.6", stroke: color, strokeWidth: "2" }) }));
97
+ }
98
+ else if (tool === "stencil") {
99
+ glyph = (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6 -translate-x-1/2 -translate-y-1/2", fill: "none", stroke: color, strokeWidth: "2", "aria-hidden": "true", children: [_jsx("line", { x1: "12", y1: "2", x2: "12", y2: "22" }), _jsx("line", { x1: "2", y1: "12", x2: "22", y2: "12" }), _jsx("rect", { x: "15", y: "15", width: "7", height: "7" })] }));
100
+ }
101
+ else {
102
+ // A pen held at an angle, tip at the top-left corner: its body width and tip say which pen it is.
103
+ const body = {
104
+ ballpoint: { width: 4, tip: "M0 0l5 2-3 3z" },
105
+ sharpie: { width: 6, tip: "M0 0l7 3-4 4z" },
106
+ marker: { width: 8, tip: "M0 0h6l2 2v6H2L0 6z" },
107
+ crayon: { width: 6, tip: "M0 0l8 4-4 4z" },
108
+ pencil: { width: 5, tip: "M0 0l6 2-4 4z" },
109
+ };
110
+ const { width, tip } = body[tool];
111
+ glyph = (_jsxs("svg", { viewBox: "0 0 28 28", className: "w-7 h-7", "aria-hidden": "true", children: [_jsx("g", { transform: "rotate(45 0 0)", children: _jsx("rect", { x: "6", y: -width / 2, width: "20", height: width, rx: "1", fill: color, stroke: "#000000", strokeOpacity: "0.5", strokeWidth: "1" }) }), _jsx("path", { d: tip, fill: color, stroke: "#000000", strokeOpacity: "0.5", strokeWidth: "0.8" })] }));
112
+ }
113
+ return (_jsxs("div", { className: "pointer-events-none absolute transition-[left,top] duration-75 ease-linear", style: { left, top }, "data-testid": "remote-cursor", children: [glyph, _jsx("span", { className: "absolute left-4 top-4 max-w-40 truncate whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium shadow", style: { backgroundColor: color, color: labelTextColor(color) }, children: cursor.name })] }));
114
+ }
115
+ /** A small outline of each stencil shape, for its button. */
116
+ function ShapeSample({ shape }) {
117
+ const paths = {
118
+ line: _jsx("line", { x1: "4", y1: "20", x2: "20", y2: "4" }),
119
+ arrow: _jsx("path", { d: "M4 20L20 4m0 0h-7m7 0v7" }),
120
+ rect: _jsx("rect", { x: "4", y: "6", width: "16", height: "12" }),
121
+ ellipse: _jsx("ellipse", { cx: "12", cy: "12", rx: "9", ry: "6.5" }),
122
+ triangle: _jsx("path", { d: "M12 4l9 16H3z" }),
123
+ star: _jsx("path", { d: "M12 3l2.6 5.9 6.4.6-4.8 4.3 1.4 6.3L12 16.8 6.4 20.1l1.4-6.3L3 9.5l6.4-.6z" }),
124
+ };
125
+ return (_jsx("svg", { viewBox: "0 0 24 24", className: "w-5 h-5", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: paths[shape] }));
126
+ }
127
+ export default function Whiteboard({ session, revision, cursors = [], canDraw = true, drawLockedTo, onClose }) {
128
+ const [tool, setTool] = useState({ kind: "pen", pen: "ballpoint" });
129
+ const [shape, setShape] = useState("rect");
130
+ const [stamp, setStamp] = useState(STAMPS[0]);
131
+ const [color, setColor] = useState(SWATCHES[0]);
132
+ const [sizeIndex, setSizeIndex] = useState(1);
133
+ const [stampSizeIndex, setStampSizeIndex] = useState(1);
134
+ const [popover, setPopover] = useState(null);
135
+ const [confirmClear, setConfirmClear] = useState(false);
136
+ const [box, setBox] = useState({ width: 0, height: 0 });
137
+ const stageRef = useRef(null);
138
+ const canvasRef = useRef(null);
139
+ const drawingRef = useRef(null);
140
+ const previewRef = useRef(null);
141
+ const frameRef = useRef(undefined);
142
+ const size = SIZES[sizeIndex];
143
+ const stampSize = STAMP_SIZES[stampSizeIndex];
144
+ // The largest 16:9 box that fits the stage.
145
+ useLayoutEffect(() => {
146
+ const stage = stageRef.current;
147
+ const fit = () => {
148
+ const { width, height } = stage.getBoundingClientRect();
149
+ const fitted = Math.min(width, (height * BOARD_WIDTH) / BOARD_HEIGHT);
150
+ setBox({ width: Math.floor(fitted), height: Math.floor((fitted * BOARD_HEIGHT) / BOARD_WIDTH) });
151
+ };
152
+ fit();
153
+ if (typeof ResizeObserver === "undefined") {
154
+ return;
155
+ }
156
+ const observer = new ResizeObserver(fit);
157
+ observer.observe(stage);
158
+ return () => observer.disconnect();
159
+ }, []);
160
+ const draw = useCallback(() => {
161
+ frameRef.current = undefined;
162
+ const canvas = canvasRef.current;
163
+ const ctx = canvas?.getContext("2d");
164
+ if (!canvas || !ctx) {
165
+ return;
166
+ }
167
+ const scale = canvas.width / BOARD_WIDTH;
168
+ renderBoard(ctx, session.board.elements, scale);
169
+ if (previewRef.current) {
170
+ ctx.save();
171
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
172
+ drawElement(ctx, previewRef.current);
173
+ ctx.restore();
174
+ }
175
+ }, [session]);
176
+ const scheduleDraw = useCallback(() => {
177
+ if (frameRef.current === undefined) {
178
+ frameRef.current = requestAnimationFrame(draw);
179
+ }
180
+ }, [draw]);
181
+ const ratio = typeof window !== "undefined" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
182
+ // A new size or pixel ratio resizes the canvas, which wipes it - so either redraws, as a change to the board does.
183
+ useEffect(() => {
184
+ scheduleDraw();
185
+ }, [revision, box, ratio, scheduleDraw]);
186
+ // This participant's pointer leaves everyone else's board with the board itself.
187
+ useEffect(() => () => session.hideCursor(), [session]);
188
+ useEffect(() => () => {
189
+ if (frameRef.current !== undefined) {
190
+ cancelAnimationFrame(frameRef.current);
191
+ }
192
+ }, []);
193
+ // Ctrl/Cmd+Z takes back this participant's own last drawing - unless they are typing somewhere (the chat).
194
+ useEffect(() => {
195
+ const onKeyDown = (event) => {
196
+ const target = event.target;
197
+ if (target?.closest?.("input, textarea, [contenteditable='true']")) {
198
+ return;
199
+ }
200
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z" && session.canDraw) {
201
+ event.preventDefault();
202
+ session.undo();
203
+ }
204
+ else if (event.key === "Escape") {
205
+ setPopover(null);
206
+ setConfirmClear(false);
207
+ }
208
+ };
209
+ document.addEventListener("keydown", onKeyDown);
210
+ return () => document.removeEventListener("keydown", onKeyDown);
211
+ }, [session]);
212
+ function toBoard(event) {
213
+ const rect = canvasRef.current.getBoundingClientRect();
214
+ const width = rect.width || BOARD_WIDTH;
215
+ const height = rect.height || BOARD_HEIGHT;
216
+ return { x: ((event.clientX - rect.left) / width) * BOARD_WIDTH, y: ((event.clientY - rect.top) / height) * BOARD_HEIGHT };
217
+ }
218
+ function erase(x, y) {
219
+ const hits = session.board.elements.filter((element) => hitTest(element, x, y, ERASER_RADIUS)).map((element) => element.id);
220
+ session.erase(hits);
221
+ }
222
+ function cursorTool() {
223
+ return tool.kind === "pen" ? tool.pen : tool.kind;
224
+ }
225
+ function shareCursor(x, y) {
226
+ session.moveCursor(x, y, cursorTool(), color, tool.kind === "stamp" ? stamp : undefined);
227
+ }
228
+ function handlePointerDown(event) {
229
+ if (event.button !== 0 || !canDraw) {
230
+ return;
231
+ }
232
+ setPopover(null);
233
+ event.currentTarget.setPointerCapture?.(event.pointerId);
234
+ const { x, y } = toBoard(event);
235
+ switch (tool.kind) {
236
+ case "pen":
237
+ drawingRef.current = { strokeId: session.beginStroke(tool.pen, color, size, x, y), lastX: x, lastY: y, startX: x, startY: y };
238
+ return;
239
+ case "stencil":
240
+ drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
241
+ return;
242
+ case "stamp":
243
+ session.addStamp(stamp, stampSize, x, y);
244
+ return;
245
+ case "eraser":
246
+ drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
247
+ erase(x, y);
248
+ return;
249
+ }
250
+ }
251
+ function handlePointerMove(event) {
252
+ const drawing = drawingRef.current;
253
+ if (!drawing) {
254
+ const { x, y } = toBoard(event);
255
+ shareCursor(x, y);
256
+ return;
257
+ }
258
+ const events = typeof event.nativeEvent.getCoalescedEvents === "function" ? event.nativeEvent.getCoalescedEvents() : [];
259
+ for (const sample of events.length > 0 ? events : [event]) {
260
+ const { x, y } = toBoard(sample);
261
+ if (tool.kind === "pen" && drawing.strokeId) {
262
+ if (Math.hypot(x - drawing.lastX, y - drawing.lastY) >= MIN_POINT_DISTANCE) {
263
+ session.extendStroke(drawing.strokeId, x, y);
264
+ drawing.lastX = x;
265
+ drawing.lastY = y;
266
+ }
267
+ }
268
+ else if (tool.kind === "eraser") {
269
+ erase(x, y);
270
+ }
271
+ else if (tool.kind === "stencil") {
272
+ previewRef.current = { kind: "shape", id: "preview", epoch: 0, shape, color, size, x1: drawing.startX, y1: drawing.startY, x2: x, y2: y };
273
+ drawing.lastX = x;
274
+ drawing.lastY = y;
275
+ scheduleDraw();
276
+ }
277
+ }
278
+ // Mid-stroke the stroke itself shows everyone where the pen is; anything else moves the pointer.
279
+ if (tool.kind !== "pen") {
280
+ const { x, y } = toBoard(event);
281
+ shareCursor(x, y);
282
+ }
283
+ }
284
+ function handlePointerUp() {
285
+ const drawing = drawingRef.current;
286
+ drawingRef.current = null;
287
+ if (!drawing) {
288
+ return;
289
+ }
290
+ if (tool.kind === "pen") {
291
+ session.endStroke();
292
+ }
293
+ else if (tool.kind === "stencil") {
294
+ previewRef.current = null;
295
+ if (Math.hypot(drawing.lastX - drawing.startX, drawing.lastY - drawing.startY) >= MIN_SHAPE_SIZE) {
296
+ session.addShape(shape, color, size, drawing.startX, drawing.startY, drawing.lastX, drawing.lastY);
297
+ }
298
+ scheduleDraw();
299
+ }
300
+ }
301
+ const cursor = tool.kind === "eraser" ? "cursor-cell" : tool.kind === "stamp" ? "cursor-copy" : "cursor-crosshair";
302
+ return (_jsxs("section", { "aria-label": "Whiteboard", className: "h-full flex flex-col gap-2", "data-testid": "whiteboard", children: [_jsxs("div", { role: "toolbar", "aria-label": "Whiteboard tools", className: "relative shrink-0 flex flex-wrap items-center gap-1 rounded-2xl bg-[#2b2d30] px-2 py-1.5 text-white", children: [PEN_TYPES.map((pen) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": PEN_LABELS[pen], title: PEN_LABELS[pen], "aria-pressed": tool.kind === "pen" && tool.pen === pen, onClick: () => setTool({ kind: "pen", pen }), children: _jsx(PenSample, { pen: pen }) }, pen))), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Stencil", title: "Stencil", "aria-pressed": tool.kind === "stencil", onClick: () => setTool({ kind: "stencil" }), children: _jsx(ShapeSample, { shape: shape }) }), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Stamp", title: "Stamp", "aria-pressed": tool.kind === "stamp", onClick: () => {
303
+ setTool({ kind: "stamp" });
304
+ setPopover((prev) => (prev === "stamps" ? null : "stamps"));
305
+ }, children: _jsx("span", { className: "text-lg leading-none", children: stamp }) }), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Eraser", title: "Eraser", "aria-pressed": tool.kind === "eraser", onClick: () => setTool({ kind: "eraser" }), children: _jsx(EraserIcon, {}) }), _jsx("span", { className: "mx-1 h-6 w-px bg-white/15", "aria-hidden": "true" }), tool.kind === "stencil" && (_jsx("div", { role: "group", "aria-label": "Stencil shape", className: "flex items-center gap-0.5", children: STENCIL_SHAPES.map((candidate) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": SHAPE_LABELS[candidate], title: SHAPE_LABELS[candidate], "aria-pressed": shape === candidate, onClick: () => setShape(candidate), children: _jsx(ShapeSample, { shape: candidate }) }, candidate))) })), (tool.kind === "pen" || tool.kind === "stencil") && (_jsx("div", { role: "group", "aria-label": "Size", className: "flex items-center gap-0.5", children: SIZES.map((candidate, index) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": `Size ${index + 1}`, "aria-pressed": sizeIndex === index, onClick: () => setSizeIndex(index), children: _jsx("span", { className: "rounded-full bg-current", style: { width: 3 + index * 3, height: 3 + index * 3 } }) }, candidate))) })), tool.kind === "stamp" && (_jsx("div", { role: "group", "aria-label": "Stamp size", className: "flex items-center gap-0.5", children: STAMP_SIZES.map((candidate, index) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": `Stamp size ${index + 1}`, "aria-pressed": stampSizeIndex === index, onClick: () => setStampSizeIndex(index), children: _jsx("span", { style: { fontSize: 9 + index * 4 }, children: stamp }) }, candidate))) })), tool.kind !== "eraser" && tool.kind !== "stamp" && (_jsxs(_Fragment, { children: [_jsx("span", { className: "mx-1 h-6 w-px bg-white/15", "aria-hidden": "true" }), _jsxs("div", { role: "radiogroup", "aria-label": "Color", className: "flex flex-wrap items-center gap-1", children: [SWATCHES.map((swatch) => (_jsx("button", { type: "button", role: "radio", "aria-checked": color === swatch, "aria-label": `Color ${swatch}`, className: `w-6 h-6 rounded-full border border-white/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${color === swatch ? "ring-2 ring-[#a8c7fa] ring-offset-2 ring-offset-[#2b2d30]" : ""}`, style: { backgroundColor: swatch }, onClick: () => setColor(swatch) }, swatch))), _jsx("button", { type: "button", "aria-label": "Custom color", "aria-expanded": popover === "wheel", title: "Custom color", className: `w-6 h-6 rounded-full p-[3px] bg-[conic-gradient(red,yellow,lime,cyan,blue,magenta,red)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${SWATCHES.includes(color) ? "" : "ring-2 ring-[#a8c7fa] ring-offset-2 ring-offset-[#2b2d30]"}`, onClick: () => setPopover((prev) => (prev === "wheel" ? null : "wheel")), children: _jsx("span", { className: "block w-full h-full rounded-full border border-black/30", style: { backgroundColor: color } }) })] })] })), _jsx("span", { className: "flex-1" }), _jsx("button", { type: "button", className: `${TOOL_BUTTON} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": "Undo", title: "Undo (Ctrl+Z)", disabled: !canDraw, onClick: () => session.undo(), children: _jsx(UndoIcon, {}) }), confirmClear ? (_jsxs("span", { className: "flex items-center gap-1 text-sm", role: "group", "aria-label": "Clear the whiteboard?", children: [_jsx("span", { children: "Clear for everyone?" }), _jsx("button", { type: "button", className: "px-2 py-1 rounded-lg bg-[#d93025] hover:bg-[#b3261e] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
306
+ session.clear();
307
+ setConfirmClear(false);
308
+ }, children: "Clear" }), _jsx("button", { type: "button", className: "px-2 py-1 rounded-lg hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => setConfirmClear(false), children: "Cancel" })] })) : (_jsx("button", { type: "button", className: `${TOOL_BUTTON} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": "Clear whiteboard", title: "Clear whiteboard", disabled: !canDraw, onClick: () => setConfirmClear(true), children: _jsx(ClearIcon, {}) })), _jsx("button", { type: "button", className: "ml-1 px-3 h-9 rounded-lg bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: onClose, children: "Back to video" }), popover === "wheel" && (_jsx("div", { role: "dialog", "aria-label": "Custom color", className: "absolute z-20 top-full mt-2 right-2 p-3 rounded-2xl bg-[#2b2d30] shadow-xl", children: _jsx(ColorWheel, { color: color, onChange: setColor }) })), popover === "stamps" && (_jsx("div", { role: "dialog", "aria-label": "Stamps", className: "absolute z-20 top-full mt-2 left-2 grid grid-cols-8 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: STAMPS.map((candidate) => (_jsx("button", { type: "button", "aria-label": `Stamp ${candidate}`, "aria-pressed": stamp === candidate, className: "w-10 h-10 rounded-lg text-2xl hover:bg-white/10 aria-pressed:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
309
+ setStamp(candidate);
310
+ setPopover(null);
311
+ }, children: candidate }, candidate))) }))] }), _jsxs("div", { ref: stageRef, className: "relative flex-1 min-h-0 flex items-center justify-center", children: [_jsx("canvas", { ref: canvasRef, width: Math.max(1, Math.round(box.width * ratio)), height: Math.max(1, Math.round(box.height * ratio)), style: { width: box.width, height: box.height }, className: `rounded-lg shadow-lg touch-none bg-white ${canDraw ? cursor : "cursor-not-allowed"}`, "aria-label": "Whiteboard canvas", role: "img", "data-testid": "whiteboard-canvas", onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: () => session.hideCursor() }), _jsx("div", { className: "pointer-events-none absolute overflow-hidden", style: { width: box.width, height: box.height }, "aria-hidden": "true", children: cursors.map((remote) => (_jsx(CursorMarker, { cursor: remote, left: (remote.x / BOARD_WIDTH) * box.width, top: (remote.y / BOARD_HEIGHT) * box.height }, remote.peer))) }), !canDraw && (_jsxs("p", { role: "status", className: "absolute top-2 left-1/2 -translate-x-1/2 rounded-full bg-[#a8c7fa] px-3 py-1 text-sm font-medium text-[#062e6f] shadow", children: ["\uD83C\uDF99\uFE0F ", drawLockedTo ? `Only ${drawLockedTo} can draw while the talking stick is on` : "Nobody can draw until the host hands out the talking stick"] }))] })] }));
312
+ }
@@ -21,3 +21,19 @@ export declare const FlipIcon: () => React.JSX.Element;
21
21
  * needs to light up (`currentColor`) the same way every other control icon here does. */
22
22
  export declare const BatonIcon: () => React.JSX.Element;
23
23
  export declare const OverflowIcon: () => React.JSX.Element;
24
+ /** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
25
+ * strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
26
+ * `BatonIcon`. */
27
+ export declare const RemoveIcon: () => React.JSX.Element;
28
+ /** A speech bubble - the in-call chat. Drawn from primitives, same reasoning as `BatonIcon`. */
29
+ export declare const ChatIcon: () => React.JSX.Element;
30
+ /** An easel board with a squiggle on it - the shared whiteboard. */
31
+ export declare const WhiteboardIcon: () => React.JSX.Element;
32
+ /** The letters "GIF" in a rounded box - the chat's GIF picker. */
33
+ export declare const GifIcon: () => React.JSX.Element;
34
+ export declare const SendIcon: () => React.JSX.Element;
35
+ export declare const UndoIcon: () => React.JSX.Element;
36
+ /** A slanted eraser block. */
37
+ export declare const EraserIcon: () => React.JSX.Element;
38
+ /** A trash can - clearing the whole whiteboard. */
39
+ export declare const ClearIcon: () => React.JSX.Element;
@@ -22,3 +22,19 @@ export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2
22
22
  * needs to light up (`currentColor`) the same way every other control icon here does. */
23
23
  export const BatonIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsxs("g", { transform: "rotate(45 12 12)", children: [_jsx("rect", { x: "5", y: "10.5", width: "13", height: "3", rx: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "3" })] }) }));
24
24
  export const OverflowIcon = () => _jsx(Icon, { path: "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" });
25
+ /** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
26
+ * strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
27
+ * `BatonIcon`. */
28
+ export const RemoveIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }), _jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })] }));
29
+ /** A speech bubble - the in-call chat. Drawn from primitives, same reasoning as `BatonIcon`. */
30
+ export const ChatIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M4 5h16v11H9l-5 4V5z" }), _jsx("line", { x1: "8", y1: "9.5", x2: "16", y2: "9.5", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12.5", x2: "13", y2: "12.5", strokeLinecap: "round" })] }));
31
+ /** An easel board with a squiggle on it - the shared whiteboard. */
32
+ export const WhiteboardIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "3", y: "4", width: "18", height: "12", rx: "1" }), _jsx("path", { d: "M7 12c1.5-3 3-3 4 0s2.5 3 4-1" }), _jsx("line", { x1: "12", y1: "16", x2: "9", y2: "21" }), _jsx("line", { x1: "12", y1: "16", x2: "15", y2: "21" })] }));
33
+ /** The letters "GIF" in a rounded box - the chat's GIF picker. */
34
+ export const GifIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "2.5", y: "5.5", width: "19", height: "13", rx: "2.5", fill: "none", stroke: "currentColor", strokeWidth: "1.8" }), _jsx("text", { x: "12", y: "15.2", textAnchor: "middle", fontSize: "7.5", fontWeight: "700", fontFamily: "system-ui, sans-serif", fill: "currentColor", children: "GIF" })] }));
35
+ export const SendIcon = () => _jsx(Icon, { path: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z", className: "w-5 h-5" });
36
+ export const UndoIcon = () => _jsx(Icon, { path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z", className: "w-5 h-5" });
37
+ /** A slanted eraser block. */
38
+ export const EraserIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-5 h-5", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M14 4l6 6-9 9H6l-3-3z" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15" }), _jsx("line", { x1: "11", y1: "19", x2: "21", y2: "19" })] }));
39
+ /** A trash can - clearing the whole whiteboard. */
40
+ export const ClearIcon = () => _jsx(Icon, { path: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", className: "w-5 h-5" });
@@ -32,10 +32,10 @@ export interface PublicVideoMeeting {
32
32
  * Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
33
33
  * already held a valid session for a real RapidMX identity when it called `join()` - `token`/`expiresAt` are then
34
34
  * omitted entirely (there is no guest token to hand over), and the caller (`[token].tsx`) must not write any
35
- * cookie of its own before connecting to `/push`: the browser's own already-existing `jwt` session cookie already
36
- * authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common anonymous
37
- * case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `GuestSignalingClient` applies `token` as a
38
- * cookie before connecting.
35
+ * cookie of its own before opening the signaling socket: the browser's own already-existing `jwt` session cookie
36
+ * already authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common
37
+ * anonymous case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `SignalingClient` applies `token` as
38
+ * a cookie before connecting.
39
39
  */
40
40
  export interface VideoMeetingJoinResult {
41
41
  meeting: PublicVideoMeeting;
@@ -44,7 +44,8 @@ export interface VideoMeetingJoinResult {
44
44
  /** The identity to identify as on the signaling channel/mesh - the caller's own real uid when `authenticated`
45
45
  * is `true`, otherwise a freshly minted synthetic `guest:<random>` uid. Always present. */
46
46
  selfUid: string;
47
- /** A short-lived guest JWT, usable against `/push`. Present only when `authenticated` is `false`. */
47
+ /** A short-lived guest JWT, usable against the meeting's signaling and relay sockets. Present only when
48
+ * `authenticated` is `false`. */
48
49
  token?: string;
49
50
  /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
50
51
  expiresAt?: string;
@@ -133,8 +134,8 @@ export declare function denyParticipant(meetingUid: string, uid: string): Promis
133
134
  export declare function setWaitingRoomEnabled(meetingUid: string, waitingRoomEnabled: boolean): Promise<void>;
134
135
  /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
135
136
  * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
136
- * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
137
- * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
137
+ * client claims. Revokes `uid`'s grant on the meeting and closes every signaling socket they hold in it, so they can
138
+ * no longer signal or relay media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
138
139
  * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
139
140
  * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
140
141
  export declare function kickParticipant(meetingUid: string, uid: string): Promise<void>;
@@ -86,8 +86,8 @@ export function setWaitingRoomEnabled(meetingUid, waitingRoomEnabled) {
86
86
  }
87
87
  /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
88
88
  * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
89
- * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
90
- * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
89
+ * client claims. Revokes `uid`'s grant on the meeting and closes every signaling socket they hold in it, so they can
90
+ * no longer signal or relay media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
91
91
  * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
92
92
  * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
93
93
  export function kickParticipant(meetingUid, uid) {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The call's text chat and shared whiteboard, over the same signaling channel the mesh uses (`../webrtc/types.ts`'s
3
+ * `chat`, `whiteboard`, `collab-sync-request` and `collab-sync` messages). Like everything else in a call they live
4
+ * only as long as the call does - there is no server-side copy; each participant's tab holds them.
5
+ *
6
+ * ## Chat
7
+ *
8
+ * `sendChat()` broadcasts one `chat` message and shows it locally at once (the server never echoes a sender's own
9
+ * messages). A received one is shown under the name its sender announced in their `hello`. At most
10
+ * `MAX_CHAT_HISTORY` messages are kept.
11
+ *
12
+ * ## Whiteboard
13
+ *
14
+ * Every drawing action becomes a `WhiteboardOp`, applied to this tab's own `WhiteboardModel` at once and broadcast. A
15
+ * stroke being drawn sends its points in batches every `POINT_FLUSH_MS` rather than one message per pointer move, so
16
+ * the others watch it being drawn without a message per pixel. `undo()` erases this participant's own most recent
17
+ * element still on the board.
18
+ *
19
+ * ## Pointers
20
+ *
21
+ * While a participant's pointer is over the board, `moveCursor()` tells everyone where it is and as which tool and
22
+ * color, at most every `CURSOR_INTERVAL_MS`; `hideCursor()` says it left. While they are drawing a stroke, no pointer
23
+ * messages are sent at all - everyone else moves their pointer along the stroke's own points instead. Pointers are
24
+ * never stored or caught up on; one not heard from for `CURSOR_IDLE_MS` is dropped, as is a participant's on `bye`.
25
+ *
26
+ * ## Who may draw
27
+ *
28
+ * `setDrawPolicy()` limits who may change the board (talking-stick mode: only whoever holds the stick). This
29
+ * participant's own drawing calls then do nothing unless it is allowed, and a received change from a participant who
30
+ * isn't is ignored - as cooperative as every other moderation signal in the call (see `../webrtc/types.ts`): each
31
+ * participant's own tab decides. A change in flight when the stick moves may land on some boards and not others.
32
+ * Pointers still show for everyone - pointing at something isn't drawing it.
33
+ *
34
+ * ## Catching up
35
+ *
36
+ * Someone joining a call already under way asks one participant for what they missed: on the first `hello` it hears
37
+ * within `SYNC_WINDOW_MS` of starting (an existing participant answering its own `hello`), it sends that participant a
38
+ * `collab-sync-request`; the answer is the chat history and the board as ops, split into `collab-sync` parts that
39
+ * each fit the server's message limit and paced `SYNC_PACE_MS` apart to stay inside its rate limit. When nothing has
40
+ * arrived from that participant for `SYNC_TIMEOUT_MS` (first part or next), the next one is asked - as it is when the
41
+ * whole answer was empty, which is what another newcomer who arrived moments earlier would send. Only the participant
42
+ * asked is listened to, and a participant answers the same requester at most once per `SYNC_ANSWER_COOLDOWN_MS`. A catch-up is that one participant's account of the call, which this tab cannot check - the chat history it
43
+ * carries names who said what, and the server stamps only the catch-up's own sender - so it is merged under the same
44
+ * validation as live messages, and live messages win wherever the two overlap.
45
+ */
46
+ import { type CursorTool, type PenType, type Stamp, type StencilShape } from "./protocol.js";
47
+ import { WhiteboardModel } from "./WhiteboardModel.js";
48
+ import type { SignalingChannel } from "../webrtc/types.js";
49
+ /** How often the points of a stroke being drawn are sent. */
50
+ export declare const POINT_FLUSH_MS = 60;
51
+ /** How long after starting a `hello` still counts as an existing participant answering ours - see this module's doc. */
52
+ export declare const SYNC_WINDOW_MS = 15000;
53
+ /** How long to wait for a catch-up to start arriving before asking someone else. */
54
+ export declare const SYNC_TIMEOUT_MS = 5000;
55
+ /** The gap between two parts of a catch-up this participant sends. */
56
+ export declare const SYNC_PACE_MS = 250;
57
+ /** Room left in each catch-up part for the message's own envelope. */
58
+ export declare const SYNC_ENVELOPE_BYTES = 1024;
59
+ /** The most stroke points one catch-up op carries, so one op always fits the server's smallest message limit. */
60
+ export declare const SYNC_POINTS_PER_OP = 600;
61
+ /** The least time between two pointer messages this participant sends. */
62
+ export declare const CURSOR_INTERVAL_MS = 80;
63
+ /** How long a participant's pointer stays on the board without news of it. */
64
+ export declare const CURSOR_IDLE_MS = 30000;
65
+ /** How long a participant waits before answering the same requester's catch-up request again. */
66
+ export declare const SYNC_ANSWER_COOLDOWN_MS = 10000;
67
+ /** One chat message as shown. */
68
+ export interface ChatEntry {
69
+ /** Unique within the call: the sender's peer id and their own message id. */
70
+ key: string;
71
+ id: string;
72
+ peer: string;
73
+ name: string;
74
+ text: string;
75
+ sentAt: number;
76
+ self: boolean;
77
+ }
78
+ /** Another participant's pointer on the board. */
79
+ export interface RemoteCursor {
80
+ peer: string;
81
+ name: string;
82
+ x: number;
83
+ y: number;
84
+ tool: CursorTool;
85
+ color: string;
86
+ stamp?: Stamp;
87
+ /** When it last moved, by this tab's clock. */
88
+ at: number;
89
+ }
90
+ /** A `chat` event with `history` set is a message from a catch-up - said before this participant arrived; one with
91
+ * `renamed` set is a message already shown, now under its sender's name. Neither is news. */
92
+ export type CollabEvent = {
93
+ type: "chat";
94
+ entry: ChatEntry;
95
+ history?: boolean;
96
+ renamed?: boolean;
97
+ } | {
98
+ type: "board";
99
+ } | {
100
+ type: "cursors";
101
+ };
102
+ /** What `CollabSession` needs from the signaling channel - `SignalingClient` is one. */
103
+ export interface CollabChannel extends SignalingChannel {
104
+ /** The server's per-message limit, in bytes - `SignalingClient.maxMessageBytes`. */
105
+ readonly maxMessageBytes?: number;
106
+ }
107
+ export interface CollabSessionOptions {
108
+ channel: CollabChannel;
109
+ /** The authenticated uid every message is sent `from`. */
110
+ selfUid: string;
111
+ /** This tab's peer id - the author of its chat messages and the prefix of its board elements' ids. */
112
+ peerId: string;
113
+ selfName: string;
114
+ now?: () => number;
115
+ }
116
+ export declare class CollabSession {
117
+ private readonly options;
118
+ readonly board: WhiteboardModel;
119
+ private chatEntries;
120
+ /** How many of `chatEntries`, from the front, came from a catch-up - the next part's history goes after them. */
121
+ private historyCount;
122
+ private readonly chatKeys;
123
+ private readonly names;
124
+ private readonly listeners;
125
+ private readonly ownIds;
126
+ private readonly outgoingPoints;
127
+ private readonly timers;
128
+ private flushTimer;
129
+ private syncTimer;
130
+ private syncTarget;
131
+ private readonly syncTried;
132
+ private readonly knownPeers;
133
+ private unsubscribe;
134
+ private startedAt;
135
+ private seq;
136
+ private stopped;
137
+ private readonly remoteCursors;
138
+ private cursorPending;
139
+ private cursorTimer;
140
+ private cursorShown;
141
+ private lastCursorSent;
142
+ private pruneTimer;
143
+ /** The stroke this participant is drawing, between `beginStroke()` and `endStroke()`. */
144
+ private activeStroke;
145
+ private drawPolicy;
146
+ private readonly syncAnswered;
147
+ private syncReceivedAnything;
148
+ constructor(options: CollabSessionOptions);
149
+ /** Every chat message, oldest first. */
150
+ get chat(): readonly ChatEntry[];
151
+ /** Everyone else's pointers currently on the board. */
152
+ get cursors(): RemoteCursor[];
153
+ /** Whether this participant may change the board now - see this module's doc comment on who may draw. */
154
+ get canDraw(): boolean;
155
+ /** Limits who may change the board to the peers `policy` accepts; `undefined` lets everyone. */
156
+ setDrawPolicy(policy: ((peer: string) => boolean) | undefined): void;
157
+ onEvent(listener: (event: CollabEvent) => void): () => void;
158
+ start(): void;
159
+ stop(): void;
160
+ /** Sends `text` as a chat message. Returns `false`, sending nothing, for blank or overlong text. */
161
+ sendChat(text: string): boolean;
162
+ /** Starts a stroke at (`x`, `y`); returns its id, or `undefined` when the board is full. */
163
+ beginStroke(pen: PenType, color: string, size: number, x: number, y: number): string | undefined;
164
+ /** Adds a point to this participant's own stroke `id` - sent with the next batch. */
165
+ extendStroke(id: string, x: number, y: number): void;
166
+ /** Finishes a stroke: whatever points are still waiting go out now. */
167
+ endStroke(): void;
168
+ addShape(shape: StencilShape, color: string, size: number, x1: number, y1: number, x2: number, y2: number): string | undefined;
169
+ addStamp(stamp: Stamp, size: number, x: number, y: number): string | undefined;
170
+ /** Erases `ids` - anyone's elements, see `protocol.ts`. */
171
+ erase(ids: string[]): void;
172
+ /** Erases this participant's own most recent element still on the board; `false` when there is none. */
173
+ undo(): boolean;
174
+ /** Clears the board for everyone. */
175
+ clear(): void;
176
+ /** Says this participant's pointer is at (`x`, `y`) on the board, as `tool` in `color` (with `stamp` for the stamp
177
+ * tool) - sent now, or with the latest position once `CURSOR_INTERVAL_MS` has passed since the last one. */
178
+ moveCursor(x: number, y: number, tool: CursorTool, color: string, stamp?: Stamp): void;
179
+ /** Says this participant's pointer has left the board. */
180
+ hideCursor(): void;
181
+ private flushCursor;
182
+ private handleCursor;
183
+ /** Moves `sender`'s pointer to where their stroke just got to - see this module's doc comment on pointers. */
184
+ private followStroke;
185
+ private pruneCursors;
186
+ private mayDraw;
187
+ private addElement;
188
+ private applyAndSend;
189
+ private flushPoints;
190
+ private handleMessage;
191
+ private handleHello;
192
+ private requestSync;
193
+ /** Asks the next participant not yet asked, if any - after a silence or an empty answer. */
194
+ private askNext;
195
+ private armSyncTimeout;
196
+ private receiveSync;
197
+ /** Sends `peer` everything said and drawn so far - see this module's doc comment. */
198
+ private sendSync;
199
+ private mergeHistory;
200
+ private addChat;
201
+ /** Keeps the latest `MAX_CHAT_HISTORY` of `entries`. */
202
+ private setChat;
203
+ private send;
204
+ private emit;
205
+ private nextId;
206
+ private nextElementId;
207
+ private now;
208
+ }