domma-cms 0.105.2 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,43 @@ 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. 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.
33
+ * Chat rows are plain objects built in JS (see addItem) - every string reaches the page as textContent.
34
+ */
27
35
  const TEMPLATE = `
28
- <div class="dmc">
29
- <header class="dmc-head">
36
+ <div class="dmc" data-bind-data-stage="stage.value">
37
+ <header class="dmc-head" data-bind-hidden="stage.value === 'in'">
30
38
  <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
39
  </header>
34
40
 
35
41
  <section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
36
42
  <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>
43
+ <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>
44
+ <div class="dmc-preview-tools">
45
+ <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>
46
+ <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>
47
+ </div>
48
+ </div>
38
49
  <div class="dmc-check">
39
50
  <h2>Ready to join?</h2>
40
51
  <p class="dmc-note" data-bind-text="deviceNote.value"></p>
41
52
  <label class="dmc-label" data-bind-hidden="!askName">Your name
42
- <input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
53
+ <input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name" placeholder="How the others will see you"></label>
43
54
  <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>
55
+ <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>
56
+ <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
57
  </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>
58
+ <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
59
  <p class="dmc-error" data-bind-hidden="!error.value" data-bind-text="error.value" role="alert"></p>
49
60
  <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
61
  </div>
51
62
  </section>
52
63
 
53
64
  <section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
65
+ <div class="dmc-pulse" aria-hidden="true">${ico('users')}</div>
54
66
  <h2>Waiting for the host</h2><p>They know you are here and will let you in shortly.</p>
55
67
  <button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
56
68
  </section>
@@ -59,50 +71,157 @@ const TEMPLATE = `
59
71
  <h2 data-bind-text="endTitle.value"></h2><p data-bind-text="endText.value"></p>
60
72
  </section>
61
73
 
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>
74
+ <section class="dmc-call" data-bind-hidden="stage.value !== 'in'" data-bind-class="sideOpen.value && 'is-chat'">
75
+ <div class="dmc-stage">
76
+ <div class="dmc-main" data-bind-data-people="people.value" data-bind-data-tiles="tiles.value">
77
+ <div class="dmc-remote" data-each="peers key=id">
78
+ <div class="dmc-cell"><figure class="dmc-tile" data-bind-data-peer="id">
79
+ <div class="dmc-avatar" aria-hidden="true"><span data-bind-text="initial"></span></div>
80
+ <video class="dmc-video" playsinline autoplay></video>
81
+ <figcaption class="dmc-chip"><span data-bind-text="caption.value"></span></figcaption></figure></div>
82
+ </div>
83
+ <figure class="dmc-tile dmc-tile--me">
84
+ <div class="dmc-avatar" data-bind-hidden="camOn.value" aria-hidden="true"><span data-bind-text="initial.value"></span></div>
85
+ <video class="dmc-self" muted playsinline autoplay data-bind-hidden="!camOn.value && !sharing.value"></video>
86
+ <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>
87
+ </figure>
88
+ <div class="dmc-alone" data-bind-hidden="tiles.value !== '0'">
89
+ <p class="dmc-alone-title">Waiting for others to join</p>
90
+ <p class="dmc-alone-sub" data-bind-text="aloneText.value"></p>
91
+ </div>
67
92
  </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
93
 
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>
94
+ <header class="dmc-top">
95
+ <div class="dmc-top-l">
96
+ <h2 class="dmc-top-title" data-bind-text="title.value"></h2>
97
+ <span class="dmc-timer" aria-label="Time in the call" data-bind-text="clock.value"></span>
98
+ </div>
99
+ <div class="dmc-top-r">
100
+ <span class="dmc-rec" data-bind-hidden="!recording.value" role="status"><span class="dmc-rec-dot" aria-hidden="true"></span>Recording</span>
101
+ <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>
102
+ </div>
103
+ </header>
104
+
105
+ <div class="dmc-waiting" data-bind-hidden="!waiting.value.length" role="region" aria-label="Waiting to join">
106
+ <h3><span class="dmc-waiting-dot" aria-hidden="true"></span><span data-bind-text="waitingTitle.value"></span></h3>
77
107
  <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>
