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,452 @@
1
+ /**
2
+ * Messages - the pure part, shared by the server (plugin.js: the badge and
3
+ * thread titles) and the admin screen. No I/O, no DOM, no globals; tested in
4
+ * tests/messages.test.js.
5
+ */
6
+
7
+ import {fmtDate} from '../../../_lib/admin/ui/dates.js';
8
+
9
+ export const FILTERS = ['inbox', 'unread', 'archived'];
10
+ export const KIND_LABEL = {dm: 'Direct', 'group-adhoc': 'Group', 'group-managed': 'Managed group'};
11
+
12
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
13
+
14
+ const idOf = (p) => (p && typeof p === 'object' ? p.id : p);
15
+ const nameOf = (p) => (p && typeof p === 'object' ? (p.name || p.email || p.id) : p) || 'Unknown';
16
+
17
+ /** What a conversation is called: a group's name, or the other person in a DM. */
18
+ export function threadTitle(t, myId) {
19
+ if (!t) return '';
20
+ if (t.kind === 'dm') {
21
+ const other = (t.participants || []).find(p => idOf(p) !== myId);
22
+ return other ? nameOf(other) : 'Just you';
23
+ }
24
+ return t.name || 'Unnamed group';
25
+ }
26
+
27
+ /** Two letters for the avatar. */
28
+ export function initials(text) {
29
+ const words = String(text || '?').trim().split(/\s+/).filter(Boolean);
30
+ return ((words[0]?.[0] || '?') + (words.length > 1 ? words[words.length - 1][0] : '')).toUpperCase();
31
+ }
32
+
33
+ /** "just now", "5 min", "3 h", "Tue", then "12/09/2026". */
34
+ export function ago(iso, now = Date.now()) {
35
+ const t = new Date(iso).getTime();
36
+ if (!Number.isFinite(t)) return '';
37
+ const s = Math.max(0, Math.round((now - t) / 1000));
38
+ if (s < 60) return 'just now';
39
+ if (s < 3600) return `${Math.round(s / 60)} min`;
40
+ if (s < 86400) return `${Math.round(s / 3600)} h`;
41
+ const d = new Date(t);
42
+ if (s < 6 * 86400) return d.toLocaleDateString('en-GB', {weekday: 'short'});
43
+ return fmtDate(d);
44
+ }
45
+
46
+ /**
47
+ * Collection entries keyed by `data.threadId` (a Map; entries with no thread are left out). The server builds it
48
+ * once per cache fill so a conversation's reads do not scan the whole site's messages (1.7.3).
49
+ */
50
+ export function indexByThread(entries) {
51
+ const map = new Map();
52
+ for (const e of entries || []) {
53
+ const id = e?.data?.threadId;
54
+ if (!id) continue;
55
+ const list = map.get(id);
56
+ if (list) list.push(e); else map.set(id, [e]);
57
+ }
58
+ return map;
59
+ }
60
+
61
+ /** Whether this user has archived the thread (a per-user flag - the others still see it). */
62
+ export const isArchived = (t, myId) => Array.isArray(t?.archivedBy) && t.archivedBy.includes(myId);
63
+
64
+ /**
65
+ * The list as shown: newest conversation first, narrowed by the filter
66
+ * (Inbox hides archived ones; Unread and Archived as named) and the search box
67
+ * (title, participants, last message).
68
+ */
69
+ export function arrangeThreads(threads, {filter = 'inbox', query = '', myId} = {}) {
70
+ const q = String(query).trim().toLowerCase();
71
+ return [...(threads || [])]
72
+ .filter(t => filter === 'archived' ? isArchived(t, myId)
73
+ : filter === 'unread' ? (t.unread > 0 && !isArchived(t, myId))
74
+ : !isArchived(t, myId))
75
+ .filter(t => !q || [threadTitle(t, myId), t.lastMessagePreview, ...(t.participants || []).map(nameOf)]
76
+ .some(v => String(v || '').toLowerCase().includes(q)))
77
+ .sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
78
+ }
79
+
80
+ /** Counts for the filter buttons. */
81
+ export function filterCounts(threads, myId) {
82
+ const list = threads || [];
83
+ const archived = list.filter(t => isArchived(t, myId));
84
+ return {
85
+ inbox: list.length - archived.length,
86
+ unread: list.filter(t => t.unread > 0 && !isArchived(t, myId)).length,
87
+ archived: archived.length
88
+ };
89
+ }
90
+
91
+ /**
92
+ * What the user may do to one message. The server re-checks both; this only
93
+ * decides what the menu offers.
94
+ */
95
+ export function messageRights(m, {myId, isAdmin = false, threadCreator = null, editWindowMinutes = 15, now = Date.now()} = {}) {
96
+ const mine = m?.senderId === myId;
97
+ const deleted = Boolean(m?.deletedAt);
98
+ const age = now - new Date(m?.createdAt).getTime();
99
+ const editLeft = editWindowMinutes * 60_000 - age;
100
+ return {
101
+ mine,
102
+ deleted,
103
+ canEdit: mine && !deleted && editLeft > 0,
104
+ editLeftMinutes: editLeft > 0 ? Math.ceil(editLeft / 60_000) : 0,
105
+ canDelete: !deleted && (mine || Boolean(isAdmin) || (Boolean(threadCreator) && threadCreator === myId))
106
+ };
107
+ }
108
+
109
+ /** An attachment's address: its stored url, else the media file of that name. */
110
+ export function attachmentUrl(a) {
111
+ if (a?.url && /^\/media\//.test(a.url)) return a.url;
112
+ const name = a?.mediaId || a?.filename;
113
+ return name ? `/media/${encodeURIComponent(name)}` : '';
114
+ }
115
+
116
+ /** Human file size. */
117
+ export function fileSize(bytes) {
118
+ const n = Number(bytes) || 0;
119
+ if (n < 1024) return `${n} B`;
120
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
121
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
122
+ }
123
+
124
+ /**
125
+ * Where one signed-in person's drafts are kept: `<base>.<user id>`, so a shared
126
+ * browser never offers one person's typing to another. No user, no key - and
127
+ * no draft is kept. The old unscoped keys (`messages.drafts`,
128
+ * `messages.newDraft`) are never read; the screen removes them.
129
+ * @param {string} base - 'messages.drafts' or 'messages.newDraft'
130
+ * @param {unknown} userId - S.get('auth_user')?.id
131
+ */
132
+ export function draftKey(base, userId) {
133
+ const id = typeof userId === 'string' || typeof userId === 'number' ? String(userId).trim() : '';
134
+ return id && /^[\w.@-]{1,128}$/.test(id) ? `${base}.${id}` : '';
135
+ }
136
+
137
+ /** The question offered for a half-made new conversation: OK throws it away, Cancel (and Escape) keeps it. */
138
+ export const NEW_DRAFT_QUESTION = 'Throw away the changes you left unsaved to a new conversation?';
139
+ export const RESTORE_ANSWERS = Object.freeze({confirmText: 'Throw away', cancelText: 'Keep them'});
140
+
141
+ /** A kept new-conversation draft, whitelisted: {kind, name, participants} - or null when there is nothing in it. */
142
+ export function cleanNewDraft(raw) {
143
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
144
+ const kind = raw.kind === 'group-adhoc' ? 'group-adhoc' : 'dm';
145
+ const name = typeof raw.name === 'string' ? raw.name.slice(0, 80) : '';
146
+ const participants = Array.isArray(raw.participants) ? raw.participants.filter(x => typeof x === 'string').slice(0, 200) : [];
147
+ return name.trim() || participants.length ? {kind, name, participants} : null;
148
+ }
149
+
150
+ /**
151
+ * Per-thread composer drafts, as stored (whitelisted - whatever an older
152
+ * build left is not trusted): {threadId: {text, attachments}}. Empty ones go.
153
+ */
154
+ export function cleanDrafts(raw) {
155
+ const out = {};
156
+ if (!raw || typeof raw !== 'object') return out;
157
+ for (const [id, d] of Object.entries(raw)) {
158
+ if (typeof id !== 'string' || !d || typeof d !== 'object') continue;
159
+ const text = typeof d.text === 'string' ? d.text.slice(0, 20000) : '';
160
+ const attachments = Array.isArray(d.attachments)
161
+ ? d.attachments.filter(a => a && typeof a.mediaId === 'string').slice(0, 20)
162
+ .map(a => ({mediaId: a.mediaId, filename: String(a.filename || a.mediaId), mime: String(a.mime || ''), size: Number(a.size) || 0, url: attachmentUrl(a)}))
163
+ : [];
164
+ if (text.trim() || attachments.length) out[id] = {text, attachments};
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * The sidebar badge (countUrl): unread messages in conversations not
171
+ * archived - a signal, not an inventory. Plain text throughout; the popover
172
+ * sets it with textContent.
173
+ *
174
+ * @param {object[]} threads - this user's, each with `unread`, participants as {id, name}
175
+ */
176
+ export function unreadBadge(threads, myId, now = Date.now()) {
177
+ const live = (threads || []).filter(t => !isArchived(t, myId));
178
+ const waiting = live.filter(t => t.unread > 0)
179
+ .sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
180
+ const unread = waiting.reduce((n, t) => n + t.unread, 0);
181
+ return {
182
+ count: unread,
183
+ showZero: true,
184
+ label: unread ? `${plural(unread, 'unread message')} in ${plural(waiting.length, 'conversation')}` : 'No unread messages',
185
+ tone: 'info',
186
+ details: {
187
+ title: 'Messages',
188
+ rows: [
189
+ {label: 'Unread', value: String(unread)},
190
+ {label: 'Conversations', value: String(live.length)},
191
+ {label: 'Archived', value: String((threads || []).length - live.length)}
192
+ ],
193
+ items: waiting.slice(0, 6).map(t => ({
194
+ text: threadTitle(t, myId),
195
+ meta: [`${t.unread} unread`, ago(t.lastMessageAt, now), String(t.lastMessagePreview || '').replace(/\s+/g, ' ').slice(0, 60)].filter(Boolean).join(' · ')
196
+ })),
197
+ empty: 'Nothing waiting - you are up to date.'
198
+ }
199
+ };
200
+ }
201
+
202
+ /** "Today", "Yesterday", then "Tue 23/09/2026" - for the day separators. */
203
+ export function dayLabel(iso, now = Date.now()) {
204
+ const d = new Date(iso);
205
+ if (!Number.isFinite(d.getTime())) return '';
206
+ const start = (x) => { const c = new Date(x); c.setHours(0, 0, 0, 0); return c.getTime(); };
207
+ const days = Math.round((start(now) - start(d)) / 86_400_000);
208
+ if (days === 0) return 'Today';
209
+ if (days === 1) return 'Yesterday';
210
+ return `${d.toLocaleDateString('en-GB', {weekday: 'short'})} ${fmtDate(d)}`;
211
+ }
212
+
213
+ /**
214
+ * The open conversation as the screen shows it: a day separator whenever the
215
+ * day changes, then each message with what it needs - who sent it (only in a
216
+ * group, only when the sender changes), its text or "message deleted", its
217
+ * attachments (addresses always rebuilt - a stored url is never trusted),
218
+ * and its time. A call line (1.4, `kind: 'call'`) is a line across the
219
+ * conversation, with Join on the "started" line of the call still running.
220
+ *
221
+ * @param {object[]} messages - oldest first
222
+ * @param {{myId, isGroup, nameOf: (id) => string, now?: number, liveCallMessageId?: string}} ctx
223
+ */
224
+ export function buildBubbles(messages, {myId, isGroup = false, nameOf = (id) => id, now = Date.now(), liveCallMessageId = ''} = {}) {
225
+ const out = [];
226
+ let lastDay = '';
227
+ let lastSender = null;
228
+ for (const m of messages || []) {
229
+ const day = dayLabel(m.createdAt, now);
230
+ if (day && day !== lastDay) {
231
+ out.push({key: `day:${day}:${m.id}`, id: '', isDay: true, dayLabel: day, isCall: false, callText: '', canJoin: false});
232
+ lastDay = day;
233
+ lastSender = null;
234
+ }
235
+ const mine = m.senderId === myId;
236
+ const deleted = Boolean(m.deletedAt);
237
+ const time = new Date(m.createdAt);
238
+ const hhmm = Number.isFinite(time.getTime()) ? time.toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'}) : '';
239
+ const isCall = m.kind === 'call' && !deleted;
240
+ if (isCall) {
241
+ out.push({
242
+ key: m.id, id: m.id, isDay: false, dayLabel: '', isCall: true, mine: false, deleted: false, showSender: false, sender: '',
243
+ bodyText: '', meta: '', msgCls: '', attachments: [],
244
+ callText: [String(m.body ?? ''), hhmm].filter(Boolean).join(' · '),
245
+ canJoin: Boolean(liveCallMessageId) && m.id === liveCallMessageId
246
+ });
247
+ lastSender = null;
248
+ continue;
249
+ }
250
+ out.push({
251
+ key: m.id,
252
+ id: m.id,
253
+ isDay: false,
254
+ dayLabel: '',
255
+ isCall: false,
256
+ callText: '',
257
+ canJoin: false,
258
+ mine,
259
+ deleted,
260
+ showSender: isGroup && !mine && m.senderId !== lastSender,
261
+ sender: nameOf(m.senderId),
262
+ bodyText: deleted ? 'message deleted' : String(m.body ?? ''),
263
+ // Typed in the conversation's call (1.6) - said so, quietly, beside the time.
264
+ meta: [hhmm, m.callChat && !deleted ? 'in the call' : '', m.editedAt && !deleted ? 'edited' : ''].filter(Boolean).join(' · '),
265
+ msgCls: [mine && 'is-mine', deleted && 'is-deleted'].filter(Boolean).join(' '),
266
+ attachments: deleted ? [] : (m.attachments || []).map((a, i) => {
267
+ const url = attachmentUrl(a);
268
+ const isImage = typeof a.mime === 'string' && a.mime.startsWith('image/');
269
+ return {k: `${i}:${url}`, url, isImage, imgSrc: isImage ? url : '', name: String(a.filename || a.mediaId || 'attachment'), size: fileSize(a.size)};
270
+ }).filter(a => a.url)
271
+ });
272
+ lastSender = m.senderId;
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * Attachments as the server stores them - never the client's word for the
279
+ * address: a media name and the file's facts, the url rebuilt from the name.
280
+ */
281
+ export function cleanAttachments(list) {
282
+ return (Array.isArray(list) ? list : []).slice(0, 20)
283
+ .filter(a => a && typeof a.mediaId === 'string' && a.mediaId && !/[\/\\]|\.\./.test(a.mediaId))
284
+ .map(a => ({
285
+ mediaId: a.mediaId,
286
+ filename: String(a.filename || a.mediaId).slice(0, 200),
287
+ mime: String(a.mime || '').slice(0, 100),
288
+ size: Math.max(0, Number(a.size) || 0),
289
+ url: `/media/${encodeURIComponent(a.mediaId)}`
290
+ }));
291
+ }
292
+
293
+ // ---- Notifications (1.3, Notifications Phase 4) -------------------------------
294
+
295
+ /** How long a message waits unread before its recipient is told; older than MAX_AGE is left alone. */
296
+ export const UNREAD_AFTER_MS = 10 * 60_000;
297
+ export const UNREAD_MAX_AGE_MS = 2 * 86_400_000;
298
+
299
+ export const unreadKey = (threadId, userId) => `unread:${threadId}:${userId}`;
300
+
301
+ /**
302
+ * Who has a message waiting unread for 10 minutes or more - one notice per
303
+ * conversation per person, to that person. Their own messages, deleted ones,
304
+ * archived conversations and anything older than two days are left out.
305
+ *
306
+ * @param {object[]} threads - {id, kind, name, participants, archivedBy}
307
+ * @param {object[]} messages - {threadId, senderId, body, createdAt, readBy, deletedAt}
308
+ * @param {Object<string, string>} names - user id -> display name
309
+ * @param {number} [now]
310
+ * @returns {object[]} notify() payloads
311
+ */
312
+ export function unreadNotices(threads, messages, names = {}, now = Date.now()) {
313
+ const byThread = new Map();
314
+ for (const m of messages || []) {
315
+ if (!m?.threadId || m.deletedAt) continue;
316
+ const at = Date.parse(m.createdAt);
317
+ if (Number.isNaN(at) || now - at < UNREAD_AFTER_MS || now - at > UNREAD_MAX_AGE_MS) continue;
318
+ if (!byThread.has(m.threadId)) byThread.set(m.threadId, []);
319
+ byThread.get(m.threadId).push(m);
320
+ }
321
+ const out = [];
322
+ for (const t of threads || []) {
323
+ const waiting = byThread.get(t.id);
324
+ if (!waiting) continue;
325
+ for (const uid of t.participants || []) {
326
+ if (isArchived(t, uid)) continue;
327
+ const mine = waiting.filter(m => m.senderId !== uid && !(m.readBy || []).includes(uid));
328
+ if (!mine.length) continue;
329
+ const senders = [...new Set(mine.map(m => names[m.senderId] || 'Someone'))];
330
+ const where = t.kind === 'dm' ? '' : ` in ${t.name || 'a group'}`;
331
+ const last = mine.at(-1);
332
+ out.push({
333
+ title: mine.length === 1 ? `${senders[0]} sent you a message${where}` : `${plural(mine.length, 'unread message')} from ${senders.join(', ')}${where}`,
334
+ body: String(last.body || (last.attachments?.length ? 'An attachment' : '')).replace(/\s+/g, ' ').trim().slice(0, 200),
335
+ severity: 'info',
336
+ link: '#/plugins/messages',
337
+ dedupeKey: unreadKey(t.id, uid),
338
+ audience: {userIds: [String(uid)]}
339
+ });
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+
345
+ // ---- Calls (1.4) ----------------------------------------------------------------
346
+
347
+ /** How many a call holds (the engine's browser mesh). */
348
+ export const CALL_MAX = 6;
349
+
350
+ /**
351
+ * The Call button in a conversation's header: its words and its tooltip.
352
+ *
353
+ * @param {object|null} t - the thread, with `call` ({byName, in}) while one is running
354
+ */
355
+ export function callButton(t) {
356
+ if (!t) return {label: 'Call', title: ''};
357
+ const size = (t.participants || []).length;
358
+ const over = size > CALL_MAX ? ` A call holds ${CALL_MAX} people - the first ${CALL_MAX} to join get in.` : '';
359
+ if (t.call) {
360
+ const n = Number(t.call.in) || 0;
361
+ return {label: n ? `Join call (${n})` : 'Join call', title: `${t.call.byName || 'Someone'} started a call - join it.${over}`};
362
+ }
363
+ return {label: 'Call', title: `Start a video call - everyone in this conversation is told and can join.${over}`};
364
+ }
365
+
366
+ /**
367
+ * The "X is calling - Join" notices: every running call someone ELSE started,
368
+ * in a conversation that is not archived, less the ones put aside.
369
+ */
370
+ export function ringingCalls(threads, myId, dismissed = []) {
371
+ const off = new Set(dismissed);
372
+ return (threads || [])
373
+ .filter(t => t.call && t.call.by !== myId && !isArchived(t, myId) && !off.has(`${t.id}|${t.call.startedAt}`))
374
+ .map(t => ({
375
+ id: t.id,
376
+ key: `${t.id}|${t.call.startedAt}`,
377
+ text: `${t.call.byName || 'Someone'} is calling`,
378
+ where: t.kind === 'dm' ? '' : (t.name || 'a group'),
379
+ count: Number(t.call.in) ? `${plural(Number(t.call.in), 'person', 'people')} in` : ''
380
+ }));
381
+ }
382
+
383
+ // ---- Call… (1.5) - a call started by picking people ------------------------------
384
+
385
+ /** How many others a call started from the picker may take: the call holds CALL_MAX, you included. */
386
+ export const CALL_PICK_MAX = CALL_MAX - 1;
387
+
388
+ const firstName = (p) => String(nameOf(p)).trim().split(/\s+/)[0] || 'Someone';
389
+
390
+ /** "Alex, Priya and Sam" - the default name of a group made for a call (80 characters at most). */
391
+ export function callGroupName(people) {
392
+ const names = (people || []).map(firstName);
393
+ const text = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : (names[0] || 'Call');
394
+ return text.slice(0, 80);
395
+ }
396
+
397
+ /**
398
+ * Where a call with the people picked goes. One person: your direct
399
+ * conversation with them (the server also finds it - DMs are one per pair).
400
+ * Several: an ad-hoc group with EXACTLY those people and you, not archived by
401
+ * you - the one used most recently if there are several; else a new group.
402
+ *
403
+ * @param {object[]} threads - this user's (participants as ids or {id, name})
404
+ * @param {string} myId
405
+ * @param {string[]} picked - the others' ids
406
+ * @returns {{id: string, thread: object}|{create: {kind: string, participants: string[]}}|null}
407
+ * null when nobody (or more than CALL_PICK_MAX) is picked
408
+ */
409
+ export function callTarget(threads, myId, picked) {
410
+ const others = [...new Set((picked || []).filter(id => typeof id === 'string' && id && id !== myId))];
411
+ if (!others.length || others.length > CALL_PICK_MAX) return null;
412
+ const want = [myId, ...others].sort().join('|');
413
+ const kind = others.length === 1 ? 'dm' : 'group-adhoc';
414
+ const match = [...(threads || [])]
415
+ .filter(t => t && t.kind === kind && (kind === 'dm' || !isArchived(t, myId))
416
+ && [...new Set((t.participants || []).map(idOf))].sort().join('|') === want)
417
+ .sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')))[0];
418
+ if (match) return {id: match.id, thread: match};
419
+ return {create: {kind, participants: others}};
420
+ }
421
+
422
+ /** What the Call… form says it will do. */
423
+ export function callTargetText(target, myId) {
424
+ if (!target) return '';
425
+ if (target.id) {
426
+ return target.thread.kind === 'dm'
427
+ ? `Calls in your conversation with ${threadTitle(target.thread, myId)}.`
428
+ : `Calls in your group "${threadTitle(target.thread, myId)}".`;
429
+ }
430
+ return target.create.kind === 'dm' ? 'Starts your conversation with them and calls.' : 'Starts a new group for these people and calls.';
431
+ }
432
+
433
+ /**
434
+ * What is left in the compose box once a message has been sent (1.8): the box
435
+ * may have changed while the request ran. Unchanged - empty. Typed on after the
436
+ * sent text - just the new part. Edited some other way - all of it, so nothing
437
+ * typed is lost. Attachments: the ones that were not sent stay.
438
+ *
439
+ * @param {{body: string, attachments: object[]}} sent
440
+ * @param {{body: string, attachments: object[]}} now
441
+ * @returns {{body: string, attachments: object[]}}
442
+ */
443
+ export function afterSend(sent, now) {
444
+ const s = String(sent?.body ?? '');
445
+ const n = String(now?.body ?? '');
446
+ let body = n;
447
+ if (n === s) body = '';
448
+ else if (s && n.startsWith(s)) body = n.slice(s.length).replace(/^\s+/, '');
449
+ const gone = new Set((sent?.attachments || []).map(a => a?.mediaId));
450
+ const attachments = (now?.attachments || []).filter(a => !gone.has(a?.mediaId));
451
+ return {body, attachments};
452
+ }
@@ -0,0 +1,76 @@
1
+ <!-- Messages. Bound with M.applyBindings - see admin/views/messages.js, which also inserts the row
2
+ templates (the router renders a templateUrl through _.render, eating any {{…}}). No page heading:
3
+ the plugin banner is the heading; New conversation and Call… are banner actions. -->
4
+ <div class="msx">
5
+ <!-- Conversations -->
6
+ <section class="msx-card msx-side">
7
+ <div class="msx-side-head">
8
+ <div class="msx-seg" role="group" aria-label="Show">
9
+ <button type="button" data-on-click="showInbox" data-bind-class="filter.value === 'inbox' && 'is-on'">Inbox <b data-bind-text="counts.value.inbox"></b></button>
10
+ <button type="button" data-on-click="showUnread" data-bind-class="filter.value === 'unread' && 'is-on'">Unread <b data-bind-text="counts.value.unread"></b></button>
11
+ <button type="button" data-on-click="showArchived" data-bind-class="filter.value === 'archived' && 'is-on'">Archived <b data-bind-text="counts.value.archived"></b></button>
12
+ </div>
13
+ <span data-help-title="Conversations" data-help="Click a conversation to open it. Right-click it (or ⋮, or Shift+F10) to mark it read or unread, archive it, rename a group, set how long its messages are kept, or leave it. Archiving is yours alone - the others still see it - and a new message brings it back to your inbox. A conversation with a draft you have not sent says so."></span>
14
+ </div>
15
+ <input type="search" class="form-input msx-search" placeholder="Search conversations…" aria-label="Search conversations" data-model="query.value">
16
+ <div class="msx-note msx-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
17
+ <p class="msx-note is-bad" data-bind-hidden="!loadError.value" data-bind-text="loadError.value"></p>
18
+ <p class="msx-note" data-bind-hidden="loading.value || loadError.value || rows.value.length" data-bind-text="emptyText.value"></p>
19
+ <div class="msx-threads" data-each="rows.value key=id"></div>
20
+ </section>
21
+
22
+ <!-- The open conversation -->
23
+ <section class="msx-card msx-main">
24
+ <div class="msx-main-head" data-bind-hidden="!active.value">
25
+ <span class="msx-avatar is-lg" aria-hidden="true" data-bind-text="activeInitials.value"></span>
26
+ <span class="msx-main-title">
27
+ <strong data-bind-text="activeTitle.value"></strong>
28
+ <small data-bind-text="activeMeta.value"></small>
29
+ </span>
30
+ <button type="button" class="btn btn-sm msx-call-btn" data-on-click="call" data-bind-disabled="callBusy.value"
31
+ data-bind-title="callTitle.value" data-bind-class="callLive.value ? 'btn-primary' : 'btn-ghost'">
32
+ <span data-icon="video" data-icon-size="15"></span> <span data-bind-text="callLabel.value"></span></button>
33
+ <span data-help-title="Calls" data-help="Call starts a video call with everyone in this conversation. It opens in a new tab; the others see a notice and join straight in - no waiting room. A call holds 6 people: in a bigger group the first 6 to join get in and the next is told it is full. A line in the conversation shows when a call started and ended. Nothing is recorded."></span>
34
+ <button type="button" class="msx-kebab msx-thread-more" aria-label="More for this conversation" title="More">⋮</button>
35
+ </div>
36
+ <div class="msx-empty-main" data-bind-hidden="active.value">
37
+ <span data-icon="message-circle" data-icon-size="32"></span>
38
+ <p>Choose a conversation, or start a new one.</p>
39
+ <div class="msx-empty-actions">
40
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="newConversation"><span data-icon="edit" data-icon-size="14"></span> New conversation</button>
41
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="callPeople"><span data-icon="video" data-icon-size="14"></span> Call…</button>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="msx-scroll" data-bind-hidden="!active.value">
46
+ <div class="msx-note msx-thread-loading dm-loading-slot" data-bind-hidden="!threadLoading.value">Loading messages…</div>
47
+ <p class="msx-note" data-bind-hidden="threadLoading.value || bubbles.value.length">No messages yet - say hello.</p>
48
+ <div class="msx-bubbles" data-each="bubbles.value key=key"></div>
49
+ </div>
50
+
51
+ <div class="msx-composer" data-bind-hidden="!active.value">
52
+ <div class="msx-attach-list" data-bind-hidden="!pending.value.length" data-each="pending.value key=mediaId"></div>
53
+ <div class="msx-compose-row">
54
+ <textarea class="form-input msx-input" rows="2" placeholder="Write a message…" aria-label="Message" data-model="compose.value"></textarea>
55
+ <div class="msx-compose-actions">
56
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="attach" data-bind-disabled="uploading.value" title="Attach files" aria-label="Attach files">
57
+ <span data-icon="paperclip" data-icon-size="15"></span></button>
58
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="!canSend.value" title="Send (Ctrl+Enter)">
59
+ <span data-icon="send" data-icon-size="15"></span> Send</button>
60
+ </div>
61
+ </div>
62
+ <small class="msx-hint"><span data-bind-text="composeHint.value"></span>
63
+ <span data-help-title="Writing" data-help="Ctrl+Enter sends. What you type is kept as a draft for each conversation, so switching away or leaving the screen loses nothing. Your own messages can be edited for a short while after sending, and deleted any time - right-click a message (or its ⋮)."></span></small>
64
+ </div>
65
+ </section>
66
+
67
+ <!-- Calls someone else started: "Sam is calling - Join" -->
68
+ <div class="msx-rings-dock" aria-live="polite">
69
+ <div class="msx-ring" data-bind-hidden="!blockedUrl.value" role="alert">
70
+ <span class="msx-ring-main"><strong>Your browser blocked the call tab</strong><small>Open it from here.</small></span>
71
+ <a class="btn btn-sm btn-primary" target="_blank" rel="noopener" data-bind-href="blockedUrl.value" data-on-click="openedBlocked">Open the call</a>
72
+ <button type="button" class="btn btn-sm btn-ghost msx-ring-close" data-on-click="clearBlocked" aria-label="Close" title="Close">×</button>
73
+ </div>
74
+ <div class="msx-rings" data-each="rings.value key=key"></div>
75
+ </div>
76
+ </div>