domma-cms 0.106.0 → 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/templates/docs/usage-tools.html +7 -0
- package/package.json +1 -1
- package/plugins/_lib/call/invite.js +172 -0
- package/plugins/_lib/call/rooms.js +19 -5
- package/plugins/_lib/call/routes.js +78 -3
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +287 -23
- package/plugins/booking/CLAUDE.md +18 -1
- package/plugins/booking/docs/guide.md +1 -0
- package/plugins/booking/lib/call.js +18 -2
- package/plugins/booking/lib/store.js +2 -0
- package/plugins/booking/plugin.js +59 -6
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +4 -4
- package/plugins/booking/tests/api.test.js +114 -0
- package/plugins/free-tier.lock.json +10 -10
- package/server/routes/api/calls.js +25 -3
- 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 +30 -3
- package/server/services/plugins.js +6 -1
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
<strong>Calls</strong> list shows how many messages it has - click that to read them (only
|
|
42
42
|
you, as the one who made the call, or an admin). If you open the call again while it lasts,
|
|
43
43
|
the earlier messages are still there.</p>
|
|
44
|
+
<p><strong>Add people</strong> while the call is on: as the host, click <strong>Add people</strong>
|
|
45
|
+
in the call's control bar, search your contacts and the team, or type a name and email, and
|
|
46
|
+
click <strong>Invite</strong>. Each person gets a link of their own - by email, and team
|
|
47
|
+
members also as a notification and, when Messages is on, a message from you. They knock and
|
|
48
|
+
you let them in. <strong>Copy link</strong> gives you a link to pass on yourself. The same
|
|
49
|
+
button is in online bookings' calls (Booking Pro) and online meetings (Calendar); in a
|
|
50
|
+
Messages call it rings in another team member.</p>
|
|
44
51
|
<p>Who may start a call is the <strong>Calls</strong> permission under <strong>System >
|
|
45
52
|
Roles</strong>; Admin is given it once, the first time a Tool with calls runs. A site without
|
|
46
53
|
any of those Tools has no instant calls at all.</p>
|
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Add people" to a call that is under way - the pure parts every Tool shares.
|
|
3
|
+
*
|
|
4
|
+
* routes.js mounts the two routes (host only, see there) and calls the Tool's
|
|
5
|
+
* `invite.search(room, q, ctx)` / `invite.send(room, people, ctx)`. What goes out
|
|
6
|
+
* (the Join email, a notification, a Messages direct message) is core's:
|
|
7
|
+
* `server/services/callInvites.js`. This file holds what needs no service:
|
|
8
|
+
* the request's shape, the invite pass, the search's matching, and limits.
|
|
9
|
+
*
|
|
10
|
+
* The invite pass: an extra guest link for people added to a call. It never
|
|
11
|
+
* reuses the host's key or a guest's own token (those carry a name and an
|
|
12
|
+
* identity - someone invited with the customer's link would take the
|
|
13
|
+
* customer's place in the call). A pass is `<nonce>.<sig>`, the HMAC of
|
|
14
|
+
* `room|nonce` under a key the Tool keeps per call (`newInviteKey()`), so the
|
|
15
|
+
* key itself never leaves the server and each pass is its own identity: two
|
|
16
|
+
* invitees never replace each other, and one turned away cannot knock again
|
|
17
|
+
* with the same link. Every invite (and every "Copy link") gets a new pass.
|
|
18
|
+
*
|
|
19
|
+
* @module _lib/call/invite
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
/** People in one invite request. */
|
|
25
|
+
export const MAX_TARGETS = 10;
|
|
26
|
+
/** People one caller may invite in a minute (a "Copy link" counts as one). */
|
|
27
|
+
export const INVITES_PER_MINUTE = 20;
|
|
28
|
+
/** Rows a search answers with. */
|
|
29
|
+
export const SEARCH_MAX = 12;
|
|
30
|
+
export const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
31
|
+
const ID_RE = /^[\w-]{1,64}$/;
|
|
32
|
+
const PASS_RE = /^([\w-]{8,24})\.([\w-]{16,64})$/;
|
|
33
|
+
|
|
34
|
+
/** A new per-call key for invite passes (kept by the Tool, never sent anywhere). */
|
|
35
|
+
export const newInviteKey = () => randomBytes(24).toString('base64url');
|
|
36
|
+
|
|
37
|
+
const sign = (key, room, nonce) => createHmac('sha256', String(key)).update(`${room}|${nonce}`).digest('base64url').slice(0, 32);
|
|
38
|
+
|
|
39
|
+
/** A new invite pass for `room` under the call's `key`. */
|
|
40
|
+
export function invitePass(key, room, nonce = randomBytes(9).toString('base64url')) {
|
|
41
|
+
if (!key) throw new Error('invitePass needs the call\'s invite key');
|
|
42
|
+
return `${nonce}.${sign(key, String(room), nonce)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The pass's nonce when it was made for this room under this key, else null. */
|
|
46
|
+
export function checkInvitePass(key, room, pass) {
|
|
47
|
+
const m = PASS_RE.exec(String(pass || ''));
|
|
48
|
+
if (!key || !m) return null;
|
|
49
|
+
const want = Buffer.from(sign(key, String(room), m[1]));
|
|
50
|
+
const got = Buffer.from(m[2]);
|
|
51
|
+
return want.length === got.length && timingSafeEqual(want, got) ? m[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "jo@example.com" -> "j•••@example.com": enough to tell two Jos apart, not enough to harvest. */
|
|
55
|
+
export function maskEmail(email) {
|
|
56
|
+
const s = String(email || '').trim();
|
|
57
|
+
const at = s.lastIndexOf('@');
|
|
58
|
+
if (at < 1) return '';
|
|
59
|
+
return `${s[0]}•••${s.slice(at)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const bool = (v) => v === true;
|
|
63
|
+
const text = (v, n) => String(v ?? '').trim().replace(/\s+/g, ' ').slice(0, n);
|
|
64
|
+
/** One line of plain text, at most `n` characters (names in subjects, notices and messages). */
|
|
65
|
+
export const cleanText = (v, n = 120) => text(v, n);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The invite request, checked: `{people: [{kind: 'contact'|'user', id} | {kind: 'email', name, email}], via}` or
|
|
69
|
+
* `{error}`. Duplicates are dropped. An empty `people` is allowed (the caller wants a link to copy).
|
|
70
|
+
*
|
|
71
|
+
* @param {object} body - {people: [{kind, id} | {name, email}], via: {email, notify, message}}
|
|
72
|
+
*/
|
|
73
|
+
export function cleanInviteRequest(body = {}) {
|
|
74
|
+
const raw = Array.isArray(body?.people) ? body.people : [];
|
|
75
|
+
if (raw.length > MAX_TARGETS) return {error: `Add at most ${MAX_TARGETS} people at a time.`};
|
|
76
|
+
const people = [];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const p of raw) {
|
|
79
|
+
if (!p || typeof p !== 'object') return {error: 'Someone in the list is not a person.'};
|
|
80
|
+
if (p.kind === 'contact' || p.kind === 'user') {
|
|
81
|
+
const id = String(p.id ?? '');
|
|
82
|
+
if (!ID_RE.test(id)) return {error: 'Someone in the list is not a person.'};
|
|
83
|
+
const k = `${p.kind}:${id}`;
|
|
84
|
+
if (!seen.has(k)) { seen.add(k); people.push({kind: p.kind, id}); }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const email = text(p.email, 200).toLowerCase();
|
|
88
|
+
if (!EMAIL_RE.test(email)) return {error: `"${text(p.email, 60) || 'An email'}" does not look like an email address.`};
|
|
89
|
+
const name = text(p.name, 120) || email.split('@')[0];
|
|
90
|
+
const k = `email:${email}`;
|
|
91
|
+
if (!seen.has(k)) { seen.add(k); people.push({kind: 'email', name, email}); }
|
|
92
|
+
}
|
|
93
|
+
const via = body?.via && typeof body.via === 'object' ? body.via : {};
|
|
94
|
+
return {people, via: {email: bool(via.email), notify: bool(via.notify), message: bool(via.message)}};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Match a search against a host's contacts and the team. Contacts' emails are masked (the rows reach a browser
|
|
99
|
+
* holding only a call link) and matched only WHOLE - a part of an address would let someone rebuild a masked one
|
|
100
|
+
* letter by letter; names match on any part. Team members show no email at all.
|
|
101
|
+
*
|
|
102
|
+
* @param {{q?: string, contacts?: {id, name, email}[], users?: {id, name}[], excludeUserIds?: string[],
|
|
103
|
+
* canMessage?: boolean, limit?: number}} opts - `canMessage`: a direct message can reach team members
|
|
104
|
+
*/
|
|
105
|
+
export function matchPeople({q = '', contacts = [], users = [], excludeUserIds = [], canMessage = false, limit = SEARCH_MAX} = {}) {
|
|
106
|
+
const want = text(q, 100).toLowerCase();
|
|
107
|
+
const hit = (...vals) => !want || vals.some(v => String(v || '').toLowerCase().includes(want));
|
|
108
|
+
const skip = new Set(excludeUserIds.map(String));
|
|
109
|
+
const team = users.filter(u => u && u.id && !skip.has(String(u.id)) && hit(u.name))
|
|
110
|
+
.map(u => ({kind: 'user', id: String(u.id), name: text(u.name, 120) || 'Team member', canNotify: true, canMessage: canMessage === true}));
|
|
111
|
+
// Contacts need something typed: an empty search offers the team only.
|
|
112
|
+
const known = want ? contacts.filter(c => c && c.id && c.email && (hit(c.name) || String(c.email).trim().toLowerCase() === want))
|
|
113
|
+
.map(c => ({kind: 'contact', id: String(c.id), name: text(c.name, 120) || c.email.split('@')[0], email: maskEmail(c.email),
|
|
114
|
+
canNotify: false, canMessage: false})) : [];
|
|
115
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
116
|
+
return [...team.sort(byName), ...known.sort(byName)].slice(0, Math.max(1, limit));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A Tool's search rows, as the browser may have them. */
|
|
120
|
+
export function cleanPeople(list) {
|
|
121
|
+
return (Array.isArray(list) ? list : []).slice(0, SEARCH_MAX * 2).filter(p => p && (p.kind === 'contact' || p.kind === 'user') && ID_RE.test(String(p.id)))
|
|
122
|
+
.map(p => ({kind: p.kind, id: String(p.id), name: text(p.name, 120) || 'Someone', ...(p.email ? {email: text(p.email, 200)} : {}),
|
|
123
|
+
canNotify: p.canNotify === true, canMessage: p.canMessage === true}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A Tool's send results, as the browser may have them. */
|
|
127
|
+
export function cleanResults(list) {
|
|
128
|
+
return (Array.isArray(list) ? list : []).slice(0, MAX_TARGETS).map(r => ({name: text(r?.name, 120) || 'Someone',
|
|
129
|
+
emailed: r?.emailed === true, notified: r?.notified === true, messaged: r?.messaged === true, ...(r?.error ? {error: text(r.error, 200)} : {})}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Counts per key in a sliding window: `take(key, n)` is true (and counts n) while the key has room left.
|
|
134
|
+
*
|
|
135
|
+
* @param {{max?: number, windowMs?: number, now?: () => number}} [opts]
|
|
136
|
+
*/
|
|
137
|
+
export function createLimiter({max = INVITES_PER_MINUTE, windowMs = 60000, now = Date.now} = {}) {
|
|
138
|
+
const seen = new Map(); // key -> [times]
|
|
139
|
+
return {
|
|
140
|
+
take(key, n = 1) {
|
|
141
|
+
const t = now();
|
|
142
|
+
const recent = (seen.get(key) || []).filter(x => x > t - windowMs);
|
|
143
|
+
if (recent.length + n > max) { seen.set(key, recent); return false; }
|
|
144
|
+
for (let i = 0; i < n; i++) recent.push(t);
|
|
145
|
+
seen.set(key, recent);
|
|
146
|
+
if (seen.size > 5000) for (const [k, l] of seen) if (!l.some(x => x > t - windowMs)) seen.delete(k);
|
|
147
|
+
return true;
|
|
148
|
+
},
|
|
149
|
+
reset: () => seen.clear()
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The words of a notification / direct message: "Alex is calling you - Join". */
|
|
154
|
+
export const inviteTitle = (hostName) => `${text(hostName, 80) || 'Someone'} is calling you - Join`;
|
|
155
|
+
export const inviteMessage = (hostName, title, link) =>
|
|
156
|
+
`${text(hostName, 80) || 'Someone'} is inviting you to join a video call${title ? ` (${text(title, 120)})` : ''} now. Join here: ${link}`;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What "Add people" offers in a call, as the join tells the page (`inviteOptions`):
|
|
160
|
+
* search - search the team (and the host's contacts, when `typed`)
|
|
161
|
+
* typed - contacts and "type a name and email"
|
|
162
|
+
* copyLink - Copy link (a new pass, nothing sent)
|
|
163
|
+
* A Tool's resolve() gives `inviteOptions` (any of the three), or the older `inviteExternal: false` (team only).
|
|
164
|
+
* `external` stays for pages that read it: typed || copyLink.
|
|
165
|
+
*/
|
|
166
|
+
export function inviteOptionsOf(r = {}) {
|
|
167
|
+
const given = r.inviteOptions && typeof r.inviteOptions === 'object' ? r.inviteOptions : {};
|
|
168
|
+
const team = r.inviteExternal === false;
|
|
169
|
+
const flag = (v, dflt) => (typeof v === 'boolean' ? v : dflt);
|
|
170
|
+
const o = {search: flag(given.search, true), typed: flag(given.typed, !team), copyLink: flag(given.copyLink, !team)};
|
|
171
|
+
return {...o, external: o.typed || o.copyLink};
|
|
172
|
+
}
|
|
@@ -133,7 +133,7 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
133
133
|
tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
|
|
134
134
|
// `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
|
|
135
135
|
push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
|
|
136
|
-
chat: room.chat.map(c => ({...c}))});
|
|
136
|
+
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
|
|
137
137
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -144,10 +144,14 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
144
144
|
*
|
|
145
145
|
* @param {string} key - the room, as the Tool names it ("booking:<id>")
|
|
146
146
|
* @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean,
|
|
147
|
-
* identity?: string, history?: object[]}} who - `history` seeds the chat of a room this join CREATES (earlier
|
|
147
|
+
* identity?: string, history?: object[], canInvite?: boolean}} who - `history` seeds the chat of a room this join CREATES (earlier
|
|
148
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).
|
|
149
152
|
*/
|
|
150
|
-
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history
|
|
153
|
+
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
|
|
154
|
+
inviteOptions = null, seeHistory = true}) {
|
|
151
155
|
let room = rooms.get(key);
|
|
152
156
|
if (!room) {
|
|
153
157
|
const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
|
|
@@ -173,7 +177,7 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
173
177
|
if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
|
|
174
178
|
const id = newId(9);
|
|
175
179
|
const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
|
|
176
|
-
host: Boolean(host), state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
|
|
180
|
+
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
|
|
177
181
|
room.people.set(person.id, person);
|
|
178
182
|
if (host || autoAdmit) {
|
|
179
183
|
// admit() also tells every host - this one included - who is already waiting.
|
|
@@ -183,7 +187,17 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
183
187
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
184
188
|
}
|
|
185
189
|
return {id: person.id, secret: person.secret, who: person.who, name: person.name, host: person.host, state: person.state, title: room.title,
|
|
186
|
-
max: room.max, recordingAllowed: room.recordingAllowed};
|
|
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};
|
|
187
201
|
},
|
|
188
202
|
|
|
189
203
|
/** Events after `since`, waiting up to pollMs for the first one. */
|
|
@@ -11,6 +11,16 @@
|
|
|
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).
|
|
@@ -24,6 +34,7 @@
|
|
|
24
34
|
|
|
25
35
|
import {CallError, createRooms} from './rooms.js';
|
|
26
36
|
import {iceServers, readCallConfig} from './ice.js';
|
|
37
|
+
import {cleanInviteRequest, cleanPeople, cleanResults, createLimiter, inviteOptionsOf} from './invite.js';
|
|
27
38
|
|
|
28
39
|
const STATUS = {gone: 410, full: 409, waiting: 409, away: 403, 'not-host': 403, 'no-recording': 403};
|
|
29
40
|
const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
|
|
@@ -43,9 +54,15 @@ const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
|
|
|
43
54
|
* `save(room, item, {identity})` is handed every new item, user and system. `room` is the Tool's own room id (no
|
|
44
55
|
* namespace); `identity` is what the Tool's resolve() gave the person concerned ('' for none) - server side only.
|
|
45
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.
|
|
46
62
|
* @returns {ReturnType<typeof createRooms>} the rooms, so the Tool can close() one or count() it
|
|
47
63
|
*/
|
|
48
|
-
export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig(), chat = null
|
|
64
|
+
export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig(), chat = null, invite = null,
|
|
65
|
+
limiter = createLimiter()}) {
|
|
49
66
|
if (!ns) throw new Error('mountCallRoutes needs a namespace');
|
|
50
67
|
const keyOf = (room) => `${ns}:${String(room || '').slice(0, 120)}`;
|
|
51
68
|
const roomOf = (key) => key.slice(ns.length + 1);
|
|
@@ -83,6 +100,7 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
83
100
|
throw err;
|
|
84
101
|
};
|
|
85
102
|
|
|
103
|
+
const inviting = typeof invite?.search === 'function' && typeof invite?.send === 'function';
|
|
86
104
|
const sweeper = setInterval(() => rooms.sweep(), 5000);
|
|
87
105
|
sweeper.unref?.();
|
|
88
106
|
fastify.addHook('onClose', async () => { clearInterval(sweeper); stopListening?.(); });
|
|
@@ -98,9 +116,14 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
98
116
|
// A room being created starts from its stored chat (a room reopened later, or after a restart).
|
|
99
117
|
const history = rooms.has(key) ? undefined : await load(roomOf(key));
|
|
100
118
|
try {
|
|
119
|
+
const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
|
|
120
|
+
const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
|
|
101
121
|
const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
|
|
102
|
-
recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history
|
|
103
|
-
|
|
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} : {})});
|
|
104
127
|
} catch (err) { return fail(reply, err); }
|
|
105
128
|
});
|
|
106
129
|
|
|
@@ -133,6 +156,58 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
133
156
|
action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
|
|
134
157
|
action('leave', (k, id, s) => rooms.leave(k, id, s));
|
|
135
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
|
+
|
|
136
211
|
return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
|
|
137
212
|
historyOfRoom: (room) => rooms.history(keyOf(room)),
|
|
138
213
|
kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.dmc{--dmc-gap: .75rem;--dmc-stage-bg: #0f1114;--dmc-glass: rgba(32, 34, 38, .72);--dmc-glass-strong: rgba(28, 30, 34, .94);--dmc-ctl: rgba(255, 255, 255, .12);--dmc-ctl-hover: rgba(255, 255, 255, .2);--dmc-on-dark: #f1f3f4;--dmc-muted-dark: #9aa0a6;--dmc-red: #e5484d;--dmc-red-hover: #d13b40;--dmc-blue: #8ab4f8;--dmc-chat-bg: #0b141a;--dmc-chat-head: #202c33;--dmc-bubble-in: #202c33;--dmc-bubble-out: #005c4b;--dmc-chat-text: #e9edef;--dmc-chat-meta: rgba(233, 237, 239, .6);--dmc-chat-sys: #182229;--dmc-chat-sys-text: #8696a0;--dmc-green: #00a884;--dmc-offset: 5rem;display:flex;flex-direction:column;gap:1rem;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.5rem;margin:0;flex:1 1 auto;letter-spacing:-.01em}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.6rem;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;transition:background-color .15s,border-color .15s,color .15s,transform .1s}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:active{transform:scale(.97)}.dmc-btn:disabled{opacity:.55;cursor:default;transform:none}.dmc-btn:focus-visible,.dmc-compose:focus-visible{outline:2px solid var(--dmc-blue);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:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff;font-weight:600}.dmc-btn--go:hover{filter:brightness(1.08);border-color:transparent}.dmc-btn--small{padding:.35rem .7rem;font-size:.88rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:2rem;align-items:center}.dmc-panel--msg{display:flex;flex-direction:column;align-items:center;text-align:center;padding:4rem 1rem;gap:.25rem}.dmc-panel--msg h2{margin:.5rem 0 .25rem}.dmc-panel--msg p{color:var(--dm-text-muted, #666);margin:0 0 1rem}.dmc-pulse{width:72px;height:72px;border-radius:50%;display:grid;place-items:center;color:#fff;background:var(--dm-primary, #1f6feb);box-shadow:0 0 color-mix(in srgb,var(--dm-primary, #1f6feb) 45%,transparent);animation:dmc-pulse 2s infinite}.dmc-pulse svg{width:30px;height:30px}@keyframes dmc-pulse{70%{box-shadow:0 0 0 18px transparent}to{box-shadow:0 0 0 0 transparent}}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#16181c;border-radius:1rem;overflow:hidden;box-shadow:0 10px 30px #0000002e}.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:#cfd3d8;margin:0}.dmc-avatar-dot{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;background:#3c4043;color:#fff;font-size:2rem;font-weight:600}.dmc-preview-tools{position:absolute;left:0;right:0;bottom:1rem;display:flex;justify-content:center;gap:.75rem}.dmc-round{width:3rem;height:3rem;padding:0;border-radius:50%;border:1px solid rgba(255,255,255,.5);background:#00000059;color:#fff;backdrop-filter:blur(6px)}.dmc-round:hover{background:#00000080;border-color:#fff}.dmc-round.is-off{background:var(--dmc-red);border-color:transparent}.dmc-check h2{margin:0 0 .25rem;font-size:1.6rem}.dmc-label{display:grid;gap:.35rem;margin:1rem 0 .5rem;font-weight:600}.dmc-input{font:inherit;padding:.6rem .8rem;border:1px solid var(--dm-border, #ccc);border-radius:.6rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:.75rem 0}.dmc-chipbtn{border-radius:999px;padding:.4rem .9rem;font-size:.9rem}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-note--rec{display:flex;gap:.5rem;align-items:flex-start;font-size:.92rem}.dmc-note--rec svg{color:var(--dm-danger, #c62828);fill:currentColor;width:12px;height:12px;margin-top:.3rem}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-check .dmc-btn--go{width:100%;padding:.8rem 1rem;border-radius:999px;font-size:1.05rem;margin-top:.5rem}.dmc-call{display:flex;gap:0;color:var(--dmc-on-dark);background:var(--dmc-stage-bg);border-radius:1rem;overflow:hidden;height:calc(100vh - var(--dmc-offset) - 1rem);height:calc(100dvh - var(--dmc-offset) - 1rem);min-height:26rem;box-shadow:0 12px 40px #00000040;color-scheme:dark}.dmc-stage{position:relative;flex:1 1 auto;min-width:0;container:dmc-stage / size;overflow:hidden}.dmc-top{position:absolute;top:0;left:0;right:0;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.85rem 1.1rem 1.6rem;background:linear-gradient(rgba(0,0,0,.6),transparent);pointer-events:none}.dmc-top>*{pointer-events:auto}.dmc-top-l{display:flex;align-items:baseline;gap:.75rem;min-width:0}.dmc-top-title{margin:0;font-size:1rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-timer{font-size:.88rem;color:var(--dmc-muted-dark);font-variant-numeric:tabular-nums}.dmc-top-r{display:flex;align-items:center;gap:.5rem;flex:none}.dmc-rec{display:inline-flex;align-items:center;gap:.4rem;color:#fff;background:var(--dmc-red);border-radius:999px;padding:.22rem .7rem;font-size:.8rem;font-weight:600}.dmc-rec-dot{width:8px;height:8px;border-radius:50%;background:#fff;animation:dmc-blink 1.4s infinite}@keyframes dmc-blink{50%{opacity:.25}}.dmc-count{display:inline-flex;align-items:center;gap:.35rem;font-size:.85rem;color:#fff;background:#ffffff24;border-radius:999px;padding:.22rem .65rem}.dmc-count svg{width:15px;height:15px}.dmc-main{position:absolute;inset:0}.dmc-remote{position:absolute;inset:3.6rem 1rem 5.6rem;display:flex;flex-wrap:wrap;align-content:center;justify-content:center;gap:var(--dmc-gap);--cols: 1;--rows: 1}.dmc-cell{container-type:size;display:grid;place-items:center;min-width:0;width:calc((100% - (var(--cols) - 1) * var(--dmc-gap)) / var(--cols));height: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:#202124;border-radius:.9rem;overflow:hidden}.dmc-cell .dmc-tile{width:min(100cqw,100cqh * 16 / 9);aspect-ratio:16 / 9;box-shadow:0 4px 18px #00000059}.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,22%,6.5rem);aspect-ratio:1;border-radius:50%;display:grid;place-items:center;background:#3c4043;color:#fff;font-size:clamp(1.2rem,4cqw,2.6rem);font-weight:600}.dmc-tile--me .dmc-avatar{z-index:2;background:#202124}.dmc-chip{position:absolute;left:.6rem;bottom:.6rem;z-index:3;display:inline-flex;align-items:center;gap:.3rem;max-width:calc(100% - 1.2rem);padding:.2rem .55rem;border-radius:.45rem;background:#0000008c;backdrop-filter:blur(6px);color:#fff;font-size:.8rem;line-height:1.3}.dmc-chip>span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-chip-mute{display:inline-flex;color:#fff;background:var(--dmc-red);border-radius:50%;padding:2px}.dmc-chip-mute svg{width:12px;height:12px}.dmc-tile--me{position:absolute;z-index:2;right:1rem;bottom:5.6rem;width:clamp(9.5rem,20%,16rem);aspect-ratio:16 / 9;box-shadow:0 6px 24px #00000080;outline:1px solid rgba(255,255,255,.14);transition:all .25s ease}.dmc-tile--me .dmc-chip{left:.4rem;bottom:.4rem;font-size:.72rem;padding:.12rem .45rem}.dmc-main:not([data-tiles="0"]):not([data-tiles="1"]) .dmc-tile--me{width:clamp(8.5rem,15%,13rem)}.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:1rem;bottom:5.6rem;font-size:.8rem;padding:.2rem .55rem}.dmc-main[data-tiles="0"] .dmc-tile--me:after{content:"";position:absolute;inset:0;z-index:2;background:radial-gradient(ellipse at center,#00000040,#0009);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:clamp(1.1rem,2.4vw,1.5rem);font-weight:600;color:#fff;text-shadow:0 1px 8px rgba(0,0,0,.6)}.dmc-alone-sub{margin:.35rem 0 0;color:#dadce0;font-size:.92rem;text-shadow:0 1px 6px rgba(0,0,0,.6)}.dmc-waiting{position:absolute;z-index:4;top:3.4rem;right:1rem;width:min(21rem,calc(100% - 2rem));padding:.85rem;border-radius:.9rem;background:var(--dmc-glass-strong);backdrop-filter:blur(14px);box-shadow:0 10px 30px #00000073;border:1px solid rgba(255,255,255,.08);animation:dmc-drop .25s ease}@keyframes dmc-drop{0%{opacity:0;transform:translateY(-6px)}}.dmc-waiting h3{display:flex;align-items:center;gap:.5rem;margin:0 0 .6rem;font-size:.9rem;font-weight:600;color:#fff}.dmc-waiting-dot{width:8px;height:8px;border-radius:50%;background:#fbbc04;box-shadow:0 0 0 3px #fbbc0440}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.55rem;max-height:14rem;overflow-y:auto}.dmc-waiting li{display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.35rem .6rem}.dmc-winitial{width:2rem;height:2rem;border-radius:50%;display:grid;place-items:center;background:#5f6368;color:#fff;font-weight:600;font-size:.9rem}.dmc-wname{font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-wbtns{grid-column:2;display:flex;gap:.4rem}.dmc-waiting .dmc-btn{border-radius:999px}.dmc-waiting .dmc-btn svg{width:15px;height:15px}.dmc-waiting .dmc-btn--go{background:var(--dmc-blue);color:#202124}.dmc-btn--ghost{background:transparent;color:#e8eaed;border-color:#ffffff40}.dmc-btn--ghost:hover{background:#ffffff14;border-color:#fff6}.dmc-bar{position:absolute;z-index:4;left:50%;bottom:1rem;transform:translate(-50%);display:flex;align-items:center;gap:.6rem;padding:.55rem .7rem;border-radius:999px;background:var(--dmc-glass);backdrop-filter:blur(14px) saturate(1.3);box-shadow:0 8px 28px #0006;border:1px solid rgba(255,255,255,.08)}.dmc-tool{width:3rem;height:3rem;padding:0;border-radius:50%;border:0;background:var(--dmc-ctl);color:#fff;flex:none}.dmc-tool:hover{background:var(--dmc-ctl-hover)}.dmc-tool svg{width:20px;height:20px}.dmc-tool.is-off{background:var(--dmc-red)}.dmc-tool.is-off:hover{background:var(--dmc-red-hover)}.dmc-tool.is-on{background:var(--dmc-blue);color:#202124}.dmc-btn--rec svg{color:var(--dmc-red);fill:currentColor;width:16px;height:16px}.dmc-btn--rec.is-on{background:var(--dmc-red);color:#fff;animation:dmc-recglow 1.6s infinite}.dmc-btn--rec.is-on svg{color:#fff}@keyframes dmc-recglow{0%,to{box-shadow:0 0 #e5484d99}50%{box-shadow:0 0 0 6px #e5484d00}}.dmc-btn--leave{background:var(--dmc-red);width:4rem;border-radius:999px}.dmc-btn--leave:hover{background:var(--dmc-red-hover)}.dmc-lbl{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}.dmc-tool:after{content:attr(aria-label);position:absolute;bottom:calc(100% + .7rem);left:50%;transform:translate(-50%,4px);padding:.3rem .55rem;border-radius:.4rem;background:#3c4043;color:#fff;font-size:.75rem;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}.dmc-tool:hover:after,.dmc-tool:focus-visible:after{opacity:1;transform:translate(-50%)}.dmc-badge{position:absolute;top:-.2rem;right:-.2rem;min-width:1.2rem;height:1.2rem;padding:0 .3rem;border-radius:999px;background:var(--dmc-green);color:#fff;font-size:.7rem;font-weight:700;line-height:1.2rem;text-align:center;box-shadow:0 0 0 2px #2a2c30}@container dmc-stage (max-aspect-ratio: 1 / 1){.dmc-main[data-tiles="2"] .dmc-remote{--cols: 1;--rows: 2}.dmc-main[data-tiles="5"] .dmc-remote,.dmc-main[data-tiles="6"] .dmc-remote{--cols: 2;--rows: 3}.dmc-cell .dmc-tile{width:100%;height:100%;aspect-ratio:auto}.dmc-tile--me{width:clamp(6.5rem,28%,9rem);aspect-ratio:3 / 4}.dmc-main:not([data-tiles="0"]):not([data-tiles="1"]) .dmc-tile--me{width:clamp(5.5rem,22%,7rem)}}.dmc-chat{flex:none;width:0;display:flex;flex-direction:column;background:var(--dmc-chat-bg);color:var(--dmc-chat-text);overflow:hidden;transition:width .25s ease;border-left:1px solid rgba(255,255,255,.06)}.dmc-call.is-chat .dmc-chat{width:21.25rem}.dmc-chat>*{min-width:21.25rem}.dmc-chat-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .75rem .7rem 1rem;background:var(--dmc-chat-head)}.dmc-chat-titles{flex:1;min-width:0;display:flex;flex-direction:column}.dmc-chat-titles h3{margin:0;font-size:1rem;font-weight:600;color:var(--dmc-chat-text)}.dmc-chat-titles span{font-size:.76rem;color:var(--dmc-chat-sys-text)}.dmc-chat-back,.dmc-chat-close{width:2.25rem;height:2.25rem;border:0;border-radius:50%;background:transparent;color:#aebac1;cursor:pointer;display:grid;place-items:center}.dmc-chat-back:hover,.dmc-chat-close:hover{background:#ffffff14;color:#fff}.dmc-chat-back svg,.dmc-chat-close svg{width:20px;height:20px}.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:.6rem .9rem 1rem;flex:1;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#374045 transparent}.dmc-chat-empty{position:absolute;inset:auto 1.5rem 45%;margin:0;text-align:center;color:var(--dmc-chat-sys-text);font-size:.85rem}.dmc-msg{display:flex;flex-direction:column;margin-top:2px}.dmc-msg.is-first{margin-top:.6rem}.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:.3rem}.dmc-day{align-self:center;margin:.5rem 0 .35rem}.dmc-day span,.dmc-sys span{display:inline-block;padding:.28rem .65rem;border-radius:.5rem;background:var(--dmc-chat-sys);color:var(--dmc-chat-sys-text);font-size:.74rem;box-shadow:0 1px .5px #00000021}.dmc-day span{text-transform:uppercase;letter-spacing:.03em;font-size:.7rem}.dmc-msg.has-day.is-first{margin-top:.2rem}.dmc-bubble{position:relative;max-width:85%;padding:.38rem .55rem .3rem .6rem;border-radius:.55rem;background:var(--dmc-bubble-in);color:var(--dmc-chat-text);font-size:.9rem;line-height:1.32;box-shadow:0 1px .5px #00000021;overflow-wrap:anywhere}.is-mine .dmc-bubble{background:var(--dmc-bubble-out)}.is-first.is-mine .dmc-bubble{border-top-right-radius:0}.is-first.is-other .dmc-bubble{border-top-left-radius:0}.is-first.is-mine .dmc-bubble:after{content:"";position:absolute;top:0;right:-8px;border-top:10px solid var(--dmc-bubble-out);border-right:8px solid transparent}.is-first.is-other .dmc-bubble:after{content:"";position:absolute;top:0;left:-8px;border-top:10px solid var(--dmc-bubble-in);border-left:8px solid transparent}.dmc-from{display:block;font-size:.8rem;font-weight:600;margin-bottom:.1rem;color:#53bdeb}.dmc-text{white-space:pre-wrap}.dmc-time{float:right;position:relative;top:.3rem;margin-left:.6rem;font-size:.68rem;line-height:1;color:var(--dmc-chat-meta);font-variant-numeric:tabular-nums}.dmc-msg[data-hue="1"] .dmc-from{color:#ffd279}.dmc-msg[data-hue="2"] .dmc-from{color:#ff72a1}.dmc-msg[data-hue="3"] .dmc-from{color:#a5b337}.dmc-msg[data-hue="4"] .dmc-from{color:#06cf9c}.dmc-msg[data-hue="5"] .dmc-from{color:#f79877}.dmc-msg[data-hue="6"] .dmc-from{color:#a791ff}.dmc-msg[data-hue="7"] .dmc-from{color:#7ee2ff}.dmc-newchip{position:absolute;left:50%;bottom:.75rem;transform:translate(-50%);display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border:0;border-radius:999px;padding:.4rem .8rem;background:#2a3942;color:var(--dmc-chat-text);font:inherit;font-size:.8rem;box-shadow:0 2px 10px #0006}.dmc-newchip svg{width:15px;height:15px;color:var(--dmc-green)}.dmc-send{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .75rem;background:var(--dmc-chat-head)}.dmc-compose{flex:1;resize:none;border:0;border-radius:1.3rem;padding:.6rem 1rem;background:#2a3942;color:var(--dmc-chat-text);line-height:1.35rem;font-size:.92rem;overflow-y:hidden;max-height:calc(1.35rem * 4 + 1.2rem);display:block}.dmc-compose::placeholder{color:var(--dmc-chat-sys-text)}.dmc-compose:focus{outline:0;box-shadow:inset 0 0 0 1px #00a88499}.dmc-sendbtn{width:2.55rem;height:2.55rem;padding:0;border:0;border-radius:50%;background:var(--dmc-green);color:#fff;flex:none}.dmc-sendbtn:hover{background:#06cf9c}.dmc-sendbtn:disabled{opacity:1;background:#2a3942;color:var(--dmc-chat-sys-text)}.dmc-sendbtn svg{width:19px;height:19px;margin-left:-2px}@media(max-width:760px){.dmc-panel{grid-template-columns:1fr;gap:1.25rem}.dmc-title{font-size:1.25rem}.dmc-call{border-radius:.75rem;min-height:30rem}.dmc-top{padding:.7rem .8rem 1.4rem}.dmc-remote{inset:3.2rem .6rem 5.4rem}.dmc-tile--me{right:.75rem;bottom:5.4rem}.dmc-main[data-tiles="0"] .dmc-tile--me .dmc-chip{bottom:5.4rem}.dmc-waiting{top:3.1rem;left:.6rem;right:.6rem;width:auto}.dmc-bar{left:.5rem;right:.5rem;bottom:.5rem;transform:none;justify-content:space-around;gap:.25rem;border-radius:1.4rem;padding:.5rem .4rem calc(.5rem + env(safe-area-inset-bottom,0px))}.dmc-tool{width:3.1rem;height:3.1rem}.dmc-btn--leave{width:3.1rem}.dmc-tool:after{display:none}.dmc-chat{position:fixed;inset:0;z-index:1000;width:auto;border:0;transform:translateY(100%);transition:transform .25s ease,visibility 0s .25s;visibility:hidden}.dmc-call.is-chat .dmc-chat{width:auto;transform:none;visibility:visible;transition:transform .25s ease}.dmc-chat>*{min-width:0}.dmc-chat-back{display:grid}.dmc-chat-close{display:none}.dmc-send{padding-bottom:calc(.6rem + env(safe-area-inset-bottom,0px))}.dmc-bubble{max-width:88%}}@media(prefers-reduced-motion:reduce){.dmc *,.dmc *:after{animation:none!important;transition:none!important}}
|
|
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}}
|