domma-cms 0.105.1 → 0.106.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.
@@ -15,6 +15,10 @@
15
15
  * After joining, every request carries `X-Call-Id` and `X-Call-Secret`
16
16
  * (headers, so the secret never lands in an access log).
17
17
  *
18
+ * Chat history: each room keeps its own (see rooms.js). A Tool that passes
19
+ * `chat: {load, save}` makes it outlive the room - `load(room)` seeds a room
20
+ * as it is created, `save(room, item)` gets every new item. See CLAUDE.md.
21
+ *
18
22
  * @module _lib/call/routes
19
23
  */
20
24
 
@@ -34,11 +38,45 @@ const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
34
38
  * who the caller is in this room, or null when they may not join. Throw nothing: null answers 404.
35
39
  * @param {ReturnType<typeof createRooms>} [opts.rooms] - for tests
36
40
  * @param {() => {stun: string[], turn: object|null}} [opts.config]
41
+ * @param {{load?: (room: string) => Promise<object[]>, save?: (room: string, item: object, meta: {identity: string}) => Promise<void>}} [opts.chat]
42
+ * optional storage for the chat: `load(room)` gives the earlier items of a room being created (oldest first),
43
+ * `save(room, item, {identity})` is handed every new item, user and system. `room` is the Tool's own room id (no
44
+ * namespace); `identity` is what the Tool's resolve() gave the person concerned ('' for none) - server side only.
45
+ * Failures (and a load slower than 5 s) are logged and otherwise ignored: a chat never breaks a call.
37
46
  * @returns {ReturnType<typeof createRooms>} the rooms, so the Tool can close() one or count() it
38
47
  */
39
- export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig()}) {
48
+ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig(), chat = null}) {
40
49
  if (!ns) throw new Error('mountCallRoutes needs a namespace');
41
50
  const keyOf = (room) => `${ns}:${String(room || '').slice(0, 120)}`;
51
+ const roomOf = (key) => key.slice(ns.length + 1);
52
+ const log = fastify.log || console;
53
+
54
+ // ---- Chat storage (optional) --------------------------------------------------------
55
+ // Saves for one room run one after another, so the store sees items in order.
56
+ const saving = new Map(); // key -> promise of the last save
57
+ const save = (key, item, meta) => {
58
+ const next = (saving.get(key) || Promise.resolve())
59
+ .then(() => chat.save(roomOf(key), item, {identity: String(meta?.identity || '')}))
60
+ .catch(err => log.warn?.({err: err?.message || String(err), room: roomOf(key)}, '[call] chat save failed'));
61
+ saving.set(key, next);
62
+ next.then(() => { if (saving.get(key) === next) saving.delete(key); });
63
+ return next;
64
+ };
65
+ const stopListening = typeof chat?.save === 'function'
66
+ ? rooms.onItem((key, item, meta) => { if (key.startsWith(`${ns}:`)) save(key, item, meta); })
67
+ : null;
68
+ const load = async (room) => {
69
+ if (typeof chat?.load !== 'function') return undefined;
70
+ let timer;
71
+ try {
72
+ const items = await Promise.race([chat.load(room),
73
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error('timed out after 5 s')), 5000); })]);
74
+ return Array.isArray(items) ? items : [];
75
+ } catch (err) {
76
+ log.warn?.({err: err?.message || String(err), room}, '[call] chat load failed');
77
+ return [];
78
+ } finally { clearTimeout(timer); }
79
+ };
42
80
  const who = (request) => [request.headers['x-call-id'], request.headers['x-call-secret']];
43
81
  const fail = (reply, err) => {
44
82
  if (err instanceof CallError) return reply.code(STATUS[err.code] || 400).send({error: err.message, code: err.code});
@@ -47,7 +85,7 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
47
85
 
48
86
  const sweeper = setInterval(() => rooms.sweep(), 5000);
49
87
  sweeper.unref?.();
50
- fastify.addHook('onClose', async () => clearInterval(sweeper));
88
+ fastify.addHook('onClose', async () => { clearInterval(sweeper); stopListening?.(); });
51
89
 
52
90
  fastify.post(`${prefix}/:room/join`, {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
53
91
  const body = request.body || {};
@@ -56,9 +94,12 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
56
94
  const t = Date.now();
57
95
  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
96
  if (r.closesAt && t > Date.parse(r.closesAt)) return reply.code(410).send({error: 'This call has ended.', code: 'closed'});
97
+ const key = keyOf(request.params.room);
98
+ // A room being created starts from its stored chat (a room reopened later, or after a restart).
99
+ const history = rooms.has(key) ? undefined : await load(roomOf(key));
59
100
  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 || ''});
101
+ 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});
62
103
  return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null});
