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.
- package/admin/js/lib/related.js +1 -1
- package/admin/js/templates/docs/usage-tools.html +11 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/ui/transcript.js +154 -0
- package/plugins/_lib/call/chatStore.js +103 -0
- package/plugins/_lib/call/invite.js +172 -0
- package/plugins/_lib/call/rooms.js +120 -23
- package/plugins/_lib/call/routes.js +131 -6
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +493 -62
- package/plugins/booking/CLAUDE.md +27 -1
- package/plugins/booking/admin/lib/kit.js +1 -0
- package/plugins/booking/admin/views/panels.js +10 -0
- package/plugins/booking/docs/guide.md +2 -1
- package/plugins/booking/lib/call.js +18 -2
- package/plugins/booking/lib/store.js +2 -0
- package/plugins/booking/plugin.js +98 -7
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +4 -4
- package/plugins/booking/tests/api.test.js +178 -1
- package/plugins/free-tier.lock.json +12 -12
- package/server/routes/api/calls.js +48 -6
- package/server/routes/call-public.js +4 -3
- package/server/services/callInvites.js +238 -0
- package/server/services/hooks.js +55 -0
- package/server/services/instantCalls.js +83 -6
- package/server/services/plugins.js +6 -1
|
@@ -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
|
-
<
|
|
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-
|
|
64
|
-
<div class="dmc-
|
|
65
|
-
<
|
|
66
|
-
<
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
<
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
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
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
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([]); //
|
|
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)
|
|
143
|
-
const
|
|
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
|
-
|
|
288
|
-
|
|
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() {
|
|
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);
|