domma-cms 0.105.2 → 0.107.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/admin/js/lib/related.js +1 -1
- package/admin/js/templates/docs/usage-tools.html +11 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/ui/transcript.js +154 -0
- package/plugins/_lib/call/chatStore.js +103 -0
- package/plugins/_lib/call/invite.js +172 -0
- package/plugins/_lib/call/rooms.js +120 -23
- package/plugins/_lib/call/routes.js +131 -6
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +493 -62
- package/plugins/booking/CLAUDE.md +27 -1
- package/plugins/booking/admin/lib/kit.js +1 -0
- package/plugins/booking/admin/views/panels.js +10 -0
- package/plugins/booking/docs/guide.md +2 -1
- package/plugins/booking/lib/call.js +18 -2
- package/plugins/booking/lib/store.js +2 -0
- package/plugins/booking/plugin.js +98 -7
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +4 -4
- package/plugins/booking/tests/api.test.js +178 -1
- package/plugins/free-tier.lock.json +12 -12
- package/server/routes/api/calls.js +48 -6
- package/server/routes/call-public.js +4 -3
- package/server/services/callInvites.js +238 -0
- package/server/services/hooks.js +55 -0
- package/server/services/instantCalls.js +83 -6
- package/server/services/plugins.js +6 -1
|
@@ -9,16 +9,23 @@
|
|
|
9
9
|
* what is waiting for them. Every request after joining carries the
|
|
10
10
|
* participant's id and secret.
|
|
11
11
|
*
|
|
12
|
+
* Each room also keeps its chat history (the last HISTORY_MAX items): what
|
|
13
|
+
* people typed, plus system lines (joined, left, recording started/stopped),
|
|
14
|
+
* so someone arriving late sees what was said. It lives as long as the room;
|
|
15
|
+
* a Tool that wants it kept longer listens with `onItem` (routes.js does, for
|
|
16
|
+
* its `chat.save`) and seeds a new room from storage with `join(..., {history})`.
|
|
17
|
+
*
|
|
12
18
|
* @module _lib/call/rooms
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
|
-
import {randomBytes, timingSafeEqual} from 'node:crypto';
|
|
21
|
+
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
16
22
|
|
|
17
23
|
export const MAX_PEOPLE = 6;
|
|
18
24
|
const SILENT_MS = 35000; // no poll for this long: gone
|
|
19
25
|
const POLL_MS = 25000; // a poll waits this long for something to happen
|
|
20
26
|
const QUEUE_MAX = 500; // events kept per participant (a stuck browser cannot grow it forever)
|
|
21
|
-
const CHAT_MAX = 1000;
|
|
27
|
+
const CHAT_MAX = 1000; // characters in one message
|
|
28
|
+
export const HISTORY_MAX = 500; // chat items a room keeps (and hands a late joiner)
|
|
22
29
|
const EMPTY_KEEP_MS = 2 * 3600e3; // an empty room remembering who was turned away
|
|
23
30
|
|
|
24
31
|
export class CallError extends Error {
|
|
@@ -28,6 +35,21 @@ export class CallError extends Error {
|
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A stable, non-reversible tag for one person across reloads: the same link (identity) always gives the same
|
|
40
|
+
* `who`, so "mine" and a sender's colour survive a reload or a restart. No identity: the participant id.
|
|
41
|
+
*/
|
|
42
|
+
export const whoOf = (identity, id) => (identity ? createHash('sha256').update(`dm-call:${identity}`).digest('base64url').slice(0, 16) : id);
|
|
43
|
+
|
|
44
|
+
/** A chat item as it is kept and handed out; anything else a store gave back is dropped. */
|
|
45
|
+
function cleanItem(raw) {
|
|
46
|
+
if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
|
|
47
|
+
const item = {id: String(raw.id).slice(0, 64), seq: Number(raw.seq) || 0, at: String(raw.at || ''), kind: raw.kind === 'system' ? 'system' : 'chat',
|
|
48
|
+
from: String(raw.from || ''), who: String(raw.who || raw.from || ''), name: String(raw.name || '').slice(0, 60), text: raw.text.slice(0, CHAT_MAX)};
|
|
49
|
+
if (raw.event) item.event = String(raw.event).slice(0, 40);
|
|
50
|
+
return item;
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
const secretOk = (a, b) => {
|
|
32
54
|
const x = Buffer.from(String(a || ''));
|
|
33
55
|
const y = Buffer.from(String(b || ''));
|
|
@@ -35,11 +57,13 @@ const secretOk = (a, b) => {
|
|
|
35
57
|
};
|
|
36
58
|
|
|
37
59
|
/**
|
|
38
|
-
* @param {{now?: () => number, pollMs?: number, silentMs?: number}} [opts]
|
|
60
|
+
* @param {{now?: () => number, pollMs?: number, silentMs?: number, onItem?: (key: string, item: object) => void}} [opts]
|
|
39
61
|
*/
|
|
40
|
-
export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT_MS} = {}) {
|
|
41
|
-
/** key -> {key, title, max, recordingAllowed, recording, people: Map<pid, person
|
|
62
|
+
export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT_MS, onItem} = {}) {
|
|
63
|
+
/** key -> {key, title, max, recordingAllowed, recording, people: Map<pid, person>, chat: item[], chatSeq} */
|
|
42
64
|
const rooms = new Map();
|
|
65
|
+
/** Told about every new chat item (user and system) - never about history a room was seeded with. */
|
|
66
|
+
const listeners = new Set(onItem ? [onItem] : []);
|
|
43
67
|
|
|
44
68
|
const newId = (n) => randomBytes(n).toString('base64url');
|
|
45
69
|
const admitted = (room) => [...room.people.values()].filter(p => p.state === 'in');
|
|
@@ -55,6 +79,23 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
55
79
|
}
|
|
56
80
|
const tell = (people, event) => people.forEach(p => push(p, event));
|
|
57
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Add one item to a room's history (capped) and hand it to the listeners. Returns the item. The listeners
|
|
84
|
+
* also get `{identity}` of the person it is about ('' for none) - for the server only, never in the item.
|
|
85
|
+
*/
|
|
86
|
+
function record(room, fields, person) {
|
|
87
|
+
room.chatSeq += 1;
|
|
88
|
+
const item = {id: newId(9), seq: room.chatSeq, at: new Date(now()).toISOString(), kind: 'chat', from: '', who: '', name: '', text: '', ...fields};
|
|
89
|
+
room.chat.push(item);
|
|
90
|
+
if (room.chat.length > HISTORY_MAX) room.chat.splice(0, room.chat.length - HISTORY_MAX);
|
|
91
|
+
for (const fn of listeners) {
|
|
92
|
+
try { fn(room.key, {...item}, {identity: person?.identity || ''}); } catch { /* a listener never breaks a call */ }
|
|
93
|
+
}
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
const system = (room, person, event, text) => record(room, {kind: 'system', event, text,
|
|
97
|
+
from: person?.id || '', who: person?.who || '', name: person?.name || ''}, person);
|
|
98
|
+
|
|
58
99
|
function me(key, pid, secret) {
|
|
59
100
|
const room = rooms.get(key);
|
|
60
101
|
const person = room?.people.get(String(pid || ''));
|
|
@@ -66,12 +107,15 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
66
107
|
function remove(room, person, why) {
|
|
67
108
|
if (!room.people.delete(person.id)) return;
|
|
68
109
|
for (const w of person.waiters.splice(0)) w();
|
|
69
|
-
if (person.state === 'in')
|
|
70
|
-
|
|
110
|
+
if (person.state === 'in') {
|
|
111
|
+
// A reload is not news: no "left" line (and the join that follows adds no "joined" line either).
|
|
112
|
+
const item = why === 'rejoined' ? undefined : system(room, person, 'left', `${person.name} left`);
|
|
113
|
+
tell(admitted(room), {type: 'left', id: person.id, name: person.name, why, ...(item && {item})});
|
|
114
|
+
} else tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
71
115
|
// The last host leaving stops a recording; nobody else can.
|
|
72
116
|
if (room.recording && !hosts(room).length) {
|
|
73
117
|
room.recording = false;
|
|
74
|
-
tell(admitted(room), {type: 'recording', on: false});
|
|
118
|
+
tell(admitted(room), {type: 'recording', on: false, item: system(room, null, 'recording-off', 'Recording stopped')});
|
|
75
119
|
}
|
|
76
120
|
// An empty room goes - or, while it remembers who was turned away, two hours after it emptied (sweep).
|
|
77
121
|
if (!room.people.size) {
|
|
@@ -82,11 +126,14 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
82
126
|
|
|
83
127
|
const waitingList = (room) => [...room.people.values()].filter(p => p.state === 'waiting').map(p => ({id: p.id, name: p.name}));
|
|
84
128
|
|
|
85
|
-
function admit(room, person) {
|
|
129
|
+
function admit(room, person, quiet = false) {
|
|
86
130
|
if (admitted(room).length >= room.max) throw new CallError('full', `This call is full (${room.max} people).`);
|
|
87
131
|
person.state = 'in';
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
|
|
133
|
+
tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
|
|
134
|
+
// `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
|
|
135
|
+
push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
|
|
136
|
+
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
|
|
90
137
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
91
138
|
}
|
|
92
139
|
|
|
@@ -96,12 +143,20 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
96
143
|
* for a host unless the room lets everyone straight in.
|
|
97
144
|
*
|
|
98
145
|
* @param {string} key - the room, as the Tool names it ("booking:<id>")
|
|
99
|
-
* @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean
|
|
146
|
+
* @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean,
|
|
147
|
+
* identity?: string, history?: object[], canInvite?: boolean}} who - `history` seeds the chat of a room this join CREATES (earlier
|
|
148
|
+
* items from storage); it is ignored when the room already exists, and never handed to the listeners.
|
|
149
|
+
* `canInvite`: this person may use the Tool's "Add people" (routes.js decides; see member()), with
|
|
150
|
+
* `inviteOptions` (what of it). `seeHistory: false`: when let in they get only the chat from their own
|
|
151
|
+
* "joined" line on - not what was said before (someone added to a conversation's call is not shown it).
|
|
100
152
|
*/
|
|
101
|
-
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = ''
|
|
153
|
+
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
|
|
154
|
+
inviteOptions = null, seeHistory = true}) {
|
|
102
155
|
let room = rooms.get(key);
|
|
103
156
|
if (!room) {
|
|
104
|
-
|
|
157
|
+
const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
|
|
158
|
+
room = {key, title, max: Math.max(2, Math.min(MAX_PEOPLE, max | 0 || MAX_PEOPLE)), recordingAllowed, recording: false, people: new Map(), away: new Set(),
|
|
159
|
+
chat: seeded, chatSeq: seeded.reduce((m, c) => Math.max(m, c.seq), 0)};
|
|
105
160
|
rooms.set(key, room);
|
|
106
161
|
}
|
|
107
162
|
room.emptySince = 0;
|
|
@@ -109,22 +164,40 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
109
164
|
if (!host && identity && room.away.has(identity)) throw new CallError('away', 'The host did not let you in to this call.');
|
|
110
165
|
// The same link joining again (a reload, a second tab) replaces its old place at once, rather than
|
|
111
166
|
// leaving a frozen copy of them in the call until the sweep notices.
|
|
112
|
-
|
|
167
|
+
let rejoin = false;
|
|
168
|
+
if (identity) {
|
|
169
|
+
for (const p of [...room.people.values()]) {
|
|
170
|
+
if (p.identity !== identity) continue;
|
|
171
|
+
rejoin = rejoin || p.state === 'in';
|
|
172
|
+
remove(room, p, 'rejoined');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
113
175
|
// remove() deletes a room it leaves empty - this one is about to have someone in it again.
|
|
114
176
|
rooms.set(key, room);
|
|
115
177
|
if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
|
|
116
|
-
const
|
|
117
|
-
|
|
178
|
+
const id = newId(9);
|
|
179
|
+
const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
|
|
180
|
+
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
|
|
118
181
|
room.people.set(person.id, person);
|
|
119
182
|
if (host || autoAdmit) {
|
|
120
183
|
// admit() also tells every host - this one included - who is already waiting.
|
|
121
|
-
admit(room, person);
|
|
184
|
+
admit(room, person, rejoin);
|
|
122
185
|
} else {
|
|
123
186
|
push(person, {type: 'knocking'});
|
|
124
187
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
125
188
|
}
|
|
126
|
-
return {id: person.id, secret: person.secret, name: person.name, host: person.host, state: person.state, title: room.title,
|
|
127
|
-
max: room.max, recordingAllowed: room.recordingAllowed};
|
|
189
|
+
return {id: person.id, secret: person.secret, who: person.who, name: person.name, host: person.host, state: person.state, title: room.title,
|
|
190
|
+
max: room.max, recordingAllowed: room.recordingAllowed, canInvite: person.canInvite};
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Who a participant is, checked by their secret (throws 'gone' like every other action): for a Tool's
|
|
195
|
+
* own actions on the call, such as "Add people". `identity` is the join's - server side only.
|
|
196
|
+
*/
|
|
197
|
+
member(key, pid, secret) {
|
|
198
|
+
const {room, person} = me(key, pid, secret);
|
|
199
|
+
return {id: person.id, name: person.name, host: person.host, identity: person.identity, canInvite: person.canInvite,
|
|
200
|
+
inviteOptions: person.inviteOptions, state: person.state, title: room.title};
|
|
128
201
|
},
|
|
129
202
|
|
|
130
203
|
/** Events after `since`, waiting up to pollMs for the first one. */
|
|
@@ -166,8 +239,10 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
166
239
|
if (person.state !== 'in') throw new CallError('waiting', 'Wait to be let in first.');
|
|
167
240
|
const clean = String(text || '').trim().slice(0, CHAT_MAX);
|
|
168
241
|
if (!clean) return false;
|
|
169
|
-
|
|
170
|
-
|
|
242
|
+
const item = record(room, {kind: 'chat', from: person.id, who: person.who, name: person.name, text: clean}, person);
|
|
243
|
+
// from/name/text stay at the top level for older pages; `item` is the whole history entry.
|
|
244
|
+
tell(admitted(room), {type: 'chat', from: person.id, name: person.name, text: clean, item});
|
|
245
|
+
return item;
|
|
171
246
|
},
|
|
172
247
|
|
|
173
248
|
/** A host lets someone in (yes) or turns them away (no). */
|
|
@@ -192,8 +267,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
192
267
|
const {room, person} = me(key, pid, secret);
|
|
193
268
|
if (!person.host || person.state !== 'in') throw new CallError('not-host', 'Only the host can record.');
|
|
194
269
|
if (!room.recordingAllowed && on) throw new CallError('no-recording', 'Recording is switched off for this call.');
|
|
270
|
+
const was = room.recording;
|
|
195
271
|
room.recording = Boolean(on);
|
|
196
|
-
|
|
272
|
+
const item = was === room.recording ? undefined
|
|
273
|
+
: system(room, person, room.recording ? 'recording-on' : 'recording-off', `${person.name} ${room.recording ? 'started' : 'stopped'} recording`);
|
|
274
|
+
tell(admitted(room), {type: 'recording', on: room.recording, by: person.name, ...(item && {item})});
|
|
197
275
|
return room.recording;
|
|
198
276
|
},
|
|
199
277
|
|
|
@@ -212,6 +290,25 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
212
290
|
}
|
|
213
291
|
},
|
|
214
292
|
|
|
293
|
+
/** Is there a room under this key right now (someone in it, waiting, or remembered)? */
|
|
294
|
+
has: (key) => rooms.has(key),
|
|
295
|
+
|
|
296
|
+
/** A copy of a room's chat history, oldest first ([] when there is no room). */
|
|
297
|
+
history(key) {
|
|
298
|
+
const room = rooms.get(key);
|
|
299
|
+
return room ? room.chat.map(c => ({...c})) : [];
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Hear about every new chat item: `fn(key, item, {identity})`, for user messages and system lines alike -
|
|
304
|
+
* `identity` is the join's identity of the person concerned ('' when none), for server-side use only (it
|
|
305
|
+
* is never put in an item). Returns a function that stops listening. A listener that throws is ignored.
|
|
306
|
+
*/
|
|
307
|
+
onItem(fn) {
|
|
308
|
+
listeners.add(fn);
|
|
309
|
+
return () => listeners.delete(fn);
|
|
310
|
+
},
|
|
311
|
+
|
|
215
312
|
/** For the admin: how many are in a room and waiting (0 when there is no call). */
|
|
216
313
|
count(key) {
|
|
217
314
|
const room = rooms.get(key);
|
|
@@ -11,15 +11,30 @@
|
|
|
11
11
|
* POST {prefix}/:room/answer {who, yes} (host: let in / turn away)
|
|
12
12
|
* POST {prefix}/:room/recording {on} (host)
|
|
13
13
|
* POST {prefix}/:room/leave
|
|
14
|
+
* GET {prefix}/:room/people?q= (invite only) -> {people: [{kind, id, name, email?, canNotify, canMessage}]}
|
|
15
|
+
* POST {prefix}/:room/invite (invite only) {people: [{kind, id} | {name, email}], via: {email, notify, message}}
|
|
16
|
+
* -> {link, results: [{name, emailed, notified, messaged, error?}]}
|
|
17
|
+
*
|
|
18
|
+
* "Add people" (optional): a Tool passing `invite: {search, send}` lets the people
|
|
19
|
+
* its resolve() marks `mayInvite` (default: the host) search and invite others
|
|
20
|
+
* while the call runs - the join answers `canInvite`, and the call screen shows
|
|
21
|
+
* the button. Checked by the caller's X-Call-Id/X-Call-Secret: someone let in,
|
|
22
|
+
* allowed to invite. Limits: MAX_TARGETS a request, INVITES_PER_MINUTE a caller
|
|
23
|
+
* (invite.js). Without `invite` the two routes do not exist.
|
|
14
24
|
*
|
|
15
25
|
* After joining, every request carries `X-Call-Id` and `X-Call-Secret`
|
|
16
26
|
* (headers, so the secret never lands in an access log).
|
|
17
27
|
*
|
|
28
|
+
* Chat history: each room keeps its own (see rooms.js). A Tool that passes
|
|
29
|
+
* `chat: {load, save}` makes it outlive the room - `load(room)` seeds a room
|
|
30
|
+
* as it is created, `save(room, item)` gets every new item. See CLAUDE.md.
|
|
31
|
+
*
|
|
18
32
|
* @module _lib/call/routes
|
|
19
33
|
*/
|
|
20
34
|
|
|
21
35
|
import {CallError, createRooms} from './rooms.js';
|
|
22
36
|
import {iceServers, readCallConfig} from './ice.js';
|
|
37
|
+
import {cleanInviteRequest, cleanPeople, cleanResults, createLimiter, inviteOptionsOf} from './invite.js';
|
|
23
38
|
|
|
24
39
|
const STATUS = {gone: 410, full: 409, waiting: 409, away: 403, 'not-host': 403, 'no-recording': 403};
|
|
25
40
|
const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
|
|
@@ -34,20 +49,61 @@ const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
|
|
|
34
49
|
* who the caller is in this room, or null when they may not join. Throw nothing: null answers 404.
|
|
35
50
|
* @param {ReturnType<typeof createRooms>} [opts.rooms] - for tests
|
|
36
51
|
* @param {() => {stun: string[], turn: object|null}} [opts.config]
|
|
52
|
+
* @param {{load?: (room: string) => Promise<object[]>, save?: (room: string, item: object, meta: {identity: string}) => Promise<void>}} [opts.chat]
|
|
53
|
+
* optional storage for the chat: `load(room)` gives the earlier items of a room being created (oldest first),
|
|
54
|
+
* `save(room, item, {identity})` is handed every new item, user and system. `room` is the Tool's own room id (no
|
|
55
|
+
* namespace); `identity` is what the Tool's resolve() gave the person concerned ('' for none) - server side only.
|
|
56
|
+
* Failures (and a load slower than 5 s) are logged and otherwise ignored: a chat never breaks a call.
|
|
57
|
+
* @param {{search: (room: string, q: string, ctx: {identity: string, name: string, request: object}) => Promise<object[]>,
|
|
58
|
+
* send: (room: string, people: object[], ctx: {identity: string, name: string, via: object, request: object}) =>
|
|
59
|
+
* Promise<{link: string, results: object[]}>}} [opts.invite] - "Add people" (see above); resolve() may add
|
|
60
|
+
* `mayInvite: boolean` (default: `host`) and `inviteExternal: false` (team members only: no contacts, typed
|
|
61
|
+
* emails or Copy link - the join answers `inviteOptions: {external}`). `people` is cleanInviteRequest()'s list; an empty one asks for a link only.
|
|
37
62
|
* @returns {ReturnType<typeof createRooms>} the rooms, so the Tool can close() one or count() it
|
|
38
63
|
*/
|
|
39
|
-
export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig()
|
|
64
|
+
export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig(), chat = null, invite = null,
|
|
65
|
+
limiter = createLimiter()}) {
|
|
40
66
|
if (!ns) throw new Error('mountCallRoutes needs a namespace');
|
|
41
67
|
const keyOf = (room) => `${ns}:${String(room || '').slice(0, 120)}`;
|
|
68
|
+
const roomOf = (key) => key.slice(ns.length + 1);
|
|
69
|
+
const log = fastify.log || console;
|
|
70
|
+
|
|
71
|
+
// ---- Chat storage (optional) --------------------------------------------------------
|
|
72
|
+
// Saves for one room run one after another, so the store sees items in order.
|
|
73
|
+
const saving = new Map(); // key -> promise of the last save
|
|
74
|
+
const save = (key, item, meta) => {
|
|
75
|
+
const next = (saving.get(key) || Promise.resolve())
|
|
76
|
+
.then(() => chat.save(roomOf(key), item, {identity: String(meta?.identity || '')}))
|
|
77
|
+
.catch(err => log.warn?.({err: err?.message || String(err), room: roomOf(key)}, '[call] chat save failed'));
|
|
78
|
+
saving.set(key, next);
|
|
79
|
+
next.then(() => { if (saving.get(key) === next) saving.delete(key); });
|
|
80
|
+
return next;
|
|
81
|
+
};
|
|
82
|
+
const stopListening = typeof chat?.save === 'function'
|
|
83
|
+
? rooms.onItem((key, item, meta) => { if (key.startsWith(`${ns}:`)) save(key, item, meta); })
|
|
84
|
+
: null;
|
|
85
|
+
const load = async (room) => {
|
|
86
|
+
if (typeof chat?.load !== 'function') return undefined;
|
|
87
|
+
let timer;
|
|
88
|
+
try {
|
|
89
|
+
const items = await Promise.race([chat.load(room),
|
|
90
|
+
new Promise((_, no) => { timer = setTimeout(() => no(new Error('timed out after 5 s')), 5000); })]);
|
|
91
|
+
return Array.isArray(items) ? items : [];
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn?.({err: err?.message || String(err), room}, '[call] chat load failed');
|
|
94
|
+
return [];
|
|
95
|
+
} finally { clearTimeout(timer); }
|
|
96
|
+
};
|
|
42
97
|
const who = (request) => [request.headers['x-call-id'], request.headers['x-call-secret']];
|
|
43
98
|
const fail = (reply, err) => {
|
|
44
99
|
if (err instanceof CallError) return reply.code(STATUS[err.code] || 400).send({error: err.message, code: err.code});
|
|
45
100
|
throw err;
|
|
46
101
|
};
|
|
47
102
|
|
|
103
|
+
const inviting = typeof invite?.search === 'function' && typeof invite?.send === 'function';
|
|
48
104
|
const sweeper = setInterval(() => rooms.sweep(), 5000);
|
|
49
105
|
sweeper.unref?.();
|
|
50
|
-
fastify.addHook('onClose', async () => clearInterval(sweeper));
|
|
106
|
+
fastify.addHook('onClose', async () => { clearInterval(sweeper); stopListening?.(); });
|
|
51
107
|
|
|
52
108
|
fastify.post(`${prefix}/:room/join`, {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
53
109
|
const body = request.body || {};
|
|
@@ -56,10 +112,18 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
56
112
|
const t = Date.now();
|
|
57
113
|
if (r.opensAt && t < Date.parse(r.opensAt)) return reply.code(425).send({error: 'This call has not opened yet.', code: 'early', opensAt: r.opensAt});
|
|
58
114
|
if (r.closesAt && t > Date.parse(r.closesAt)) return reply.code(410).send({error: 'This call has ended.', code: 'closed'});
|
|
115
|
+
const key = keyOf(request.params.room);
|
|
116
|
+
// A room being created starts from its stored chat (a room reopened later, or after a restart).
|
|
117
|
+
const history = rooms.has(key) ? undefined : await load(roomOf(key));
|
|
59
118
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
119
|
+
const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
|
|
120
|
+
const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
|
|
121
|
+
const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
|
|
122
|
+
recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history, canInvite,
|
|
123
|
+
inviteOptions, seeHistory: r.history !== false});
|
|
124
|
+
// What "Add people" offers this person: {search, typed, copyLink, external} (invite.js inviteOptionsOf).
|
|
125
|
+
return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null,
|
|
126
|
+
...(inviteOptions ? {inviteOptions} : {})});
|
|
63
127
|
} catch (err) { return fail(reply, err); }
|
|
64
128
|
});
|
|
65
129
|
|
|
@@ -79,11 +143,72 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
79
143
|
} catch (err) { return fail(reply, err); }
|
|
80
144
|
});
|
|
81
145
|
action('signal', (k, id, s, b) => rooms.signal(k, id, s, b.to, b.data));
|
|
82
|
-
|
|
146
|
+
fastify.post(`${prefix}/:room/chat`, BUSY, async (request, reply) => {
|
|
147
|
+
const [id, secret] = who(request);
|
|
148
|
+
const key = keyOf(request.params.room);
|
|
149
|
+
let item;
|
|
150
|
+
try { item = rooms.chat(key, id, secret, (request.body || {}).text); } catch (err) { return fail(reply, err); }
|
|
151
|
+
// Answered once the store has had it (a failed save is only logged).
|
|
152
|
+
if (item && saving.has(key)) await saving.get(key);
|
|
153
|
+
return reply.send({ok: true, result: item || false});
|
|
154
|
+
});
|
|
83
155
|
action('answer', (k, id, s, b) => rooms.answer(k, id, s, b.who, b.yes === true));
|
|
84
156
|
action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
|
|
85
157
|
action('leave', (k, id, s) => rooms.leave(k, id, s));
|
|
86
158
|
|
|
159
|
+
// ---- Add people (a Tool with `invite`) ---------------------------------------------------
|
|
160
|
+
if (inviting) {
|
|
161
|
+
/** The caller, in the call and allowed to invite - else the reply is already sent. */
|
|
162
|
+
const inviter = (request, reply) => {
|
|
163
|
+
const [id, secret] = who(request);
|
|
164
|
+
let m;
|
|
165
|
+
try { m = rooms.member(keyOf(request.params.room), id, secret); } catch (err) { fail(reply, err); return null; }
|
|
166
|
+
if (m.state !== 'in' || !m.canInvite) { reply.code(403).send({error: 'Only the host can add people to this call.', code: 'not-host'}); return null; }
|
|
167
|
+
return m;
|
|
168
|
+
};
|
|
169
|
+
const warn = (err, what) => log.warn?.({err: err?.message || String(err)}, `[call] ${what} failed`);
|
|
170
|
+
|
|
171
|
+
fastify.get(`${prefix}/:room/people`, {config: {rateLimit: {max: 120, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
172
|
+
const m = inviter(request, reply);
|
|
173
|
+
if (!m) return reply;
|
|
174
|
+
const q = String(request.query?.q || '').trim().slice(0, 100);
|
|
175
|
+
let people = [];
|
|
176
|
+
if (m.inviteOptions?.search === false) return reply.header('Cache-Control', 'no-store').send({people: []});
|
|
177
|
+
try { people = await invite.search(String(request.params.room), q, {identity: m.identity, name: m.name, request}); } catch (err) { warn(err, 'people search'); }
|
|
178
|
+
reply.header('Cache-Control', 'no-store');
|
|
179
|
+
return reply.send({people: cleanPeople(people)});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
fastify.post(`${prefix}/:room/invite`, {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
183
|
+
const m = inviter(request, reply);
|
|
184
|
+
if (!m) return reply;
|
|
185
|
+
const req = cleanInviteRequest(request.body || {});
|
|
186
|
+
if (req.error) return reply.code(400).send({error: req.error});
|
|
187
|
+
// What this caller was offered is what they may do (the Tool checks again).
|
|
188
|
+
const o = m.inviteOptions || {};
|
|
189
|
+
if (!req.people.length && o.copyLink === false) return reply.code(409).send({error: 'There is no link to copy for this call.'});
|
|
190
|
+
const offered = (p) => (p.kind === 'email' ? o.typed !== false : p.kind === 'contact' ? o.typed !== false && o.search !== false : o.search !== false);
|
|
191
|
+
if (!req.people.every(offered)) {
|
|
192
|
+
return reply.code(400).send({error: o.search === false ? 'Copy the link and pass it on yourself.' : 'Only team members can be added to this call.'});
|
|
193
|
+
}
|
|
194
|
+
if (!limiter.take(`${ns}:${m.identity || m.id}`, Math.max(1, req.people.length))) {
|
|
195
|
+
return reply.code(429).send({error: 'That is a lot of people in a minute - wait a moment and try again.'});
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const out = await invite.send(String(request.params.room), req.people, {identity: m.identity, name: m.name, via: req.via, request});
|
|
199
|
+
// A link to copy, or (a Tool whose links are per person - Messages) the results alone.
|
|
200
|
+
if (!out || out.error || (!out.link && !(req.people.length && Array.isArray(out.results)))) {
|
|
201
|
+
return reply.code(409).send({error: out?.error || 'People cannot be added to this call now.'});
|
|
202
|
+
}
|
|
203
|
+
return reply.send({link: String(out.link || ''), results: cleanResults(out.results)});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
warn(err, 'invite');
|
|
206
|
+
return reply.code(500).send({error: 'The invitations could not be sent.'});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
87
211
|
return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
|
|
212
|
+
historyOfRoom: (room) => rooms.history(keyOf(room)),
|
|
88
213
|
kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
|
|
89
214
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-rec svg,.dmc-count svg{width:18px;height:18px;flex:none}.dmc-rec svg{width:10px;height:10px;fill:currentColor}.dmc-count{display:inline-flex;align-items:center;gap:.35rem}.dmc-tool{flex-direction:column;gap:.2rem;min-width:5.5rem;padding:.5rem .75rem;font-size:.85rem}.dmc-btn.is-on{border-color:var(--dm-primary, #1f6feb);color:var(--dm-primary, #1f6feb)}.dmc-btn--rec.is-on{border-color:var(--dm-danger, #c62828);color:var(--dm-danger, #c62828)}.dmc-btn--rec.is-on svg{fill:currentColor}.dmc-badge{position:absolute;top:.2rem;right:.35rem;min-width:1.1rem;height:1.1rem;padding:0 .25rem;border-radius:999px;background:var(--dm-danger, #c62828);color:#fff;font-size:.7rem;line-height:1.1rem;text-align:center}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 auto;min-width:3rem}.dmc-lbl{display:none}.dmc-tool{padding:.6rem}}
|
|
1
|
+
.dmc{--dmc-accent: #4f7cff;--dmc-accent-solid: color-mix(in srgb, var(--dmc-accent) 84%, #000);--dmc-accent-soft: color-mix(in srgb, var(--dmc-accent) 18%, transparent);--dmc-accent-text: color-mix(in srgb, var(--dmc-accent) 55%, #fff);--dmc-gap: 8px;--dmc-base: #1b1d21;--dmc-panel: #25282d;--dmc-raised: #2f3338;--dmc-line: #363a40;--dmc-on-dark: #e8eaed;--dmc-muted-dark: #9aa0a8;--dmc-red: #d9453f;--dmc-red-hover: #c23a35;--dmc-page: #f3f4f6;--dmc-card: #fff;--dmc-ink: #1f2328;--dmc-ink-muted: #5b6470;--dmc-border: #dde0e4;--dmc-offset: 0px;--dmc-left: 0px;--dmc-right: 0px;display:flex;flex-direction:column;gap:1rem;color:var(--dmc-ink)}.dmc [hidden]{display:none!important}html.dmc-page,html.dmc-page body{overflow:hidden!important}.dmc.dmc--app{position:fixed;z-index:40;top:var(--dmc-offset);left:var(--dmc-left);right:var(--dmc-right);bottom:0;margin:0;background:var(--dmc-page);overflow-y:auto;overscroll-behavior:contain;padding:2.5rem 1rem;align-items:center;justify-content:safe center;gap:1rem}.dmc.dmc--app[data-stage=in]{padding:0;background:var(--dmc-base);overflow:hidden}.dmc.dmc--app>*{width:min(60rem,100%);flex:none}.dmc.dmc--app>.dmc-call{width:100%;flex:1 1 auto}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc.dmc--app:not([data-stage=check])>.dmc-head{width:min(28rem,100%);justify-content:center;text-align:center}.dmc-title{font-size:1.35rem;font-weight:600;margin:0;flex:1 1 auto;letter-spacing:-.01em;color:var(--dmc-ink)}.dmc-btn{font:inherit;font-size:.9rem;cursor:pointer;padding:.5rem .95rem;min-height:2.5rem;border-radius:8px;border:1px solid var(--dmc-border);background:var(--dmc-card);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative;font-weight:500;transition:background-color .15s,border-color .15s,color .15s}.dmc-btn:hover{border-color:#b9bec5;background:#f7f8f9}.dmc-btn:disabled{opacity:.55;cursor:default}.dmc-btn:focus-visible,.dmc-compose:focus-visible,.dmc-input:focus-visible{outline:2px solid var(--dmc-accent);outline-offset:2px}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-count svg,.dmc-note svg,.dmc-chip svg{width:18px;height:18px;flex:none}.dmc-btn.is-off{background:#fdecec;border-color:#f3c4c2;color:#b3261e}.dmc-btn--go{background:var(--dmc-accent-solid);border-color:transparent;color:#fff;font-weight:600}.dmc-btn--go:hover{background:color-mix(in srgb,var(--dmc-accent) 74%,#000);border-color:transparent}.dmc-btn--small{padding:.3rem .65rem;min-height:2rem;font-size:.82rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.75rem;align-items:center;background:var(--dmc-card);border:1px solid var(--dmc-border);border-radius:12px;padding:1.5rem;box-shadow:0 1px 2px #1018280d,0 8px 24px #1018280f}.dmc-panel--msg{display:flex;flex-direction:column;align-items:center;text-align:center;padding:2.5rem 2rem;gap:.25rem;width:min(28rem,100%)!important;align-self:center}.dmc-panel--msg h2{margin:.75rem 0 .25rem;font-size:1.2rem;font-weight:600;color:var(--dmc-ink)}.dmc-panel--msg p{color:var(--dmc-ink-muted);margin:0 0 1.25rem;font-size:.92rem;line-height:1.5}.dmc-pulse{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;color:var(--dmc-accent-solid);background:var(--dmc-accent-soft);box-shadow:0 0 color-mix(in srgb,var(--dmc-accent) 35%,transparent);animation:dmc-pulse 2.2s infinite}.dmc-pulse svg{width:26px;height:26px}@keyframes dmc-pulse{70%{box-shadow:0 0 0 16px transparent}to{box-shadow:0 0 0 0 transparent}}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:var(--dmc-base);border-radius:10px;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.6rem;color:var(--dmc-muted-dark);margin:0;font-size:.9rem}.dmc-avatar-dot{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;background:var(--dmc-raised);color:var(--dmc-on-dark);font-size:1.8rem;font-weight:600}.dmc-preview-tools{position:absolute;left:0;right:0;bottom:.75rem;display:flex;justify-content:center;gap:.5rem}.dmc-round{width:44px;height:44px;min-height:0;padding:0;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:#1b1d21b8;color:#fff;backdrop-filter:blur(6px)}.dmc-round:hover{background:#2f3338e6;border-color:#ffffff4d}.dmc-round.is-off{background:var(--dmc-red);border-color:transparent;color:#fff}.dmc-check h2{margin:0 0 .35rem;font-size:1.25rem;font-weight:600;color:var(--dmc-ink)}.dmc-label{display:grid;gap:.35rem;margin:1rem 0 .25rem;font-weight:500;font-size:.88rem;color:var(--dmc-ink)}.dmc-input{font:inherit;font-size:.95rem;padding:.55rem .75rem;min-height:2.5rem;border:1px solid var(--dmc-border);border-radius:8px;background:#fff;color:var(--dmc-ink);min-width:0}.dmc-label .dmc-input:focus{outline:0;border-color:var(--dmc-accent);box-shadow:0 0 0 3px var(--dmc-accent-soft)}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:.9rem 0}.dmc-chipbtn{font-size:.86rem}.dmc-note{color:var(--dmc-ink-muted);font-size:.9rem;line-height:1.45;margin:0}.dmc-note--rec{display:flex;gap:.5rem;align-items:flex-start;font-size:.85rem;margin:.25rem 0 0}.dmc-note--rec svg{color:var(--dmc-red);fill:currentColor;width:10px;height:10px;margin-top:.35rem}.dmc-error{color:#b3261e;font-weight:500;font-size:.9rem;margin:.5rem 0 0}.dmc-check .dmc-btn--go{width:100%;min-height:2.75rem;margin-top:1rem;font-size:.95rem}.dmc-call{display:flex;color:var(--dmc-on-dark);background:var(--dmc-base);overflow:hidden;color-scheme:dark;min-height:0;font-size:14px}.dmc-stage{position:relative;flex:1 1 auto;min-width:0;display:flex;flex-direction:column}.dmc-top{order:-1;flex:none;display:flex;align-items:center;justify-content:space-between;gap:1rem;height:44px;padding:0 1rem;background:var(--dmc-panel);border-bottom:1px solid var(--dmc-line)}.dmc-top-l{display:flex;align-items:center;gap:.75rem;min-width:0}.dmc-top .dmc-top-title{margin:0;font-size:.92rem;line-height:1.3;font-weight:600;letter-spacing:0;color:var(--dmc-on-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-timer{flex:none;font-size:.82rem;color:var(--dmc-muted-dark);font-variant-numeric:tabular-nums;padding-left:.75rem;border-left:1px solid var(--dmc-line)}.dmc-top-r{display:flex;align-items:center;gap:.6rem;flex:none}.dmc-rec{display:inline-flex;align-items:center;gap:.4rem;color:#ff8a85;background:#d9453f29;border:1px solid rgba(217,69,63,.45);border-radius:6px;padding:.12rem .5rem;font-size:.74rem;font-weight:600;letter-spacing:.02em}.dmc-rec-dot{width:7px;height:7px;border-radius:50%;background:#ff5a52;animation:dmc-blink 1.4s infinite}@keyframes dmc-blink{50%{opacity:.3}}.dmc-count{display:inline-flex;align-items:center;gap:.35rem;font-size:.82rem;color:var(--dmc-muted-dark);font-variant-numeric:tabular-nums}.dmc-count svg{width:16px;height:16px}.dmc-main{position:relative;flex:1 1 auto;min-height:0}.dmc-remote{position:absolute;inset:var(--dmc-gap);display:flex;flex-wrap:wrap;align-content:center;justify-content:center;gap:var(--dmc-gap);--cols: 1;--rows: 1}.dmc-cell{min-width:0;min-height:0;display:flex;width:var(--tile-w, calc((100% - (var(--cols) - 1) * var(--dmc-gap)) / var(--cols)));height:var(--tile-h, calc((100% - (var(--rows) - 1) * var(--dmc-gap)) / var(--rows)))}.dmc-main[data-tiles="2"] .dmc-remote{--cols: 2}.dmc-main[data-tiles="3"] .dmc-remote,.dmc-main[data-tiles="4"] .dmc-remote{--cols: 2;--rows: 2}.dmc-main[data-tiles="5"] .dmc-remote,.dmc-main[data-tiles="6"] .dmc-remote{--cols: 3;--rows: 2}.dmc-tile{position:relative;margin:0;background:var(--dmc-panel);border-radius:8px;overflow:hidden}.dmc-cell .dmc-tile{flex:1 1 auto;width:100%;height:100%}.dmc-tile video{position:relative;z-index:1}.dmc-avatar{position:absolute;inset:0;display:grid;place-items:center;z-index:0}.dmc-avatar span{width:clamp(3rem,18%,6rem);aspect-ratio:1;border-radius:50%;display:grid;place-items:center;background:var(--dmc-raised);color:var(--dmc-on-dark);font-size:clamp(1.1rem,2.4vw,2.2rem);font-weight:600}.dmc-tile--me .dmc-avatar{z-index:2;background:var(--dmc-panel)}.dmc-chip{position:absolute;left:.5rem;bottom:.5rem;z-index:3;display:inline-flex;align-items:center;gap:.3rem;max-width:calc(100% - 1rem);padding:.18rem .5rem;border-radius:6px;background:#1b1d21c7;color:var(--dmc-on-dark);font-size:.78rem;font-weight:500;line-height:1.35}.dmc-chip>span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-chip-mute{display:inline-flex;color:#ff8a85}.dmc-chip-mute svg{width:13px;height:13px}.dmc-tile--me{position:absolute;z-index:2;right:calc(var(--dmc-gap) * 2);bottom:calc(var(--dmc-gap) * 2);width:clamp(10rem,17%,15rem);aspect-ratio:16 / 9;box-shadow:0 6px 20px #00000073;outline:1px solid rgba(255,255,255,.14)}.dmc-tile--me .dmc-chip{left:.35rem;bottom:.35rem;font-size:.7rem;padding:.1rem .4rem}.dmc-main[data-tiles="0"] .dmc-tile--me{inset:0;width:auto;aspect-ratio:auto;border-radius:0;outline:0;box-shadow:none}.dmc-main[data-tiles="0"] .dmc-tile--me .dmc-chip{left:.75rem;bottom:.75rem;font-size:.78rem;padding:.18rem .5rem}.dmc-main[data-tiles="0"] .dmc-tile--me:after{content:"";position:absolute;inset:0;z-index:2;background:#1b1d218c;pointer-events:none}.dmc-alone{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:1rem;pointer-events:none}.dmc-alone-title{margin:0;font-size:1.1rem;font-weight:600;color:#fff}.dmc-alone-sub{margin:.35rem 0 0;color:#c9cdd2;font-size:.88rem}.dmc-waiting{position:absolute;z-index:4;top:calc(44px + .75rem);right:.75rem;width:min(20rem,calc(100% - 1.5rem));padding:.85rem;border-radius:10px;background:var(--dmc-raised);box-shadow:0 12px 32px #00000073;border:1px solid #40454c;animation:dmc-drop .2s ease}@keyframes dmc-drop{0%{opacity:0;transform:translateY(-4px)}}.dmc-waiting h3{display:flex;align-items:center;gap:.5rem;margin:0 0 .7rem;font-size:.85rem;font-weight:600;color:var(--dmc-on-dark)}.dmc-waiting-dot{width:8px;height:8px;border-radius:50%;background:var(--dmc-accent);box-shadow:0 0 0 3px var(--dmc-accent-soft)}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.7rem;max-height:14rem;overflow-y:auto}.dmc-waiting li{display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.4rem .6rem}.dmc-winitial{width:2rem;height:2rem;border-radius:50%;display:grid;place-items:center;background:#454a52;color:#fff;font-weight:600;font-size:.85rem}.dmc-wname{font-weight:500;color:var(--dmc-on-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-wbtns{grid-column:2;display:flex;gap:.4rem}.dmc-waiting .dmc-btn svg{width:15px;height:15px}.dmc-waiting .dmc-btn--go{background:var(--dmc-accent-solid);color:#fff}.dmc-btn--ghost{background:transparent;color:var(--dmc-on-dark);border-color:#4a4f57}.dmc-btn--ghost:hover{background:#ffffff0f;border-color:#626872}.dmc-bar{flex:none;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:.5rem;padding:.5rem 1rem;background:var(--dmc-panel);border-top:1px solid var(--dmc-line)}.dmc-bar-mid{display:flex;align-items:center;gap:.35rem}.dmc-bar-end{display:flex;justify-content:flex-end}.dmc-tool{flex-direction:column;gap:3px;min-width:4.25rem;height:3.5rem;min-height:0;padding:.3rem .6rem;border-radius:10px;border:1px solid transparent;background:transparent;color:var(--dmc-on-dark);flex:none;font-weight:400}.dmc-tool:hover{background:var(--dmc-raised);border-color:transparent}.dmc-tool svg{width:20px;height:20px}.dmc-lbl{font-size:.72rem;line-height:1;color:var(--dmc-muted-dark);white-space:nowrap}.dmc-tool:hover .dmc-lbl{color:var(--dmc-on-dark)}.dmc-tool.is-off{background:transparent;color:#ff8a85}.dmc-tool.is-off:hover{background:#d9453f24}.dmc-tool.is-off .dmc-lbl{color:#ff8a85}.dmc-tool.is-on{background:var(--dmc-accent-soft);color:var(--dmc-accent-text)}.dmc-tool.is-on .dmc-lbl{color:var(--dmc-accent-text)}.dmc-btn--rec svg{color:var(--dmc-red);fill:currentColor;width:16px;height:16px;margin:2px 0}.dmc-btn--rec.is-on{background:#d9453f2e;color:#ff8a85}.dmc-btn--rec.is-on .dmc-lbl{color:#ff8a85}.dmc-btn--rec.is-on svg{color:#ff5a52;animation:dmc-blink 1.4s infinite}.dmc-btn--leave{background:var(--dmc-red);color:#fff;flex-direction:row;gap:.45rem;height:2.75rem;min-width:0;padding:0 1.1rem;border-radius:10px;font-weight:600}.dmc-btn--leave:hover{background:var(--dmc-red-hover)}.dmc-btn--leave .dmc-lbl,.dmc-btn--leave:hover .dmc-lbl{color:#fff;font-size:.85rem}.dmc-btn--leave svg{width:18px;height:18px}.dmc-badge{position:absolute;top:2px;right:10px;min-width:1.1rem;height:1.1rem;padding:0 .28rem;border-radius:999px;background:var(--dmc-accent-solid);color:#fff;font-size:.66rem;font-weight:600;line-height:1.1rem;text-align:center;box-shadow:0 0 0 2px var(--dmc-panel)}.dmc-chat{flex:none;width:0;display:flex;flex-direction:column;background:var(--dmc-panel);color:var(--dmc-on-dark);overflow:hidden;transition:width .2s ease;border-left:1px solid var(--dmc-line);font-size:14px}.dmc-call:not(.is-chat) .dmc-chat{border-left-width:0}.dmc-call.is-chat .dmc-chat{width:20rem}.dmc-chat>*{min-width:20rem}.dmc-chat-head{flex:none;display:flex;align-items:center;gap:.5rem;height:44px;padding:0 .5rem 0 1rem;border-bottom:1px solid var(--dmc-line)}.dmc-chat-titles{flex:1;min-width:0;display:flex;align-items:baseline;gap:.6rem}.dmc-chat-titles h3{margin:0;font-size:.92rem;font-weight:600;color:var(--dmc-on-dark)}.dmc-chat-titles span{font-size:.75rem;color:var(--dmc-muted-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-chat-back,.dmc-chat-close{width:2rem;height:2rem;border:0;border-radius:8px;background:transparent;color:var(--dmc-muted-dark);cursor:pointer;display:grid;place-items:center}.dmc-chat-back:hover,.dmc-chat-close:hover{background:var(--dmc-raised);color:var(--dmc-on-dark)}.dmc-chat-back:focus-visible,.dmc-chat-close:focus-visible{outline:2px solid var(--dmc-accent);outline-offset:1px}.dmc-chat-back svg,.dmc-chat-close svg{width:18px;height:18px}.dmc-chat-back{display:none}.dmc-chat-body{position:relative;flex:1;min-height:0;display:flex}.dmc-messages{list-style:none;margin:0;padding:.5rem 1rem 1rem;flex:1;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#43484f transparent}.dmc-chat-empty{position:absolute;inset:auto 1.5rem 45%;margin:0;text-align:center;color:var(--dmc-muted-dark);font-size:.85rem;line-height:1.5}.dmc-msg{display:flex;flex-direction:column;margin-top:3px}.dmc-msg.is-first{margin-top:.85rem}.dmc-msg.is-mine{align-items:flex-end}.dmc-msg.is-other{align-items:flex-start}.dmc-msg.is-sys{align-items:center;margin-top:.6rem}.dmc-msg.is-sys+.dmc-msg.is-sys:not(.has-day){margin-top:.2rem}.dmc-msg.has-day.is-first{margin-top:.35rem}.dmc-day{align-self:stretch;display:flex;align-items:center;gap:.75rem;margin:.5rem 0;color:var(--dmc-muted-dark);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.dmc-day:before,.dmc-day:after{content:"";flex:1;height:1px;background:var(--dmc-line)}.dmc-sys{color:#80868e;font-size:.76rem;line-height:1.4;text-align:center}.dmc-meta{display:flex;align-items:baseline;gap:.5rem;margin:0 .15rem .2rem;max-width:88%}.dmc-from{font-size:.8rem;font-weight:500;color:#8fb0ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-time{font-size:.7rem;color:#80868e;font-variant-numeric:tabular-nums;flex:none}.dmc-bubble{max-width:88%;padding:.45rem .7rem;border-radius:8px;background:var(--dmc-raised);color:var(--dmc-on-dark);line-height:1.42;overflow-wrap:anywhere}.is-mine .dmc-bubble{background:color-mix(in srgb,var(--dmc-accent) 20%,var(--dmc-panel));color:#f1f4ff}.dmc-text{white-space:pre-wrap}.dmc-msg[data-hue="1"] .dmc-from{color:#6fc2ad}.dmc-msg[data-hue="2"] .dmc-from{color:#d6ad72}.dmc-msg[data-hue="3"] .dmc-from{color:#bf9fdc}.dmc-msg[data-hue="4"] .dmc-from{color:#e0948e}.dmc-msg[data-hue="5"] .dmc-from{color:#9fcb8d}.dmc-msg[data-hue="6"] .dmc-from{color:#7fbad6}.dmc-msg[data-hue="7"] .dmc-from{color:#d49fba}.dmc-newchip{position:absolute;left:50%;bottom:.75rem;transform:translate(-50%);display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border:1px solid #40454c;border-radius:8px;padding:.35rem .7rem;background:var(--dmc-raised);color:var(--dmc-on-dark);font:inherit;font-size:.78rem;box-shadow:0 4px 14px #0006}.dmc-newchip svg{width:14px;height:14px;color:var(--dmc-accent-text)}.dmc-send{flex:none;display:flex;align-items:flex-end;gap:.5rem;padding:.75rem 1rem;border-top:1px solid var(--dmc-line)}.dmc-compose{flex:1;resize:none;border:1px solid #40454c;border-radius:8px;padding:.5rem .7rem;min-height:0;background:var(--dmc-base);color:var(--dmc-on-dark);line-height:1.35rem;font-size:14px;overflow-y:hidden;max-height:calc(6.4rem + 2px);display:block}.dmc-compose::placeholder{color:#80868e}.dmc-compose:focus{outline:0;border-color:var(--dmc-accent);box-shadow:0 0 0 3px var(--dmc-accent-soft)}.dmc-sendbtn{width:2.4rem;height:2.4rem;min-height:0;padding:0;border:0;border-radius:8px;background:var(--dmc-accent-solid);color:#fff;flex:none}.dmc-sendbtn:hover{background:color-mix(in srgb,var(--dmc-accent) 74%,#000)}.dmc-sendbtn:disabled{opacity:1;background:var(--dmc-raised);color:#80868e}.dmc-sendbtn svg{width:17px;height:17px;margin-left:-2px}.dmc-invite{flex:none;width:0;display:flex;flex-direction:column;background:var(--dmc-panel);color:var(--dmc-on-dark);overflow:hidden;transition:width .2s ease;border-left:0 solid var(--dmc-line);font-size:14px}.dmc-invite.is-open{width:22rem;border-left-width:1px}.dmc-invite>*{min-width:22rem}.dmc .dmc-invite ul{margin:0;padding:0;list-style:none}.dmc .dmc-invite li{margin:0}.dmc-inv-body{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:.85rem 1rem 1rem;scrollbar-width:thin;scrollbar-color:#43484f transparent}.dmc-inv-input{font:inherit;font-size:14px;width:100%;min-width:0;min-height:2.4rem;padding:.45rem .7rem;border:1px solid #40454c;border-radius:8px;background:var(--dmc-base);color:var(--dmc-on-dark)}.dmc-inv-input::placeholder{color:#80868e}.dmc-inv-input:focus{outline:0;border-color:var(--dmc-accent);box-shadow:0 0 0 3px var(--dmc-accent-soft)}.dmc-inv-search{position:relative;display:block}.dmc-inv-search .dmc-inv-input{padding-left:2.2rem}.dmc-inv-sico{position:absolute;left:.7rem;top:50%;transform:translateY(-50%);display:inline-flex;color:#80868e;pointer-events:none}.dmc-inv-sico svg{width:16px;height:16px}.dmc-inv-label{margin:1rem 0 .4rem;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--dmc-muted-dark)}.dmc-inv-chips{margin-top:.75rem}.dmc-inv-chips ul{display:flex;flex-wrap:wrap;gap:.35rem}.dmc-inv-chip{display:inline-flex;align-items:center;gap:.35rem;max-width:100%;padding:.2rem .25rem .2rem .6rem;border-radius:6px;background:var(--dmc-accent-soft);color:var(--dmc-accent-text);font-size:.8rem}.dmc-inv-chipname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#f1f4ff;font-weight:500}.dmc-inv-chipkind{font-size:.68rem;opacity:.85}.dmc-inv-chipx{display:grid;place-items:center;width:1.4rem;height:1.4rem;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer;padding:0}.dmc-inv-chipx:hover{background:#ffffff1a}.dmc-inv-chipx:focus-visible{outline:2px solid var(--dmc-accent);outline-offset:1px}.dmc-inv-chipx svg{width:13px;height:13px}.dmc-inv-list{list-style:none;margin:0;padding:0;display:grid;gap:2px}.dmc-inv-row{width:100%;display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:.6rem;padding:.45rem .5rem;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.dmc-inv-row:hover{background:var(--dmc-raised)}.dmc-inv-row:focus-visible{outline:2px solid var(--dmc-accent);outline-offset:-2px}.dmc-inv-row.is-picked{background:var(--dmc-accent-soft)}.dmc-inv-av{width:2rem;height:2rem;border-radius:50%;display:grid;place-items:center;background:#454a52;color:#fff;font-weight:600;font-size:.82rem}.dmc-inv-who{min-width:0;display:flex;flex-direction:column}.dmc-inv-name{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-inv-mail{font-size:.75rem;color:var(--dmc-muted-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-inv-kind{font-size:.68rem;font-weight:600;padding:.1rem .4rem;border-radius:4px;border:1px solid #4a4f57;color:var(--dmc-muted-dark)}.dmc-inv-kind[data-kind=user]{border-color:transparent;background:#3a3f46;color:#c9cdd2}.dmc-inv-tick{width:1.25rem;height:1.25rem;border-radius:50%;display:grid;place-items:center;border:1.5px solid #5a6069;color:transparent}.dmc-inv-tick svg{width:12px;height:12px}.dmc-inv-row.is-picked .dmc-inv-tick{background:var(--dmc-accent-solid);border-color:transparent;color:#fff}.dmc-inv-only{margin:.25rem 0 0;color:var(--dmc-on-dark);font-size:.88rem;line-height:1.5}.dmc-inv-empty{margin:.5rem .25rem;color:var(--dmc-muted-dark);font-size:.85rem;line-height:1.45}.dmc-inv-pair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto;gap:.4rem}.dmc-inv-add{width:2.4rem;min-height:2.4rem;padding:0;background:var(--dmc-raised);border-color:#40454c;color:var(--dmc-on-dark)}.dmc-inv-add:hover{background:#3a3f46;border-color:#5a6069}.dmc-inv-add svg{width:17px;height:17px}.dmc-inv-foot{flex:none;border-top:1px solid var(--dmc-line);padding:.75rem 1rem calc(.85rem + env(safe-area-inset-bottom,0px));display:grid;gap:.55rem}.dmc-inv-via{border:0;margin:0;padding:0;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:.35rem 1rem}.dmc-inv-via legend{float:left;width:100%;margin:0 0 .1rem;padding:0;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--dmc-muted-dark)}.dmc-inv-via label{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.86rem}.dmc-inv-via input{width:16px;height:16px;margin:0;accent-color:var(--dmc-accent)}.dmc-inv-hint{margin:0;font-size:.75rem;line-height:1.45;color:#80868e}.dmc-inv-status{margin:0;font-size:.82rem;line-height:1.4;color:#9fcb8d}.dmc-inv-status.is-err{color:#ff8a85}.dmc-inv-results{list-style:none;margin:0;padding:0;display:grid;gap:.25rem;max-height:7.5rem;overflow-y:auto;font-size:.8rem}.dmc-inv-res{display:flex;gap:.4rem;line-height:1.35;color:var(--dmc-muted-dark)}.dmc-inv-res:before{content:"";flex:none;width:6px;height:6px;margin-top:.4em;border-radius:50%;background:#6fb56a}.dmc-inv-res.is-err:before{background:#e0605a}.dmc-inv-rname{color:var(--dmc-on-dark);font-weight:500;flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmc-inv-link{font-size:.78rem;color:var(--dmc-muted-dark)}.dmc-inv-btns{display:flex;gap:.5rem}.dmc-inv-btns .dmc-btn{flex:1;min-height:2.5rem}.dmc-inv-copy svg,.dmc-inv-send svg{width:16px;height:16px}.dmc-inv-send:disabled{opacity:1;background:var(--dmc-raised);color:#80868e}.dmc-tool--invite svg{width:20px;height:20px}@media(max-width:760px){.dmc.dmc--app{padding:1rem .75rem}.dmc-panel{grid-template-columns:1fr;gap:1.1rem;padding:1rem}.dmc-panel--msg{padding:2rem 1.25rem}.dmc-title{font-size:1.15rem}.dmc-top{padding:0 .75rem}.dmc-waiting{left:.5rem;right:.5rem;top:calc(44px + .5rem);width:auto}.dmc-bar{display:flex;justify-content:space-around;gap:.25rem;padding:.45rem .4rem calc(.45rem + env(safe-area-inset-bottom,0px))}.dmc-bar-mid{display:contents}.dmc-tool{width:44px;height:44px;min-width:0;padding:0;background:var(--dmc-raised)}.dmc-tool.is-off{background:#d9453f33}.dmc-lbl{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%)}.dmc-bar-side:empty{display:none}.dmc-bar-end{display:contents}.dmc-btn--leave{width:52px;padding:0;background:var(--dmc-red)}.dmc-badge{top:-4px;right:-4px}.dmc-tile--me{right:.5rem;bottom:.5rem}.dmc.dmc--app:has(.dmc-call.is-chat){z-index:1000}.dmc-chat{position:fixed;inset:0;z-index:1000;width:auto;border:0;transform:translateY(100%);transition:transform .22s ease,visibility 0s .22s;visibility:hidden}.dmc-call.is-chat .dmc-chat{width:auto;transform:none;visibility:visible;transition:transform .22s ease}.dmc-chat>*{min-width:0}.dmc-chat-back{display:grid}.dmc-chat-close{display:none}.dmc-chat-head{padding:0 .75rem 0 .35rem}.dmc-send{padding-bottom:calc(.75rem + env(safe-area-inset-bottom,0px))}.dmc.dmc--app:has(.dmc-invite.is-open){z-index:1000}.dmc-invite{position:fixed;inset:0;z-index:1000;width:auto;border:0;transform:translateY(100%);transition:transform .22s ease,visibility 0s .22s;visibility:hidden}.dmc-invite.is-open{width:auto;border:0;transform:none;visibility:visible;transition:transform .22s ease}.dmc-invite>*{min-width:0}}@media(max-width:760px)and (orientation:portrait){.dmc-tile--me{width:clamp(6rem,26%,8.5rem);aspect-ratio:3 / 4}}@media(prefers-reduced-motion:reduce){.dmc *,.dmc *:after{animation:none!important;transition:none!important}}
|