63
104
  } catch (err) { return fail(reply, err); }
64
105
  });
@@ -79,11 +120,20 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
79
120
  } catch (err) { return fail(reply, err); }
80
121
  });
81
122
  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));
123
+ fastify.post(`${prefix}/:room/chat`, BUSY, async (request, reply) => {
124
+ const [id, secret] = who(request);
125
+ const key = keyOf(request.params.room);
126
+ let item;
127
+ try { item = rooms.chat(key, id, secret, (request.body || {}).text); } catch (err) { return fail(reply, err); }
128
+ // Answered once the store has had it (a failed save is only logged).
129
+ if (item && saving.has(key)) await saving.get(key);
130
+ return reply.send({ok: true, result: item || false});
131
+ });
83
132
  action('answer', (k, id, s, b) => rooms.answer(k, id, s, b.who, b.yes === true));
84
133
  action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
85
134
  action('leave', (k, id, s) => rooms.leave(k, id, s));
86
135
 
87
136
  return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
137
+ historyOfRoom: (room) => rooms.history(keyOf(room)),
88
138
  kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
89
139
  }
@@ -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-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}}
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Mounts on every `[data-dm-call]` element. Its JSON config:
5
5
  *
6
- * {api, room, cred: {t|key|h: '…'}, title, name?, askName?}
6
+ * {api, room, cred: {t|key|h: '…'}, title, name?, askName?, recordingAllowed?, closesAt?}
7
7
  *
8
8
  * api the Tool's call API (e.g. /api/plugins/booking-pro/call)
9
9
  * room the room id the Tool gave the link
@@ -15,6 +15,8 @@
15
15
  * Browsers connect to each other directly (a mesh - up to 6 people); the
16
16
  * site only passes the messages that set that up (see _lib/call/routes.js).
17
17
  * Every piece of text goes in through bindings (textContent), never as HTML.
18
+ * The chat shows the room's history (`admitted.chat`) then live items, de-duplicated by id
19
+ * (item shape: plugins/_lib/call/CLAUDE.md, "Chat history").
18
20
  */
19
21
 
20
22
  const M = window.M;
@@ -24,33 +26,42 @@ const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size=
24
26
  /** Two icons, one shown by a condition: the bindings are on the wrappers, never on the icons. */
25
27
  const pair = (cond, on, off) => `<span class="dmc-i" data-bind-hidden="!(${cond})">${ico(on)}</span><span class="dmc-i" data-bind-hidden="${cond}">${ico(off)}</span>`;
26
28
 