108
+ <li><span class="dmc-winitial" aria-hidden="true" data-bind-text="initial"></span><span class="dmc-wname" data-bind-text="name"></span>
109
+ <span class="dmc-wbtns">
110
+ <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>
111
+ <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>
112
+ </span></li>
81
113
  </ul>
82
114
  </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>
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">
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>
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>
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>
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">
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>
129
+ </nav>
130
+ </div>
131
+
132
+ <aside class="dmc-chat" aria-label="Chat" data-bind-aria-hidden="!sideOpen.value" data-bind-inert="!sideOpen.value">
133
+ <header class="dmc-chat-head">
134
+ <button type="button" class="dmc-chat-back" data-on-click="toggleSide" aria-label="Back to the call">${ico('arrow-left')}</button>
135
+ <div class="dmc-chat-titles"><h3>Chat</h3><span data-bind-text="chatSub.value"></span></div>
136
+ <button type="button" class="dmc-chat-close" data-on-click="toggleSide" aria-label="Close the chat">${ico('x')}</button>
137
+ </header>
138
+ <div class="dmc-chat-body">
139
+ <ol class="dmc-messages" data-each="messages key=id" aria-live="polite">
140
+ <li data-bind-class="cls" data-bind-data-hue="hue">
141
+ <div class="dmc-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
142
+ <div class="dmc-sys" data-bind-hidden="!sys"><span data-bind-text="text"></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>
145
+ </li>
87
146
  </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>
147
+ <p class="dmc-chat-empty" data-bind-hidden="messages.value.length">No messages yet. Messages are seen by everyone in the call.</p>
148
+ <button type="button" class="dmc-newchip" data-bind-hidden="!newBelow.value" data-on-click="toBottom">${ico('arrow-down')}<span>New messages</span></button>
92
149
  </div>
150
+ <form class="dmc-send" data-on-submit="sendChat">
151
+ <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>
152
+ <button type="submit" class="dmc-btn dmc-sendbtn" aria-label="Send" data-bind-disabled="!canSend.value">${ico('send')}</button>
153
+ </form>
93
154
  </aside>
94
155
 
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>
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>
103
211
  </section>
104
212
  </div>`;
105
213
 
214
+ /** A stable colour for a sender (8 hues, picked by a hash of their tag). */
215
+ const hueOf = (key) => {
216
+ let h = 0;
217
+ for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) >>> 0;
218
+ return String(h % 8);
219
+ };
220
+ const initialOf = (n) => (String(n || '').trim()[0] || '?').toUpperCase();
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@<>"]+$/;
224
+
106
225
  function mount(el) {
107
226
  let cfg;
108
227
  try { cfg = JSON.parse(el.getAttribute('data-dm-call') || '{}'); } catch { cfg = {}; }
@@ -133,20 +252,65 @@ function mount(el) {
133
252
  const draft = M.observable('');
134
253
  const peers = M.observableArray([]); // {id, name, host, caption: observable}
135
254
  const waiting = M.observableArray([]); // {id, name}
136
- const messages = M.observableArray([]); // {id, name, text, mine}
255
+ const messages = M.observableArray([]); // chat rows, built by addItem()
256
+ const newBelow = M.observable(false); // the reader scrolled up and something new arrived
257
+ const clock = M.observable('0:00');
258
+ const tiles = M.observable('0'); // remote tiles on the stage (self is the picture-in-picture)
137
259
  const ended = M.observable({title: 'You have left the call', text: 'You can close this page.'});
138
260
  const max = M.observable(6);
139
261
 
140
262
  const endTitle = M.computed(() => ended.value.title);
141
263
  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}` : ''));
