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.
@@ -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') tell(admitted(room), {type: 'left', id: person.id, name: person.name, why});
70
- else tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
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
- tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person)});
89
- push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording});
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}} who
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
- room = {key, title, max: Math.max(2, Math.min(MAX_PEOPLE, max | 0 || MAX_PEOPLE)), recordingAllowed, recording: false, people: new Map(), away: new Set()};
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
- if (identity) for (const p of [...room.people.values()]) if (p.identity === identity) remove(room, p, 'rejoined');
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 person = {id: newId(9), secret: newId(24), identity: String(identity || ''), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
117
- host: Boolean(host), state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
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
- tell(admitted(room), {type: 'chat', from: person.id, name: person.name, text: clean});
170
- return true;
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
- tell(admitted(room), {type: 'recording', on: room.recording, by: person.name});
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 joined = rooms.join(keyOf(request.params.room), {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
61
- recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || ''});
62
- return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null});
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
- action('chat', (k, id, s, b) => rooms.chat(k, id, s, b.text));
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}}