29
+ /**
30
+ * The screen. In the call it is a dark stage (tiles, a top bar, a floating control bar whose labels are for
31
+ * screen readers and the hover tooltip - CSS reads aria-label) beside a WhatsApp-style chat panel.
32
+ * Chat rows are plain objects built in JS (see addItem) - every string reaches the page as textContent.
33
+ */
27
34
  const TEMPLATE = `
28
- <div class="dmc">
29
- <header class="dmc-head">
35
+ <div class="dmc" data-bind-data-stage="stage.value">
36
+ <header class="dmc-head" data-bind-hidden="stage.value === 'in'">
30
37
  <h1 class="dmc-title" data-bind-text="title.value"></h1>
31
- <span class="dmc-rec" data-bind-hidden="!recording.value" role="status">${ico('circle')} Recording</span>
32
- <span class="dmc-count" data-bind-hidden="!countText.value">${ico('users')} <span data-bind-text="countText.value"></span></span>
33
38
  </header>
34
39
 
35
40
  <section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
36
41
  <div class="dmc-preview"><video class="dmc-self-preview" muted playsinline autoplay></video>
37
- <p class="dmc-nocam" data-bind-hidden="camOn.value">Camera off</p></div>
42
+ <div class="dmc-nocam" data-bind-hidden="camOn.value"><span class="dmc-avatar-dot" data-bind-text="initial.value"></span><span>Camera off</span></div>
43
+ <div class="dmc-preview-tools">
44
+ <button type="button" class="dmc-btn dmc-round" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-aria-label="micOn.value ? 'Turn the microphone off' : 'Turn the microphone on'">${pair('micOn.value', 'mic', 'mic-off')}</button>
45
+ <button type="button" class="dmc-btn dmc-round" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-aria-label="camOn.value ? 'Turn the camera off' : 'Turn the camera on'">${pair('camOn.value', 'video', 'video-off')}</button>
46
+ </div>
47
+ </div>
38
48
  <div class="dmc-check">
39
49
  <h2>Ready to join?</h2>
40
50
  <p class="dmc-note" data-bind-text="deviceNote.value"></p>
41
51
  <label class="dmc-label" data-bind-hidden="!askName">Your name
42
- <input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
52
+ <input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name" placeholder="How the others will see you"></label>
43
53
  <div class="dmc-row">
44
- <button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
45
- <button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
54
+ <button type="button" class="dmc-btn dmc-chipbtn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
55
+ <button type="button" class="dmc-btn dmc-chipbtn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
46
56
  </div>
47
- <p class="dmc-note" data-bind-hidden="!recordingAllowed.value">The host may record this call. You will see a red Recording notice while they do.</p>
57
+ <p class="dmc-note dmc-note--rec" data-bind-hidden="!recordingAllowed.value">${ico('circle')}<span>The host may record this call. You will see a red Recording notice while they do.</span></p>
48
58
  <p class="dmc-error" data-bind-hidden="!error.value" data-bind-text="error.value" role="alert"></p>
49
59
  <button type="button" class="dmc-btn dmc-btn--go" data-on-click="join" data-bind-disabled="busy.value">${ico('phone')}<span>Join the call</span></button>
50
60
  </div>
51
61
  </section>
52
62
 
53
63
  <section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
64
+ <div class="dmc-pulse" aria-hidden="true">${ico('users')}</div>
54
65
  <h2>Waiting for the host</h2><p>They know you are here and will let you in shortly.</p>
55
66
  <button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
56
67
  </section>
@@ -59,50 +70,92 @@ const TEMPLATE = `
59
70
  <h2 data-bind-text="endTitle.value"></h2><p data-bind-text="endText.value"></p>
60
71
  </section>
61
72
 
62
- <section class="dmc-call" data-bind-hidden="stage.value !== 'in'">
63
- <div class="dmc-main" data-bind-data-people="people.value">
64
- <div class="dmc-grid">
65
- <figure class="dmc-tile dmc-tile--me"><video class="dmc-self" muted playsinline autoplay></video>
66
- <figcaption data-bind-text="meCaption.value"></figcaption></figure>
73
+ <section class="dmc-call" data-bind-hidden="stage.value !== 'in'" data-bind-class="sideOpen.value && 'is-chat'">
74
+ <div class="dmc-stage">
75
+ <div class="dmc-main" data-bind-data-people="people.value" data-bind-data-tiles="tiles.value">
76
+ <div class="dmc-remote" data-each="peers key=id">
77
+ <div class="dmc-cell"><figure class="dmc-tile" data-bind-data-peer="id">
78
+ <div class="dmc-avatar" aria-hidden="true"><span data-bind-text="initial"></span></div>
79
+ <video class="dmc-video" playsinline autoplay></video>
80
+ <figcaption class="dmc-chip"><span data-bind-text="caption.value"></span></figcaption></figure></div>
81
+ </div>
82
+ <figure class="dmc-tile dmc-tile--me">
83
+ <div class="dmc-avatar" data-bind-hidden="camOn.value" aria-hidden="true"><span data-bind-text="initial.value"></span></div>
84
+ <video class="dmc-self" muted playsinline autoplay data-bind-hidden="!camOn.value && !sharing.value"></video>
85
+ <figcaption class="dmc-chip"><span class="dmc-chip-mute" data-bind-hidden="micOn.value" title="Your microphone is off">${ico('mic-off')}</span><span data-bind-text="meCaption.value"></span></figcaption>
86
+ </figure>
87
+ <div class="dmc-alone" data-bind-hidden="tiles.value !== '0'">
88
+ <p class="dmc-alone-title">Waiting for others to join</p>
89
+ <p class="dmc-alone-sub" data-bind-text="aloneText.value"></p>
90
+ </div>
67
91
  </div>
