@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,216 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The shared whiteboard's state: its elements in drawing order and its epoch, changed only by `apply()`ing
7
+ * `WhiteboardOp`s - this participant's own as much as everyone else's, so every participant's board is built the same
8
+ * way from the same ops. See `protocol.ts` for the rules an op is held to (who may add to which element, what the
9
+ * epoch is for).
10
+ *
11
+ * Ops reach a participant in whatever order the network delivers them, and a newcomer's catch-up races the live ops
12
+ * sent while it travels, so `apply()` is built to give the same board whatever the order:
13
+ *
14
+ * - `points` for a stroke that hasn't arrived yet, or that start past the points it has so far, wait (a bounded
15
+ * number of them) and are applied the moment the gap is filled. Points already held are kept as they are.
16
+ * - An `add` for an element already on the board adds only what it has beyond it (more points) - a catch-up repeating
17
+ * what live ops already brought is harmless.
18
+ * - A removed element stays removed: its id is remembered, so a catch-up sent before the erase cannot bring it back.
19
+ */
20
+ import { MAX_BOARD_ELEMENTS, MAX_POINTS_PER_OP, MAX_STROKE_POINTS, isOwnId, type BoardElement, type WhiteboardOp } from "./protocol.js";
21
+
22
+ /** The most `points` ops held while their stroke, or the points before them, are still missing. */
23
+ export const MAX_PENDING_POINT_OPS = 400;
24
+
25
+ /** The most removed ids remembered. */
26
+ export const MAX_TOMBSTONES = 10_000;
27
+
28
+ type PointsOp = Extract<WhiteboardOp, { op: "points" }>;
29
+
30
+ export class WhiteboardModel {
31
+ private readonly byId = new Map<string, BoardElement>();
32
+ private readonly pending: PointsOp[] = [];
33
+ private readonly tombstones = new Set<string>();
34
+ private currentEpoch = 0;
35
+ /** Bumped on every change, so a view can tell whether it needs to draw again. */
36
+ private rev = 0;
37
+
38
+ get epoch(): number {
39
+ return this.currentEpoch;
40
+ }
41
+
42
+ get revision(): number {
43
+ return this.rev;
44
+ }
45
+
46
+ /** Every element, in drawing order (oldest first). */
47
+ get elements(): BoardElement[] {
48
+ return [...this.byId.values()];
49
+ }
50
+
51
+ get size(): number {
52
+ return this.byId.size;
53
+ }
54
+
55
+ get(id: string): BoardElement | undefined {
56
+ return this.byId.get(id);
57
+ }
58
+
59
+ /** Whether one more element fits on the board. */
60
+ get hasRoom(): boolean {
61
+ return this.byId.size < MAX_BOARD_ELEMENTS;
62
+ }
63
+
64
+ /**
65
+ * Applies `op` - sent by peer `author`, or, for `null`, carried by a catch-up on its authors' behalf (it names no
66
+ * single author; see `CollabSession` on how far a catch-up is trusted). Returns whether the board changed.
67
+ */
68
+ apply(op: WhiteboardOp, author: string | null): boolean {
69
+ switch (op.op) {
70
+ case "add":
71
+ return this.add(op.element, author);
72
+ case "points":
73
+ return this.points(op, author);
74
+ case "remove":
75
+ return this.remove(op.ids);
76
+ case "clear":
77
+ return this.clear(op.epoch);
78
+ }
79
+ }
80
+
81
+ /** The ops that rebuild this board from nothing - a newcomer's catch-up. A long stroke is split into an `add` and
82
+ * `points` ops of at most `pointsPerOp` points each (never more than `MAX_POINTS_PER_OP`). */
83
+ toOps(pointsPerOp: number = MAX_POINTS_PER_OP): WhiteboardOp[] {
84
+ const ops: WhiteboardOp[] = [];
85
+ const perOp = Math.max(1, Math.floor(Math.min(pointsPerOp, MAX_POINTS_PER_OP)) || 1) * 2;
86
+ for (const element of this.byId.values()) {
87
+ if (element.kind !== "stroke" || element.points.length <= perOp) {
88
+ ops.push({ op: "add", element: element.kind === "stroke" ? { ...element, points: [...element.points] } : { ...element } });
89
+ continue;
90
+ }
91
+ ops.push({ op: "add", element: { ...element, points: element.points.slice(0, perOp) } });
92
+ for (let i = perOp; i < element.points.length; i += perOp) {
93
+ ops.push({ op: "points", id: element.id, start: i / 2, points: element.points.slice(i, i + perOp) });
94
+ }
95
+ }
96
+ return ops;
97
+ }
98
+
99
+ private add(element: BoardElement, author: string | null): boolean {
100
+ if ((author !== null && !isOwnId(element.id, author)) || element.epoch < this.currentEpoch || this.tombstones.has(element.id)) {
101
+ return false;
102
+ }
103
+ const existing = this.byId.get(element.id);
104
+ if (existing) {
105
+ // Only a stroke grows; anything else is already all there.
106
+ if (existing.kind !== "stroke" || element.kind !== "stroke") {
107
+ return false;
108
+ }
109
+ return this.writePoints(existing, 0, element.points);
110
+ }
111
+ if (!this.hasRoom) {
112
+ return false;
113
+ }
114
+ this.byId.set(element.id, element.kind === "stroke" ? { ...element, points: element.points.slice(0, MAX_STROKE_POINTS * 2) } : { ...element });
115
+ this.changed();
116
+ const added = this.byId.get(element.id)!;
117
+ if (added.kind === "stroke") {
118
+ this.drainPending(added);
119
+ }
120
+ return true;
121
+ }
122
+
123
+ private points(op: PointsOp, author: string | null): boolean {
124
+ if ((author !== null && !isOwnId(op.id, author)) || this.tombstones.has(op.id)) {
125
+ return false;
126
+ }
127
+ const element = this.byId.get(op.id);
128
+ if (element && element.kind !== "stroke") {
129
+ return false;
130
+ }
131
+ if (!element || op.start * 2 > element.points.length) {
132
+ this.hold(op);
133
+ return false;
134
+ }
135
+ return this.writePoints(element, op.start, op.points);
136
+ }
137
+
138
+ /** Writes `points` into `stroke` from point index `start`, then whatever was waiting for them. */
139
+ private writePoints(stroke: Extract<BoardElement, { kind: "stroke" }>, start: number, points: number[]): boolean {
140
+ const offset = start * 2;
141
+ const end = Math.min(offset + points.length, MAX_STROKE_POINTS * 2);
142
+ if (end <= stroke.points.length) {
143
+ return false;
144
+ }
145
+ for (let i = Math.max(offset, stroke.points.length); i < end; i++) {
146
+ stroke.points[i] = points[i - offset];
147
+ }
148
+ this.changed();
149
+ this.drainPending(stroke);
150
+ return true;
151
+ }
152
+
153
+ private hold(op: PointsOp): void {
154
+ if (this.pending.length >= MAX_PENDING_POINT_OPS) {
155
+ this.pending.shift();
156
+ }
157
+ this.pending.push(op);
158
+ }
159
+
160
+ /** Applies every held `points` op for `stroke` that now connects to what it has. */
161
+ private drainPending(stroke: Extract<BoardElement, { kind: "stroke" }>): void {
162
+ let progressed = true;
163
+ while (progressed) {
164
+ progressed = false;
165
+ for (let i = 0; i < this.pending.length; i++) {
166
+ const op = this.pending[i];
167
+ if (op.id === stroke.id && op.start * 2 <= stroke.points.length) {
168
+ this.pending.splice(i, 1);
169
+ this.writePoints(stroke, op.start, op.points);
170
+ progressed = true;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ private remove(ids: string[]): boolean {
178
+ let changed = false;
179
+ for (const id of ids) {
180
+ if (this.tombstones.size >= MAX_TOMBSTONES) {
181
+ this.tombstones.delete(this.tombstones.values().next().value as string);
182
+ }
183
+ this.tombstones.add(id);
184
+ changed = this.byId.delete(id) || changed;
185
+ }
186
+ if (changed) {
187
+ this.changed();
188
+ }
189
+ return changed;
190
+ }
191
+
192
+ private clear(epoch: number): boolean {
193
+ if (epoch <= this.currentEpoch) {
194
+ return false;
195
+ }
196
+ this.currentEpoch = epoch;
197
+ const cleared = new Set<string>();
198
+ for (const [id, element] of this.byId) {
199
+ if (element.epoch < epoch) {
200
+ this.byId.delete(id);
201
+ cleared.add(id);
202
+ }
203
+ }
204
+ // Drop only the points held for strokes just cleared away: one not here yet may belong to the new epoch - a
205
+ // newcomer's catch-up starts with a clear that races the live ops of a stroke being drawn - and the rest can
206
+ // never drain anyway (an older epoch's `add` is refused), so they just age out under the cap.
207
+ const kept = this.pending.filter((op) => !cleared.has(op.id));
208
+ this.pending.splice(0, this.pending.length, ...kept);
209
+ this.changed();
210
+ return true;
211
+ }
212
+
213
+ private changed(): void {
214
+ this.rev += 1;
215
+ }
216
+ }
@@ -0,0 +1,375 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Renders a chat message's Markdown as React elements - never as HTML. A message is another participant's text, so it
7
+ * is parsed here into a small set of known elements and everything else stays literal text that React escapes; there
8
+ * is no `dangerouslySetInnerHTML` anywhere, and a raw `<script>` in a message is shown as those characters.
9
+ *
10
+ * What it understands - the everyday subset of Markdown people type into chat:
11
+ *
12
+ * - Blocks: paragraphs (a single line break is kept as a break), `#`/`##`/`###` headings, `>` quotes, `-`/`*`/`+`
13
+ * bulleted and `1.` numbered lists, and ``` fenced code blocks.
14
+ * - Inline: `**bold**`/`__bold__`, `*italic*`/`_italic_`, `~~strikethrough~~`, `` `code` ``, `[text](url)` links,
15
+ * `![alt](url)` images, and a backslash to keep any of those characters literal.
16
+ * - A bare `http(s)://` address becomes a link - and when it points at an image (by its file extension, as a GIF
17
+ * from the GIF picker does), the image itself is shown instead, falling back to the link if it doesn't load.
18
+ * - A message of nothing but one to three emoji is shown large.
19
+ *
20
+ * Only `http:`, `https:` and `mailto:` links are made clickable (anything else, `javascript:` included, stays text);
21
+ * links open in a new tab without a referrer, and images load without one either.
22
+ */
23
+ import React, { useState } from "react";
24
+
25
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i;
26
+ const BARE_URL = /^https?:\/\/[^\s<>"'`]+/i;
27
+ const ONLY_EMOJI = /^(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|‍|️|⃣|[#*0-9]️?⃣|\s)+$/u;
28
+ /** The deepest a quote may nest, or inline formatting may nest inside itself. */
29
+ const MAX_DEPTH = 4;
30
+
31
+ /** `url` when it is safe to link to (`http:`, `https:`, `mailto:`), else `undefined`. */
32
+ export function safeLinkUrl(url: string): string | undefined {
33
+ try {
34
+ const parsed = new URL(url);
35
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? parsed.href : undefined;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ /** `url` when it is safe to load as an image (`http:`, `https:`), else `undefined`. */
42
+ export function safeImageUrl(url: string): string | undefined {
43
+ const safe = safeLinkUrl(url);
44
+ return safe && !safe.startsWith("mailto:") ? safe : undefined;
45
+ }
46
+
47
+ /** Whether `url` names an image by its path's file extension (query and fragment aside). */
48
+ export function isImageUrl(url: string): boolean {
49
+ try {
50
+ const parsed = new URL(url);
51
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && IMAGE_EXTENSIONS.test(parsed.pathname);
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Whether `text` is just one to three emoji (and whitespace) - shown large. */
58
+ export function isEmojiOnly(text: string): boolean {
59
+ const trimmed = text.trim();
60
+ if (!trimmed || !ONLY_EMOJI.test(trimmed) || !/\p{Extended_Pictographic}|\p{Regional_Indicator}|[#*0-9]️?⃣/u.test(trimmed)) {
61
+ return false;
62
+ }
63
+ // Count what a reader sees as one emoji each: a ZWJ sequence, skin tone or variation selector belongs to the emoji
64
+ // before it, and a flag is a pair of regional indicators.
65
+ const base = trimmed.replace(/\s+/g, "").replace(/‍\p{Extended_Pictographic}/gu, "").replace(/[\p{Emoji_Modifier}️⃣]/gu, "");
66
+ const flags = (base.match(/\p{Regional_Indicator}/gu)?.length ?? 0) / 2;
67
+ const others = base.replace(/\p{Regional_Indicator}/gu, "").match(/\p{Extended_Pictographic}|[#*0-9]/gu)?.length ?? 0;
68
+ return Math.ceil(flags) + others <= 3;
69
+ }
70
+
71
+ /** The end of a bare URL starting at the beginning of `text`: trailing punctuation that more likely ends the sentence
72
+ * than the address is left out, and a closing parenthesis only stays when the address opened one. */
73
+ export function bareUrlLength(text: string): number {
74
+ const match = BARE_URL.exec(text);
75
+ if (!match) {
76
+ return 0;
77
+ }
78
+ let url = match[0];
79
+ for (;;) {
80
+ const last = url[url.length - 1];
81
+ if (".,;:!?'\"*_~".includes(last)) {
82
+ url = url.slice(0, -1);
83
+ } else if (last === ")" && (url.match(/\(/g)?.length ?? 0) < (url.match(/\)/g)?.length ?? 0)) {
84
+ url = url.slice(0, -1);
85
+ } else {
86
+ break;
87
+ }
88
+ }
89
+ return url.length;
90
+ }
91
+
92
+ type Block =
93
+ | { type: "paragraph"; lines: string[] }
94
+ | { type: "heading"; level: 1 | 2 | 3; text: string }
95
+ | { type: "code"; text: string }
96
+ | { type: "quote"; blocks: Block[] }
97
+ | { type: "list"; ordered: boolean; start: number; items: string[] };
98
+
99
+ const FENCE = /^\s*```/;
100
+ const QUOTE = /^\s*>\s?(.*)$/;
101
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
102
+ const NUMBERED = /^\s*(\d{1,9})[.)]\s+(.*)$/;
103
+ const HEADING = /^(#{1,3})\s+(.*)$/;
104
+
105
+ /** Splits `text` into blocks - see this module's doc comment for which. */
106
+ export function parseBlocks(text: string, depth = 0): Block[] {
107
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
108
+ const blocks: Block[] = [];
109
+ let i = 0;
110
+ while (i < lines.length) {
111
+ const line = lines[i];
112
+ if (!line.trim()) {
113
+ i++;
114
+ continue;
115
+ }
116
+ if (FENCE.test(line)) {
117
+ const body: string[] = [];
118
+ i++;
119
+ while (i < lines.length && !FENCE.test(lines[i])) {
120
+ body.push(lines[i++]);
121
+ }
122
+ i++;
123
+ blocks.push({ type: "code", text: body.join("\n") });
124
+ continue;
125
+ }
126
+ if (QUOTE.test(line) && depth < MAX_DEPTH) {
127
+ const body: string[] = [];
128
+ while (i < lines.length && QUOTE.test(lines[i])) {
129
+ body.push(QUOTE.exec(lines[i++])![1]);
130
+ }
131
+ blocks.push({ type: "quote", blocks: parseBlocks(body.join("\n"), depth + 1) });
132
+ continue;
133
+ }
134
+ const heading = HEADING.exec(line);
135
+ if (heading) {
136
+ blocks.push({ type: "heading", level: heading[1].length as 1 | 2 | 3, text: heading[2] });
137
+ i++;
138
+ continue;
139
+ }
140
+ if (BULLET.test(line) || NUMBERED.test(line)) {
141
+ const ordered = !BULLET.test(line);
142
+ const pattern = ordered ? NUMBERED : BULLET;
143
+ const items: string[] = [];
144
+ const start = ordered ? Number(NUMBERED.exec(line)![1]) : 1;
145
+ while (i < lines.length && pattern.test(lines[i])) {
146
+ const match = pattern.exec(lines[i++])!;
147
+ items.push(ordered ? match[2] : match[1]);
148
+ }
149
+ blocks.push({ type: "list", ordered, start, items });
150
+ continue;
151
+ }
152
+ const paragraph: string[] = [];
153
+ // Past MAX_DEPTH a quote line is plain text - stopping at it here would leave `i` where it is, for ever.
154
+ while (i < lines.length && lines[i].trim() && !FENCE.test(lines[i]) && (depth >= MAX_DEPTH || !QUOTE.test(lines[i])) &&!HEADING.test(lines[i]) && !BULLET.test(lines[i]) && !NUMBERED.test(lines[i])) {
155
+ paragraph.push(lines[i++]);
156
+ }
157
+ blocks.push({ type: "paragraph", lines: paragraph });
158
+ }
159
+ return blocks;
160
+ }
161
+
162
+ const ESCAPABLE = "\\`*_~[]()!#>-+.";
163
+ const EMPHASIS: { marker: string; tag: "strong" | "em" | "del" }[] = [
164
+ { marker: "**", tag: "strong" },
165
+ { marker: "__", tag: "strong" },
166
+ { marker: "~~", tag: "del" },
167
+ { marker: "*", tag: "em" },
168
+ { marker: "_", tag: "em" },
169
+ ];
170
+
171
+ /** Where the `[text](url)` (or image) starting at `open` (the `[`) ends, with its parts - or `undefined`. */
172
+ function readLink(text: string, open: number): { label: string; url: string; end: number } | undefined {
173
+ let depth = 0;
174
+ let close = -1;
175
+ for (let j = open; j < text.length; j++) {
176
+ if (text[j] === "\\") {
177
+ j++;
178
+ } else if (text[j] === "[") {
179
+ depth++;
180
+ } else if (text[j] === "]" && --depth === 0) {
181
+ close = j;
182
+ break;
183
+ }
184
+ }
185
+ if (close < 0 || text[close + 1] !== "(") {
186
+ return undefined;
187
+ }
188
+ const end = text.indexOf(")", close + 2);
189
+ if (end < 0) {
190
+ return undefined;
191
+ }
192
+ const url = text.slice(close + 2, end).trim();
193
+ return url && !/\s/.test(url) ? { label: text.slice(open + 1, close), url, end: end + 1 } : undefined;
194
+ }
195
+
196
+ /** Renders `text`'s inline formatting - see this module's doc comment. `inLink` stops a link nesting in a link. */
197
+ export function renderInline(text: string, keyPrefix = "i", depth = 0, inLink = false): React.ReactNode[] {
198
+ const nodes: React.ReactNode[] = [];
199
+ let plain = "";
200
+ const flush = () => {
201
+ if (plain) {
202
+ nodes.push(plain);
203
+ plain = "";
204
+ }
205
+ };
206
+ const key = () => `${keyPrefix}-${nodes.length}`;
207
+ let i = 0;
208
+ while (i < text.length) {
209
+ const ch = text[i];
210
+ if (ch === "\\" && i + 1 < text.length && ESCAPABLE.includes(text[i + 1])) {
211
+ plain += text[i + 1];
212
+ i += 2;
213
+ continue;
214
+ }
215
+ if (ch === "`") {
216
+ const close = text.indexOf("`", i + 1);
217
+ if (close > i + 1) {
218
+ flush();
219
+ nodes.push(
220
+ <code key={key()} className="px-1 rounded bg-black/30 font-mono text-[0.9em]">
221
+ {text.slice(i + 1, close)}
222
+ </code>,
223
+ );
224
+ i = close + 1;
225
+ continue;
226
+ }
227
+ }
228
+ if (ch === "!" && text[i + 1] === "[" && !inLink) {
229
+ const link = readLink(text, i + 1);
230
+ const src = link && safeImageUrl(link.url);
231
+ if (link && src) {
232
+ flush();
233
+ nodes.push(<ChatImage key={key()} src={src} alt={link.label} />);
234
+ i = link.end;
235
+ continue;
236
+ }
237
+ }
238
+ if (ch === "[" && !inLink) {
239
+ const link = readLink(text, i);
240
+ const href = link && safeLinkUrl(link.url);
241
+ if (link && href) {
242
+ flush();
243
+ nodes.push(
244
+ <ChatLink key={key()} href={href}>
245
+ {depth < MAX_DEPTH ? renderInline(link.label, key(), depth + 1, true) : link.label}
246
+ </ChatLink>,
247
+ );
248
+ i = link.end;
249
+ continue;
250
+ }
251
+ }
252
+ if ((ch === "h" || ch === "H") && !inLink && (i === 0 || !/[\w/]/.test(text[i - 1]))) {
253
+ const length = bareUrlLength(text.slice(i));
254
+ if (length > 0) {
255
+ const url = text.slice(i, i + length);
256
+ const href = safeLinkUrl(url);
257
+ if (href) {
258
+ flush();
259
+ nodes.push(isImageUrl(href) ? <ChatImage key={key()} src={href} alt="" fallbackLabel={url} /> : <ChatLink key={key()} href={href}>{url}</ChatLink>);
260
+ i += length;
261
+ continue;
262
+ }
263
+ }
264
+ }
265
+ const emphasis = depth < MAX_DEPTH ? EMPHASIS.find(({ marker }) => text.startsWith(marker, i)) : undefined;
266
+ if (emphasis) {
267
+ const { marker, tag } = emphasis;
268
+ const contentStart = i + marker.length;
269
+ // An underscore inside a word (snake_case) is not emphasis.
270
+ const intraword = marker[0] === "_" && i > 0 && /\w/.test(text[i - 1]);
271
+ let close = text.indexOf(marker, contentStart);
272
+ while (close > contentStart && marker === "*" && text[close + 1] === "*") {
273
+ close = text.indexOf(marker, close + 2);
274
+ }
275
+ const inner = close > contentStart ? text.slice(contentStart, close) : "";
276
+ const closesIntraword = marker[0] === "_" && /\w/.test(text[close + marker.length] ?? "");
277
+ if (!intraword && inner && !/^\s/.test(inner) && !/\s$/.test(inner) && !closesIntraword) {
278
+ flush();
279
+ const Tag = tag;
280
+ nodes.push(<Tag key={key()}>{renderInline(inner, key(), depth + 1, inLink)}</Tag>);
281
+ i = close + marker.length;
282
+ continue;
283
+ }
284
+ }
285
+ plain += ch;
286
+ i++;
287
+ }
288
+ flush();
289
+ return nodes;
290
+ }
291
+
292
+ function ChatLink({ href, children }: { href: string; children: React.ReactNode }) {
293
+ return (
294
+ <a href={href} target="_blank" rel="noopener noreferrer nofollow" className="text-[#8ab4f8] underline break-all">
295
+ {children}
296
+ </a>
297
+ );
298
+ }
299
+
300
+ /** An image in a message, linking to itself. One that fails to load becomes a plain link (`fallbackLabel`, its
301
+ * address, for a bare URL) or, for `![alt](url)`, its alt text as a link. */
302
+ export function ChatImage({ src, alt, fallbackLabel }: { src: string; alt: string; fallbackLabel?: string }) {
303
+ const [failed, setFailed] = useState(false);
304
+ if (failed) {
305
+ return <ChatLink href={src}>{fallbackLabel ?? (alt || src)}</ChatLink>;
306
+ }
307
+ return (
308
+ <a href={src} target="_blank" rel="noopener noreferrer nofollow" className="block my-1 w-fit">
309
+ <img src={src} alt={alt} loading="lazy" referrerPolicy="no-referrer" className="max-h-60 max-w-full rounded-lg" onError={() => setFailed(true)} />
310
+ </a>
311
+ );
312
+ }
313
+
314
+ function renderBlocks(blocks: Block[], keyPrefix: string): React.ReactNode[] {
315
+ return blocks.map((block, index) => {
316
+ const key = `${keyPrefix}-${index}`;
317
+ switch (block.type) {
318
+ case "paragraph":
319
+ return (
320
+ <p key={key} className="whitespace-pre-wrap break-words">
321
+ {block.lines.map((line, n) => (
322
+ <React.Fragment key={n}>
323
+ {n > 0 && <br />}
324
+ {renderInline(line, `${key}-${n}`)}
325
+ </React.Fragment>
326
+ ))}
327
+ </p>
328
+ );
329
+ case "heading": {
330
+ const sizes = { 1: "text-lg", 2: "text-base", 3: "text-sm" };
331
+ return (
332
+ <p key={key} role="heading" aria-level={block.level} className={`${sizes[block.level]} font-semibold break-words`}>
333
+ {renderInline(block.text, key)}
334
+ </p>
335
+ );
336
+ }
337
+ case "code":
338
+ return (
339
+ <pre key={key} className="my-1 p-2 rounded-lg bg-black/30 font-mono text-xs overflow-x-auto">
340
+ <code>{block.text}</code>
341
+ </pre>
342
+ );
343
+ case "quote":
344
+ return (
345
+ <blockquote key={key} className="my-1 pl-2 border-l-2 border-white/30 text-white/80">
346
+ {renderBlocks(block.blocks, key)}
347
+ </blockquote>
348
+ );
349
+ case "list": {
350
+ const items = block.items.map((item, n) => (
351
+ <li key={n} className="break-words">
352
+ {renderInline(item, `${key}-${n}`)}
353
+ </li>
354
+ ));
355
+ return block.ordered ? (
356
+ <ol key={key} start={block.start} className="list-decimal pl-5">
357
+ {items}
358
+ </ol>
359
+ ) : (
360
+ <ul key={key} className="list-disc pl-5">
361
+ {items}
362
+ </ul>
363
+ );
364
+ }
365
+ }
366
+ });
367
+ }
368
+
369
+ /** One chat message's text, rendered - see this module's doc comment. */
370
+ export default function ChatMarkdown({ text }: { text: string }) {
371
+ if (isEmojiOnly(text)) {
372
+ return <p className="text-4xl leading-tight">{text.trim()}</p>;
373
+ }
374
+ return <div className="space-y-1 text-sm">{renderBlocks(parseBlocks(text), "b")}</div>;
375
+ }
@@ -0,0 +1,67 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Hue/saturation/value conversions for the whiteboard's color wheel (`apps/meet/_ColorWheel.tsx`). Hue is in degrees
6
+ * [0, 360), saturation and value in [0, 1]; colors are lowercase `#rrggbb`. */
7
+
8
+ export interface Hsv {
9
+ h: number;
10
+ s: number;
11
+ v: number;
12
+ }
13
+
14
+ /** `[r, g, b]`, each 0-255, of an HSV color. */
15
+ export function hsvToRgb({ h, s, v }: Hsv): [number, number, number] {
16
+ const hue = ((h % 360) + 360) % 360;
17
+ const c = v * s;
18
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
19
+ const m = v - c;
20
+ const sector = Math.floor(hue / 60);
21
+ const [r, g, b] = [
22
+ [c, x, 0],
23
+ [x, c, 0],
24
+ [0, c, x],
25
+ [0, x, c],
26
+ [x, 0, c],
27
+ [c, 0, x],
28
+ ][sector];
29
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
30
+ }
31
+
32
+ export function hsvToHex(hsv: Hsv): string {
33
+ return `#${hsvToRgb(hsv)
34
+ .map((channel) => channel.toString(16).padStart(2, "0"))
35
+ .join("")}`;
36
+ }
37
+
38
+ /** The HSV of a `#rrggbb` color; black for anything else. */
39
+ export function hexToHsv(hex: string): Hsv {
40
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
41
+ if (!match) {
42
+ return { h: 0, s: 0, v: 0 };
43
+ }
44
+ const [r, g, b] = match.slice(1).map((part) => parseInt(part, 16) / 255);
45
+ const max = Math.max(r, g, b);
46
+ const delta = max - Math.min(r, g, b);
47
+ let h = 0;
48
+ if (delta > 0) {
49
+ if (max === r) {
50
+ h = 60 * (((g - b) / delta) % 6);
51
+ } else if (max === g) {
52
+ h = 60 * ((b - r) / delta + 2);
53
+ } else {
54
+ h = 60 * ((r - g) / delta + 4);
55
+ }
56
+ }
57
+ return { h: (h + 360) % 360, s: max === 0 ? 0 : delta / max, v: max };
58
+ }
59
+
60
+ /** The hue and saturation at (`x`, `y`) on a wheel of `radius` centered at (`radius`, `radius`): hue by angle
61
+ * (red at the right, going clockwise), saturation by distance from the center, clamped to the rim. */
62
+ export function wheelPoint(x: number, y: number, radius: number): { h: number; s: number } {
63
+ const dx = x - radius;
64
+ const dy = y - radius;
65
+ const h = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
66
+ return { h, s: Math.min(1, Math.hypot(dx, dy) / radius) };
67
+ }