domma-cms 0.106.0 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,9 @@ const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size=
27
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>`;
28
28
 
29
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.
30
+ * The screen. The whole of it is a full-bleed layer below the site's header (layout()). In the call: a slate
31
+ * stage (a slim top bar, tiles that fill it, a docked control bar - icon + label, icons only on a phone) beside a
32
+ * chat panel.
32
33
  * Chat rows are plain objects built in JS (see addItem) - every string reaches the page as textContent.
33
34
  */
34
35
  const TEMPLATE = `
@@ -113,12 +114,18 @@ const TEMPLATE = `
113
114
  </div>
114
115
 
115
116
  <nav class="dmc-bar" aria-label="Call controls">
117
+ <div class="dmc-bar-side" aria-hidden="true"></div>
118
+ <div class="dmc-bar-mid">
116
119
  <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
120
  <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>
121
+ <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 share' : 'Share'"></span></button>
122
+ <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">Chat</span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unreadText.value"></span></button>
123
+ <button type="button" class="dmc-btn dmc-tool dmc-tool--invite" data-slot="invite" data-on-click="toggleInvite" data-bind-hidden="!canInvite.value" data-bind-class="inviteOpen.value && 'is-on'" data-bind-aria-pressed="inviteOpen.value" aria-label="Add people">${ico('user-plus')}<span class="dmc-lbl">Add people</span></button>
120
124
  <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>
125
+ </div>
126
+ <div class="dmc-bar-side dmc-bar-end">
121
127
  <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>
128
+ </div>
122
129
  </nav>
123
130
  </div>
124
131
 
@@ -133,7 +140,8 @@ const TEMPLATE = `
133
140
  <li data-bind-class="cls" data-bind-data-hue="hue">
134
141
  <div class="dmc-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
135
142
  <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>
143
+ <div class="dmc-meta" data-bind-hidden="!showMeta"><span class="dmc-from" data-bind-hidden="!showName" data-bind-text="name"></span><span class="dmc-time" data-bind-text="time"></span></div>
144
+ <div class="dmc-bubble" data-bind-hidden="sys" data-bind-title="time"><span class="dmc-text" data-bind-text="text"></span></div>
137
145
  </li>
138
146
  </ol>
139
147
  <p class="dmc-chat-empty" data-bind-hidden="messages.value.length">No messages yet. Messages are seen by everyone in the call.</p>
