domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,1219 @@
1
+ /**
2
+ * Messages - admin screen. Route: #/plugins/messages
3
+ *
4
+ * Conversations on the left (Inbox / Unread / Archived, search), the open one
5
+ * on the right with its composer. Click a conversation to open it; right-click
6
+ * it (or ⋮, or Shift+F10) to mark it read or unread, archive it, rename a
7
+ * group, set how long its messages are kept or leave it. Right-click a message
8
+ * (or its ⋮) to copy, edit or delete it. New conversation (and, for admins,
9
+ * Managed groups) are banner actions opening slideovers.
10
+ *
11
+ * Built on the Tool upgrade path: domma-reactive state bound with
12
+ * M.applyBindings, context menus, help popovers, banner actions, a sidebar
13
+ * badge (countUrl: unread), viewLifetime teardown (which also closes the live
14
+ * event stream), and drafts - what you type is kept per conversation, so
15
+ * switching away or leaving the screen loses nothing; the forms ask before
16
+ * throwing typed work away. Pure logic: ../lib/messages.js.
17
+ *
18
+ * Calls (1.4): Call in a conversation's header starts its video call (or
19
+ * joins the one running) in a new tab - the tab is opened ON the click, before
20
+ * anything is awaited, or the pop-up blocker takes it. Everyone else in the
21
+ * conversation gets a "Sam is calling - Join" notice at the bottom right.
22
+ *
23
+ * Call… (1.5): a banner action (and a button on the empty screen) opens the
24
+ * people picker the new-conversation form uses; pick 1 to 5 and Call reuses
25
+ * your DM with that person, or the ad-hoc group of exactly those people, or
26
+ * makes one (callTarget), opens it here and starts its call - the tab again
27
+ * opened on the click, before anything is awaited.
28
+ *
29
+ * Admin helpers are imported at run time (loadKit) - a plugin must not import
30
+ * admin/ statically, since the updater replaces it wholesale.
31
+ */
32
+ import {
33
+ CALL_MAX, CALL_PICK_MAX, FILTERS, KIND_LABEL, arrangeThreads, buildBubbles, callButton, callGroupName, callTarget,
34
+ callTargetText, cleanDrafts, cleanNewDraft, draftKey, filterCounts, NEW_DRAFT_QUESTION, RESTORE_ANSWERS, ago, afterSend, initials, isArchived, messageRights, plural, ringingCalls, threadTitle
35
+ } from '../lib/messages.js';
36
+
37
+ const BASE = '/plugins/messages';
38
+ const ROUTE = /^#\/plugins\/messages\b/;
39
+ const SIDEBAR_URL = '#/plugins/messages';
40
+ // Drafts are kept per signed-in person (draftKey): `messages.drafts.<user id>`, `messages.newDraft.<user id>`.
41
+ const DRAFTS_BASE = 'messages.drafts';
42
+ const NEW_DRAFT_BASE = 'messages.newDraft';
43
+ const FILTER_KEY = 'messages.filter';
44
+
45
+ const ROW = `
46
+ <div class="msx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
47
+ <span class="msx-avatar" aria-hidden="true" data-bind-class="avatarCls" data-bind-text="initials"></span>
48
+ <span class="msx-row-main">
49
+ <span class="msx-row-top"><span class="msx-row-title" data-bind-text="title"></span><span class="msx-row-when" data-bind-text="when"></span></span>
50
+ <span class="msx-row-preview"><span class="msx-draft" data-bind-hidden="!hasDraft">Draft: </span><span data-bind-text="preview"></span></span>
51
+ </span>
52
+ <span class="msx-chip is-unread" data-bind-hidden="!unread" data-bind-text="unread"></span>
53
+ <button type="button" class="msx-kebab" aria-label="Actions for {{title}}" title="Actions">⋮</button>
54
+ </div>`;
55
+
56
+ const BUBBLE = `
57
+ <div class="msx-item">
58
+ <div class="msx-day" data-bind-hidden="!isDay" data-bind-text="dayLabel"></div>
59
+ <div class="msx-callline" data-bind-hidden="!isCall"><span data-bind-text="callText"></span>
60
+ <button type="button" class="btn btn-xs btn-primary msx-call-join" data-bind-hidden="!canJoin">Join</button></div>
61
+ <div class="msx-msg" data-id="{{id}}" tabindex="0" data-bind-class="msgCls" data-bind-hidden="isDay || isCall">
62
+ <div class="msx-bubble">
63
+ <span class="msx-sender" data-bind-hidden="!showSender" data-bind-text="sender"></span>
64
+ <div class="msx-body" data-bind-text="bodyText"></div>
65
+ <div class="msx-atts" data-bind-hidden="!attachments.length">{{#each attachments key=k}}<div class="msx-att">
66
+ <a class="msx-att-img" href="{{url}}" target="_blank" rel="noopener noreferrer" data-bind-hidden="!isImage"><img data-bind-src="imgSrc" alt="{{name}}" loading="lazy"></a>
67
+ <a class="msx-att-file" href="{{url}}" target="_blank" rel="noopener noreferrer" data-bind-hidden="isImage">📎 {{name}} <small>{{size}}</small></a>
68
+ </div>{{/each}}</div>
69
+ <span class="msx-meta" data-bind-text="meta"></span>
70
+ </div>
71
+ <button type="button" class="msx-kebab" aria-label="Message actions" title="Actions" data-bind-hidden="deleted">⋮</button>
72
+ </div>
73
+ </div>`;
74
+
75
+ /** A call someone else started - "Sam is calling - Join". */
76
+ const RING = `
77
+ <div class="msx-ring" role="status">
78
+ <span class="msx-ring-dot" aria-hidden="true"></span>
79
+ <span class="msx-ring-main"><strong data-bind-text="text"></strong>
80
+ <small data-bind-hidden="!where" data-bind-text="where"></small>
81
+ <small data-bind-hidden="!count" data-bind-text="count"></small></span>
82
+ <button type="button" class="btn btn-sm btn-primary msx-ring-join" data-join="{{id}}">Join</button>
83
+ <button type="button" class="btn btn-sm btn-ghost msx-ring-close" data-dismiss="{{key}}" aria-label="Not now" title="Not now">×</button>
84
+ </div>`;
85
+
86
+ const PENDING = `<span class="msx-att-chip">{{filename}} <button type="button" data-remove="{{mediaId}}" aria-label="Remove {{filename}}">×</button></span>`;
87
+
88
+ /** Filled by loadKit() on first mount. */
89
+ let apiRequest, api, addBannerAction, helpPopovers, onceClosed, openMenuAt, viewLifetime, loadingLib;
90
+ let lifetime;
91
+
92
+ async function loadKit() {
93
+ if (lifetime) return;
94
+ const [apiMod, chrome, help, kit] = await Promise.all([
95
+ import('/admin/js/api.js'),
96
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
97
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
98
+ import('/admin/js/lib/tool-kit.js')
99
+ ]);
100
+ ({apiRequest, api} = apiMod);
101
+ addBannerAction = chrome.addBannerAction || (() => null);
102
+ helpPopovers = help.helpPopovers || (() => () => {});
103
+ ({onceClosed, openMenuAt, viewLifetime} = kit);
104
+ // Skeletons while lists load (core 1.x; older cores show the slot's text).
105
+ loadingLib = await import('/admin/js/lib/loading.js').catch(() => null);
106
+ lifetime = viewLifetime(ROUTE);
107
+ }
108
+
109
+ /** E.confirm / E.prompt / slideover titles render HTML: user text goes in escaped. */
110
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
111
+
112
+ function fragment(html) {
113
+ const t = document.createElement('template');
114
+ t.innerHTML = html.trim();
115
+ return t.content.firstElementChild;
116
+ }
117
+
118
+ function refreshBadge() {
119
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
120
+ }
121
+
122
+ // An empty key (no one signed in) reads nothing and keeps nothing.
123
+ const readStore = (key, fallback) => { if (!key) return fallback; try { return S.get(key) ?? fallback; } catch { return fallback; } };
124
+ const writeStore = (key, value) => { if (!key) return; try { value == null ? (S.remove ? S.remove(key) : S.set(key, null)) : S.set(key, value); } catch { /* a convenience */ } };
125
+ const userId = () => { try { return S.get('auth_user')?.id; } catch { return ''; } };
126
+
127
+ /**
128
+ * Work that finishes after its screen has gone (a send, an upload): it changes
129
+ * ONE conversation's draft - in storage, read fresh (never a stale copy of the
130
+ * whole map written back), and in a screen opened since, which hears it as
131
+ * `messages:settle` and applies the same change to what it holds.
132
+ * @param {string} key - the drafts key of the person who started the work (draftKey)
133
+ * @param {string} id - the conversation
134
+ * @param {(d: {body: string, attachments: object[]}) => {body: string, attachments: object[]}} change
135
+ */
136
+ function settleElsewhere(key, id, change) {
137
+ if (!key) return;
138
+ const stored = cleanDrafts(readStore(key, {}));
139
+ const d = stored[id];
140
+ const next = change({body: d?.text || '', attachments: d?.attachments || []});
141
+ if (next.body.trim() || next.attachments.length) stored[id] = {text: next.body, attachments: next.attachments};
142
+ else delete stored[id];
143
+ writeStore(key, Object.keys(stored).length ? stored : null);
144
+ try { window.dispatchEvent(new CustomEvent('messages:settle', {detail: {key, id, change}})); } catch { /* none */ }
145
+ }
146
+
147
+ export const messagesView = {
148
+ templateUrl: '/plugins/messages/admin/templates/messages.html',
149
+
150
+ async onMount($container) {
151
+ await loadKit();
152
+ if (!ROUTE.test(location.hash)) return;
153
+ lifetime.reset();
154
+ const root = $container.get(0).querySelector('.msx') || $container.get(0);
155
+ const here = () => ROUTE.test(location.hash) && root.isConnected;
156
+ const scope = (fn) => { lifetime.add(fn); return fn; };
157
+
158
+ // ---- State ----
159
+ const me = M.observable({id: null, name: '', isAdmin: false, editWindowMinutes: 15, defaultRetentionDays: 90, globalRetentionCeilingDays: 365});
160
+ const threads = M.observable([]);
161
+ const loading = M.observable(true);
162
+ const loadError = M.observable('');
163
+ const savedFilter = readStore(FILTER_KEY, 'inbox');
164
+ const filter = M.observable(FILTERS.includes(savedFilter) ? savedFilter : 'inbox');
165
+ const query = M.observable('');
166
+ const activeId = M.observable('');
167
+ const messages = M.observable([]);
168
+ const threadLoading = M.observable(false);
169
+ const compose = M.observable('');
170
+ const pending = M.observable([]); // attachments uploaded, not yet sent
171
+ const uploading = M.observable(false);
172
+ const sending = M.observable(false);
173
+ const now = M.observable(Date.now());
174
+ const draftsV = M.observable(0); // bumps when the drafts change
175
+ const dismissed = M.observable([]); // call notices put aside ("thread|startedAt")
176
+ const rings = M.observable([]);
177
+ const callBusy = M.observable(false);
178
+ const blockedUrl = M.observable(''); // the call's address when the browser blocked its tab
179
+ // This screen is one person's: their keys are fixed for its life. The unscoped keys of
180
+ // older builds are never offered to anyone - removed when found.
181
+ const DRAFTS_KEY = draftKey(DRAFTS_BASE, userId());
182
+ const NEW_DRAFT_KEY = draftKey(NEW_DRAFT_BASE, userId());
183
+ writeStore(DRAFTS_BASE, null);
184
+ writeStore(NEW_DRAFT_BASE, null);
185
+ let drafts = cleanDrafts(readStore(DRAFTS_KEY, {}));
186
+
187
+ scope(M.effect(() => { writeStore(FILTER_KEY, filter.value); }));
188
+ const tick = setInterval(() => { now.value = Date.now(); }, 60_000);
189
+ scope(() => clearInterval(tick));
190
+
191
+ const myId = () => me.peek().id;
192
+ const byId = (id) => threads.peek().find(t => t.id === id) || null;
193
+ const active = M.computed(() => threads.value.find(t => t.id === activeId.value) || null);
194
+
195
+ const rows = M.observable([]);
196
+ scope(M.effect(() => {
197
+ draftsV.value;
198
+ const mine = me.value.id;
199
+ const t0 = now.value;
200
+ rows.value = arrangeThreads(threads.value, {filter: filter.value, query: query.value, myId: mine}).map((t) => {
201
+ const title = threadTitle(t, mine);
202
+ const d = drafts[t.id];
203
+ return {
204
+ id: t.id,
205
+ title,
206
+ initials: initials(title),
207
+ avatarCls: t.kind === 'dm' ? '' : 'is-group',
208
+ when: t.lastMessageAt ? ago(t.lastMessageAt, t0) : '',
209
+ preview: d ? (d.text.trim() || plural(d.attachments.length, 'attachment')) : (t.lastMessagePreview || (t.kind === 'dm' ? 'No messages yet' : KIND_LABEL[t.kind])),
210
+ hasDraft: Boolean(d),
211
+ unread: t.unread > 0 ? t.unread : 0,
212
+ rowCls: [t.id === activeId.value && 'is-active', t.unread > 0 && 'is-unread'].filter(Boolean).join(' ')
213
+ };
214
+ });
215
+ }));
216
+
217
+ const bubbles = M.observable([]);
218
+ scope(M.effect(() => {
219
+ const t = active.value;
220
+ const people = new Map((t?.participants || []).map(p => [p.id, p.name || p.email || p.id]));
221
+ bubbles.value = buildBubbles(messages.value, {
222
+ myId: me.value.id, isGroup: Boolean(t && t.kind !== 'dm'), nameOf: (id) => people.get(id) || 'Someone', now: now.value,
223
+ liveCallMessageId: t?.call?.messageId || ''
224
+ });
225
+ }));
226
+
227
+ // A plain observable fed by an effect (a data-each over a computed has frozen in the admin before).
228
+ scope(M.effect(() => { rings.value = ringingCalls(threads.value, me.value.id, dismissed.value); }));
229
+ // The Call button, from observables (not from `active`: a computed of a computed has frozen too).
230
+ const activeThread = () => threads.value.find(t => t.id === activeId.value) || null;
231
+
232
+ const data = {
233
+ threads, loading, loadError, filter, query, rows, active, bubbles, threadLoading, compose, pending, uploading,
234
+ counts: M.computed(() => filterCounts(threads.value, me.value.id)),
235
+ emptyText: M.computed(() => (threads.value.length
236
+ ? (filter.value === 'archived' ? 'Nothing archived.' : filter.value === 'unread' ? 'Nothing unread - you are up to date.' : 'No conversations match.')
237
+ : 'No conversations yet - start one with New conversation in the banner.')),
238
+ activeTitle: M.computed(() => threadTitle(active.value, me.value.id)),
239
+ activeInitials: M.computed(() => initials(threadTitle(active.value, me.value.id))),
240
+ activeMeta: M.computed(() => {
241
+ const t = active.value;
242
+ if (!t) return '';
243
+ const names = (t.participants || []).filter(p => p.id !== me.value.id).map(p => p.name || p.email || p.id);
244
+ const keep = t.retentionDays ?? me.value.defaultRetentionDays;
245
+ return [KIND_LABEL[t.kind], t.kind === 'dm' ? '' : plural((t.participants || []).length, 'member'),
246
+ t.kind === 'dm' ? '' : names.slice(0, 4).join(', ') + (names.length > 4 ? '…' : ''),
247
+ `kept ${keep} days`, isArchived(t, me.value.id) ? 'archived' : ''].filter(Boolean).join(' · ');
248
+ }),
249
+ rings, callBusy, blockedUrl,
250
+ callLabel: M.computed(() => callButton(activeThread()).label),
251
+ callTitle: M.computed(() => callButton(activeThread()).title),
252
+ callLive: M.computed(() => Boolean(activeThread()?.call)),
253
+ canSend: M.computed(() => !sending.value && !uploading.value && (compose.value.trim().length > 0 || pending.value.length > 0)),
254
+ composeHint: M.computed(() => (uploading.value ? 'Uploading…' : drafts[activeId.value] && (compose.value || pending.value.length) ? 'Draft kept - Ctrl+Enter sends.' : 'Ctrl+Enter sends.'))
255
+ };
256
+
257
+ // ---- Drafts: what you type is kept per conversation ----
258
+ let saveTimer = null;
259
+ function stashDraft(id = activeId.peek()) {
260
+ if (!id) return;
261
+ const text = compose.peek();
262
+ const atts = pending.peek();
263
+ if (text.trim() || atts.length) drafts[id] = {text, attachments: atts};
264
+ else delete drafts[id];
265
+ drafts = cleanDrafts(drafts);
266
+ writeStore(DRAFTS_KEY, Object.keys(drafts).length ? drafts : null);
267
+ draftsV.value++;
268
+ }
269
+ scope(M.effect(() => {
270
+ compose.value; pending.value;
271
+ clearTimeout(saveTimer);
272
+ saveTimer = setTimeout(() => stashDraft(), 500);
273
+ }));
274
+ scope(() => { clearTimeout(saveTimer); stashDraft(); });
275
+ /** One conversation's draft changed by work that outlived its screen (settleElsewhere). */
276
+ function applyChange(id, change) {
277
+ if (activeId.peek() === id) {
278
+ const next = change({body: compose.peek(), attachments: pending.peek()});
279
+ compose.value = next.body;
280
+ pending.value = next.attachments;
281
+ stashDraft(id);
282
+ return;
283
+ }
284
+ const d = drafts[id];
285
+ const next = change({body: d?.text || '', attachments: d?.attachments || []});
286
+ if (next.body.trim() || next.attachments.length) drafts[id] = {text: next.body, attachments: next.attachments};
287
+ else delete drafts[id];
288
+ drafts = cleanDrafts(drafts);
289
+ writeStore(DRAFTS_KEY, Object.keys(drafts).length ? drafts : null);
290
+ draftsV.value++;
291
+ }
292
+ const onSettle = (e) => { if (here() && e.detail?.id && e.detail.key === DRAFTS_KEY) applyChange(e.detail.id, e.detail.change); };
293
+ window.addEventListener('messages:settle', onSettle);
294
+ scope(() => window.removeEventListener('messages:settle', onSettle));
295
+
296
+ // ---- Server ----
297
+ /** A skeleton in `sel` while `promise` runs - the first load only (the slot is hidden afterwards). */
298
+ // Not keyed on slot.hidden: the bindings flush on a microtask, so a flag set just before still reads
299
+ // the old state. One skeleton per slot at a time - overlapping ones would restore each other's styles.
300
+ const skeletonOn = new Set();
301
+ const withSkeleton = (sel, promise) => {
302
+ const slot = root.querySelector(sel);
303
+ if (!slot || !loadingLib?.whileLoading || skeletonOn.has(slot)) return promise;
304
+ skeletonOn.add(slot);
305
+ return loadingLib.whileLoading(slot, promise, loadingLib.SHAPES?.iconRows ?? loadingLib.SHAPES?.rows)
306
+ .finally(() => skeletonOn.delete(slot));
307
+ };
308
+ async function loadThreads() {
309
+ try {
310
+ const list = await withSkeleton('.msx-loading', apiRequest(`${BASE}/threads`));
311
+ if (!here()) return;
312
+ threads.value = Array.isArray(list) ? list : [];
313
+ loadError.value = '';
314
+ } catch (err) {
315
+ loadError.value = `Could not load conversations: ${err.message}`;
316
+ } finally {
317
+ loading.value = false;
318
+ }
319
+ }
320
+ let reloadTimer = null;
321
+ const reloadSoon = () => { clearTimeout(reloadTimer); reloadTimer = setTimeout(() => { if (here()) loadThreads(); }, 300); };
322
+ scope(() => clearTimeout(reloadTimer));
323
+
324
+ const patchThread = (id, patch) => { threads.value = threads.peek().map(t => (t.id === id ? {...t, ...patch} : t)); };
325
+
326
+ async function markRead(id) {
327
+ const t = byId(id);
328
+ if (!t || !(t.unread > 0)) return;
329
+ patchThread(id, {unread: 0});
330
+ try { await apiRequest(`${BASE}/threads/${encodeURIComponent(id)}/read`, {method: 'POST'}); } catch { /* the next load corrects it */ }
331
+ refreshBadge();
332
+ }
333
+
334
+ async function markUnread(id) {
335
+ try {
336
+ await apiRequest(`${BASE}/threads/${encodeURIComponent(id)}/unread`, {method: 'POST'});
337
+ patchThread(id, {unread: Math.max(1, byId(id)?.unread || 0)});
338
+ if (activeId.peek() === id) closeThread();
339
+ refreshBadge();
340
+ } catch (err) {
341
+ E.toast(err.message || 'Could not mark it unread', {type: 'warning'});
342
+ }
343
+ }
344
+
345
+ async function setArchived(id, archived) {
346
+ const mine = myId();
347
+ const t = byId(id);
348
+ if (!t) return;
349
+ try {
350
+ await apiRequest(`${BASE}/threads/${encodeURIComponent(id)}/archive`, {method: 'POST', body: JSON.stringify({archived})});
351
+ const others = (t.archivedBy || []).filter(x => x !== mine);
352
+ patchThread(id, {archivedBy: archived ? [...others, mine] : others});
353
+ E.toast(archived ? `Archived "${threadTitle(t, mine)}" - a new message brings it back.` : 'Back in your inbox.', {type: 'success'});
354
+ if (archived && activeId.peek() === id && filter.peek() !== 'archived') closeThread();
355
+ refreshBadge();
356
+ } catch (err) {
357
+ E.toast(`Not changed: ${err.message}`, {type: 'error'});
358
+ }
359
+ }
360
+
361
+ async function leave(id) {
362
+ const t = byId(id);
363
+ if (!t) return;
364
+ const title = threadTitle(t, myId());
365
+ const last = (t.participants || []).length <= 1;
366
+ if (!await E.confirm(`Leave "${esc(title)}"?${last ? ' You are the last one in it, so the conversation and its messages are deleted.' : ' You stop receiving its messages; the others keep it.'}`)) return;
367
+ try {
368
+ await apiRequest(`${BASE}/threads/${encodeURIComponent(id)}`, {method: 'DELETE'});
369
+ delete drafts[id];
370
+ writeStore(DRAFTS_KEY, Object.keys(drafts).length ? drafts : null);
371
+ draftsV.value++;
372
+ threads.value = threads.peek().filter(x => x.id !== id);
373
+ if (activeId.peek() === id) closeThread();
374
+ E.toast(`Left "${title}".`, {type: 'success'});
375
+ refreshBadge();
376
+ } catch (err) {
377
+ E.toast(`Could not leave: ${err.message}`, {type: 'error'});
378
+ }
379
+ }
380
+
381
+ // ---- Opening a conversation ----
382
+ let openSeq = 0;
383
+ async function openThread(id) {
384
+ if (!byId(id)) return;
385
+ if (activeId.peek() && activeId.peek() !== id) stashDraft();
386
+ const mine = ++openSeq;
387
+ activeId.value = id;
388
+ const d = drafts[id];
389
+ compose.value = d?.text || '';
390
+ pending.value = d?.attachments || [];
391
+ messages.value = [];
392
+ threadLoading.value = true;
393
+ try {
394
+ const list = await withSkeleton('.msx-thread-loading', apiRequest(`${BASE}/threads/${encodeURIComponent(id)}/messages?limit=200`));
395
+ if (!here() || mine !== openSeq) return;
396
+ messages.value = Array.isArray(list) ? list : [];
397
+ } catch (err) {
398
+ E.toast(`Could not load the messages: ${err.message}`, {type: 'error'});
399
+ } finally {
400
+ if (mine === openSeq) threadLoading.value = false;
401
+ }
402
+ markRead(id);
403
+ requestAnimationFrame(() => root.querySelector('.msx-input')?.focus());
404
+ }
405
+
406
+ function closeThread() {
407
+ stashDraft();
408
+ activeId.value = '';
409
+ messages.value = [];
410
+ compose.value = '';
411
+ pending.value = [];
412
+ }
413
+
414
+ // Newest at the bottom, in view - after each render of the bubbles.
415
+ const scroller = root.querySelector('.msx-scroll');
416
+ let stick = true;
417
+ const onScroll = () => { stick = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80; };
418
+ scroller.addEventListener('scroll', onScroll);
419
+ scope(() => scroller.removeEventListener('scroll', onScroll));
420
+ scope(M.effect(() => {
421
+ bubbles.value;
422
+ requestAnimationFrame(() => { if (stick && scroller.isConnected) scroller.scrollTop = scroller.scrollHeight; });
423
+ }));
424
+
425
+ // ---- Sending ----
426
+ async function send() {
427
+ const t = active.peek();
428
+ const body = compose.peek();
429
+ const atts = pending.peek();
430
+ if (!t || sending.peek() || (!body.trim() && !atts.length)) return;
431
+ sending.value = true;
432
+ try {
433
+ const msg = await apiRequest(`${BASE}/threads/${encodeURIComponent(t.id)}/messages`, {
434
+ method: 'POST', body: JSON.stringify({body, attachments: atts})
435
+ });
436
+ // Whatever happens next, the sent text must not come back as a draft (and be sent twice).
437
+ const sentPart = {body, attachments: atts};
438
+ const clear = (d) => afterSend(sentPart, d);
439
+ if (!here()) {
440
+ // Left meanwhile: the teardown kept the box as a draft - take the sent part out of it,
441
+ // in storage and in any screen opened since.
442
+ settleElsewhere(DRAFTS_KEY, t.id, clear);
443
+ return;
444
+ }
445
+ const stillOn = activeId.peek() === t.id;
446
+ // Still on it: keep anything typed or attached while it was sending. Moved to another
447
+ // conversation: only this one's draft changes - the box and bubbles on screen are not its.
448
+ applyChange(t.id, clear);
449
+ if (stillOn) { stick = true; messages.value = [...messages.peek(), msg]; }
450
+ patchThread(t.id, {
451
+ lastMessageAt: msg.createdAt,
452
+ lastMessagePreview: body.trim().slice(0, 120) || (atts.length ? `Attachment: ${atts[0].filename}` : ''),
453
+ archivedBy: []
454
+ });
455
+ } catch (err) {
456
+ E.toast(`Not sent: ${err.message || 'unknown error'} - your text is kept.`, {type: 'error'});
457
+ } finally {
458
+ sending.value = false;
459
+ }
460
+ }
461
+
462
+ // ---- Calls (1.4) ----
463
+ /**
464
+ * Start this conversation's call, or join the one running: the server
465
+ * answers with this person's own two-hour link to the call page.
466
+ */
467
+ async function joinCall(id, opened) {
468
+ // Opened on the click itself, before any await - after one, the pop-up blocker takes it.
469
+ // Call… opens it on its own click and hands it over (undefined = not opened yet).
470
+ const tab = opened === undefined ? window.open('', '_blank') : opened;
471
+ callBusy.value = true;
472
+ try {
473
+ const res = await apiRequest(`${BASE}/threads/${encodeURIComponent(id)}/call`, {method: 'POST'});
474
+ const url = new URL(res.url, location.origin).href;
475
+ if (res.call) patchThread(id, {call: res.call});
476
+ if (res.call) dismissed.value = [...dismissed.peek(), `${id}|${res.call.startedAt}`];
477
+ if (tab) { tab.opener = null; tab.location.href = url; } else blockedUrl.value = url;
478
+ } catch (err) {
479
+ tab?.close();
480
+ E.toast(`No call: ${err.message || 'unknown error'}`, {type: 'error'});
481
+ } finally {
482
+ callBusy.value = false;
483
+ }
484
+ }
485
+
486
+ function attach() {
487
+ const input = document.createElement('input');
488
+ input.type = 'file';
489
+ input.multiple = true;
490
+ input.addEventListener('change', async () => {
491
+ const files = [...(input.files || [])];
492
+ if (!files.length) return;
493
+ uploading.value = true;
494
+ const tid = activeId.peek(); // attachments belong to the conversation they were added on
495
+ for (const file of files) {
496
+ const fd = new FormData();
497
+ fd.append('file', file);
498
+ try {
499
+ const res = await api.media.upload(fd);
500
+ const saved = Array.isArray(res) ? res[0] : res;
501
+ if (!saved?.name) throw new Error('no file name came back');
502
+ const att = {mediaId: saved.name, filename: file.name, mime: file.type, size: file.size, url: saved.url};
503
+ const add = (d) => ({body: d.body, attachments: [...d.attachments, att]});
504
+ if (!tid) continue;
505
+ if (here()) applyChange(tid, add);
506
+ else settleElsewhere(DRAFTS_KEY, tid, add); // left mid-upload: it still reaches the draft
507
+ } catch (err) {
508
+ E.toast(`${file.name} was not attached: ${err.message}`, {type: 'error'});
509
+ }
510
+ }
511
+ if (here()) uploading.value = false;
512
+ });
513
+ input.click();
514
+ }
515
+
516
+ // ---- Slideover forms ----
517
+ const forms = [];
518
+ const panels = new Set();
519
+ const dirtyForm = () => [...forms].reverse().find(f => f.dirty()) || null;
520
+
521
+ /** A slideover form: asks once before discarding; leaving the screen closes it (the caller keeps any draft). */
522
+ function openPanel({title, size = 'md', html, model, methods, dirty = () => false, onLeave = () => {}, cleanup = () => {}}) {
523
+ const so = E.slideover({title, size, position: 'right'});
524
+ const panel = fragment(html);
525
+ so.element.appendChild(panel);
526
+ const disposeHelp = helpPopovers(panel);
527
+ const handle = M.applyBindings(model, panel, {methods});
528
+ const form = {dirty, onLeave};
529
+ forms.push(form);
530
+ panels.add(so);
531
+ onceClosed(so, () => {
532
+ cleanup();
533
+ handle.dispose();
534
+ disposeHelp();
535
+ panels.delete(so);
536
+ const at = forms.indexOf(form);
537
+ if (at >= 0) forms.splice(at, 1);
538
+ });
539
+ const close = so.close.bind(so);
540
+ let closed = false;
541
+ let asking = false;
542
+ so.close = async (force) => {
543
+ if (!closed && force !== true && dirty()) {
544
+ if (asking) return undefined;
545
+ asking = true;
546
+ const ok = await E.confirm('Discard what you have entered?');
547
+ asking = false;
548
+ if (!ok) return undefined;
549
+ }
550
+ closed = true;
551
+ return close();
552
+ };
553
+ I.scan(panel);
554
+ so.open();
555
+ return {so, panel};
556
+ }
557
+
558
+ let people = null;
559
+ async function loadPeople() {
560
+ if (people) return people;
561
+ try {
562
+ const list = await apiRequest(`${BASE}/people`);
563
+ people = (Array.isArray(list) ? list : []).filter(p => p.id !== myId());
564
+ } catch { people = []; }
565
+ return people;
566
+ }
567
+
568
+ /** The people picker both forms share - a search box and a tick list. */
569
+ const PEOPLE_LIST = `
570
+ <input type="search" class="form-input" placeholder="Find someone…" aria-label="Find someone" data-model="find.value">
571
+ <div class="msx-people" data-each="peopleRows.value key=id">
572
+ <label class="msx-person"><input type="checkbox" data-id="{{id}}" data-bind-checked="on"> <span>{{name}}</span></label>
573
+ </div>
574
+ <p class="msx-hint-block" data-bind-hidden="!noOne">There is no one else on this site to message yet. Add people in
575
+ <a href="#/users">Users</a> - they can message once their role has the Messages permission.</p>
576
+ <p class="msx-hint-block" data-bind-hidden="!noMatch.value">No one matches that.</p>`;
577
+ function peopleModel(all, picked) {
578
+ const find = M.observable('');
579
+ // Not an empty list with no explanation: a site with nobody else on it says so (it looked broken).
580
+ const noOne = all.length === 0;
581
+ return {
582
+ find,
583
+ noOne,
584
+ noMatch: M.computed(() => !noOne && Boolean(find.value.trim()) && !all.some(p => String(p.name).toLowerCase().includes(find.value.trim().toLowerCase()))),
585
+ peopleRows: M.computed(() => {
586
+ const q = find.value.trim().toLowerCase();
587
+ const on = new Set(picked.value);
588
+ return all.filter(p => !q || String(p.name).toLowerCase().includes(q)).map(p => ({id: p.id, name: p.name, on: on.has(p.id)}));
589
+ })
590
+ };
591
+ }
592
+ function wirePeople(panel, picked, single) {
593
+ panel.querySelector('.msx-people').addEventListener('change', (e) => {
594
+ const id = e.target?.dataset?.id;
595
+ if (!id) return;
596
+ const cur = picked.peek();
597
+ picked.value = e.target.checked ? (single() ? [id] : [...new Set([...cur, id])]) : cur.filter(x => x !== id);
598
+ });
599
+ }
600
+
601
+ /** `onOpen`: called once the panel is on screen (a kept draft is removed only then). */
602
+ async function newConversation(draft = null, onOpen = null) {
603
+ const all = await loadPeople();
604
+ if (!here()) return;
605
+ const kind = M.observable(draft?.kind === 'group-adhoc' ? 'group-adhoc' : 'dm');
606
+ const name = M.observable(draft?.name || '');
607
+ const picked = M.observable((draft?.participants || []).filter(id => all.some(p => p.id === id)));
608
+ const saving = M.observable(false);
609
+ const opened = JSON.stringify({k: kind.peek(), n: name.peek(), p: picked.peek()});
610
+ const dirty = () => Boolean(draft) || JSON.stringify({k: kind.peek(), n: name.peek(), p: picked.peek()}) !== opened;
611
+ const model = {
612
+ kind, name, saving, ...peopleModel(all, picked),
613
+ isGroup: M.computed(() => kind.value !== 'dm'),
614
+ problem: M.computed(() => {
615
+ if (kind.value === 'dm' && picked.value.length !== 1) return picked.value.length ? 'A direct message is with one person - untick the others, or make it a group.' : 'Choose who to message.';
616
+ if (kind.value !== 'dm' && !picked.value.length) return 'Choose at least one person.';
617
+ if (kind.value !== 'dm' && !name.value.trim()) return 'A group needs a name.';
618
+ return '';
619
+ }),
620
+ pickedText: M.computed(() => plural(picked.value.length, 'person', 'people') + ' chosen')
621
+ };
622
+ model.cannot = M.computed(() => Boolean(model.problem.value) || saving.value);
623
+ const {so, panel} = openPanel({
624
+ title: 'New conversation', size: 'md', dirty,
625
+ onLeave: () => writeStore(NEW_DRAFT_KEY, {kind: kind.peek(), name: name.peek(), participants: picked.peek()}),
626
+ html: `
627
+ <div class="msx-form">
628
+ <div>
629
+ <label class="form-label" for="msx-nc-kind">Kind
630
+ <span data-help="A direct message is between you and one other person, and there is only ever one per pair - starting it again opens the one you have. A group has a name and any number of people; whoever starts it can rename it."></span></label>
631
+ <select id="msx-nc-kind" class="form-input" data-model="kind.value">
632
+ <option value="dm">Direct message</option>
633
+ <option value="group-adhoc">Group</option>
634
+ </select>
635
+ </div>
636
+ <div data-bind-hidden="!isGroup.value">
637
+ <label class="form-label" for="msx-nc-name">Group name</label>
638
+ <input id="msx-nc-name" type="text" class="form-input" maxlength="80" placeholder="e.g. Editorial team" data-model="name.value">
639
+ </div>
640
+ <div>
641
+ <label class="form-label">People <small class="msx-hint-block" data-bind-text="pickedText.value"></small></label>
642
+ ${PEOPLE_LIST}
643
+ </div>
644
+ <small class="msx-err" data-bind-hidden="!problem.value" data-bind-text="problem.value"></small>
645
+ <div class="msx-actions">
646
+ <small class="msx-hint-block">Ctrl+Enter starts it.</small>
647
+ <span class="msx-spacer"></span>
648
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
649
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="start" data-bind-disabled="cannot.value">
650
+ <span data-icon="message-circle" data-icon-size="13"></span> Start</button>
651
+ </div>
652
+ </div>`,
653
+ model,
654
+ methods: {
655
+ cancel: () => so.close(),
656
+ async start() {
657
+ if (model.cannot.peek()) return;
658
+ saving.value = true;
659
+ try {
660
+ const thread = await apiRequest(`${BASE}/threads`, {method: 'POST', body: JSON.stringify({
661
+ kind: kind.peek(), name: name.peek().trim(), participants: picked.peek()})});
662
+ so.close(true);
663
+ await loadThreads();
664
+ if (here()) { filter.value = 'inbox'; openThread(thread.id); }
665
+ } catch (err) {
666
+ E.toast(`Not started: ${err.message}`, {type: 'error'});
667
+ } finally {
668
+ saving.value = false;
669
+ }
670
+ }
671
+ }
672
+ });
673
+ onOpen?.();
674
+ wirePeople(panel, picked, () => kind.peek() === 'dm');
675
+ panel.addEventListener('keydown', (e) => {
676
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); panel.querySelector('[data-on-click="start"]').click(); }
677
+ });
678
+ }
679
+
680
+ /**
681
+ * Call… - pick 1 to 5 people and call them: in your conversation with
682
+ * them if there is one (callTarget), else in a new one.
683
+ */
684
+ async function callPeople() {
685
+ const all = await loadPeople();
686
+ if (!here()) return;
687
+ const picked = M.observable([]);
688
+ const name = M.observable('');
689
+ const saving = M.observable(false);
690
+ const mine = myId();
691
+ const target = () => callTarget(threads.value, mine, picked.value);
692
+ const model = {
693
+ name, saving, ...peopleModel(all, picked),
694
+ problem: M.computed(() => {
695
+ const n = picked.value.length;
696
+ if (!n) return 'Choose who to call.';
697
+ if (n > CALL_PICK_MAX) return `A call holds ${CALL_MAX} people, you included - choose up to ${CALL_PICK_MAX}.`;
698
+ return '';
699
+ }),
700
+ where: M.computed(() => callTargetText(target(), mine)),
701
+ newGroup: M.computed(() => target()?.create?.kind === 'group-adhoc'),
702
+ namePlaceholder: M.computed(() => callGroupName([{name: me.value.name || 'You'},
703
+ ...picked.value.map(id => all.find(p => p.id === id) || {name: 'Someone'})])),
704
+ pickedText: M.computed(() => `${plural(picked.value.length, 'person', 'people')} chosen - up to ${CALL_PICK_MAX}`)
705
+ };
706
+ model.cannot = M.computed(() => Boolean(model.problem.value) || saving.value);
707
+ const {so, panel} = openPanel({
708
+ title: 'Call…', size: 'md', dirty: () => Boolean(picked.peek().length || name.peek().trim()),
709
+ html: `
710
+ <div class="msx-form">
711
+ <div>
712
+ <label class="form-label">Who to call <small class="msx-hint-block" data-bind-text="pickedText.value"></small>
713
+ <span data-help="Pick the people to call - one to ${CALL_PICK_MAX}, since a call holds ${CALL_MAX} people, you included. One person: the call is in your conversation with them. Several: in your group with exactly those people, or a new group if there is none. The call opens in a new tab and they see a notice to join."></span></label>
714
+ ${PEOPLE_LIST}
715
+ </div>
716
+ <div data-bind-hidden="!newGroup.value">
717
+ <label class="form-label" for="msx-cp-name">Group name <small class="msx-hint-block">optional</small></label>
718
+ <input id="msx-cp-name" type="text" class="form-input" maxlength="80" data-bind-placeholder="namePlaceholder.value" data-model="name.value">
719
+ </div>
720
+ <small class="msx-hint-block" data-bind-hidden="problem.value" data-bind-text="where.value"></small>
721
+ <small class="msx-err" data-bind-hidden="!problem.value" data-bind-text="problem.value"></small>
722
+ <div class="msx-actions">
723
+ <small class="msx-hint-block">Ctrl+Enter calls.</small>
724
+ <span class="msx-spacer"></span>
725
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
726
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="call" data-bind-disabled="cannot.value">
727
+ <span data-icon="video" data-icon-size="13"></span> Call</button>
728
+ </div>
729
+ </div>`,
730
+ model,
731
+ methods: {
732
+ cancel: () => so.close(),
733
+ call() {
734
+ if (model.cannot.peek()) return;
735
+ const tgt = callTarget(threads.peek(), mine, picked.peek());
736
+ if (!tgt) return;
737
+ // On this click, before any await - after one, the pop-up blocker takes it.
738
+ const tab = window.open('', '_blank');
739
+ saving.value = true;
740
+ (async () => {
741
+ let id = tgt.id;
742
+ try {
743
+ if (!id) {
744
+ const body = tgt.create.kind === 'dm' ? tgt.create
745
+ : {...tgt.create, name: name.peek().trim() || model.namePlaceholder.peek()};
746
+ id = (await apiRequest(`${BASE}/threads`, {method: 'POST', body: JSON.stringify(body)})).id;
747
+ }
748
+ } catch (err) {
749
+ tab?.close();
750
+ saving.value = false;
751
+ E.toast(`No call: ${err.message || 'unknown error'}`, {type: 'error'});
752
+ return;
753
+ }
754
+ so.close(true);
755
+ if (!byId(id)) await loadThreads();
756
+ if (!here()) { tab?.close(); return; }
757
+ // The call's "started" line brings it back to everyone's inbox.
758
+ filter.value = 'inbox';
759
+ query.value = '';
760
+ await joinCall(id, tab);
761
+ if (here()) openThread(id);
762
+ })();
763
+ }
764
+ }
765
+ });
766
+ wirePeople(panel, picked, () => false);
767
+ panel.addEventListener('keydown', (e) => {
768
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); panel.querySelector('[data-on-click="call"]').click(); }
769
+ });
770
+ requestAnimationFrame(() => panel.querySelector('input[type="search"]')?.focus());
771
+ }
772
+
773
+ function editMessage(m) {
774
+ const r = messageRights(m, {myId: myId(), editWindowMinutes: me.peek().editWindowMinutes});
775
+ if (!r.canEdit) { E.toast('The time to edit this message has passed.', {type: 'warning'}); return; }
776
+ const text = M.observable(String(m.body ?? ''));
777
+ const saving = M.observable(false);
778
+ const model = {text, saving, left: `You can edit it for about ${plural(r.editLeftMinutes, 'more minute')}.`};
779
+ model.cannot = M.computed(() => saving.value || !text.value.trim() || text.value === m.body);
780
+ // No draft kept for this one: the edit window is minutes long, so an edit
781
+ // offered back on a later visit would almost always be refused.
782
+ const {so, panel} = openPanel({
783
+ title: 'Edit message', size: 'md', dirty: () => text.peek() !== String(m.body ?? ''),
784
+ html: `
785
+ <div class="msx-form">
786
+ <textarea class="form-input" rows="6" aria-label="Message" data-model="text.value"></textarea>
787
+ <small class="msx-hint-block" data-bind-text="left"></small>
788
+ <div class="msx-actions">
789
+ <small class="msx-hint-block">Ctrl+Enter saves.</small>
790
+ <span class="msx-spacer"></span>
791
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
792
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
793
+ <span data-icon="check" data-icon-size="13"></span> Save</button>
794
+ </div>
795
+ </div>`,
796
+ model,
797
+ methods: {
798
+ cancel: () => so.close(),
799
+ async save() {
800
+ if (model.cannot.peek()) return;
801
+ saving.value = true;
802
+ try {
803
+ const updated = await apiRequest(`${BASE}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}`, {
804
+ method: 'PUT', body: JSON.stringify({body: text.peek()})});
805
+ messages.value = messages.peek().map(x => (x.id === m.id ? updated : x));
806
+ so.close(true);
807
+ } catch (err) {
808
+ E.toast(`Not saved: ${err.message}`, {type: 'error'});
809
+ } finally {
810
+ saving.value = false;
811
+ }
812
+ }
813
+ }
814
+ });
815
+ const area = panel.querySelector('textarea');
816
+ area.addEventListener('keydown', (e) => {
817
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); model.cannot.peek() || panel.querySelector('[data-on-click="save"]').click(); }
818
+ });
819
+ requestAnimationFrame(() => area.focus());
820
+ }
821
+
822
+ async function deleteMessage(m) {
823
+ if (!await E.confirm('Delete this message? The others see "message deleted" in its place.')) return;
824
+ try {
825
+ const updated = await apiRequest(`${BASE}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}`, {method: 'DELETE'});
826
+ messages.value = messages.peek().map(x => (x.id === m.id ? updated : x));
827
+ } catch (err) {
828
+ E.toast(`Not deleted: ${err.message}`, {type: 'error'});
829
+ }
830
+ }
831
+
832
+ function renameGroup(t) {
833
+ const name = M.observable(t.name || '');
834
+ const saving = M.observable(false);
835
+ const model = {name, saving};
836
+ model.cannot = M.computed(() => saving.value || !name.value.trim() || name.value.trim() === t.name);
837
+ const {so, panel} = openPanel({
838
+ title: `Rename ${esc(t.name || 'group')}`, size: 'sm', dirty: () => name.peek() !== (t.name || ''),
839
+ html: `
840
+ <div class="msx-form">
841
+ <div><label class="form-label" for="msx-rn">Name</label>
842
+ <input id="msx-rn" type="text" class="form-input" maxlength="80" data-model="name.value"></div>
843
+ <div class="msx-actions"><span class="msx-spacer"></span>
844
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
845
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Rename</button></div>
846
+ </div>`,
847
+ model,
848
+ methods: {
849
+ cancel: () => so.close(),
850
+ async save() {
851
+ if (model.cannot.peek()) return;
852
+ saving.value = true;
853
+ try {
854
+ await apiRequest(`${BASE}/threads/${encodeURIComponent(t.id)}`, {method: 'PUT', body: JSON.stringify({name: name.peek().trim()})});
855
+ patchThread(t.id, {name: name.peek().trim()});
856
+ so.close(true);
857
+ } catch (err) {
858
+ E.toast(`Not renamed: ${err.message}`, {type: 'error'});
859
+ } finally {
860
+ saving.value = false;
861
+ }
862
+ }
863
+ }
864
+ });
865
+ panel.querySelector('input').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); panel.querySelector('[data-on-click="save"]').click(); } });
866
+ }
867
+
868
+ function retention(t) {
869
+ const cfg = me.peek();
870
+ const start = t.retentionDays == null ? '' : String(t.retentionDays);
871
+ const days = M.observable(start);
872
+ const saving = M.observable(false);
873
+ const model = {
874
+ days, saving,
875
+ hint: M.computed(() => {
876
+ const n = Number(days.value);
877
+ if (days.value === '') return `Using the site's default: ${cfg.defaultRetentionDays} days.`;
878
+ if (!Number.isInteger(n) || n < 1) return 'A whole number of days, 1 or more.';
879
+ return n > cfg.globalRetentionCeilingDays ? `The site keeps nothing longer than ${cfg.globalRetentionCeilingDays} days - that is what it will be.` : `Messages older than ${plural(n, 'day')} are removed.`;
880
+ })
881
+ };
882
+ model.cannot = M.computed(() => {
883
+ const n = Number(days.value);
884
+ return saving.value || days.value === start || (days.value !== '' && (!Number.isInteger(n) || n < 1));
885
+ });
886
+ const {so} = openPanel({
887
+ title: 'Keep messages for…', size: 'sm', dirty: () => days.peek() !== start,
888
+ html: `
889
+ <div class="msx-form">
890
+ <div><label class="form-label" for="msx-rt">Days
891
+ <span data-help="How long this conversation's messages are kept, for everyone in it. Older ones are removed the next time it is opened. Leave it empty for the site's default. The site sets a limit nobody can go past."></span></label>
892
+ <input id="msx-rt" type="number" min="1" step="1" class="form-input" placeholder="Site default" data-model="days.value">
893
+ <small class="msx-hint-block" data-bind-text="hint.value"></small></div>
894
+ <div class="msx-actions"><span class="msx-spacer"></span>
895
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
896
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button></div>
897
+ </div>`,
898
+ model,
899
+ methods: {
900
+ cancel: () => so.close(),
901
+ async save() {
902
+ if (model.cannot.peek()) return;
903
+ saving.value = true;
904
+ try {
905
+ const saved = await apiRequest(`${BASE}/threads/${encodeURIComponent(t.id)}`, {method: 'PUT',
906
+ body: JSON.stringify({retentionDays: days.peek() === '' ? null : Number(days.peek())})});
907
+ patchThread(t.id, {retentionDays: saved?.retentionDays ?? null});
908
+ so.close(true);
909
+ } catch (err) {
910
+ E.toast(`Not saved: ${err.message}`, {type: 'error'});
911
+ } finally {
912
+ saving.value = false;
913
+ }
914
+ }
915
+ }
916
+ });
917
+ }
918
+
919
+ async function managedGroups() {
920
+ const all = await loadPeople();
921
+ if (!here()) return;
922
+ const groups = M.observable([]);
923
+ const name = M.observable('');
924
+ const picked = M.observable([]);
925
+ const saving = M.observable(false);
926
+ const model = {groups, name, saving, ...peopleModel(all, picked), noGroups: M.computed(() => !groups.value.length)};
927
+ model.cannot = M.computed(() => saving.value || !name.value.trim());
928
+ const load = async () => {
929
+ try {
930
+ const list = await apiRequest(`${BASE}/managed-groups`);
931
+ groups.value = (Array.isArray(list) ? list : []).map(g => ({id: g.id, name: g.name || 'Unnamed', meta: plural((g.participants || []).length, 'member')}));
932
+ } catch (err) { E.toast(`Could not load the groups: ${err.message}`, {type: 'error'}); }
933
+ };
934
+ const {so, panel} = openPanel({
935
+ title: 'Managed groups', size: 'md', dirty: () => Boolean(name.peek().trim() || picked.peek().length),
936
+ html: `
937
+ <div class="msx-form">
938
+ <div>
939
+ <strong>Groups <span data-help="Managed groups are made by an admin for a standing team - Editors, Support. Unlike a group anyone starts, only an admin can create them."></span></strong>
940
+ <p class="msx-hint-block" data-bind-hidden="!noGroups.value">None yet.</p>
941
+ <div data-each="groups.value key=id"><div class="msx-group-row"><span class="msx-avatar is-group" aria-hidden="true">#</span><strong style="flex:1">{{name}}</strong><small class="msx-hint-block">{{meta}}</small></div></div>
942
+ </div>
943
+ <div>
944
+ <strong>New managed group</strong>
945
+ <label class="form-label" for="msx-mg-name">Name</label>
946
+ <input id="msx-mg-name" type="text" class="form-input" maxlength="80" placeholder="e.g. Editors" data-model="name.value">
947
+ <label class="form-label">People</label>
948
+ ${PEOPLE_LIST}
949
+ </div>
950
+ <div class="msx-actions"><span class="msx-spacer"></span>
951
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
952
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
953
+ <span data-icon="users" data-icon-size="13"></span> Create group</button></div>
954
+ </div>`,
955
+ model,
956
+ methods: {
957
+ cancel: () => so.close(),
958
+ async create() {
959
+ if (model.cannot.peek()) return;
960
+ saving.value = true;
961
+ try {
962
+ await apiRequest(`${BASE}/managed-groups`, {method: 'POST', body: JSON.stringify({name: name.peek().trim(), participants: picked.peek()})});
963
+ E.toast(`Created "${name.peek().trim()}".`, {type: 'success'});
964
+ name.value = '';
965
+ picked.value = [];
966
+ await load();
967
+ loadThreads();
968
+ } catch (err) {
969
+ E.toast(`Not created: ${err.message}`, {type: 'error'});
970
+ } finally {
971
+ saving.value = false;
972
+ }
973
+ }
974
+ }
975
+ });
976
+ wirePeople(panel, picked, () => false);
977
+ load();
978
+ }
979
+
980
+ // ---- Context menus ----
981
+ function threadItems(id) {
982
+ const t = byId(id);
983
+ if (!t) return [];
984
+ const mine = myId();
985
+ const arch = isArchived(t, mine);
986
+ const canRename = t.kind !== 'dm' && (t.createdBy === mine || me.peek().isAdmin);
987
+ return [
988
+ {header: threadTitle(t, mine)},
989
+ {label: 'Open', icon: 'message-circle', action: () => openThread(id)},
990
+ {label: t.call ? 'Join call' : 'Call', icon: 'video', action: () => { joinCall(id); if (activeId.peek() !== id) openThread(id); }},
991
+ t.unread > 0
992
+ ? {label: 'Mark as read', icon: 'check', action: () => markRead(id)}
993
+ : {label: 'Mark as unread', icon: 'mail', action: () => markUnread(id)},
994
+ arch
995
+ ? {label: 'Move to inbox', icon: 'inbox', action: () => setArchived(id, false)}
996
+ : {label: 'Archive', icon: 'archive', action: () => setArchived(id, true)},
997
+ {type: 'divider'},
998
+ ...(canRename ? [{label: 'Rename…', icon: 'edit', action: () => renameGroup(t)}] : []),
999
+ // The server lets only whoever started it (or an admin) change this.
1000
+ ...((t.createdBy === mine || me.peek().isAdmin) ? [{label: 'Keep messages for…', icon: 'clock', action: () => retention(t)}] : []),
1001
+ {type: 'divider'},
1002
+ {label: 'Leave conversation…', icon: 'log-out', danger: true, action: later(() => leave(id))}
1003
+ ];
1004
+ }
1005
+
1006
+ function messageItems(id) {
1007
+ const m = messages.peek().find(x => x.id === id);
1008
+ if (!m || m.deletedAt) return [];
1009
+ const r = messageRights(m, {myId: myId(), isAdmin: me.peek().isAdmin, threadCreator: active.peek()?.kind !== 'dm' ? active.peek()?.createdBy : null, editWindowMinutes: me.peek().editWindowMinutes});
1010
+ return [
1011
+ {label: 'Copy text', icon: 'copy', disabled: !m.body, action: () => copyText(m.body)},
1012
+ ...(r.mine ? [{label: r.canEdit ? `Edit… (${plural(r.editLeftMinutes, 'min')} left)` : 'Edit - time has passed', icon: 'edit', disabled: !r.canEdit, action: () => editMessage(m)}] : []),
1013
+ ...(r.canDelete ? [{type: 'divider'}, {label: 'Delete…', icon: 'trash', danger: true, action: later(() => deleteMessage(m))}] : [])
1014
+ ];
1015
+ }
1016
+
1017
+ async function copyText(text) {
1018
+ try { await navigator.clipboard.writeText(String(text || '')); E.toast('Copied.', {type: 'success', duration: 1500}); }
1019
+ catch { E.toast('Copying is not allowed here - select the text instead.', {type: 'warning'}); }
1020
+ }
1021
+
1022
+ /**
1023
+ * A menu item that asks a question: run a task later, so the Enter that chose the
1024
+ * item has finished before the confirm opens - it answered OK otherwise (Leave left
1025
+ * the conversation on Enter, unasked).
1026
+ */
1027
+ const later = (fn) => () => { setTimeout(fn, 0); };
1028
+
1029
+ /** Click = main action, ⋮ / right-click / Shift+F10 = the menu, Enter = main action. */
1030
+ function wireList(list, match, items, main) {
1031
+ const menu = E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact', items: (row) => items(row.dataset.id)});
1032
+ scope(() => menu?.destroy?.());
1033
+ const onClick = (e) => {
1034
+ const row = e.target.closest(match);
1035
+ if (!row) return;
1036
+ const kebab = e.target.closest('.msx-kebab');
1037
+ if (kebab) { openMenuAt(kebab); return; }
1038
+ if (e.target.closest('a')) return;
1039
+ main?.(row.dataset.id);
1040
+ };
1041
+ const onKey = (e) => {
1042
+ const row = e.target.closest(match);
1043
+ if (!row) return;
1044
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
1045
+ else if (e.key === 'Enter' && e.target === row) main?.(row.dataset.id);
1046
+ };
1047
+ list.addEventListener('click', onClick);
1048
+ list.addEventListener('keydown', onKey);
1049
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); });
1050
+ }
1051
+
1052
+ // ---- Bindings ----
1053
+ root.querySelector('.msx-threads').innerHTML = ROW;
1054
+ root.querySelector('.msx-bubbles').innerHTML = BUBBLE;
1055
+ root.querySelector('.msx-attach-list').innerHTML = PENDING;
1056
+ root.querySelector('.msx-rings').innerHTML = RING;
1057
+ const bindings = M.applyBindings(data, root, {
1058
+ methods: {
1059
+ showInbox: () => { filter.value = 'inbox'; },
1060
+ showUnread: () => { filter.value = 'unread'; },
1061
+ showArchived: () => { filter.value = 'archived'; },
1062
+ send: () => send(),
1063
+ attach: () => attach(),
1064
+ call: () => { const id = activeId.peek(); if (id && !callBusy.peek()) joinCall(id); },
1065
+ newConversation: () => newConversation(),
1066
+ callPeople: () => callPeople(),
1067
+ clearBlocked: () => { blockedUrl.value = ''; },
1068
+ // After the click has followed the link: cleared at once, the link would be empty when it is followed.
1069
+ openedBlocked: () => { setTimeout(() => { blockedUrl.value = ''; }, 0); }
1070
+ }
1071
+ });
1072
+ scope(() => bindings.dispose());
1073
+ scope(helpPopovers(root, {position: 'bottom'}));
1074
+
1075
+ wireList(root.querySelector('.msx-threads'), '.msx-row', threadItems, (id) => openThread(id));
1076
+ wireList(root.querySelector('.msx-bubbles'), '.msx-msg', messageItems, null);
1077
+
1078
+ // The ⋮ beside the open conversation's name: the same menu as its row.
1079
+ const head = root.querySelector('.msx-main-head');
1080
+ const headMenu = E.contextMenu(head, {cursor: 'glyph', match: '.msx-thread-more', inherit: false, density: 'compact',
1081
+ items: () => threadItems(activeId.peek())});
1082
+ scope(() => headMenu?.destroy?.());
1083
+ const onHead = (e) => { const b = e.target.closest('.msx-thread-more'); if (b) openMenuAt(b); };
1084
+ head.addEventListener('click', onHead);
1085
+ scope(() => head.removeEventListener('click', onHead));
1086
+
1087
+ const input = root.querySelector('.msx-input');
1088
+ const onInputKey = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); send(); } };
1089
+ input.addEventListener('keydown', onInputKey);
1090
+ scope(() => input.removeEventListener('keydown', onInputKey));
1091
+
1092
+ // Call notices: Join (the tab opens on this click) or put it aside; Join on a "started" line.
1093
+ const ringList = root.querySelector('.msx-rings');
1094
+ const onRing = (e) => {
1095
+ const join = e.target.closest('[data-join]')?.dataset.join;
1096
+ if (join) { joinCall(join); if (activeId.peek() !== join) openThread(join); return; }
1097
+ const off = e.target.closest('[data-dismiss]')?.dataset.dismiss;
1098
+ if (off) dismissed.value = [...dismissed.peek(), off];
1099
+ };
1100
+ ringList.addEventListener('click', onRing);
1101
+ scope(() => ringList.removeEventListener('click', onRing));
1102
+ const bubbleList = root.querySelector('.msx-bubbles');
1103
+ const onLineJoin = (e) => { if (e.target.closest('.msx-call-join') && activeId.peek()) joinCall(activeId.peek()); };
1104
+ bubbleList.addEventListener('click', onLineJoin);
1105
+ scope(() => bubbleList.removeEventListener('click', onLineJoin));
1106
+
1107
+ const attList = root.querySelector('.msx-attach-list');
1108
+ const onRemove = (e) => {
1109
+ const id = e.target.closest('[data-remove]')?.dataset.remove;
1110
+ if (id) pending.value = pending.peek().filter(a => a.mediaId !== id);
1111
+ };
1112
+ attList.addEventListener('click', onRemove);
1113
+ scope(() => attList.removeEventListener('click', onRemove));
1114
+
1115
+ addBannerAction($container, {id: 'messages-new', icon: 'edit', title: 'New conversation', onClick: () => newConversation()});
1116
+ addBannerAction($container, {id: 'messages-call', icon: 'video', title: 'Call… - pick people to call', label: 'Call…', onClick: () => callPeople()});
1117
+
1118
+ // ---- Live: the event stream (closed when the screen goes) ----
1119
+ let sse = null;
1120
+ function connect() {
1121
+ const token = readStore('auth_token', '');
1122
+ if (!token || typeof EventSource !== 'function') return;
1123
+ sse = new EventSource(`/api${BASE}/stream?token=${encodeURIComponent(token)}`);
1124
+ sse.addEventListener('message:new', (e) => {
1125
+ let payload;
1126
+ try { payload = JSON.parse(e.data); } catch { return; }
1127
+ const {threadId, message} = payload || {};
1128
+ if (!threadId || !message) return;
1129
+ const t = byId(threadId);
1130
+ if (!t) { reloadSoon(); refreshBadge(); return; }
1131
+ const open = activeId.peek() === threadId && !document.hidden;
1132
+ // A call line arrives already read (the notice did the telling), and "ended" leaves an archive alone.
1133
+ const isCall = message.kind === 'call';
1134
+ // Already read by me: my own (typed in a call - 1.6) or said in a call I was in.
1135
+ const seen = isCall || (Array.isArray(message.readBy) && message.readBy.includes(myId()));
1136
+ patchThread(threadId, {
1137
+ lastMessageAt: message.createdAt,
1138
+ lastMessagePreview: String(message.body ?? '').slice(0, 120) || (message.attachments?.length ? `Attachment: ${message.attachments[0].filename}` : ''),
1139
+ ...(isCall && message.call?.event === 'ended' ? {} : {archivedBy: []}),
1140
+ unread: open || seen ? (open ? 0 : t.unread || 0) : (t.unread || 0) + 1
1141
+ });
1142
+ if (activeId.peek() === threadId) {
1143
+ if (messages.peek().some(x => x.id === message.id)) return;
1144
+ messages.value = [...messages.peek(), message];
1145
+ if (open) apiRequest(`${BASE}/threads/${encodeURIComponent(threadId)}/read`, {method: 'POST'}).catch(() => {});
1146
+ }
1147
+ refreshBadge();
1148
+ });
1149
+ const replace = (e) => {
1150
+ let payload;
1151
+ try { payload = JSON.parse(e.data); } catch { return; }
1152
+ const {threadId, message} = payload || {};
1153
+ if (activeId.peek() === threadId && message) messages.value = messages.peek().map(x => (x.id === message.id ? message : x));
1154
+ };
1155
+ sse.addEventListener('message:edited', replace);
1156
+ sse.addEventListener('message:deleted', replace);
1157
+ sse.addEventListener('thread:created', () => { reloadSoon(); refreshBadge(); });
1158
+ sse.addEventListener('thread:updated', () => reloadSoon());
1159
+ const onCall = (e) => {
1160
+ let payload;
1161
+ try { payload = JSON.parse(e.data); } catch { return; }
1162
+ const {threadId, call} = payload || {};
1163
+ if (!threadId) return;
1164
+ if (!byId(threadId)) { reloadSoon(); return; }
1165
+ patchThread(threadId, {call: e.type === 'call:ended' ? null : call || null});
1166
+ };
1167
+ sse.addEventListener('call:started', onCall);
1168
+ sse.addEventListener('call:updated', onCall);
1169
+ sse.addEventListener('call:ended', onCall);
1170
+ }
1171
+ scope(() => { try { sse?.close(); } catch { /* gone */ } sse = null; });
1172
+
1173
+ // ---- Leaving ----
1174
+ const onUnload = (e) => {
1175
+ stashDraft();
1176
+ if (dirtyForm() || sending.peek() || uploading.peek()) { e.preventDefault(); e.returnValue = ''; }
1177
+ };
1178
+ window.addEventListener('beforeunload', onUnload);
1179
+ scope(() => window.removeEventListener('beforeunload', onUnload));
1180
+ scope(() => {
1181
+ [...forms].forEach(f => { if (f.dirty()) f.onLeave(); });
1182
+ [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } });
1183
+ });
1184
+
1185
+ I.scan(root);
1186
+ try {
1187
+ me.value = {...me.peek(), ...(await apiRequest(`${BASE}/me`))};
1188
+ } catch { /* keep the defaults - the server still decides every right */ }
1189
+ if (!here()) return;
1190
+ if (me.peek().isAdmin) {
1191
+ addBannerAction($container, {id: 'messages-groups', icon: 'users', title: 'Managed groups', onClick: () => managedGroups()});
1192
+ }
1193
+ await loadThreads();
1194
+ if (!here()) return;
1195
+ connect();
1196
+ I.scan(root);
1197
+ refreshBadge();
1198
+
1199
+ // A new conversation left half-made: offer it back.
1200
+ // OK throws it away, Cancel / Escape keeps it (opened again). Removed only once answered.
1201
+ const nd = cleanNewDraft(readStore(NEW_DRAFT_KEY, null));
1202
+ if (nd) {
1203
+ const asked = E.confirm(NEW_DRAFT_QUESTION, {...RESTORE_ANSWERS});
1204
+ // Leaving while asked takes the question away too (as core's protectToolEditor does) - Cancel, so the draft stays.
1205
+ let question = null;
1206
+ try { question = [...document.querySelectorAll('.dm-dialog-overlay')].at(-1) ?? null; } catch { /* none */ }
1207
+ scope(() => { const q = question; question = null; try { q?.querySelector('.dm-dialog-cancel')?.click(); q?.remove(); } catch { /* gone */ } });
1208
+ const throwAway = Boolean(await asked);
1209
+ question = null;
1210
+ if (!here()) return; // left while asked: it stays for next time
1211
+ // Kept: removed only once the panel is open (newConversation may still wait for the people list, and be left).
1212
+ if (!throwAway) { newConversation(nd, () => writeStore(NEW_DRAFT_KEY, null)); return; }
1213
+ writeStore(NEW_DRAFT_KEY, null);
1214
+ } else writeStore(NEW_DRAFT_KEY, null);
1215
+ // Otherwise open the newest conversation with something unread, if any.
1216
+ const first = arrangeThreads(threads.peek(), {filter: 'unread', myId: myId()})[0];
1217
+ if (first) openThread(first.id);
1218
+ }
1219
+ };