@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,675 @@
|
|
|
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 {
|
|
51
|
+
BOARD_HEIGHT,
|
|
52
|
+
BOARD_WIDTH,
|
|
53
|
+
MAX_CHAT_HISTORY,
|
|
54
|
+
MAX_CHAT_LENGTH,
|
|
55
|
+
MAX_POINTS_PER_OP,
|
|
56
|
+
MAX_STROKE_POINTS,
|
|
57
|
+
clampCoordinate,
|
|
58
|
+
parseChat,
|
|
59
|
+
parseCursor,
|
|
60
|
+
parseSync,
|
|
61
|
+
parseWhiteboardOp,
|
|
62
|
+
type BoardElement,
|
|
63
|
+
type ChatHistoryEntry,
|
|
64
|
+
type CursorTool,
|
|
65
|
+
type PenType,
|
|
66
|
+
type Stamp,
|
|
67
|
+
type StencilShape,
|
|
68
|
+
type WhiteboardOp,
|
|
69
|
+
} from "./protocol.js";
|
|
70
|
+
import { WhiteboardModel } from "./WhiteboardModel.js";
|
|
71
|
+
import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
|
|
72
|
+
|
|
73
|
+
/** How often the points of a stroke being drawn are sent. */
|
|
74
|
+
export const POINT_FLUSH_MS = 60;
|
|
75
|
+
/** How long after starting a `hello` still counts as an existing participant answering ours - see this module's doc. */
|
|
76
|
+
export const SYNC_WINDOW_MS = 15_000;
|
|
77
|
+
/** How long to wait for a catch-up to start arriving before asking someone else. */
|
|
78
|
+
export const SYNC_TIMEOUT_MS = 5_000;
|
|
79
|
+
/** The gap between two parts of a catch-up this participant sends. */
|
|
80
|
+
export const SYNC_PACE_MS = 250;
|
|
81
|
+
/** Room left in each catch-up part for the message's own envelope. */
|
|
82
|
+
export const SYNC_ENVELOPE_BYTES = 1024;
|
|
83
|
+
/** The most stroke points one catch-up op carries, so one op always fits the server's smallest message limit. */
|
|
84
|
+
export const SYNC_POINTS_PER_OP = 600;
|
|
85
|
+
/** The least time between two pointer messages this participant sends. */
|
|
86
|
+
export const CURSOR_INTERVAL_MS = 80;
|
|
87
|
+
/** How long a participant's pointer stays on the board without news of it. */
|
|
88
|
+
export const CURSOR_IDLE_MS = 30_000;
|
|
89
|
+
/** How long a participant waits before answering the same requester's catch-up request again. */
|
|
90
|
+
export const SYNC_ANSWER_COOLDOWN_MS = 10_000;
|
|
91
|
+
/** What a message is sized against when the channel doesn't say. */
|
|
92
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024;
|
|
93
|
+
|
|
94
|
+
/** One chat message as shown. */
|
|
95
|
+
export interface ChatEntry {
|
|
96
|
+
/** Unique within the call: the sender's peer id and their own message id. */
|
|
97
|
+
key: string;
|
|
98
|
+
id: string;
|
|
99
|
+
peer: string;
|
|
100
|
+
name: string;
|
|
101
|
+
text: string;
|
|
102
|
+
sentAt: number;
|
|
103
|
+
self: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Another participant's pointer on the board. */
|
|
107
|
+
export interface RemoteCursor {
|
|
108
|
+
peer: string;
|
|
109
|
+
name: string;
|
|
110
|
+
x: number;
|
|
111
|
+
y: number;
|
|
112
|
+
tool: CursorTool;
|
|
113
|
+
color: string;
|
|
114
|
+
stamp?: Stamp;
|
|
115
|
+
/** When it last moved, by this tab's clock. */
|
|
116
|
+
at: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A `chat` event with `history` set is a message from a catch-up - said before this participant arrived; one with
|
|
120
|
+
* `renamed` set is a message already shown, now under its sender's name. Neither is news. */
|
|
121
|
+
export type CollabEvent = { type: "chat"; entry: ChatEntry; history?: boolean; renamed?: boolean } | { type: "board" } | { type: "cursors" };
|
|
122
|
+
|
|
123
|
+
/** What `CollabSession` needs from the signaling channel - `SignalingClient` is one. */
|
|
124
|
+
export interface CollabChannel extends SignalingChannel {
|
|
125
|
+
/** The server's per-message limit, in bytes - `SignalingClient.maxMessageBytes`. */
|
|
126
|
+
readonly maxMessageBytes?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CollabSessionOptions {
|
|
130
|
+
channel: CollabChannel;
|
|
131
|
+
/** The authenticated uid every message is sent `from`. */
|
|
132
|
+
selfUid: string;
|
|
133
|
+
/** This tab's peer id - the author of its chat messages and the prefix of its board elements' ids. */
|
|
134
|
+
peerId: string;
|
|
135
|
+
selfName: string;
|
|
136
|
+
now?: () => number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class CollabSession {
|
|
140
|
+
readonly board = new WhiteboardModel();
|
|
141
|
+
private chatEntries: ChatEntry[] = [];
|
|
142
|
+
/** How many of `chatEntries`, from the front, came from a catch-up - the next part's history goes after them. */
|
|
143
|
+
private historyCount = 0;
|
|
144
|
+
private readonly chatKeys = new Set<string>();
|
|
145
|
+
private readonly names = new Map<string, string>();
|
|
146
|
+
private readonly listeners = new Set<(event: CollabEvent) => void>();
|
|
147
|
+
private readonly ownIds: string[] = [];
|
|
148
|
+
private readonly outgoingPoints = new Map<string, { start: number; points: number[] }>();
|
|
149
|
+
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
|
|
150
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
private syncTimer: ReturnType<typeof setTimeout> | undefined;
|
|
152
|
+
private syncTarget: string | undefined;
|
|
153
|
+
private readonly syncTried = new Set<string>();
|
|
154
|
+
private readonly knownPeers: string[] = [];
|
|
155
|
+
private unsubscribe: (() => void) | undefined;
|
|
156
|
+
private startedAt = 0;
|
|
157
|
+
private seq = 0;
|
|
158
|
+
private stopped = false;
|
|
159
|
+
private readonly remoteCursors = new Map<string, RemoteCursor>();
|
|
160
|
+
private cursorPending: { x: number; y: number; tool: CursorTool; color: string; stamp?: Stamp } | undefined;
|
|
161
|
+
private cursorTimer: ReturnType<typeof setTimeout> | undefined;
|
|
162
|
+
private cursorShown = false;
|
|
163
|
+
private lastCursorSent = -Infinity;
|
|
164
|
+
private pruneTimer: ReturnType<typeof setInterval> | undefined;
|
|
165
|
+
/** The stroke this participant is drawing, between `beginStroke()` and `endStroke()`. */
|
|
166
|
+
private activeStroke: string | undefined;
|
|
167
|
+
private drawPolicy: ((peer: string) => boolean) | undefined;
|
|
168
|
+
private readonly syncAnswered = new Map<string, number>();
|
|
169
|
+
private syncReceivedAnything = false;
|
|
170
|
+
|
|
171
|
+
constructor(private readonly options: CollabSessionOptions) {}
|
|
172
|
+
|
|
173
|
+
/** Every chat message, oldest first. */
|
|
174
|
+
get chat(): readonly ChatEntry[] {
|
|
175
|
+
return this.chatEntries;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Everyone else's pointers currently on the board. */
|
|
179
|
+
get cursors(): RemoteCursor[] {
|
|
180
|
+
return [...this.remoteCursors.values()];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Whether this participant may change the board now - see this module's doc comment on who may draw. */
|
|
184
|
+
get canDraw(): boolean {
|
|
185
|
+
return this.mayDraw(this.options.peerId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Limits who may change the board to the peers `policy` accepts; `undefined` lets everyone. */
|
|
189
|
+
setDrawPolicy(policy: ((peer: string) => boolean) | undefined): void {
|
|
190
|
+
this.drawPolicy = policy;
|
|
191
|
+
if (!this.canDraw) {
|
|
192
|
+
this.endStroke();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
onEvent(listener: (event: CollabEvent) => void): () => void {
|
|
197
|
+
this.listeners.add(listener);
|
|
198
|
+
return () => this.listeners.delete(listener);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
start(): void {
|
|
202
|
+
if (this.unsubscribe || this.stopped) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.startedAt = this.now();
|
|
206
|
+
this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
|
|
207
|
+
this.pruneTimer = setInterval(() => this.pruneCursors(), CURSOR_IDLE_MS / 6);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
stop(): void {
|
|
211
|
+
if (this.stopped) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Before `stopped` is set, which makes `send()` drop everything.
|
|
215
|
+
this.flushPoints();
|
|
216
|
+
this.stopped = true;
|
|
217
|
+
this.unsubscribe?.();
|
|
218
|
+
this.unsubscribe = undefined;
|
|
219
|
+
clearTimeout(this.flushTimer);
|
|
220
|
+
clearTimeout(this.syncTimer);
|
|
221
|
+
clearTimeout(this.cursorTimer);
|
|
222
|
+
clearInterval(this.pruneTimer);
|
|
223
|
+
for (const timer of this.timers) {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
}
|
|
226
|
+
this.timers.clear();
|
|
227
|
+
this.listeners.clear();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Sends `text` as a chat message. Returns `false`, sending nothing, for blank or overlong text. */
|
|
231
|
+
sendChat(text: string): boolean {
|
|
232
|
+
if (!text.trim() || text.length > MAX_CHAT_LENGTH || this.stopped) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const payload = { id: this.nextId(), text, sentAt: this.now() };
|
|
236
|
+
this.send({ kind: "chat", chat: payload });
|
|
237
|
+
this.addChat({ ...payload, peer: this.options.peerId, name: this.options.selfName }, true);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Starts a stroke at (`x`, `y`); returns its id, or `undefined` when the board is full. */
|
|
242
|
+
beginStroke(pen: PenType, color: string, size: number, x: number, y: number): string | undefined {
|
|
243
|
+
const id = this.addElement({ kind: "stroke", id: this.nextElementId(), epoch: this.board.epoch, pen, color, size, points: [clampX(x), clampY(y)] });
|
|
244
|
+
this.activeStroke = id;
|
|
245
|
+
return id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Adds a point to this participant's own stroke `id` - sent with the next batch. */
|
|
249
|
+
extendStroke(id: string, x: number, y: number): void {
|
|
250
|
+
const stroke = this.board.get(id);
|
|
251
|
+
if (!stroke || stroke.kind !== "stroke" || stroke.points.length >= MAX_STROKE_POINTS * 2 || !this.canDraw) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const point = [clampX(x), clampY(y)];
|
|
255
|
+
const start = stroke.points.length / 2;
|
|
256
|
+
if (!this.board.apply({ op: "points", id, start, points: point }, this.options.peerId)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const batch = this.outgoingPoints.get(id);
|
|
260
|
+
if (batch) {
|
|
261
|
+
batch.points.push(...point);
|
|
262
|
+
} else {
|
|
263
|
+
this.outgoingPoints.set(id, { start, points: point });
|
|
264
|
+
}
|
|
265
|
+
// A batch never outgrows what one op may carry, even if the timer below runs late.
|
|
266
|
+
if ((batch?.points.length ?? 0) >= MAX_POINTS_PER_OP * 2) {
|
|
267
|
+
this.flushPoints();
|
|
268
|
+
}
|
|
269
|
+
this.flushTimer ??= setTimeout(() => {
|
|
270
|
+
this.flushTimer = undefined;
|
|
271
|
+
this.flushPoints();
|
|
272
|
+
}, POINT_FLUSH_MS);
|
|
273
|
+
this.emit({ type: "board" });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Finishes a stroke: whatever points are still waiting go out now. */
|
|
277
|
+
endStroke(): void {
|
|
278
|
+
this.activeStroke = undefined;
|
|
279
|
+
clearTimeout(this.flushTimer);
|
|
280
|
+
this.flushTimer = undefined;
|
|
281
|
+
this.flushPoints();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
addShape(shape: StencilShape, color: string, size: number, x1: number, y1: number, x2: number, y2: number): string | undefined {
|
|
285
|
+
const [a, b, c, d] = [clampX(x1), clampY(y1), clampX(x2), clampY(y2)];
|
|
286
|
+
return this.addElement({ kind: "shape", id: this.nextElementId(), epoch: this.board.epoch, shape, color, size, x1: a, y1: b, x2: c, y2: d });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
addStamp(stamp: Stamp, size: number, x: number, y: number): string | undefined {
|
|
290
|
+
return this.addElement({ kind: "stamp", id: this.nextElementId(), epoch: this.board.epoch, stamp, size, x: clampX(x), y: clampY(y) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Erases `ids` - anyone's elements, see `protocol.ts`. */
|
|
294
|
+
erase(ids: string[]): void {
|
|
295
|
+
const present = ids.filter((id) => this.board.get(id));
|
|
296
|
+
if (present.length > 0 && this.canDraw) {
|
|
297
|
+
this.applyAndSend({ op: "remove", ids: present });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Erases this participant's own most recent element still on the board; `false` when there is none. */
|
|
302
|
+
undo(): boolean {
|
|
303
|
+
while (this.ownIds.length > 0 && this.canDraw) {
|
|
304
|
+
const id = this.ownIds.pop()!;
|
|
305
|
+
if (this.board.get(id)) {
|
|
306
|
+
this.endStroke();
|
|
307
|
+
this.applyAndSend({ op: "remove", ids: [id] });
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Clears the board for everyone. */
|
|
315
|
+
clear(): void {
|
|
316
|
+
if (!this.canDraw || this.stopped) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.endStroke();
|
|
320
|
+
this.applyAndSend({ op: "clear", epoch: this.board.epoch + 1 });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Says this participant's pointer is at (`x`, `y`) on the board, as `tool` in `color` (with `stamp` for the stamp
|
|
324
|
+
* tool) - sent now, or with the latest position once `CURSOR_INTERVAL_MS` has passed since the last one. */
|
|
325
|
+
moveCursor(x: number, y: number, tool: CursorTool, color: string, stamp?: Stamp): void {
|
|
326
|
+
if (this.stopped) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.cursorPending = { x: clampX(x), y: clampY(y), tool, color, ...(stamp ? { stamp } : {}) };
|
|
330
|
+
if (this.cursorTimer === undefined) {
|
|
331
|
+
const wait = Math.max(0, this.lastCursorSent + CURSOR_INTERVAL_MS - this.now());
|
|
332
|
+
this.cursorTimer = setTimeout(() => {
|
|
333
|
+
this.cursorTimer = undefined;
|
|
334
|
+
this.flushCursor();
|
|
335
|
+
}, wait);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Says this participant's pointer has left the board. */
|
|
340
|
+
hideCursor(): void {
|
|
341
|
+
clearTimeout(this.cursorTimer);
|
|
342
|
+
this.cursorTimer = undefined;
|
|
343
|
+
this.cursorPending = undefined;
|
|
344
|
+
if (this.cursorShown) {
|
|
345
|
+
this.cursorShown = false;
|
|
346
|
+
this.send({ kind: "whiteboard-cursor", cursor: { hidden: true } });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private flushCursor(): void {
|
|
351
|
+
const cursor = this.cursorPending;
|
|
352
|
+
this.cursorPending = undefined;
|
|
353
|
+
// Mid-stroke, the stroke's own points show where the pointer is - see this module's doc comment.
|
|
354
|
+
if (!cursor || this.activeStroke !== undefined) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.lastCursorSent = this.now();
|
|
358
|
+
this.cursorShown = true;
|
|
359
|
+
this.send({ kind: "whiteboard-cursor", cursor });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private handleCursor(sender: string, raw: unknown): void {
|
|
363
|
+
const cursor = parseCursor(raw);
|
|
364
|
+
if (!cursor) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if ("hidden" in cursor) {
|
|
368
|
+
if (this.remoteCursors.delete(sender)) {
|
|
369
|
+
this.emit({ type: "cursors" });
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.remoteCursors.set(sender, { peer: sender, name: this.names.get(sender) ?? "Guest", ...cursor, at: this.now() });
|
|
374
|
+
this.emit({ type: "cursors" });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Moves `sender`'s pointer to where their stroke just got to - see this module's doc comment on pointers. */
|
|
378
|
+
private followStroke(sender: string, op: WhiteboardOp): void {
|
|
379
|
+
const id = op.op === "add" ? op.element.id : op.op === "points" ? op.id : undefined;
|
|
380
|
+
const stroke = id === undefined ? undefined : this.board.get(id);
|
|
381
|
+
if (!stroke || stroke.kind !== "stroke") {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const previous = this.remoteCursors.get(sender);
|
|
385
|
+
this.remoteCursors.set(sender, {
|
|
386
|
+
peer: sender,
|
|
387
|
+
name: this.names.get(sender) ?? "Guest",
|
|
388
|
+
tool: stroke.pen,
|
|
389
|
+
color: stroke.color,
|
|
390
|
+
x: stroke.points[stroke.points.length - 2],
|
|
391
|
+
y: stroke.points[stroke.points.length - 1],
|
|
392
|
+
...(previous?.tool === stroke.pen && previous.stamp ? { stamp: previous.stamp } : {}),
|
|
393
|
+
at: this.now(),
|
|
394
|
+
});
|
|
395
|
+
this.emit({ type: "cursors" });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private pruneCursors(): void {
|
|
399
|
+
const cutoff = this.now() - CURSOR_IDLE_MS;
|
|
400
|
+
let changed = false;
|
|
401
|
+
for (const [peer, cursor] of this.remoteCursors) {
|
|
402
|
+
if (cursor.at < cutoff) {
|
|
403
|
+
this.remoteCursors.delete(peer);
|
|
404
|
+
changed = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (changed) {
|
|
408
|
+
this.emit({ type: "cursors" });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private mayDraw(peer: string): boolean {
|
|
413
|
+
return !this.drawPolicy || this.drawPolicy(peer);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private addElement(element: BoardElement): string | undefined {
|
|
417
|
+
if (!this.board.hasRoom || this.stopped || !this.canDraw) {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
this.endStroke();
|
|
421
|
+
this.applyAndSend({ op: "add", element: element.kind === "stroke" ? { ...element, points: [...element.points] } : element });
|
|
422
|
+
this.ownIds.push(element.id);
|
|
423
|
+
return element.id;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private applyAndSend(op: WhiteboardOp): void {
|
|
427
|
+
if (this.board.apply(op, this.options.peerId)) {
|
|
428
|
+
this.emit({ type: "board" });
|
|
429
|
+
}
|
|
430
|
+
this.send({ kind: "whiteboard", whiteboard: op });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private flushPoints(): void {
|
|
434
|
+
for (const [id, batch] of this.outgoingPoints) {
|
|
435
|
+
this.send({ kind: "whiteboard", whiteboard: { op: "points", id, start: batch.start, points: batch.points } });
|
|
436
|
+
}
|
|
437
|
+
this.outgoingPoints.clear();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private handleMessage(message: SignalMessage): void {
|
|
441
|
+
if (message.type !== "video-meeting-signal") {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const sender = message.peer ?? message.from;
|
|
445
|
+
if (sender === this.options.peerId || (message.to !== undefined && message.to !== this.options.peerId)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
switch (message.kind) {
|
|
449
|
+
case "hello":
|
|
450
|
+
this.handleHello(sender, message.name);
|
|
451
|
+
return;
|
|
452
|
+
case "bye":
|
|
453
|
+
if (this.remoteCursors.delete(sender)) {
|
|
454
|
+
this.emit({ type: "cursors" });
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
case "whiteboard-cursor":
|
|
458
|
+
this.handleCursor(sender, message.cursor);
|
|
459
|
+
return;
|
|
460
|
+
case "chat": {
|
|
461
|
+
const chat = parseChat(message.chat);
|
|
462
|
+
if (chat) {
|
|
463
|
+
this.addChat({ ...chat, peer: sender, name: this.names.get(sender) ?? "Guest" }, false);
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
case "whiteboard": {
|
|
468
|
+
const op = parseWhiteboardOp(message.whiteboard);
|
|
469
|
+
if (op && this.mayDraw(sender) && this.board.apply(op, sender)) {
|
|
470
|
+
this.emit({ type: "board" });
|
|
471
|
+
this.followStroke(sender, op);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
case "collab-sync-request":
|
|
476
|
+
this.sendSync(sender);
|
|
477
|
+
return;
|
|
478
|
+
case "collab-sync":
|
|
479
|
+
this.receiveSync(sender, message.sync);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private handleHello(sender: string, name: string | undefined): void {
|
|
485
|
+
if (typeof name === "string" && name) {
|
|
486
|
+
const known = this.names.has(sender);
|
|
487
|
+
this.names.set(sender, name);
|
|
488
|
+
// Chat that beat this participant's `hello` was shown as "Guest" - give it their name now.
|
|
489
|
+
if (!known && this.chatEntries.some((entry) => entry.peer === sender && entry.name === "Guest")) {
|
|
490
|
+
this.chatEntries = this.chatEntries.map((entry) => (entry.peer === sender && entry.name === "Guest" ? { ...entry, name } : entry));
|
|
491
|
+
const own = this.chatEntries.filter((entry) => entry.peer === sender);
|
|
492
|
+
const renamed = own[own.length - 1];
|
|
493
|
+
this.emit({ type: "chat", entry: renamed, renamed: true });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!this.knownPeers.includes(sender)) {
|
|
497
|
+
this.knownPeers.push(sender);
|
|
498
|
+
}
|
|
499
|
+
if (this.syncTarget === undefined && this.syncTried.size === 0 && this.now() - this.startedAt < SYNC_WINDOW_MS) {
|
|
500
|
+
this.requestSync(sender);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private requestSync(peer: string): void {
|
|
505
|
+
this.syncTarget = peer;
|
|
506
|
+
this.syncTried.add(peer);
|
|
507
|
+
this.syncReceivedAnything = false;
|
|
508
|
+
this.send({ kind: "collab-sync-request", to: peer });
|
|
509
|
+
this.armSyncTimeout();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Asks the next participant not yet asked, if any - after a silence or an empty answer. */
|
|
513
|
+
private askNext(): void {
|
|
514
|
+
this.syncTarget = undefined;
|
|
515
|
+
const next = this.knownPeers.find((candidate) => !this.syncTried.has(candidate));
|
|
516
|
+
if (next) {
|
|
517
|
+
this.requestSync(next);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private armSyncTimeout(): void {
|
|
522
|
+
clearTimeout(this.syncTimer);
|
|
523
|
+
this.syncTimer = setTimeout(() => {
|
|
524
|
+
this.syncTimer = undefined;
|
|
525
|
+
this.askNext();
|
|
526
|
+
}, SYNC_TIMEOUT_MS);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private receiveSync(sender: string, raw: unknown): void {
|
|
530
|
+
if (sender !== this.syncTarget) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const sync = parseSync(raw);
|
|
534
|
+
if (!sync) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this.syncReceivedAnything ||= sync.chat.length > 0 || sync.ops.length > 0 || sync.epoch > 0;
|
|
538
|
+
let boardChanged = this.board.apply({ op: "clear", epoch: sync.epoch }, null);
|
|
539
|
+
for (const op of sync.ops) {
|
|
540
|
+
boardChanged = this.board.apply(op, null) || boardChanged;
|
|
541
|
+
}
|
|
542
|
+
if (boardChanged) {
|
|
543
|
+
this.emit({ type: "board" });
|
|
544
|
+
}
|
|
545
|
+
this.mergeHistory(sync.chat);
|
|
546
|
+
if (sync.part < sync.of - 1) {
|
|
547
|
+
this.armSyncTimeout();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
clearTimeout(this.syncTimer);
|
|
551
|
+
this.syncTimer = undefined;
|
|
552
|
+
if (this.syncReceivedAnything) {
|
|
553
|
+
this.syncTarget = undefined;
|
|
554
|
+
} else {
|
|
555
|
+
// Nothing at all - likely another newcomer who had nothing yet either. Someone else may have more.
|
|
556
|
+
this.askNext();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Sends `peer` everything said and drawn so far - see this module's doc comment. */
|
|
561
|
+
private sendSync(peer: string): void {
|
|
562
|
+
const answeredAt = this.syncAnswered.get(peer);
|
|
563
|
+
if (answeredAt !== undefined && this.now() - answeredAt < SYNC_ANSWER_COOLDOWN_MS) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this.syncAnswered.set(peer, this.now());
|
|
567
|
+
const budget = (this.options.channel.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES) - SYNC_ENVELOPE_BYTES;
|
|
568
|
+
const encoder = new TextEncoder();
|
|
569
|
+
const parts: { chat: ChatHistoryEntry[]; ops: WhiteboardOp[] }[] = [];
|
|
570
|
+
let current: { chat: ChatHistoryEntry[]; ops: WhiteboardOp[] } = { chat: [], ops: [] };
|
|
571
|
+
let used = 0;
|
|
572
|
+
const place = (item: { chat?: ChatHistoryEntry; op?: WhiteboardOp }) => {
|
|
573
|
+
const size = encoder.encode(JSON.stringify(item.chat ?? item.op)).length + 1;
|
|
574
|
+
if (used + size > budget && used > 0) {
|
|
575
|
+
parts.push(current);
|
|
576
|
+
current = { chat: [], ops: [] };
|
|
577
|
+
used = 0;
|
|
578
|
+
}
|
|
579
|
+
if (item.chat) {
|
|
580
|
+
current.chat.push(item.chat);
|
|
581
|
+
} else {
|
|
582
|
+
current.ops.push(item.op!);
|
|
583
|
+
}
|
|
584
|
+
used += size;
|
|
585
|
+
};
|
|
586
|
+
for (const entry of this.chatEntries) {
|
|
587
|
+
place({ chat: { id: entry.id, peer: entry.peer, name: entry.name, text: entry.text, sentAt: entry.sentAt } });
|
|
588
|
+
}
|
|
589
|
+
for (const op of this.board.toOps(SYNC_POINTS_PER_OP)) {
|
|
590
|
+
place({ op });
|
|
591
|
+
}
|
|
592
|
+
parts.push(current);
|
|
593
|
+
const epoch = this.board.epoch;
|
|
594
|
+
parts.forEach((part, index) => {
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
this.timers.delete(timer);
|
|
597
|
+
this.send({ kind: "collab-sync", to: peer, sync: { part: index, of: parts.length, epoch, ...part } });
|
|
598
|
+
}, index * SYNC_PACE_MS);
|
|
599
|
+
this.timers.add(timer);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private mergeHistory(history: ChatHistoryEntry[]): void {
|
|
604
|
+
const fresh: ChatEntry[] = [];
|
|
605
|
+
for (const entry of history) {
|
|
606
|
+
const key = `${entry.peer}:${entry.id}`;
|
|
607
|
+
if (!this.chatKeys.has(key)) {
|
|
608
|
+
this.chatKeys.add(key);
|
|
609
|
+
fresh.push({ ...entry, key, self: entry.peer === this.options.peerId });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (fresh.length === 0) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// History comes before whatever arrived live while it travelled, and after the history of earlier parts.
|
|
616
|
+
const merged = [...this.chatEntries.slice(0, this.historyCount), ...fresh, ...this.chatEntries.slice(this.historyCount)];
|
|
617
|
+
this.historyCount += fresh.length;
|
|
618
|
+
this.setChat(merged);
|
|
619
|
+
for (const entry of fresh) {
|
|
620
|
+
this.emit({ type: "chat", entry, history: true });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private addChat(entry: Omit<ChatEntry, "key" | "self">, self: boolean): void {
|
|
625
|
+
const key = `${entry.peer}:${entry.id}`;
|
|
626
|
+
if (this.chatKeys.has(key)) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.chatKeys.add(key);
|
|
630
|
+
const full: ChatEntry = { ...entry, key, self };
|
|
631
|
+
this.setChat([...this.chatEntries, full]);
|
|
632
|
+
this.emit({ type: "chat", entry: full });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Keeps the latest `MAX_CHAT_HISTORY` of `entries`. */
|
|
636
|
+
private setChat(entries: ChatEntry[]): void {
|
|
637
|
+
const dropped = Math.max(0, entries.length - MAX_CHAT_HISTORY);
|
|
638
|
+
this.chatEntries = entries.slice(dropped);
|
|
639
|
+
this.historyCount = Math.max(0, this.historyCount - dropped);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private send(partial: Omit<SignalMessage, "type" | "from">): void {
|
|
643
|
+
if (this.stopped) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.options.peerId, ...partial });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private emit(event: CollabEvent): void {
|
|
650
|
+
for (const listener of [...this.listeners]) {
|
|
651
|
+
listener(event);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private nextId(): string {
|
|
656
|
+
this.seq += 1;
|
|
657
|
+
return `${this.now().toString(36)}-${this.seq}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private nextElementId(): string {
|
|
661
|
+
return `${this.options.peerId}:${this.nextId()}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private now(): number {
|
|
665
|
+
return (this.options.now ?? Date.now)();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function clampX(x: number): number {
|
|
670
|
+
return clampCoordinate(x, BOARD_WIDTH);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function clampY(y: number): number {
|
|
674
|
+
return clampCoordinate(y, BOARD_HEIGHT);
|
|
675
|
+
}
|