@@ -144,6 +152,62 @@ const TEMPLATE = `
144
152
  <button type="submit" class="dmc-btn dmc-sendbtn" aria-label="Send" data-bind-disabled="!canSend.value">${ico('send')}</button>
145
153
  </form>
146
154
  </aside>
155
+
156
+ <aside class="dmc-invite" aria-label="Add people" data-bind-class="inviteOpen.value && 'is-open'" data-bind-aria-hidden="!inviteOpen.value" data-bind-inert="!inviteOpen.value">
157
+ <header class="dmc-chat-head">
158
+ <button type="button" class="dmc-chat-back" data-on-click="toggleInvite" aria-label="Back to the call">${ico('arrow-left')}</button>
159
+ <div class="dmc-chat-titles"><h3>Add people</h3><span data-bind-text="inviteSub.value"></span></div>
160
+ <button type="button" class="dmc-chat-close" data-on-click="toggleInvite" aria-label="Close Add people">${ico('x')}</button>
161
+ </header>
162
+ <div class="dmc-inv-body">
163
+ <p class="dmc-inv-only" data-bind-hidden="inviteSearch.value || inviteTyped.value">Make a link for each person you want to add and pass it on yourself - by email, text or chat. Each link is theirs alone: they type their name and knock, and you let them in.</p>
164
+ <div class="dmc-inv-find" data-bind-hidden="!inviteSearch.value">
165
+ <label class="dmc-inv-search"><span class="dmc-inv-sico" aria-hidden="true">${ico('search')}</span>
166
+ <input class="dmc-inv-input dmc-inv-q" type="search" data-model="query.value" data-on-input="searchSoon" maxlength="100" autocomplete="off" data-bind-placeholder="searchHint.value" data-bind-aria-label="searchHint.value"></label>
167
+ <div class="dmc-inv-chips" data-bind-hidden="!picked.value.length" aria-label="Adding">
168
+ <ul data-each="picked key=key">
169
+ <li class="dmc-inv-chip"><span class="dmc-inv-chipname" data-bind-text="name"></span><span class="dmc-inv-chipkind" data-bind-text="badge"></span>
170
+ <button type="button" class="dmc-inv-chipx" data-on-click="$parent.unpick($data)" data-bind-aria-label="removeLabel"><span class="dmc-i">${ico('x')}</span></button></li>
171
+ </ul>
172
+ </div>
173
+ <p class="dmc-inv-label" data-bind-text="foundTitle.value"></p>
174
+ <ul class="dmc-inv-list" data-each="found key=key">
175
+ <li><button type="button" class="dmc-inv-row" data-on-click="$parent.pick($data)" data-bind-class="picked.value && 'is-picked'" data-bind-aria-pressed="picked.value">
176
+ <span class="dmc-inv-av" aria-hidden="true" data-bind-text="initial"></span>
177
+ <span class="dmc-inv-who"><span class="dmc-inv-name" data-bind-text="name"></span><span class="dmc-inv-mail" data-bind-hidden="!email" data-bind-text="email"></span></span>
178
+ <span class="dmc-inv-kind" data-bind-data-kind="kind" data-bind-text="badge"></span>
179
+ <span class="dmc-inv-tick" aria-hidden="true"><span class="dmc-i">${ico('check')}</span></span>
180
+ </button></li>
181
+ </ul>
182
+ <p class="dmc-inv-empty" data-bind-hidden="!emptyText.value" data-bind-text="emptyText.value"></p>
183
+ </div>
184
+ <div class="dmc-inv-typed" data-bind-hidden="!inviteTyped.value">
185
+ <p class="dmc-inv-label">Or type a name and email</p>
186
+ <div class="dmc-inv-pair">
187
+ <input class="dmc-inv-input" data-model="typedName.value" maxlength="120" autocomplete="off" placeholder="Name" aria-label="Their name">
188
+ <input class="dmc-inv-input" type="email" data-model="typedEmail.value" maxlength="200" autocomplete="off" placeholder="name@example.com" aria-label="Their email" data-on-keydown="typedKey">
189
+ <button type="button" class="dmc-btn dmc-inv-add" data-on-click="addTyped" aria-label="Add this person">${ico('plus')}</button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ <div class="dmc-inv-foot">
194
+ <fieldset class="dmc-inv-via" data-bind-hidden="!inviteSearch.value && !inviteTyped.value"><legend>Send them</legend>
195
+ <label><input type="checkbox" data-model="viaNotify.value"><span>Notification</span></label>
196
+ <label data-bind-hidden="!canMessage.value"><input type="checkbox" data-model="viaMessage.value"><span>Message</span></label>
197
+ <label><input type="checkbox" data-model="viaEmail.value"><span>Email</span></label>
198
+ </fieldset>
199
+ <p class="dmc-inv-hint" data-bind-hidden="!inviteSearch.value && !inviteTyped.value" data-bind-text="viaHint.value"></p>
200
+ <p class="dmc-inv-status" role="status" data-bind-hidden="!inviteNote.value" data-bind-class="inviteBad.value && 'is-err'" data-bind-text="inviteNote.value"></p>
201
+ <ul class="dmc-inv-results" data-each="sent key=id">
202
+ <li data-bind-class="cls"><span class="dmc-inv-rname" data-bind-text="name"></span><span class="dmc-inv-rtext" data-bind-text="text"></span></li>
203
+ </ul>
204
+ <input class="dmc-inv-input dmc-inv-link" readonly data-bind-hidden="!copiedLink.value" data-bind-value="copiedLink.value" aria-label="The Join link" data-on-focus="selectLink">
205
+ <div class="dmc-inv-btns">
206
+ <button type="button" class="dmc-btn dmc-btn--ghost dmc-inv-copy" data-bind-hidden="!inviteCopy.value" data-on-click="copyLink" data-bind-disabled="inviteBusy.value">${ico('copy')}<span>Copy link</span></button>
207
+ <button type="button" class="dmc-btn dmc-btn--go dmc-inv-send" data-bind-hidden="!inviteSearch.value && !inviteTyped.value" data-on-click="sendInvites" data-bind-disabled="!canSendInvites.value">${ico('send')}<span data-bind-text="sendLabel.value"></span></button>
208
+ </div>
209
+ </div>
210
+ </aside>
147
211
  </section>
148
212
  </div>`;
149
213
 
@@ -155,6 +219,8 @@ const hueOf = (key) => {
155
219
  };
156
220
  const initialOf = (n) => (String(n || '').trim()[0] || '?').toUpperCase();
