@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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The call's text chat and shared whiteboard, over the same signaling channel the mesh uses (`../webrtc/types.ts`'s
|
|
7
|
+
* `chat`, `whiteboard`, `collab-sync-request` and `collab-sync` messages). Like everything else in a call they live
|
|
8
|
+
* only as long as the call does - there is no server-side copy; each participant's tab holds them.
|
|
9
|
+
*
|
|
10
|
+
* ## Chat
|
|
11
|
+
*
|
|
12
|
+
* `sendChat()` broadcasts one `chat` message and shows it locally at once (the server never echoes a sender's own
|
|
13
|
+
* messages). A received one is shown under the name its sender announced in their `hello`. At most
|
|
14
|
+
* `MAX_CHAT_HISTORY` messages are kept.
|
|
15
|
+
*
|
|
16
|
+
* ## Whiteboard
|
|
17
|
+
*
|
|
18
|
+
* Every drawing action becomes a `WhiteboardOp`, applied to this tab's own `WhiteboardModel` at once and broadcast. A
|
|
19
|
+
* stroke being drawn sends its points in batches every `POINT_FLUSH_MS` rather than one message per pointer move, so
|
|
20
|
+
* the others watch it being drawn without a message per pixel. `undo()` erases this participant's own most recent
|
|
21
|
+
* element still on the board.
|
|
22
|
+
*
|
|
23
|
+
* ## Pointers
|
|
24
|
+
*
|
|
25
|
+
* While a participant's pointer is over the board, `moveCursor()` tells everyone where it is and as which tool and
|
|
26
|
+
* color, at most every `CURSOR_INTERVAL_MS`; `hideCursor()` says it left. While they are drawing a stroke, no pointer
|
|
27
|
+
* messages are sent at all - everyone else moves their pointer along the stroke's own points instead. Pointers are
|
|
28
|
+
* never stored or caught up on; one not heard from for `CURSOR_IDLE_MS` is dropped, as is a participant's on `bye`.
|
|
29
|
+
*
|
|
30
|
+
* ## Who may draw
|
|
31
|
+
*
|
|
32
|
+
* `setDrawPolicy()` limits who may change the board (talking-stick mode: only whoever holds the stick). This
|
|
33
|
+
* participant's own drawing calls then do nothing unless it is allowed, and a received change from a participant who
|
|
34
|
+
* isn't is ignored - as cooperative as every other moderation signal in the call (see `../webrtc/types.ts`): each
|
|
35
|
+
* participant's own tab decides. A change in flight when the stick moves may land on some boards and not others.
|
|
36
|
+
* Pointers still show for everyone - pointing at something isn't drawing it.
|
|
37
|
+
*
|
|
38
|
+
* ## Catching up
|
|
39
|
+
*
|
|
40
|
+
* Someone joining a call already under way asks one participant for what they missed: on the first `hello` it hears
|
|
41
|
+
* within `SYNC_WINDOW_MS` of starting (an existing participant answering its own `hello`), it sends that participant a
|
|
42
|
+
* `collab-sync-request`; the answer is the chat history and the board as ops, split into `collab-sync` parts that
|
|
43
|
+
* each fit the server's message limit and paced `SYNC_PACE_MS` apart to stay inside its rate limit. When nothing has
|
|
44
|
+
* arrived from that participant for `SYNC_TIMEOUT_MS` (first part or next), the next one is asked - as it is when the
|
|
45
|
+
* whole answer was empty, which is what another newcomer who arrived moments earlier would send. Only the participant
|
|
46
|
+
* 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
|
|
47
|
+
* carries names who said what, and the server stamps only the catch-up's own sender - so it is merged under the same
|
|
48
|
+
* validation as live messages, and live messages win wherever the two overlap.
|
|
49
|
+
*/
|
|
50
|
+
import { BOARD_HEIGHT, BOARD_WIDTH, MAX_CHAT_HISTORY, MAX_CHAT_LENGTH, MAX_POINTS_PER_OP, MAX_STROKE_POINTS, clampCoordinate, parseChat, parseCursor, parseSync, parseWhiteboardOp, } from "./protocol.js";
|
|
51
|
+
import { WhiteboardModel } from "./WhiteboardModel.js";
|
|
52
|
+
/** How often the points of a stroke being drawn are sent. */
|
|
53
|
+
export const POINT_FLUSH_MS = 60;
|
|
54
|
+
/** How long after starting a `hello` still counts as an existing participant answering ours - see this module's doc. */
|
|
55
|
+
export const SYNC_WINDOW_MS = 15000;
|
|
56
|
+
/** How long to wait for a catch-up to start arriving before asking someone else. */
|
|
57
|
+
export const SYNC_TIMEOUT_MS = 5000;
|
|
58
|
+
/** The gap between two parts of a catch-up this participant sends. */
|
|
59
|
+
export const SYNC_PACE_MS = 250;
|
|
60
|
+
/** Room left in each catch-up part for the message's own envelope. */
|
|
61
|
+
export const SYNC_ENVELOPE_BYTES = 1024;
|
|
62
|
+
/** The most stroke points one catch-up op carries, so one op always fits the server's smallest message limit. */
|
|
63
|
+
export const SYNC_POINTS_PER_OP = 600;
|
|
64
|
+
/** The least time between two pointer messages this participant sends. */
|
|
65
|
+
export const CURSOR_INTERVAL_MS = 80;
|
|
66
|
+
/** How long a participant's pointer stays on the board without news of it. */
|
|
67
|
+
export const CURSOR_IDLE_MS = 30000;
|
|
68
|
+
/** How long a participant waits before answering the same requester's catch-up request again. */
|
|
69
|
+
export const SYNC_ANSWER_COOLDOWN_MS = 10000;
|
|
70
|
+
/** What a message is sized against when the channel doesn't say. */
|
|
71
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024;
|
|
72
|
+
export class CollabSession {
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
this.board = new WhiteboardModel();
|
|
76
|
+
this.chatEntries = [];
|
|
77
|
+
/** How many of `chatEntries`, from the front, came from a catch-up - the next part's history goes after them. */
|
|
78
|
+
this.historyCount = 0;
|
|
79
|
+
this.chatKeys = new Set();
|
|
80
|
+
this.names = new Map();
|
|
81
|
+
this.listeners = new Set();
|
|
82
|
+
this.ownIds = [];
|
|
83
|
+
this.outgoingPoints = new Map();
|
|
84
|
+
this.timers = new Set();
|
|
85
|
+
this.syncTried = new Set();
|
|
86
|
+
this.knownPeers = [];
|
|
87
|
+
this.startedAt = 0;
|
|
88
|
+
this.seq = 0;
|
|
89
|
+
this.stopped = false;
|
|
90
|
+
this.remoteCursors = new Map();
|
|
91
|
+
this.cursorShown = false;
|
|
92
|
+
this.lastCursorSent = -Infinity;
|
|
93
|
+
this.syncAnswered = new Map();
|
|
94
|
+
this.syncReceivedAnything = false;
|
|
95
|
+
}
|
|
96
|
+
/** Every chat message, oldest first. */
|
|
97
|
+
get chat() {
|
|
98
|
+
return this.chatEntries;
|
|
99
|
+
}
|
|
100
|
+
/** Everyone else's pointers currently on the board. */
|
|
101
|
+
get cursors() {
|
|
102
|
+
return [...this.remoteCursors.values()];
|
|
103
|
+
}
|
|
104
|
+
/** Whether this participant may change the board now - see this module's doc comment on who may draw. */
|
|
105
|
+
get canDraw() {
|
|
106
|
+
return this.mayDraw(this.options.peerId);
|
|
107
|
+
}
|
|
108
|
+
/** Limits who may change the board to the peers `policy` accepts; `undefined` lets everyone. */
|
|
109
|
+
setDrawPolicy(policy) {
|
|
110
|
+
this.drawPolicy = policy;
|
|
111
|
+
if (!this.canDraw) {
|
|
112
|
+
this.endStroke();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
onEvent(listener) {
|
|
116
|
+
this.listeners.add(listener);
|
|
117
|
+
return () => this.listeners.delete(listener);
|
|
118
|
+
}
|
|
119
|
+
start() {
|
|
120
|
+
if (this.unsubscribe || this.stopped) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.startedAt = this.now();
|
|
124
|
+
this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
|
|
125
|
+
this.pruneTimer = setInterval(() => this.pruneCursors(), CURSOR_IDLE_MS / 6);
|
|
126
|
+
}
|
|
127
|
+
stop() {
|
|
128
|
+
if (this.stopped) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Before `stopped` is set, which makes `send()` drop everything.
|
|
132
|
+
this.flushPoints();
|
|
133
|
+
this.stopped = true;
|
|
134
|
+
this.unsubscribe?.();
|
|
135
|
+
this.unsubscribe = undefined;
|
|
136
|
+
clearTimeout(this.flushTimer);
|
|
137
|
+
clearTimeout(this.syncTimer);
|
|
138
|
+
clearTimeout(this.cursorTimer);
|
|
139
|
+
clearInterval(this.pruneTimer);
|
|
140
|
+
for (const timer of this.timers) {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
this.timers.clear();
|
|
144
|
+
this.listeners.clear();
|
|
145
|
+
}
|
|
146
|
+
/** Sends `text` as a chat message. Returns `false`, sending nothing, for blank or overlong text. */
|
|
147
|
+
sendChat(text) {
|
|
148
|
+
if (!text.trim() || text.length > MAX_CHAT_LENGTH || this.stopped) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const payload = { id: this.nextId(), text, sentAt: this.now() };
|
|
152
|
+
this.send({ kind: "chat", chat: payload });
|
|
153
|
+
this.addChat({ ...payload, peer: this.options.peerId, name: this.options.selfName }, true);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/** Starts a stroke at (`x`, `y`); returns its id, or `undefined` when the board is full. */
|
|
157
|
+
beginStroke(pen, color, size, x, y) {
|
|
158
|
+
const id = this.addElement({ kind: "stroke", id: this.nextElementId(), epoch: this.board.epoch, pen, color, size, points: [clampX(x), clampY(y)] });
|
|
159
|
+
this.activeStroke = id;
|
|
160
|
+
return id;
|
|
161
|
+
}
|
|
162
|
+
/** Adds a point to this participant's own stroke `id` - sent with the next batch. */
|
|
163
|
+
extendStroke(id, x, y) {
|
|
164
|
+
const stroke = this.board.get(id);
|
|
165
|
+
if (!stroke || stroke.kind !== "stroke" || stroke.points.length >= MAX_STROKE_POINTS * 2 || !this.canDraw) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const point = [clampX(x), clampY(y)];
|
|
169
|
+
const start = stroke.points.length / 2;
|
|
170
|
+
if (!this.board.apply({ op: "points", id, start, points: point }, this.options.peerId)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const batch = this.outgoingPoints.get(id);
|
|
174
|
+
if (batch) {
|
|
175
|
+
batch.points.push(...point);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.outgoingPoints.set(id, { start, points: point });
|
|
179
|
+
}
|
|
180
|
+
// A batch never outgrows what one op may carry, even if the timer below runs late.
|
|
181
|
+
if ((batch?.points.length ?? 0) >= MAX_POINTS_PER_OP * 2) {
|
|
182
|
+
this.flushPoints();
|
|
183
|
+
}
|
|
184
|
+
this.flushTimer ?? (this.flushTimer = setTimeout(() => {
|
|
185
|
+
this.flushTimer = undefined;
|
|
186
|
+
this.flushPoints();
|
|
187
|
+
}, POINT_FLUSH_MS));
|
|
188
|
+
this.emit({ type: "board" });
|
|
189
|
+
}
|
|
190
|
+
/** Finishes a stroke: whatever points are still waiting go out now. */
|
|
191
|
+
endStroke() {
|
|
192
|
+
this.activeStroke = undefined;
|
|
193
|
+
clearTimeout(this.flushTimer);
|
|
194
|
+
this.flushTimer = undefined;
|
|
195
|
+
this.flushPoints();
|
|
196
|
+
}
|
|
197
|
+
addShape(shape, color, size, x1, y1, x2, y2) {
|
|
198
|
+
const [a, b, c, d] = [clampX(x1), clampY(y1), clampX(x2), clampY(y2)];
|
|
199
|
+
return this.addElement({ kind: "shape", id: this.nextElementId(), epoch: this.board.epoch, shape, color, size, x1: a, y1: b, x2: c, y2: d });
|
|
200
|
+
}
|
|
201
|
+
addStamp(stamp, size, x, y) {
|
|
202
|
+
return this.addElement({ kind: "stamp", id: this.nextElementId(), epoch: this.board.epoch, stamp, size, x: clampX(x), y: clampY(y) });
|
|
203
|
+
}
|
|
204
|
+
/** Erases `ids` - anyone's elements, see `protocol.ts`. */
|
|
205
|
+
erase(ids) {
|
|
206
|
+
const present = ids.filter((id) => this.board.get(id));
|
|
207
|
+
if (present.length > 0 && this.canDraw) {
|
|
208
|
+
this.applyAndSend({ op: "remove", ids: present });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Erases this participant's own most recent element still on the board; `false` when there is none. */
|
|
212
|
+
undo() {
|
|
213
|
+
while (this.ownIds.length > 0 && this.canDraw) {
|
|
214
|
+
const id = this.ownIds.pop();
|
|
215
|
+
if (this.board.get(id)) {
|
|
216
|
+
this.endStroke();
|
|
217
|
+
this.applyAndSend({ op: "remove", ids: [id] });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/** Clears the board for everyone. */
|
|
224
|
+
clear() {
|
|
225
|
+
if (!this.canDraw || this.stopped) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.endStroke();
|
|
229
|
+
this.applyAndSend({ op: "clear", epoch: this.board.epoch + 1 });
|
|
230
|
+
}
|
|
231
|
+
/** Says this participant's pointer is at (`x`, `y`) on the board, as `tool` in `color` (with `stamp` for the stamp
|
|
232
|
+
* tool) - sent now, or with the latest position once `CURSOR_INTERVAL_MS` has passed since the last one. */
|
|
233
|
+
moveCursor(x, y, tool, color, stamp) {
|
|
234
|
+
if (this.stopped) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.cursorPending = { x: clampX(x), y: clampY(y), tool, color, ...(stamp ? { stamp } : {}) };
|
|
238
|
+
if (this.cursorTimer === undefined) {
|
|
239
|
+
const wait = Math.max(0, this.lastCursorSent + CURSOR_INTERVAL_MS - this.now());
|
|
240
|
+
this.cursorTimer = setTimeout(() => {
|
|
241
|
+
this.cursorTimer = undefined;
|
|
242
|
+
this.flushCursor();
|
|
243
|
+
}, wait);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Says this participant's pointer has left the board. */
|
|
247
|
+
hideCursor() {
|
|
248
|
+
clearTimeout(this.cursorTimer);
|
|
249
|
+
this.cursorTimer = undefined;
|
|
250
|
+
this.cursorPending = undefined;
|
|
251
|
+
if (this.cursorShown) {
|
|
252
|
+
this.cursorShown = false;
|
|
253
|
+
this.send({ kind: "whiteboard-cursor", cursor: { hidden: true } });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
flushCursor() {
|
|
257
|
+
const cursor = this.cursorPending;
|
|
258
|
+
this.cursorPending = undefined;
|
|
259
|
+
// Mid-stroke, the stroke's own points show where the pointer is - see this module's doc comment.
|
|
260
|
+
if (!cursor || this.activeStroke !== undefined) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.lastCursorSent = this.now();
|
|
264
|
+
this.cursorShown = true;
|
|
265
|
+
this.send({ kind: "whiteboard-cursor", cursor });
|
|
266
|
+
}
|
|
267
|
+
handleCursor(sender, raw) {
|
|
268
|
+
const cursor = parseCursor(raw);
|
|
269
|
+
if (!cursor) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if ("hidden" in cursor) {
|
|
273
|
+
if (this.remoteCursors.delete(sender)) {
|
|
274
|
+
this.emit({ type: "cursors" });
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.remoteCursors.set(sender, { peer: sender, name: this.names.get(sender) ?? "Guest", ...cursor, at: this.now() });
|
|
279
|
+
this.emit({ type: "cursors" });
|
|
280
|
+
}
|
|
281
|
+
/** Moves `sender`'s pointer to where their stroke just got to - see this module's doc comment on pointers. */
|
|
282
|
+
followStroke(sender, op) {
|
|
283
|
+
const id = op.op === "add" ? op.element.id : op.op === "points" ? op.id : undefined;
|
|
284
|
+
const stroke = id === undefined ? undefined : this.board.get(id);
|
|
285
|
+
if (!stroke || stroke.kind !== "stroke") {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const previous = this.remoteCursors.get(sender);
|
|
289
|
+
this.remoteCursors.set(sender, {
|
|
290
|
+
peer: sender,
|
|
291
|
+
name: this.names.get(sender) ?? "Guest",
|
|
292
|
+
tool: stroke.pen,
|
|
293
|
+
color: stroke.color,
|
|
294
|
+
x: stroke.points[stroke.points.length - 2],
|
|
295
|
+
y: stroke.points[stroke.points.length - 1],
|
|
296
|
+
...(previous?.tool === stroke.pen && previous.stamp ? { stamp: previous.stamp } : {}),
|
|
297
|
+
at: this.now(),
|
|
298
|
+
});
|
|
299
|
+
this.emit({ type: "cursors" });
|
|
300
|
+
}
|
|
301
|
+
pruneCursors() {
|
|
302
|
+
const cutoff = this.now() - CURSOR_IDLE_MS;
|
|
303
|
+
let changed = false;
|
|
304
|
+
for (const [peer, cursor] of this.remoteCursors) {
|
|
305
|
+
if (cursor.at < cutoff) {
|
|
306
|
+
this.remoteCursors.delete(peer);
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (changed) {
|
|
311
|
+
this.emit({ type: "cursors" });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
mayDraw(peer) {
|
|
315
|
+
return !this.drawPolicy || this.drawPolicy(peer);
|
|
316
|
+
}
|
|
317
|
+
addElement(element) {
|
|
318
|
+
if (!this.board.hasRoom || this.stopped || !this.canDraw) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
this.endStroke();
|
|
322
|
+
this.applyAndSend({ op: "add", element: element.kind === "stroke" ? { ...element, points: [...element.points] } : element });
|
|
323
|
+
this.ownIds.push(element.id);
|
|
324
|
+
return element.id;
|
|
325
|
+
}
|
|
326
|
+
applyAndSend(op) {
|
|
327
|
+
if (this.board.apply(op, this.options.peerId)) {
|
|
328
|
+
this.emit({ type: "board" });
|
|
329
|
+
}
|
|
330
|
+
this.send({ kind: "whiteboard", whiteboard: op });
|
|
331
|
+
}
|
|
332
|
+
flushPoints() {
|
|
333
|
+
for (const [id, batch] of this.outgoingPoints) {
|
|
334
|
+
this.send({ kind: "whiteboard", whiteboard: { op: "points", id, start: batch.start, points: batch.points } });
|
|
335
|
+
}
|
|
336
|
+
this.outgoingPoints.clear();
|
|
337
|
+
}
|
|
338
|
+
handleMessage(message) {
|
|
339
|
+
if (message.type !== "video-meeting-signal") {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const sender = message.peer ?? message.from;
|
|
343
|
+
if (sender === this.options.peerId || (message.to !== undefined && message.to !== this.options.peerId)) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
switch (message.kind) {
|
|
347
|
+
case "hello":
|
|
348
|
+
this.handleHello(sender, message.name);
|
|
349
|
+
return;
|
|
350
|
+
case "bye":
|
|
351
|
+
if (this.remoteCursors.delete(sender)) {
|
|
352
|
+
this.emit({ type: "cursors" });
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
case "whiteboard-cursor":
|
|
356
|
+
this.handleCursor(sender, message.cursor);
|
|
357
|
+
return;
|
|
358
|
+
case "chat": {
|
|
359
|
+
const chat = parseChat(message.chat);
|
|
360
|
+
if (chat) {
|
|
361
|
+
this.addChat({ ...chat, peer: sender, name: this.names.get(sender) ?? "Guest" }, false);
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
case "whiteboard": {
|
|
366
|
+
const op = parseWhiteboardOp(message.whiteboard);
|
|
367
|
+
if (op && this.mayDraw(sender) && this.board.apply(op, sender)) {
|
|
368
|
+
this.emit({ type: "board" });
|
|
369
|
+
this.followStroke(sender, op);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
case "collab-sync-request":
|
|
374
|
+
this.sendSync(sender);
|
|
375
|
+
return;
|
|
376
|
+
case "collab-sync":
|
|
377
|
+
this.receiveSync(sender, message.sync);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
handleHello(sender, name) {
|
|
382
|
+
if (typeof name === "string" && name) {
|
|
383
|
+
const known = this.names.has(sender);
|
|
384
|
+
this.names.set(sender, name);
|
|
385
|
+
// Chat that beat this participant's `hello` was shown as "Guest" - give it their name now.
|
|
386
|
+
if (!known && this.chatEntries.some((entry) => entry.peer === sender && entry.name === "Guest")) {
|
|
387
|
+
this.chatEntries = this.chatEntries.map((entry) => (entry.peer === sender && entry.name === "Guest" ? { ...entry, name } : entry));
|
|
388
|
+
const own = this.chatEntries.filter((entry) => entry.peer === sender);
|
|
389
|
+
const renamed = own[own.length - 1];
|
|
390
|
+
this.emit({ type: "chat", entry: renamed, renamed: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!this.knownPeers.includes(sender)) {
|
|
394
|
+
this.knownPeers.push(sender);
|
|
395
|
+
}
|
|
396
|
+
if (this.syncTarget === undefined && this.syncTried.size === 0 && this.now() - this.startedAt < SYNC_WINDOW_MS) {
|
|
397
|
+
this.requestSync(sender);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
requestSync(peer) {
|
|
401
|
+
this.syncTarget = peer;
|
|
402
|
+
this.syncTried.add(peer);
|
|
403
|
+
this.syncReceivedAnything = false;
|
|
404
|
+
this.send({ kind: "collab-sync-request", to: peer });
|
|
405
|
+
this.armSyncTimeout();
|
|
406
|
+
}
|
|
407
|
+
/** Asks the next participant not yet asked, if any - after a silence or an empty answer. */
|
|
408
|
+
askNext() {
|
|
409
|
+
this.syncTarget = undefined;
|
|
410
|
+
const next = this.knownPeers.find((candidate) => !this.syncTried.has(candidate));
|
|
411
|
+
if (next) {
|
|
412
|
+
this.requestSync(next);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
armSyncTimeout() {
|
|
416
|
+
clearTimeout(this.syncTimer);
|
|
417
|
+
this.syncTimer = setTimeout(() => {
|
|
418
|
+
this.syncTimer = undefined;
|
|
419
|
+
this.askNext();
|
|
420
|
+
}, SYNC_TIMEOUT_MS);
|
|
421
|
+
}
|
|
422
|
+
receiveSync(sender, raw) {
|
|
423
|
+
if (sender !== this.syncTarget) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const sync = parseSync(raw);
|
|
427
|
+
if (!sync) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
this.syncReceivedAnything || (this.syncReceivedAnything = sync.chat.length > 0 || sync.ops.length > 0 || sync.epoch > 0);
|
|
431
|
+
let boardChanged = this.board.apply({ op: "clear", epoch: sync.epoch }, null);
|
|
432
|
+
for (const op of sync.ops) {
|
|
433
|
+
boardChanged = this.board.apply(op, null) || boardChanged;
|
|
434
|
+
}
|
|
435
|
+
if (boardChanged) {
|
|
436
|
+
this.emit({ type: "board" });
|
|
437
|
+
}
|
|
438
|
+
this.mergeHistory(sync.chat);
|
|
439
|
+
if (sync.part < sync.of - 1) {
|
|
440
|
+
this.armSyncTimeout();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
clearTimeout(this.syncTimer);
|
|
444
|
+
this.syncTimer = undefined;
|
|
445
|
+
if (this.syncReceivedAnything) {
|
|
446
|
+
this.syncTarget = undefined;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
// Nothing at all - likely another newcomer who had nothing yet either. Someone else may have more.
|
|
450
|
+
this.askNext();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Sends `peer` everything said and drawn so far - see this module's doc comment. */
|
|
454
|
+
sendSync(peer) {
|
|
455
|
+
const answeredAt = this.syncAnswered.get(peer);
|
|
456
|
+
if (answeredAt !== undefined && this.now() - answeredAt < SYNC_ANSWER_COOLDOWN_MS) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.syncAnswered.set(peer, this.now());
|
|
460
|
+
const budget = (this.options.channel.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES) - SYNC_ENVELOPE_BYTES;
|
|
461
|
+
const encoder = new TextEncoder();
|
|
462
|
+
const parts = [];
|
|
463
|
+
let current = { chat: [], ops: [] };
|
|
464
|
+
let used = 0;
|
|
465
|
+
const place = (item) => {
|
|
466
|
+
const size = encoder.encode(JSON.stringify(item.chat ?? item.op)).length + 1;
|
|
467
|
+
if (used + size > budget && used > 0) {
|
|
468
|
+
parts.push(current);
|
|
469
|
+
current = { chat: [], ops: [] };
|
|
470
|
+
used = 0;
|
|
471
|
+
}
|
|
472
|
+
if (item.chat) {
|
|
473
|
+
current.chat.push(item.chat);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
current.ops.push(item.op);
|
|
477
|
+
}
|
|
478
|
+
used += size;
|
|
479
|
+
};
|
|
480
|
+
for (const entry of this.chatEntries) {
|
|
481
|
+
place({ chat: { id: entry.id, peer: entry.peer, name: entry.name, text: entry.text, sentAt: entry.sentAt } });
|
|
482
|
+
}
|
|
483
|
+
for (const op of this.board.toOps(SYNC_POINTS_PER_OP)) {
|
|
484
|
+
place({ op });
|
|
485
|
+
}
|
|
486
|
+
parts.push(current);
|
|
487
|
+
const epoch = this.board.epoch;
|
|
488
|
+
parts.forEach((part, index) => {
|
|
489
|
+
const timer = setTimeout(() => {
|
|
490
|
+
this.timers.delete(timer);
|
|
491
|
+
this.send({ kind: "collab-sync", to: peer, sync: { part: index, of: parts.length, epoch, ...part } });
|
|
492
|
+
}, index * SYNC_PACE_MS);
|
|
493
|
+
this.timers.add(timer);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
mergeHistory(history) {
|
|
497
|
+
const fresh = [];
|
|
498
|
+
for (const entry of history) {
|
|
499
|
+
const key = `${entry.peer}:${entry.id}`;
|
|
500
|
+
if (!this.chatKeys.has(key)) {
|
|
501
|
+
this.chatKeys.add(key);
|
|
502
|
+
fresh.push({ ...entry, key, self: entry.peer === this.options.peerId });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (fresh.length === 0) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// History comes before whatever arrived live while it travelled, and after the history of earlier parts.
|
|
509
|
+
const merged = [...this.chatEntries.slice(0, this.historyCount), ...fresh, ...this.chatEntries.slice(this.historyCount)];
|
|
510
|
+
this.historyCount += fresh.length;
|
|
511
|
+
this.setChat(merged);
|
|
512
|
+
for (const entry of fresh) {
|
|
513
|
+
this.emit({ type: "chat", entry, history: true });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
addChat(entry, self) {
|
|
517
|
+
const key = `${entry.peer}:${entry.id}`;
|
|
518
|
+
if (this.chatKeys.has(key)) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.chatKeys.add(key);
|
|
522
|
+
const full = { ...entry, key, self };
|
|
523
|
+
this.setChat([...this.chatEntries, full]);
|
|
524
|
+
this.emit({ type: "chat", entry: full });
|
|
525
|
+
}
|
|
526
|
+
/** Keeps the latest `MAX_CHAT_HISTORY` of `entries`. */
|
|
527
|
+
setChat(entries) {
|
|
528
|
+
const dropped = Math.max(0, entries.length - MAX_CHAT_HISTORY);
|
|
529
|
+
this.chatEntries = entries.slice(dropped);
|
|
530
|
+
this.historyCount = Math.max(0, this.historyCount - dropped);
|
|
531
|
+
}
|
|
532
|
+
send(partial) {
|
|
533
|
+
if (this.stopped) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.options.peerId, ...partial });
|
|
537
|
+
}
|
|
538
|
+
emit(event) {
|
|
539
|
+
for (const listener of [...this.listeners]) {
|
|
540
|
+
listener(event);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
nextId() {
|
|
544
|
+
this.seq += 1;
|
|
545
|
+
return `${this.now().toString(36)}-${this.seq}`;
|
|
546
|
+
}
|
|
547
|
+
nextElementId() {
|
|
548
|
+
return `${this.options.peerId}:${this.nextId()}`;
|
|
549
|
+
}
|
|
550
|
+
now() {
|
|
551
|
+
return (this.options.now ?? Date.now)();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function clampX(x) {
|
|
555
|
+
return clampCoordinate(x, BOARD_WIDTH);
|
|
556
|
+
}
|
|
557
|
+
function clampY(y) {
|
|
558
|
+
return clampCoordinate(y, BOARD_HEIGHT);
|
|
559
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared whiteboard's state: its elements in drawing order and its epoch, changed only by `apply()`ing
|
|
3
|
+
* `WhiteboardOp`s - this participant's own as much as everyone else's, so every participant's board is built the same
|
|
4
|
+
* way from the same ops. See `protocol.ts` for the rules an op is held to (who may add to which element, what the
|
|
5
|
+
* epoch is for).
|
|
6
|
+
*
|
|
7
|
+
* Ops reach a participant in whatever order the network delivers them, and a newcomer's catch-up races the live ops
|
|
8
|
+
* sent while it travels, so `apply()` is built to give the same board whatever the order:
|
|
9
|
+
*
|
|
10
|
+
* - `points` for a stroke that hasn't arrived yet, or that start past the points it has so far, wait (a bounded
|
|
11
|
+
* number of them) and are applied the moment the gap is filled. Points already held are kept as they are.
|
|
12
|
+
* - An `add` for an element already on the board adds only what it has beyond it (more points) - a catch-up repeating
|
|
13
|
+
* what live ops already brought is harmless.
|
|
14
|
+
* - A removed element stays removed: its id is remembered, so a catch-up sent before the erase cannot bring it back.
|
|
15
|
+
*/
|
|
16
|
+
import { type BoardElement, type WhiteboardOp } from "./protocol.js";
|
|
17
|
+
/** The most `points` ops held while their stroke, or the points before them, are still missing. */
|
|
18
|
+
export declare const MAX_PENDING_POINT_OPS = 400;
|
|
19
|
+
/** The most removed ids remembered. */
|
|
20
|
+
export declare const MAX_TOMBSTONES = 10000;
|
|
21
|
+
export declare class WhiteboardModel {
|
|
22
|
+
private readonly byId;
|
|
23
|
+
private readonly pending;
|
|
24
|
+
private readonly tombstones;
|
|
25
|
+
private currentEpoch;
|
|
26
|
+
/** Bumped on every change, so a view can tell whether it needs to draw again. */
|
|
27
|
+
private rev;
|
|
28
|
+
get epoch(): number;
|
|
29
|
+
get revision(): number;
|
|
30
|
+
/** Every element, in drawing order (oldest first). */
|
|
31
|
+
get elements(): BoardElement[];
|
|
32
|
+
get size(): number;
|
|
33
|
+
get(id: string): BoardElement | undefined;
|
|
34
|
+
/** Whether one more element fits on the board. */
|
|
35
|
+
get hasRoom(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Applies `op` - sent by peer `author`, or, for `null`, carried by a catch-up on its authors' behalf (it names no
|
|
38
|
+
* single author; see `CollabSession` on how far a catch-up is trusted). Returns whether the board changed.
|
|
39
|
+
*/
|
|
40
|
+
apply(op: WhiteboardOp, author: string | null): boolean;
|
|
41
|
+
/** The ops that rebuild this board from nothing - a newcomer's catch-up. A long stroke is split into an `add` and
|
|
42
|
+
* `points` ops of at most `pointsPerOp` points each (never more than `MAX_POINTS_PER_OP`). */
|
|
43
|
+
toOps(pointsPerOp?: number): WhiteboardOp[];
|
|
44
|
+
private add;
|
|
45
|
+
private points;
|
|
46
|
+
/** Writes `points` into `stroke` from point index `start`, then whatever was waiting for them. */
|
|
47
|
+
private writePoints;
|
|
48
|
+
private hold;
|
|
49
|
+
/** Applies every held `points` op for `stroke` that now connects to what it has. */
|
|
50
|
+
private drainPending;
|
|
51
|
+
private remove;
|
|
52
|
+
private clear;
|
|
53
|
+
private changed;
|
|
54
|
+
}
|