68
- <div class="dmc-remote" data-each="peers key=id">
69
- <figure class="dmc-tile" data-bind-data-peer="id"><video class="dmc-video" playsinline autoplay></video>
70
- <figcaption data-bind-text="caption.value"></figcaption></figure>
71
- </div>
72
- </div>
73
92
 
74
- <aside class="dmc-side" data-bind-hidden="!(sideOpen.value || waiting.value.length)">
75
- <div class="dmc-waiting" data-bind-hidden="!waiting.value.length">
76
- <h3>Waiting to join</h3>
93
+ <header class="dmc-top">
94
+ <div class="dmc-top-l">
95
+ <h2 class="dmc-top-title" data-bind-text="title.value"></h2>
96
+ <span class="dmc-timer" aria-label="Time in the call" data-bind-text="clock.value"></span>
97
+ </div>
98
+ <div class="dmc-top-r">
99
+ <span class="dmc-rec" data-bind-hidden="!recording.value" role="status"><span class="dmc-rec-dot" aria-hidden="true"></span>Recording</span>
100
+ <span class="dmc-count" data-bind-hidden="!countText.value" data-bind-title="countTitle.value" data-bind-aria-label="countTitle.value">${ico('users')}<span data-bind-text="countText.value"></span></span>
101
+ </div>
102
+ </header>
103
+
104
+ <div class="dmc-waiting" data-bind-hidden="!waiting.value.length" role="region" aria-label="Waiting to join">
105
+ <h3><span class="dmc-waiting-dot" aria-hidden="true"></span><span data-bind-text="waitingTitle.value"></span></h3>
77
106
  <ul data-each="waiting key=id">
78
- <li><span data-bind-text="name"></span>
79
- <button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">${ico('check')}<span>Let in</span></button>
80
- <button type="button" class="dmc-btn dmc-btn--small" data-on-click="$parent.turnAway($data)">${ico('x')}<span>Turn away</span></button></li>
107
+ <li><span class="dmc-winitial" aria-hidden="true" data-bind-text="initial"></span><span class="dmc-wname" data-bind-text="name"></span>
108
+ <span class="dmc-wbtns">
109
+ <button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">${ico('check')}<span>Let in</span></button>
110
+ <button type="button" class="dmc-btn dmc-btn--small dmc-btn--ghost" data-on-click="$parent.turnAway($data)">${ico('x')}<span>Turn away</span></button>
111
+ </span></li>
81
112
  </ul>
82
113
  </div>
83
- <div class="dmc-chat" data-bind-hidden="!sideOpen.value">
84
- <h3>Chat</h3>
85
- <ol class="dmc-messages" data-each="messages key=id">
86
- <li data-bind-class="mine && 'is-mine'"><strong data-bind-text="name"></strong> <span data-bind-text="text"></span></li>
114
+
115
+ <nav class="dmc-bar" aria-label="Call controls">
116
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-aria-label="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
117
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-aria-label="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
118
+ <button type="button" class="dmc-btn dmc-tool dmc-tool--share" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-aria-label="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
119
+ <button type="button" class="dmc-btn dmc-tool dmc-tool--chat" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-aria-label="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unreadText.value"></span></button>
120
+ <button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-aria-label="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
121
+ <button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" aria-label="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
122
+ </nav>
123
+ </div>
124
+
125
+ <aside class="dmc-chat" aria-label="Chat" data-bind-aria-hidden="!sideOpen.value" data-bind-inert="!sideOpen.value">
126
+ <header class="dmc-chat-head">
127
+ <button type="button" class="dmc-chat-back" data-on-click="toggleSide" aria-label="Back to the call">${ico('arrow-left')}</button>
128
+ <div class="dmc-chat-titles"><h3>Chat</h3><span data-bind-text="chatSub.value"></span></div>
129
+ <button type="button" class="dmc-chat-close" data-on-click="toggleSide" aria-label="Close the chat">${ico('x')}</button>
130
+ </header>
131
+ <div class="dmc-chat-body">
132
+ <ol class="dmc-messages" data-each="messages key=id" aria-live="polite">
133
+ <li data-bind-class="cls" data-bind-data-hue="hue">
134
+ <div class="dmc-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
135
+ <div class="dmc-sys" data-bind-hidden="!sys"><span data-bind-text="text"></span></div>
136
+ <div class="dmc-bubble" data-bind-hidden="sys"><span class="dmc-from" data-bind-hidden="!showName" data-bind-text="name"></span><span class="dmc-text" data-bind-text="text"></span><span class="dmc-time" data-bind-text="time"></span></div>
137
+ </li>
87
138
  </ol>
