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