264
+ const meCaption = M.computed(() => `${name.value || 'You'} (you)`);
265
+ const initial = M.computed(() => initialOf(name.value || 'You'));
266
+ const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1}` : ''));
267
+ const countTitle = M.computed(() => `${peers.value.length + 1} of ${max.value} people in the call`);
144
268
  const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
145
269
  const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
270
+ const unreadText = M.computed(() => (unread.value > 99 ? '99+' : String(unread.value)));
271
+ const chatSub = M.computed(() => `Everyone in the call · ${peers.value.length + 1}`);
272
+ const waitingTitle = M.computed(() => (waiting.value.length === 1 ? '1 person waiting to join' : `${waiting.value.length} people waiting to join`));
273
+ 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.'));
274
+ const canSend = M.computed(() => /\S/.test(draft.value || ''));
146
275
  const title = M.observable(cfg.title || 'Call');
147
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
+
148
312
  // ---- The connection to the site ---------------------------------------------------
149
- let me = null; // {id, secret}
313
+ let me = null; // {id, secret, who}
150
314
  let since = 0;
151
315
  let polling = false;
152
316
  const headers = () => ({'Content-Type': 'application/json', 'X-Call-Id': me?.id || '', 'X-Call-Secret': me?.secret || ''});
@@ -158,6 +322,8 @@ function mount(el) {
158
322
  let screen = null;
159
323
  let iceServers = [];
160
324
  const pcs = new Map(); // peer id -> {pc, polite, makingOffer, ignore, stream}
325
+ // peers is kept in step with tiles (a plain observable, not a computed over a computed).
326
+ const stopTiles = M.effect(() => { tiles.value = String(Math.min(6, peers.value.length)); });
161
327
 
162
328
  const selfVideos = () => el.querySelectorAll('.dmc-self, .dmc-self-preview');
163
329
  const showSelf = () => selfVideos().forEach(v => { v.srcObject = screen || local; });
@@ -263,12 +429,56 @@ function mount(el) {
263
429
  // ---- Events from the site ----------------------------------------------------------
264
430
  function addPeer(p) {
265
431
  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('')};
432
+ const row = {id: p.id, name: p.name, host: p.host, initial: initialOf(p.name), caption: M.observable('')};
267
433
  row.caption.value = captionFor(row, 'new');
268
434
  peers.push(row);
269
435
  connect(row);
270
436
  }
271
437
 
438
+ // ---- Chat -------------------------------------------------------------------------------
439
+ // Items come from the site as {id, seq, at, kind: 'chat'|'system', from, who, name, text, event?}: the history
440
+ // in `admitted.chat`, then one per `chat` event (and on joined/left/recording events as `item`). Each becomes a
441
+ // display row here, grouped against the row before it (a name + time line opens each group); the same id never shows twice.
442
+ const seenIds = new Set();
443
+ let lastRow = null;
444
+ const scroller = () => el.querySelector('.dmc-messages');
445
+ const nearBottom = () => { const l = scroller(); return !l || l.scrollHeight - l.scrollTop - l.clientHeight < 80; };
446
+ const toBottom = (smooth) => requestAnimationFrame(() => {
447
+ const l = scroller();
448
+ if (l) l.scrollTo({top: l.scrollHeight, behavior: smooth ? 'smooth' : 'auto'});
449
+ newBelow.value = false;
450
+ });
451
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
452
+ function dayLabel(d) {
453
+ const today = new Date();
454
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
455
+ if (dayKey(d) === dayKey(today)) return 'Today';
456
+ if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
457
+ return d.toLocaleDateString([], {weekday: 'short', day: 'numeric', month: 'short', ...(d.getFullYear() !== today.getFullYear() && {year: 'numeric'})});
458
+ }
459
+
460
+ function addItem(raw, {live = true} = {}) {
461
+ if (!raw || typeof raw.text !== 'string' || !raw.id || seenIds.has(raw.id)) return;
462
+ seenIds.add(raw.id);
463
+ const at = new Date(raw.at || Date.now());
464
+ const when = Number.isNaN(at.getTime()) ? new Date() : at;
465
+ const sys = raw.kind === 'system';
466
+ const sender = raw.who || raw.from || raw.name;
467
+ const mine = !sys && Boolean(me) && ((raw.who && raw.who === me.who) || raw.from === me.id);
468
+ const day = !lastRow || dayKey(lastRow.when) !== dayKey(when) ? dayLabel(when) : '';
469
+ const first = sys || Boolean(day) || !lastRow || lastRow.sys || lastRow.sender !== sender || when - lastRow.when > GROUP_MS;
470
+ const row = {id: raw.id, sys, mine, text: raw.text, name: raw.name || 'Someone', day, sender, when,
471
+ showName: first && !mine && !sys, showMeta: first && !sys, hue: hueOf(sender),
472
+ time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
473
+ cls: ['dmc-msg', sys ? 'is-sys' : mine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()};
474
+ const stick = nearBottom() || mine || !live;
475
+ messages.push(row);
476
+ lastRow = row;
477
+ if (live && !sys && !mine && !sideOpen.value) unread.value += 1;
478
+ if (stick) toBottom(live);
479
+ else if (live) newBelow.value = true;
480
+ }
481
+
272
482
  function handle(e) {
273
483
  if (!me) return; // the call ended part-way through a batch of events
274
484
  switch (e.type) {
@@ -276,19 +486,21 @@ function mount(el) {
276
486
  case 'admitted':
277
487
  stage.value = 'in';
278
488
  recording.value = e.recording === true;
489
+ (e.chat || []).forEach(item => addItem(item, {live: false}));
279
490
  (e.peers || []).forEach(addPeer);
280
491
  showSelf();
492
+ startClock();
493
+ layout();
281
494
  break;
282
- case 'joined': addPeer(e.peer); break;
283
- case 'left': dropPeer(e.id); break;
495
+ case 'joined': addPeer(e.peer); addItem(e.item); break;
496
+ case 'left': dropPeer(e.id); addItem(e.item); break;
284
497
  case 'signal': onSignal(e.from, e.data || {}); break;
285
- case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name})); break;
498
+ case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name, initial: initialOf(w.name)})); break;
286
499
  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; });
500
+ // An older site sends from/name/text only: make an item of it (its queue seq is unique per person).
501
+ 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
502
  break;
291
- case 'recording': recording.value = e.on === true; break;
503
+ case 'recording': recording.value = e.on === true; addItem(e.item); break;
292
504
  case 'turned-away': finish('away', 'The host did not let you in', 'You can close this page.'); break;
293
505
  case 'ended': finish('ended', 'The call has ended', 'You can close this page.'); break;
294
506
  default: break;
@@ -313,8 +525,85 @@ function mount(el) {
313
525
  }
314
526
  const pause = (ms) => new Promise(r => setTimeout(r, ms));
315
527
 
528
+ // ---- The stage: its height and the call timer ---------------------------------------------
529
+ let clockTimer = null;
530
+ function startClock() {
531
+ if (clockTimer) return;
532
+ const from = Date.now();
533
+ const tick = () => {
534
+ const s = Math.floor((Date.now() - from) / 1000);
535
+ const hh = Math.floor(s / 3600); const mm = Math.floor(s / 60) % 60; const ss = String(s % 60).padStart(2, '0');
536
+ clock.value = hh ? `${hh}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`;
537
+ };
538
+ tick();
539
+ clockTimer = setInterval(tick, 1000);
540
+ }
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`);
599
+ }
600
+ function layout() { requestAnimationFrame(() => { layoutChrome(); layoutTiles(); }); }
601
+ window.addEventListener('resize', layout);
602
+
316
603
  function finish(kind, title, text) {
317
604
  ended.value = {title, text};
605
+ inviteOpen.value = false;
606
+ clearInterval(clockTimer); clockTimer = null;
318
607
  stage.value = kind;
319
608
  stopRecording();
320
609
  for (const id of [...pcs.keys()]) dropPeer(id);
@@ -380,16 +669,100 @@ function mount(el) {
380
669
 
381
670
  // ---- What the buttons do -------------------------------------------------------------
382
671
  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,
672
+ title, people, tiles, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
673
+ stage, name, initial, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
674
+ unreadText, draft, canSend, peers, waiting, waitingTitle, messages, newBelow, endTitle, endText, meCaption, countText, countTitle,
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
+ },
386
750
 
387
751
  toggleMic() { micOn.value = !micOn.value; setTracks(); },
388
752
  toggleCam() {
389
753
  if (!local?.getVideoTracks().length) { error.value = 'No camera is available.'; return; }
390
754
  camOn.value = !camOn.value; setTracks();
391
755
  },
392
- toggleSide() { sideOpen.value = !sideOpen.value; if (sideOpen.value) unread.value = 0; },
756
+ toggleSide() {
757
+ sideOpen.value = !sideOpen.value;
758
+ if (!sideOpen.value) return;
759
+ inviteOpen.value = false;
760
+ unread.value = 0;
761
+ toBottom(false);
762
+ // Not on a phone: the keyboard would cover the messages the moment the sheet opens.
763
+ if (window.matchMedia('(min-width: 761px)').matches) setTimeout(() => el.querySelector('.dmc-compose')?.focus({preventScroll: true}), 250);
764
+ },
765
+ toBottom() { toBottom(true); },
393
766
 
394
767
  async join() {
395
768
  error.value = '';
@@ -400,13 +773,19 @@ function mount(el) {
400
773
  const body = await r.json().catch(() => ({}));
401
774
  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
775
  if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
403
- me = {id: body.id, secret: body.secret};
776
+ me = {id: body.id, secret: body.secret, who: body.who || body.id};
404
777
  iceServers = body.iceServers || [];
405
778
  host.value = body.host === true;
406
779
  if (body.name) name.value = body.name;
407
780
  max.value = body.max || 6;
408
781
  recordingAllowed.value = body.recordingAllowed === true;
409
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;
410
789
  stage.value = body.state === 'in' ? 'in' : 'waiting';
411
790
  since = 0;
412
791
  pollLoop();
@@ -424,11 +803,27 @@ function mount(el) {
424
803
  turnAway(w) { post('answer', {who: w.id, yes: false}); },
425
804
 
426
805
  sendChat() {
427
- const text = draft.value.trim();
806
+ const text = String(draft.value || '').trim();
428
807
  if (text && me) post('chat', {text});
429
808
  draft.value = '';
809
+ requestAnimationFrame(() => model.grow());
430
810
  return false;
431
811
  },
812
+ /** Enter sends, Shift+Enter is a new line (and nothing is sent mid-composition, e.g. an IME). */
813
+ chatKey(e) {
814
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); model.sendChat(); }
815
+ },
816
+ /** The box grows with what is typed, up to four lines, then scrolls. */
817
+ grow() {
818
+ const box = el.querySelector('.dmc-compose');
819
+ if (!box) return;
820
+ const cs = getComputedStyle(box);
821
+ const edges = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
822
+ const most = (parseFloat(cs.lineHeight) || 20) * 4 + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + edges;
823
+ box.style.height = 'auto';
824
+ box.style.height = `${Math.min(box.scrollHeight + edges, most)}px`;
825
+ box.style.overflowY = box.scrollHeight + edges > most ? 'auto' : 'hidden';
826
+ },
432
827
 
433
828
  async toggleShare() {
434
829
  // Each connection's video sender: the camera's, or one added for a screen when there was no camera
@@ -471,14 +866,50 @@ function mount(el) {
471
866
  };
472
867
 
473
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
+
474
900
  const handleBindings = M.applyBindings(model, el.firstElementChild);
475
901
  // Icons after binding (I.scan swaps each span for an SVG); new waiting rows get theirs as they arrive.
476
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); }
477
906
  scan();
478
- const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
907
+ const stopScan = M.effect(() => { waiting.value; found.value; picked.value; requestAnimationFrame(scan); });
908
+ // Reading scrolled back up: the "new messages" chip goes once they are at the bottom again.
909
+ el.querySelector('.dmc-messages')?.addEventListener('scroll', () => { if (newBelow.value && nearBottom()) newBelow.value = false; }, {passive: true});
479
910
  window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
480
911
  getDevices().then(setTracks);
481
- return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
912
+ return () => { stopLayout?.(); stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
482
913
  }
483
914
 
484
915
  document.querySelectorAll('[data-dm-call]').forEach(mount);