88
- <form class="dmc-send" data-on-submit="sendChat">
89
- <input class="dmc-input" data-model="draft.value" maxlength="1000" placeholder="Message everyone" aria-label="Message everyone">
90
- <button type="submit" class="dmc-btn dmc-btn--small" aria-label="Send">${ico('message-circle')}<span>Send</span></button>
91
- </form>
139
+ <p class="dmc-chat-empty" data-bind-hidden="messages.value.length">No messages yet. Messages are seen by everyone in the call.</p>
140
+ <button type="button" class="dmc-newchip" data-bind-hidden="!newBelow.value" data-on-click="toBottom">${ico('arrow-down')}<span>New messages</span></button>
92
141
  </div>
142
+ <form class="dmc-send" data-on-submit="sendChat">
143
+ <textarea class="dmc-input dmc-compose" rows="1" data-model="draft.value" maxlength="1000" placeholder="Type a message" aria-label="Message everyone" data-on-keydown="chatKey" data-on-input="grow"></textarea>
144
+ <button type="submit" class="dmc-btn dmc-sendbtn" aria-label="Send" data-bind-disabled="!canSend.value">${ico('send')}</button>
145
+ </form>
93
146
  </aside>
94
-
95
- <nav class="dmc-bar" aria-label="Call controls">
96
- <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-title="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
97
- <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-title="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
98
- <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-title="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
99
- <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-title="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unread.value"></span></button>
100
- <button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-title="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
101
- <button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" title="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
102
- </nav>
103
147
  </section>