157
221
  const GROUP_MS = 5 * 60e3;
222
+ const INVITE_MAX = 10; // people in one invite (the engine's MAX_TARGETS)
223
+ const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
158
224
 
159
225
  function mount(el) {
160
226
  let cfg;
@@ -208,6 +274,41 @@ function mount(el) {
208
274
  const canSend = M.computed(() => /\S/.test(draft.value || ''));
209
275
  const title = M.observable(cfg.title || 'Call');
210
276
 
277
+ // ---- Add people (the host, when the Tool allows it: join answers canInvite) ----------------
278
+ const canInvite = M.observable(false);
279
+ // What the Tool offers this host (join's inviteOptions): search the team (+ contacts when typed), contacts and
280
+ // "type a name and email", Copy link. Messages: search only. Booking's host with no account on the site: Copy link only.
281
+ const inviteSearch = M.observable(true);
282
+ const inviteTyped = M.observable(true);
283
+ const inviteCopy = M.observable(true);
284
+ const inviteOpen = M.observable(false);
285
+ const query = M.observable('');
286
+ const found = M.observableArray([]); // search rows {key, kind, id, name, email, badge, initial, picked: observable}
287
+ const picked = M.observableArray([]); // chips {key, kind, id, name, email?, badge, removeLabel}
288
+ const typedName = M.observable('');
289
+ const typedEmail = M.observable('');
290
+ const viaNotify = M.observable(true);
291
+ const viaMessage = M.observable(true);
292
+ const viaEmail = M.observable(true);
293
+ const canMessage = M.observable(false); // a search said a direct message can reach the team
294
+ const inviteBusy = M.observable(false);
295
+ const inviteNote = M.observable('');
296
+ const inviteBad = M.observable(false);
297
+ const sent = M.observableArray([]); // {id, name, text, cls}
298
+ const copiedLink = M.observable('');
299
+ const searched = M.observable(false);
300
+ const inviteSub = M.computed(() => (!inviteSearch.value ? 'Copy a link to pass on' : inviteTyped.value ? 'Contacts, the team or any email' : 'Ring another team member'));
301
+ const searchHint = M.computed(() => (inviteTyped.value ? 'Search your contacts and team' : 'Search the team'));
302
+ const foundTitle = M.computed(() => ((query.value || '').trim() ? 'Results' : 'Your team'));
303
+ const emptyText = M.computed(() => (!searched.value || found.value.length ? ''
304
+ : (query.value || '').trim() ? `Nobody found.${inviteTyped.value ? ' Type their name and email below.' : ''}` : 'Nobody else to add.'));
305
+ const viaHint = M.computed(() => (inviteTyped.value
306
+ ? 'Notification and Message reach team members only; contacts and typed addresses get the email. Everyone knocks and you let them in.'
307
+ : 'They get a notification and a message in Messages with their own link; tick Email to send one too.'));
308
+ const canSendInvites = M.computed(() => picked.value.length > 0 && !inviteBusy.value
309
+ && (viaEmail.value || viaNotify.value || (viaMessage.value && canMessage.value)));
310
+ const sendLabel = M.computed(() => (picked.value.length > 1 ? `Invite ${picked.value.length}` : 'Invite'));
311
+
211
312
  // ---- The connection to the site ---------------------------------------------------
212
313
  let me = null; // {id, secret, who}
213
314
  let since = 0;
@@ -337,7 +438,7 @@ function mount(el) {
337
438
  // ---- Chat -------------------------------------------------------------------------------
338
439
  // Items come from the site as {id, seq, at, kind: 'chat'|'system', from, who, name, text, event?}: the history
339
440
  // 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.
441
+ // display row here, grouped against the row before it (a name + time line opens each group); the same id never shows twice.
341
442
  const seenIds = new Set();
342
443
  let lastRow = null;
343
444
  const scroller = () => el.querySelector('.dmc-messages');
@@ -367,7 +468,7 @@ function mount(el) {
367
468
  const day = !lastRow || dayKey(lastRow.when) !== dayKey(when) ? dayLabel(when) : '';
368
469
  const first = sys || Boolean(day) || !lastRow || lastRow.sys || lastRow.sender !== sender || when - lastRow.when > GROUP_MS;
369
470
  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),
471
+ showName: first && !mine && !sys, showMeta: first && !sys, hue: hueOf(sender),
371
472
  time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
372
473
  cls: ['dmc-msg', sys ? 'is-sys' : mine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()};
373
474
  const stick = nearBottom() || mine || !live;
@@ -389,7 +490,7 @@ function mount(el) {
389
490
  (e.peers || []).forEach(addPeer);
390
491
  showSelf();
391
492
  startClock();
392
- fit();
493
+ layout();
393
494
  break;
394
495
  case 'joined': addPeer(e.peer); addItem(e.item); break;
395
496
  case 'left': dropPeer(e.id); addItem(e.item); break;
@@ -437,23 +538,71 @@ function mount(el) {
437
538
  tick();
438
539
  clockTimer = setInterval(tick, 1000);
439
540
  }
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
- });
541
+ /**
542
+ * The screen is a full-bleed layer (fixed) over the page below the site's header - measured, not guessed: the
543
+ * navbar's bottom edge (or its side, for a vertical one); without a recognisable header, where the mount point
544
+ * was. The page behind does not scroll.
545
+ */
546
+ let flowTop = null;
547
+ function layoutChrome() {
548
+ const root = el.firstElementChild;
549
+ if (!root) return;
550
+ if (flowTop === null) {
551
+ try { window.scrollTo(0, 0); } catch { /* never mind */ }
552
+ flowTop = el.getBoundingClientRect().top;
553
+ root.classList.add('dmc--app');
554
+ document.documentElement.classList.add('dmc-page');
555
+ }
556
+ const vw = window.innerWidth; const vh = window.innerHeight;
557
+ let top = flowTop; let left = 0; let right = 0;
558
+ const nav = [document.getElementById('site-navbar'), document.querySelector('body > header, body > nav, header[role="banner"], .site-header')]
559
+ .find(n => n && !el.contains(n) && n.getBoundingClientRect().height > 0);
560
+ if (nav) {
561
+ const r = nav.getBoundingClientRect();
562
+ const vertical = r.height > vh * 0.6 && r.width < vw * 0.5;
563
+ if (vertical && r.left < vw / 2) { top = 0; left = r.right; } else if (vertical) { top = 0; right = vw - r.left; } else top = r.bottom;
564
+ }
565
+ const clamp = (v, most) => `${Math.round(Math.max(0, Math.min(v, most)))}px`;
566
+ root.style.setProperty('--dmc-offset', clamp(top, vh * 0.35));
567
+ root.style.setProperty('--dmc-left', clamp(left, vw * 0.4));
568
+ root.style.setProperty('--dmc-right', clamp(right, vw * 0.4));
569
+ }
570
+ /**
571
+ * Tiles fill the stage: for this stage's size, the columns x rows (and the tile size) that show the most of
572
+ * everyone. A tile stretches to its cell and the video covers it (no letterbox bands); a tile may be tall
573
+ * (the sides of the picture are cropped - faces are central) but never wider than 2.4:1, which would crop heads.
574
+ * Score: tile area x sqrt(share of the 16:9 picture still visible).
575
+ */
576
+ function layoutTiles() {
577
+ const box = el.querySelector('.dmc-remote');
578
+ const n = Math.min(6, peers.value.length);
579
+ if (!box || !n || !box.clientWidth) return;
580
+ const gap = parseFloat(getComputedStyle(box).columnGap) || 8;
581
+ const W = box.clientWidth; const H = box.clientHeight;
582
+ const WIDEST = 2.4; const TALLEST = 0.5; const VIDEO = 16 / 9;
583
+ let best = null;
584
+ for (let cols = 1; cols <= n; cols++) {
585
+ const rows = Math.ceil(n / cols);
586
+ let w = (W - (cols - 1) * gap) / cols; let h = (H - (rows - 1) * gap) / rows;
587
+ if (w <= 0 || h <= 0) continue;
588
+ if (w / h > WIDEST) w = h * WIDEST;
589
+ if (w / h < TALLEST) h = w / TALLEST;
590
+ const aspect = w / h;
591
+ const score = w * h * Math.sqrt(Math.min(aspect / VIDEO, VIDEO / aspect));
592
+ if (!best || score > best.score) best = {cols, rows, w, h, score};
593
+ }
594
+ if (!best) return;
595
+ box.style.setProperty('--cols', String(best.cols));
596
+ box.style.setProperty('--rows', String(best.rows));
597
+ box.style.setProperty('--tile-w', `${Math.floor(best.w)}px`);
598
+ box.style.setProperty('--tile-h', `${Math.floor(best.h)}px`);
452
599
  }
453
- window.addEventListener('resize', () => { if (stage.value === 'in') fit(); });
600
+ function layout() { requestAnimationFrame(() => { layoutChrome(); layoutTiles(); }); }
601
+ window.addEventListener('resize', layout);
454
602
 
455
603
  function finish(kind, title, text) {
456
604
  ended.value = {title, text};
605
+ inviteOpen.value = false;
457
606
  clearInterval(clockTimer); clockTimer = null;
458
607
  stage.value = kind;
459
608
  stopRecording();
@@ -524,6 +673,80 @@ function mount(el) {
524
673
  stage, name, initial, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
525
674
  unreadText, draft, canSend, peers, waiting, waitingTitle, messages, newBelow, endTitle, endText, meCaption, countText, countTitle,
526
675
  clock, chatSub, aloneText,
676
+ canInvite, inviteSearch, inviteTyped, inviteCopy, inviteOpen, query, found, picked, typedName, typedEmail, viaNotify, viaMessage, viaEmail, canMessage,
677
+ inviteBusy, inviteNote, inviteBad, sent, copiedLink, inviteSub, searchHint, foundTitle, emptyText, viaHint, canSendInvites, sendLabel,
678
+
679
+ toggleInvite() {
680
+ inviteOpen.value = !inviteOpen.value;
681
+ if (!inviteOpen.value) return;
682
+ sideOpen.value = false;
683
+ if (!searched.value && inviteSearch.value) runSearch();
684
+ if (window.matchMedia('(min-width: 761px)').matches) setTimeout(() => el.querySelector('.dmc-inv-q')?.focus({preventScroll: true}), 250);
685
+ },
686
+ searchSoon() {
687
+ clearTimeout(searchTimer);
688
+ searchTimer = setTimeout(runSearch, 250);
689
+ },
690
+ pick(row) {
691
+ const at = picked.value.find(p => p.key === row.key);
692
+ if (at) { model.unpick(at); return; }
693
+ if (picked.value.length >= INVITE_MAX) { note(`Add at most ${INVITE_MAX} people at a time.`, true); return; }
694
+ picked.push({key: row.key, kind: row.kind, id: row.id, name: row.name, badge: row.badge, removeLabel: `Remove ${row.name}`});
695
+ row.picked.value = true;
696
+ },
697
+ unpick(chip) {
698
+ picked.remove(chip);
699
+ const row = found.value.find(r => r.key === chip.key);
700
+ if (row) row.picked.value = false;
701
+ },
702
+ addTyped() {
703
+ const email = String(typedEmail.value || '').trim().toLowerCase();
704
+ if (!EMAIL_RE.test(email)) { note('That email address does not look right.', true); return; }
705
+ const name = String(typedName.value || '').trim().slice(0, 120) || email.split('@')[0];
706
+ const key = `email:${email}`;
707
+ if (!picked.value.some(p => p.key === key)) {
708
+ if (picked.value.length >= INVITE_MAX) { note(`Add at most ${INVITE_MAX} people at a time.`, true); return; }
709
+ picked.push({key, kind: 'email', name, email, badge: 'Email', removeLabel: `Remove ${name}`});
710
+ }
711
+ typedName.value = ''; typedEmail.value = ''; note('');
712
+ },
713
+ typedKey(e) { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); model.addTyped(); } },
714
+ selectLink(e) { try { e.target.select(); } catch { /* never mind */ } },
715
+
716
+ async sendInvites() {
717
+ if (!canSendInvites.value || !me) return;
718
+ inviteBusy.value = true; note('');
719
+ const people = picked.value.map(p => (p.kind === 'email' ? {name: p.name, email: p.email} : {kind: p.kind, id: p.id}));
720
+ try {
721
+ const r = await post('invite', {people, via: {email: viaEmail.value, notify: viaNotify.value, message: viaMessage.value && canMessage.value}});
722
+ const body = await r.json().catch(() => ({}));
723
+ if (!r.ok) { note(body.error || 'The invitations could not be sent.', true); return; }
724
+ const results = Array.isArray(body.results) ? body.results : [];
725
+ sent.value = results.map((x, i) => ({id: `${Date.now()}-${i}`, name: x.name, text: resultText(x),
726
+ cls: `dmc-inv-res ${x.emailed || x.notified || x.messaged ? 'is-ok' : 'is-err'}`}));
727
+ for (const p of [...picked.value]) model.unpick(p);
728
+ const reached = results.filter(x => x.emailed || x.notified || x.messaged).length;
729
+ note(reached ? (inviteTyped.value ? 'Sent. They will knock - let them in from the waiting list.' : 'Sent. They join as soon as they open their link.')
730
+ : 'Nobody could be reached - see below.', !reached);
731
+ } catch {
732
+ note('Could not reach the site. Check your connection and try again.', true);
733
+ } finally { inviteBusy.value = false; }
734
+ },
735
+ async copyLink() {
736
+ if (!me) return;
737
+ inviteBusy.value = true; note('');
738
+ try {
739
+ const r = await post('invite', {people: [], via: {}});
740
+ const body = await r.json().catch(() => ({}));
741
+ if (!r.ok || !body.link) { note(body.error || 'No link could be made.', true); return; }
742
+ copiedLink.value = body.link;
743
+ let copied = false;
744
+ try { await navigator.clipboard.writeText(body.link); copied = true; } catch { copied = false; }
745
+ note(copied ? 'Link copied. Whoever opens it knocks, and you let them in.' : 'Copy the link below. Whoever opens it knocks, and you let them in.');
746
+ } catch {
747
+ note('Could not reach the site. Check your connection and try again.', true);
748
+ } finally { inviteBusy.value = false; }
749
+ },
527
750
 
528
751
  toggleMic() { micOn.value = !micOn.value; setTracks(); },
529
752
  toggleCam() {
@@ -533,6 +756,7 @@ function mount(el) {
533
756
  toggleSide() {
534
757
  sideOpen.value = !sideOpen.value;
535
758
  if (!sideOpen.value) return;
759
+ inviteOpen.value = false;
536
760
  unread.value = 0;
537
761
  toBottom(false);
538
762
  // Not on a phone: the keyboard would cover the messages the moment the sheet opens.
@@ -556,6 +780,12 @@ function mount(el) {
556
780
  max.value = body.max || 6;
557
781
  recordingAllowed.value = body.recordingAllowed === true;
558
782
  if (body.title) title.value = body.title;
783
+ canInvite.value = body.canInvite === true;
784
+ const offer = body.inviteOptions || {};
785
+ inviteSearch.value = offer.search !== false;
786
+ inviteTyped.value = typeof offer.typed === 'boolean' ? offer.typed : offer.external !== false;
787
+ inviteCopy.value = typeof offer.copyLink === 'boolean' ? offer.copyLink : offer.external !== false;
788
+ if (!inviteTyped.value) viaEmail.value = false;
559
789
  stage.value = body.state === 'in' ? 'in' : 'waiting';
560
790
  since = 0;
561
791
  pollLoop();
@@ -636,16 +866,50 @@ function mount(el) {
636
866
  };
637
867
 
638
868
  el.innerHTML = TEMPLATE;
869
+ let searchTimer = null;
870
+ let searchSeq = 0;
871
+ function note(text, bad = false) { inviteNote.value = text; inviteBad.value = Boolean(bad && text); }
872
+ /** The people the Tool offers for this search (the team for an empty one). Late answers are dropped. */
873
+ async function runSearch() {
874
+ if (!me || !canInvite.value || !inviteSearch.value) return;
875
+ const mine = ++searchSeq;
876
+ const q = String(query.value || '').trim();
877
+ try {
878
+ const r = await fetch(`${url('people')}?q=${encodeURIComponent(q)}`, {headers: headers(), cache: 'no-store'});
879
+ const body = await r.json().catch(() => ({}));
880
+ if (mine !== searchSeq) return;
881
+ if (!r.ok) { note(body.error || 'Could not search just now.', true); return; }
882
+ const chosen = new Set(picked.value.map(p => p.key));
883
+ const rows = (Array.isArray(body.people) ? body.people : []).map(p => ({key: `${p.kind}:${p.id}`, kind: p.kind, id: p.id, name: p.name,
884
+ email: p.email || '', badge: p.kind === 'user' ? 'Team' : 'Contact', initial: initialOf(p.name), canMessage: p.canMessage === true,
885
+ picked: M.observable(chosen.has(`${p.kind}:${p.id}`))}));
886
+ if (rows.some(x => x.kind === 'user' && x.canMessage)) canMessage.value = true;
887
+ found.value = rows;
888
+ searched.value = true;
889
+ } catch {
890
+ if (mine === searchSeq) note('Could not reach the site. Check your connection and try again.', true);
891
+ }
892
+ }
893
+ const resultText = (x) => {
894
+ const did = [x.notified && 'notified', x.messaged && 'messaged', x.emailed && 'emailed'].filter(Boolean);
895
+ const said = did.length ? `${did.slice(0, -1).join(', ')}${did.length > 1 ? ' and ' : ''}${did.at(-1)}` : '';
896
+ const text = said ? said[0].toUpperCase() + said.slice(1) : '';
897
+ return [text, x.error || ''].filter(Boolean).join(' - ') || 'Invited';
898
+ };
899
+
639
900
  const handleBindings = M.applyBindings(model, el.firstElementChild);
640
901
  // Icons after binding (I.scan swaps each span for an SVG); new waiting rows get theirs as they arrive.
641
902
  const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
903
+ layout();
904
+ const stopLayout = M.effect(() => { peers.value; requestAnimationFrame(layoutTiles); });
905
+ if (typeof ResizeObserver === 'function') { const box = el.querySelector('.dmc-remote'); if (box) new ResizeObserver(() => layoutTiles()).observe(box); }
642
906
  scan();
643
- const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
907
+ const stopScan = M.effect(() => { waiting.value; found.value; picked.value; requestAnimationFrame(scan); });
644
908
  // Reading scrolled back up: the "new messages" chip goes once they are at the bottom again.
645
909
  el.querySelector('.dmc-messages')?.addEventListener('scroll', () => { if (newBelow.value && nearBottom()) newBelow.value = false; }, {passive: true});
646
910
  window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
647
911
  getDevices().then(setTracks);
648
- return () => { stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
912
+ return () => { stopLayout?.(); stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
649
913
  }
650
914
 
651
915
  document.querySelectorAll('[data-dm-call]').forEach(mount);
@@ -20,6 +20,22 @@ service - no import of the Calendar, which is a separate paid Tool).
20
20
  Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
21
  pattern - it does not supersede it. Payment is Pro only.
22
22
 
23
+ 1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
24
+ `_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
25
+ `mountCallRoutes` - so an older core simply has no button). The HOST (the person's host link) searches the contacts
26
+ of the person's linked ACTIVE account (`hostUserOf`) and the team, and invites contacts, team members or any name +
27
+ email. With NO linked account the host link may be held by an outsider (it goes to the person's email address and
28
+ the .ics), so resolve answers `inviteOptions: {search: false, typed: false, copyLink: true}` - Copy link only: no
29
+ directory, no contacts, nothing sent (the engine and `send` both refuse people). Links use core's `inviteOrigin`
30
+ (`site.baseUrl` first).
31
+ Each invitee gets their OWN pass `?b=<booking>&g=<nonce>.<hmac>` signed with the session's `callExtraKey` - made on the
32
+ first invite and written to EVERY booking of the session (`changeBooking` patch, online only, never replaced; the
33
+ stored key is re-read after writing, so two sends at once agree), never the customer's token or the host key (a
34
+ customer token carries their name and identity: an invitee would replace them). `whoIsCalling` answers `{host: false,
35
+ name: '', identity: '<session>:inv:<nonce>', invited: true}` for a pass; the page asks their name; they knock. Core
36
+ sends (Join email, a notification + a DM from the person's user for team members). `adminBooking` strips
37
+ `callExtraKey`. Tests: tests/api.test.js "1.6 Add people" (a linked host; a host with no account).
38
+
23
39
  1.5.0 (2026-09-30): the call's chat is saved per SESSION (`sessionOf` - a class's shared call has one chat) in
24
40
  `data/call-chat.json` (`options.dataDir` in tests) through core's `_lib/call/chatStore.js` (dynamic import beside
25
41
  `mountCallRoutes`; an older core has none and nothing changes), passed as the engine's `chat` - so the call reopened
@@ -110,7 +126,8 @@ all off - bookings hold customers' details). Settings: `config/plugins.json` `bo
110
126
  A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
111
127
  `blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
112
128
  `createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
113
- from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`. Services and bookables carry `ext` too (Pro's fields, kept by the
129
+ from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`, `callExtraKey` (1.6, the session's key for
130
+ Add people passes; never sent to the admin). Services and bookables carry `ext` too (Pro's fields, kept by the
114
131
  free Tool untouched).
115
132
 
116
133
  ## Extensions (Booking Pro)
@@ -58,6 +58,7 @@ Booking has its own permission, **Booking**, which admins have by default. **See
58
58
 
59
59
  Booking Pro adds the following to the free Tool:
60
60
  - Online appointments, held as a video call on your site. What is typed in the call's chat is kept: open the booking and click **Call chat** to read it afterwards (a class shares one chat).
61
+ - **Add people** during an online call: the person being booked clicks **Add people** in the call, searches their contacts and the team (or types a name and email), and sends each person their own Join link - by email, and for team members also as a notification and a message in Messages. They knock and you let them in. **Copy link** gives you a link to pass on yourself. This needs the person to be linked to their account on the site (**People & rooms**); without one, their call offers **Copy link** only.
61
62
  - **Call now**: open an online booking's call early, or start a video call with anyone straight away - from Booking, a contact or an email.
62
63
  - Services that need more than one person or room at once.
63
64
  - Classes and group sessions with places and a waiting list.
@@ -6,7 +6,10 @@
6
6
  * same service, start and person - so everyone booked into an online class
7
7
  * shares one call, and a one-to-one appointment is a session of one. Links
8
8
  * name the session and the booking: `?b=<booking>&t=<customer token>` for the
9
- * customer, `&h=<host key>` for the person they booked. Booking (free) serves
9
+ * customer, `&h=<host key>` for the person they booked, `&g=<invite pass>` for
10
+ * someone the host added while the call runs ("Add people", 1.6: a pass under
11
+ * the session's `callExtraKey` - never the customer's token or the host key -
12
+ * who names themselves and knocks). Booking (free) serves
10
13
  * these for any booking that is already online, so a lapsed Pro licence
11
14
  * strands nobody; only Pro can make a service online in the first place.
12
15
  *
@@ -15,6 +18,12 @@
15
18
 
16
19
  import {createHash, timingSafeEqual} from 'crypto';
17
20
 
21
+ // Add people (1.6) needs a core with _lib/call/invite.js; an older one has no invite passes.
22
+ let invite = null;
23
+ try { invite = await import('../../_lib/call/invite.js'); } catch { invite = null; }
24
+ /** Does this core have "Add people"? */
25
+ export const invitesAvailable = () => Boolean(invite);
26
+
18
27
  export const OPENS_BEFORE_MIN = 10;
19
28
  export const CLOSES_AFTER_MIN = 60;
20
29
  const HOLDING = new Set(['pending', 'confirmed', 'completed', 'no-show']);
@@ -33,6 +42,10 @@ export const joinPath = (settings, b) => `${settings.bookingPath}/call/${session
33
42
  /** The host's (the person booked) - never shown to the customer. */
34
43
  export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
35
44
 
45
+ /** A Join link for someone added to the call (1.6): a new pass under the session's extra key. */
46
+ export const invitePath = (settings, b, key) => (invite && key
47
+ ? `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&g=${encodeURIComponent(invite.invitePass(key, sessionOf(b)))}` : '');
48
+
36
49
  /** How long a call opened early ("Call now", Booking 1.4) stays open at least. */
37
50
  export const OPENED_EARLY_HOURS = 2;
38
51
 
@@ -60,7 +73,7 @@ export function callWindow(b, now = Date.now()) {
60
73
  *
61
74
  * @param {object|null} b - the booking named by `cred.b`
62
75
  * @param {string} room - the room in the address
63
- * @param {{t?: string, h?: string}} cred
76
+ * @param {{t?: string, h?: string, g?: string}} cred
64
77
  */
65
78
  export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
66
79
  if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
@@ -68,5 +81,8 @@ export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
68
81
  // identity: a link the host turns away cannot knock again (the engine remembers it per room).
69
82
  if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
70
83
  if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
84
+ // Someone the host added (1.6): their own pass, their own name, and they knock.
85
+ const nonce = cred.g && b.callExtraKey && invite ? invite.checkInvitePass(b.callExtraKey, room, cred.g) : null;
86
+ if (nonce) return {...base, host: false, name: '', identity: `${room}:inv:${nonce}`, invited: true};
71
87
  return null;
72
88
  }
@@ -313,6 +313,8 @@ export function changeBooking(id, patch, by = '') {
313
313
  data.callOpenedAt = String(patch.callOpenedAt || '');
314
314
  if (data.callOpenedAt) data.history = [...(data.history || []), {at: data.callOpenedAt, what: 'call-opened', by}].slice(-50);
315
315
  }
316
+ // "Add people" (1.6): the session's key for invite passes - set once, never replaced.
317
+ if (patch.callExtraKey !== undefined && data.online === true && !data.callExtraKey) data.callExtraKey = String(patch.callExtraKey || '').slice(0, 64);
316
318
  if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
317
319
  return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
318
320
  });