104
148
  </div>`;
105
149
 
150
+ /** A stable colour for a sender (8 hues, picked by a hash of their tag). */
151
+ const hueOf = (key) => {
152
+ let h = 0;
153
+ for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) >>> 0;
154
+ return String(h % 8);
155
+ };
156
+ const initialOf = (n) => (String(n || '').trim()[0] || '?').toUpperCase();
157
+ const GROUP_MS = 5 * 60e3;
158
+
106
159
  function mount(el) {
107
160
  let cfg;
108
161
  try { cfg = JSON.parse(el.getAttribute('data-dm-call') || '{}'); } catch { cfg = {}; }
@@ -133,20 +186,30 @@ function mount(el) {
133
186
  const draft = M.observable('');
134
187
  const peers = M.observableArray([]); // {id, name, host, caption: observable}
135
188
  const waiting = M.observableArray([]); // {id, name}
136
- const messages = M.observableArray([]); // {id, name, text, mine}
189
+ const messages = M.observableArray([]); // chat rows, built by addItem()
190
+ const newBelow = M.observable(false); // the reader scrolled up and something new arrived
191
+ const clock = M.observable('0:00');
192
+ const tiles = M.observable('0'); // remote tiles on the stage (self is the picture-in-picture)
137
193
  const ended = M.observable({title: 'You have left the call', text: 'You can close this page.'});
138
194
  const max = M.observable(6);
139
195
 
140
196
  const endTitle = M.computed(() => ended.value.title);
141
197
  const endText = M.computed(() => ended.value.text);
142
- const meCaption = M.computed(() => `${name.value || 'You'} (you)${micOn.value ? '' : ' · muted'}`);
143
- const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1} of ${max.value}` : ''));
198
+ const meCaption = M.computed(() => `${name.value || 'You'} (you)`);
199
+ const initial = M.computed(() => initialOf(name.value || 'You'));
200
+ const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1}` : ''));
201
+ const countTitle = M.computed(() => `${peers.value.length + 1} of ${max.value} people in the call`);
144
202
  const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
145
203
  const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
204
+ const unreadText = M.computed(() => (unread.value > 99 ? '99+' : String(unread.value)));
205
+ const chatSub = M.computed(() => `Everyone in the call · ${peers.value.length + 1}`);
206
+ const waitingTitle = M.computed(() => (waiting.value.length === 1 ? '1 person waiting to join' : `${waiting.value.length} people waiting to join`));
207
+ const aloneText = M.computed(() => (host.value ? 'Share the link - you will be asked to let each guest in.' : 'The others will appear here as they join.'));
208
+ const canSend = M.computed(() => /\S/.test(draft.value || ''));
146
209
  const title = M.observable(cfg.title || 'Call');
147
210
 
148
211
  // ---- The connection to the site ---------------------------------------------------
149
- let me = null; // {id, secret}
212
+ let me = null; // {id, secret, who}
150
213
  let since = 0;
151
214
  let polling = false;
152
215
  const headers = () => ({'Content-Type': 'application/json', 'X-Call-Id': me?.id || '', 'X-Call-Secret': me?.secret || ''});
@@ -158,6 +221,8 @@ function mount(el) {
158
221
  let screen = null;
159
222
  let iceServers = [];
160
223
  const pcs = new Map(); // peer id -> {pc, polite, makingOffer, ignore, stream}
224
+ // peers is kept in step with tiles (a plain observable, not a computed over a computed).
225
+ const stopTiles = M.effect(() => { tiles.value = String(Math.min(6, peers.value.length)); });
161
226
 
162
227
  const selfVideos = () => el.querySelectorAll('.dmc-self, .dmc-self-preview');
163
228
  const showSelf = () => selfVideos().forEach(v => { v.srcObject = screen || local; });
@@ -263,12 +328,56 @@ function mount(el) {
263
328
  // ---- Events from the site ----------------------------------------------------------
264
329
  function addPeer(p) {
265
330
  if (p.id === me.id || peers.value.some(x => x.id === p.id)) return;
266
- const row = {id: p.id, name: p.name, host: p.host, caption: M.observable('')};
331
+ const row = {id: p.id, name: p.name, host: p.host, initial: initialOf(p.name), caption: M.observable('')};
267
332
  row.caption.value = captionFor(row, 'new');
268
333
  peers.push(row);
269
334
  connect(row);
270
335
  }
271
336
 
337
+ // ---- Chat -------------------------------------------------------------------------------
338
+ // Items come from the site as {id, seq, at, kind: 'chat'|'system', from, who, name, text, event?}: the history
339
+ // in `admitted.chat`, then one per `chat` event (and on joined/left/recording events as `item`). Each becomes a
340
+ // display row here, grouped the WhatsApp way against the row before it; the same id never shows twice.
341
+ const seenIds = new Set();
342
+ let lastRow = null;
343
+ const scroller = () => el.querySelector('.dmc-messages');
344
+ const nearBottom = () => { const l = scroller(); return !l || l.scrollHeight - l.scrollTop - l.clientHeight < 80; };
345
+ const toBottom = (smooth) => requestAnimationFrame(() => {
346
+ const l = scroller();
347
+ if (l) l.scrollTo({top: l.scrollHeight, behavior: smooth ? 'smooth' : 'auto'});
348
+ newBelow.value = false;
349
+ });
350
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
351
+ function dayLabel(d) {
352
+ const today = new Date();
353
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
354
+ if (dayKey(d) === dayKey(today)) return 'Today';
355
+ if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
356
+ return d.toLocaleDateString([], {weekday: 'short', day: 'numeric', month: 'short', ...(d.getFullYear() !== today.getFullYear() && {year: 'numeric'})});
357
+ }
358
+
359
+ function addItem(raw, {live = true} = {}) {
360
+ if (!raw || typeof raw.text !== 'string' || !raw.id || seenIds.has(raw.id)) return;
361
+ seenIds.add(raw.id);
362
+ const at = new Date(raw.at || Date.now());
363
+ const when = Number.isNaN(at.getTime()) ? new Date() : at;
364
+ const sys = raw.kind === 'system';
365
+ const sender = raw.who || raw.from || raw.name;
366
+ const mine = !sys && Boolean(me) && ((raw.who && raw.who === me.who) || raw.from === me.id);
367
+ const day = !lastRow || dayKey(lastRow.when) !== dayKey(when) ? dayLabel(when) : '';
368
+ const first = sys || Boolean(day) || !lastRow || lastRow.sys || lastRow.sender !== sender || when - lastRow.when > GROUP_MS;
369
+ const row = {id: raw.id, sys, mine, text: raw.text, name: raw.name || 'Someone', day, sender, when,
370
+ showName: first && !mine && !sys, hue: hueOf(sender),
371
+ time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
372
+ cls: ['dmc-msg', sys ? 'is-sys' : mine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()};
373
+ const stick = nearBottom() || mine || !live;
374
+ messages.push(row);
375
+ lastRow = row;
376
+ if (live && !sys && !mine && !sideOpen.value) unread.value += 1;
377
+ if (stick) toBottom(live);
378
+ else if (live) newBelow.value = true;
379
+ }
380
+
272
381
  function handle(e) {
273
382
  if (!me) return; // the call ended part-way through a batch of events
274
383
  switch (e.type) {
@@ -276,19 +385,21 @@ function mount(el) {
276
385
  case 'admitted':
277
386
  stage.value = 'in';
278
387
  recording.value = e.recording === true;
388
+ (e.chat || []).forEach(item => addItem(item, {live: false}));
279
389
  (e.peers || []).forEach(addPeer);
280
390
  showSelf();
391
+ startClock();
392
+ fit();
281
393
  break;
282
- case 'joined': addPeer(e.peer); break;
283
- case 'left': dropPeer(e.id); break;
394
+ case 'joined': addPeer(e.peer); addItem(e.item); break;
395
+ case 'left': dropPeer(e.id); addItem(e.item); break;
284
396
  case 'signal': onSignal(e.from, e.data || {}); break;
285
- case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name})); break;
397
+ case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name, initial: initialOf(w.name)})); break;
286
398
  case 'chat':
287
- messages.push({id: `${e.seq}`, name: e.name, text: e.text, mine: e.from === me.id});
288
- if (!sideOpen.value && e.from !== me.id) unread.value += 1;
289
- requestAnimationFrame(() => { const list = el.querySelector('.dmc-messages'); if (list) list.scrollTop = list.scrollHeight; });
399
+ // An older site sends from/name/text only: make an item of it (its queue seq is unique per person).
400
+ addItem(e.item || {id: `q${e.seq}`, kind: 'chat', at: new Date(e.at || Date.now()).toISOString(), from: e.from, name: e.name, text: e.text});
290
401
  break;
291
- case 'recording': recording.value = e.on === true; break;
402
+ case 'recording': recording.value = e.on === true; addItem(e.item); break;
292
403
  case 'turned-away': finish('away', 'The host did not let you in', 'You can close this page.'); break;
293
404
  case 'ended': finish('ended', 'The call has ended', 'You can close this page.'); break;
294
405
  default: break;
@@ -313,8 +424,37 @@ function mount(el) {
313
424
  }
314
425
  const pause = (ms) => new Promise(r => setTimeout(r, ms));
315
426
 
427
+ // ---- The stage: its height and the call timer ---------------------------------------------
428
+ let clockTimer = null;
429
+ function startClock() {
430
+ if (clockTimer) return;
431
+ const from = Date.now();
432
+ const tick = () => {
433
+ const s = Math.floor((Date.now() - from) / 1000);
434
+ const hh = Math.floor(s / 3600); const mm = Math.floor(s / 60) % 60; const ss = String(s % 60).padStart(2, '0');
435
+ clock.value = hh ? `${hh}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`;
436
+ };
437
+ tick();
438
+ clockTimer = setInterval(tick, 1000);
439
+ }
440
+ /** The stage fills the window below whatever the site puts above it (its header): measured, not guessed. */
441
+ function fit() {
442
+ requestAnimationFrame(() => {
443
+ const call = el.querySelector('.dmc-call');
444
+ if (!call || call.hidden) return;
445
+ const top = call.getBoundingClientRect().top + window.scrollY;
446
+ el.style.setProperty('--dmc-offset', `${Math.round(Math.max(0, Math.min(top, window.innerHeight * 0.35)))}px`);
447
+ requestAnimationFrame(() => {
448
+ const r = call.getBoundingClientRect();
449
+ if (r.bottom > window.innerHeight + 2 || r.top < 0) call.scrollIntoView({block: 'end', behavior: 'smooth'});
450
+ });
451
+ });
452
+ }
453
+ window.addEventListener('resize', () => { if (stage.value === 'in') fit(); });
454
+
316
455
  function finish(kind, title, text) {
317
456
  ended.value = {title, text};
457
+ clearInterval(clockTimer); clockTimer = null;
318
458
  stage.value = kind;
319
459
  stopRecording();
320
460
  for (const id of [...pcs.keys()]) dropPeer(id);
@@ -380,16 +520,25 @@ function mount(el) {
380
520
 
381
521
  // ---- What the buttons do -------------------------------------------------------------
382
522
  const model = {
383
- title, people, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
384
- stage, name, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
385
- draft, peers, waiting, messages, endTitle, endText, meCaption, countText,
523
+ title, people, tiles, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
524
+ stage, name, initial, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
525
+ unreadText, draft, canSend, peers, waiting, waitingTitle, messages, newBelow, endTitle, endText, meCaption, countText, countTitle,
526
+ clock, chatSub, aloneText,
386
527
 
387
528
  toggleMic() { micOn.value = !micOn.value; setTracks(); },
388
529
  toggleCam() {
389
530
  if (!local?.getVideoTracks().length) { error.value = 'No camera is available.'; return; }
390
531
  camOn.value = !camOn.value; setTracks();
391
532
  },
392
- toggleSide() { sideOpen.value = !sideOpen.value; if (sideOpen.value) unread.value = 0; },
533
+ toggleSide() {
534
+ sideOpen.value = !sideOpen.value;
535
+ if (!sideOpen.value) return;
536
+ unread.value = 0;
537
+ toBottom(false);
538
+ // Not on a phone: the keyboard would cover the messages the moment the sheet opens.
539
+ if (window.matchMedia('(min-width: 761px)').matches) setTimeout(() => el.querySelector('.dmc-compose')?.focus({preventScroll: true}), 250);
540
+ },
541
+ toBottom() { toBottom(true); },
393
542
 
394
543
  async join() {
395
544
  error.value = '';
@@ -400,7 +549,7 @@ function mount(el) {
400
549
  const body = await r.json().catch(() => ({}));
401
550
  if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
402
551
  if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
403
- me = {id: body.id, secret: body.secret};
552
+ me = {id: body.id, secret: body.secret, who: body.who || body.id};
404
553
  iceServers = body.iceServers || [];
405
554
  host.value = body.host === true;
406
555
  if (body.name) name.value = body.name;
@@ -424,11 +573,27 @@ function mount(el) {
424
573
  turnAway(w) { post('answer', {who: w.id, yes: false}); },
425
574
 
426
575
  sendChat() {
427
- const text = draft.value.trim();
576
+ const text = String(draft.value || '').trim();
428
577
  if (text && me) post('chat', {text});
429
578
  draft.value = '';
579
+ requestAnimationFrame(() => model.grow());
430
580
  return false;
431
581
  },
582
+ /** Enter sends, Shift+Enter is a new line (and nothing is sent mid-composition, e.g. an IME). */
583
+ chatKey(e) {
584
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); model.sendChat(); }
585
+ },
586
+ /** The box grows with what is typed, up to four lines, then scrolls. */
587
+ grow() {
588
+ const box = el.querySelector('.dmc-compose');
589
+ if (!box) return;
590
+ const cs = getComputedStyle(box);
591
+ const edges = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
592
+ const most = (parseFloat(cs.lineHeight) || 20) * 4 + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + edges;
593
+ box.style.height = 'auto';
594
+ box.style.height = `${Math.min(box.scrollHeight + edges, most)}px`;
595
+ box.style.overflowY = box.scrollHeight + edges > most ? 'auto' : 'hidden';
596
+ },
432
597
 
433
598
  async toggleShare() {
434
599
  // Each connection's video sender: the camera's, or one added for a screen when there was no camera
@@ -476,9 +641,11 @@ function mount(el) {
476
641
  const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
477
642
  scan();
478
643
  const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
644
+ // Reading scrolled back up: the "new messages" chip goes once they are at the bottom again.
645
+ el.querySelector('.dmc-messages')?.addEventListener('scroll', () => { if (newBelow.value && nearBottom()) newBelow.value = false; }, {passive: true});
479
646
  window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
480
647
  getDevices().then(setTracks);
481
- return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
648
+ return () => { stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
482
649
  }
483
650
 
484
651
  document.querySelectorAll('[data-dm-call]').forEach(mount);