domma-cms 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,15 @@ import {
82
82
  suggest
83
83
  } from './contacts.js';
84
84
 
85
+ // Admin helpers by run-time import: a static import of /admin would take the
86
+ // window down on an admin that predates them, so each has a stand-in.
87
+ const toolKit = await import('/admin/js/lib/tool-kit.js').catch(() => ({}));
88
+ const helpPopovers = (await import('/admin/js/lib/help-popover.js').catch(() => ({}))).helpPopovers ?? (() => () => {});
89
+ const openMenuAt = toolKit.openMenuAt ?? ((el) => {
90
+ const r = el.getBoundingClientRect();
91
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
92
+ });
93
+
85
94
  /**
86
95
  * Turn plain text into the minimal HTML that preserves its line breaks.
87
96
  *
@@ -209,6 +218,98 @@ function attachSuggestions(input, contacts, groups) {
209
218
  }
210
219
  }
211
220
 
221
+ /**
222
+ * The compose window's markup. Built as a string and parsed into a `<div>` this
223
+ * module owns, never passed through `setContent` (which strips inputs and
224
+ * buttons). View state is bound with domma-reactive; the inputs themselves are
225
+ * read directly, because the editor, the address suggestions and the template
226
+ * picker all work on the live field.
227
+ *
228
+ * No `{{…}}` in the attachment row but the key: Domma's `data-each` compiler
229
+ * drops the whitespace beside an interpolation, so labels come from JS.
230
+ */
231
+ const FORM = `
232
+ <div class="mail-compose-form">
233
+ <div class="form-group" data-bind-hidden="!hasFrom">
234
+ <label class="form-label">From
235
+ <span data-help-title="From" data-help="Which of this mailbox's identities sends the message. Changing it swaps the signature in the message for the new one's, where the old signature can still be found unchanged."></span>
236
+ </label>
237
+ <select class="form-select" data-ref="from"></select>
238
+ </div>
239
+ <div>
240
+ <label class="form-label">To
241
+ <span data-help-title="Recipients" data-help="Separate several addresses with commas. Name <address> works as well as a bare address. Start typing to pick from Contacts; a group puts all of its members in at once."></span>
242
+ </label>
243
+ <input type="text" class="form-input" data-ref="to" placeholder="someone@example.com">
244
+ <button type="button" class="btn btn-sm btn-ghost mail-extras-toggle" data-on-click="toggleExtras"
245
+ data-bind-text="extrasLabel.value" data-bind-aria-expanded="extrasAria.value"></button>
246
+ </div>
247
+ <div data-bind-hidden="!extrasOpen.value">
248
+ <label class="form-label">Cc</label>
249
+ <input type="text" class="form-input" data-ref="cc">
250
+ </div>
251
+ <div data-bind-hidden="!extrasOpen.value">
252
+ <label class="form-label">Bcc
253
+ <span data-help="Bcc recipients get the message, but nobody else sees that they did - not even the other Bcc recipients."></span>
254
+ </label>
255
+ <input type="text" class="form-input" data-ref="bcc">
256
+ </div>
257
+ <div data-bind-hidden="!extrasOpen.value">
258
+ <label class="form-label">Priority
259
+ <span data-help="Marks the message as urgent or unimportant in the recipient's mail client. Most show it as a flag or an icon; none sends it any faster."></span>
260
+ </label>
261
+ <select class="form-select" data-ref="priority">
262
+ <option value="normal">Normal</option>
263
+ <option value="high">High</option>
264
+ <option value="low">Low</option>
265
+ </select>
266
+ <div class="flex items-center gap-1 mt-2">
267
+ <label class="form-check">
268
+ <input type="checkbox" class="form-check-input" data-ref="receipt">
269
+ <span class="form-check-label">Ask for a read receipt</span>
270
+ </label>
271
+ <span data-help-title="Read receipts" data-help="A request, not a guarantee. Many mail clients ignore it, or ask the recipient first - so no receipt does not mean the message is unread."></span>
272
+ </div>
273
+ </div>
274
+ <div>
275
+ <label class="form-label">Subject</label>
276
+ <input type="text" class="form-input" data-ref="subject">
277
+ </div>
278
+ <div class="mail-compose-body">
279
+ <label class="form-label">Message</label>
280
+ <div class="flex items-center gap-2 mail-template-row" data-bind-hidden="!hasTemplates">
281
+ <select class="form-select form-select-sm mail-template-picker" data-ref="template">
282
+ <option value="">Insert a template…</option>
283
+ </select>
284
+ <span data-help-title="Templates" data-help="Canned text from Mail Settings. Choosing one adds it above what is already written, fills in the recipient's name and your own, and sets the subject if it is empty."></span>
285
+ </div>
286
+ <div class="mail-compose-editor" data-ref="editor"></div>
287
+ </div>
288
+ <div class="flex items-center gap-2 flex-wrap">
289
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickFiles">Attach files</button>
290
+ <span data-bind-class="attachTone.value" data-bind-text="attachSummary.value"></span>
291
+ <span data-help-title="Attachments" data-help="The running total is shown as you add files, and turns red over this mailbox's limit - a message that large cannot be sent. Right-click a file (or use its ⋮, or Shift+F10) to remove it."></span>
292
+ </div>
293
+ <input type="file" multiple hidden data-ref="files">
294
+ <div class="mail-attach-list" data-each="attachRows.value key=key"></div>
295
+ <div data-bind-class="statusClass.value" data-bind-text="statusText.value"></div>
296
+ <div class="flex justify-end items-center gap-2">
297
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="discard">Discard</button>
298
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="saveDraft" data-bind-disabled="saving.value">Save draft</button>
299
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="later" title="Send this at a time you choose">Later…</button>
300
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send"
301
+ data-bind-disabled="cannotSend.value" data-bind-text="sendLabel.value" data-bind-title="sendTitle.value"></button>
302
+ <span data-help-title="Sending" data-help="Later… sends at a time you choose, and the button then says when. With an undo delay set in Mail Settings, Send holds the message for those seconds so you can take it back; it has not gone until they pass."></span>
303
+ </div>
304
+ </div>`;
305
+
306
+ /** One attachment. Only the key is interpolated; see FORM. */
307
+ const ATTACH_ROW = `
308
+ <div class="mail-box-row mail-attach-row" data-key="{{key}}" tabindex="0">
309
+ <span class="flex-1 text-sm mail-ellipsis" data-bind-text="label"></span>
310
+ <button type="button" class="btn btn-sm btn-ghost mail-attach-kebab" aria-label="Attachment actions" title="Actions">⋮</button>
311
+ </div>`;
312
+
212
313
  export function openCompose({
213
314
  draft: opened = {}, base, accountId, api, maxAttachmentBytes = 20_000_000,
214
315
  onSent = () => {}, onSaved = () => {}, onQueued = () => {}, contacts = [],
@@ -229,9 +330,6 @@ export function openCompose({
229
330
  // on. `applyIdentity` decides whether this mode gets one.
230
331
  const draft = applyIdentity(opened, currentIdentity, openedAs);
231
332
 
232
- /** A time this message should go at, rather than now. */
233
- let scheduledAt = null;
234
-
235
333
  const slideover = E.slideover({
236
334
  title: draft.savedUid
237
335
  ? 'Draft'
@@ -240,44 +338,144 @@ export function openCompose({
240
338
  position: 'right'
241
339
  });
242
340
 
243
- const form = document.createElement('div');
244
- form.className = 'mail-compose-form';
341
+ const host = document.createElement('div');
342
+ host.innerHTML = FORM;
343
+ const form = host.firstElementChild;
344
+ /** @param {string} name @returns {HTMLElement} */
345
+ const ref = (name) => form.querySelector(`[data-ref="${name}"]`);
346
+
347
+ const fromSelect = ref('from');
348
+ const toInput = ref('to');
349
+ const ccInput = ref('cc');
350
+ const bccInput = ref('bcc');
351
+ const prioritySelect = ref('priority');
352
+ const receiptInput = ref('receipt');
353
+ const subjectInput = ref('subject');
354
+ const picker = ref('template');
355
+ const editorHost = ref('editor');
356
+ const fileInput = ref('files');
357
+
358
+ // ---- View state ------------------------------------------------------
359
+
360
+ /** A time this message should go at, rather than now. */
361
+ const scheduledAt = M.observable(null);
362
+
363
+ // Cc, Bcc and priority are folded away until wanted. Most messages use
364
+ // none of them, and empty rows between the recipient and the subject are
365
+ // rows of nothing in the part of the window someone looks at first.
366
+ //
367
+ // Opened when the draft already carries any of them: a reply-all or a
368
+ // reopened draft with a hidden Cc would be a message quietly going
369
+ // somewhere the sender cannot see, and a reopened urgent draft with the
370
+ // picker folded away would look ordinary.
371
+ const extrasOpen = M.observable(Boolean(
372
+ draft.cc?.length || draft.bcc?.length || ['high', 'low'].includes(draft.priority)
373
+ || draft.readReceipt === true
374
+ ));
375
+ const extrasLabel = M.observable('');
376
+ // A string: a bound boolean becomes an empty attribute, and aria-expanded needs "true"/"false".
377
+ const extrasAria = M.observable('false');
378
+
379
+ // Attachments, seeded from the draft so reopening one keeps its files -
380
+ // normalised to the picker's shape on the way in. The server calls the
381
+ // length `size` and the picker calls it `bytes`, and seeding the server's
382
+ // shape verbatim renders "notes.txt (undefined)" with a NaN running total.
383
+ // Falling back to the base64 length is the honest measure anyway: that is
384
+ // what will actually be sent.
385
+ let nextKey = 0;
386
+ const attachments = M.observable((draft.attachments ?? []).map(file => ({
387
+ key: `a${nextKey++}`,
388
+ filename: file.filename,
389
+ contentType: file.contentType,
390
+ content: file.content,
391
+ bytes: file.bytes ?? file.size ?? Math.floor(String(file.content ?? '').length * 3 / 4)
392
+ })));
393
+ const attachRows = M.observable([]);
394
+ const attachSummary = M.observable('');
395
+ const attachTone = M.observable('text-xs text-muted');
396
+ const overLimit = M.observable(false);
397
+
398
+ const statusText = M.observable('');
399
+ const statusClass = M.observable('text-sm mail-status text-muted');
400
+ const saving = M.observable(false);
401
+ const sending = M.observable(false);
402
+ const cannotSend = M.observable(false);
403
+ const sendLabel = M.observable('Send');
404
+ const sendTitle = M.observable('');
405
+
406
+ const effects = [
407
+ M.effect(() => {
408
+ extrasLabel.value = extrasOpen.value ? 'Hide Cc and Bcc' : 'Cc / Bcc';
409
+ extrasAria.value = String(extrasOpen.value);
410
+ }),
411
+ // The total is shown as it grows rather than only on send, because
412
+ // being told a message is too large after writing it is a poor moment
413
+ // to find out - and red once it is over, which is the only reason the
414
+ // number is on screen at all.
415
+ M.effect(() => {
416
+ const list = attachments.value;
417
+ const total = list.reduce((n, a) => n + a.bytes, 0);
418
+ overLimit.value = total > maxAttachmentBytes;
419
+ attachSummary.value = list.length
420
+ ? `${list.length} file${list.length === 1 ? '' : 's'}, ${formatSize(total)} of ${formatSize(maxAttachmentBytes)}`
421
+ : '';
422
+ attachTone.value = overLimit.value ? 'text-xs text-danger' : 'text-xs text-muted';
423
+ attachRows.value = list.map(a => ({key: a.key, label: `${a.filename} (${formatSize(a.bytes)})`}));
424
+ }),
425
+ M.effect(() => {
426
+ cannotSend.value = sending.value || overLimit.value;
427
+ }),
428
+ // Three different things wear the Send button: send now, hold briefly
429
+ // so it can be taken back, and send at a chosen time. Calling all
430
+ // three "Send" is how someone finds out about the ten second hold by
431
+ // watching a message not arrive.
432
+ M.effect(() => {
433
+ const at = scheduledAt.value;
434
+ sendLabel.value = at ? `Send ${describeWhen(at)}` : 'Send';
435
+ sendTitle.value = at
436
+ ? new Date(at).toLocaleString()
437
+ : (undoSeconds > 0 ? `Held for ${undoSeconds} seconds so you can take it back.` : '');
438
+ })
439
+ ];
245
440
 
246
441
  /**
247
- * @param {string} label
248
- * @param {string} value
249
- * @param {string} [placeholder]
250
- * @returns {HTMLInputElement}
442
+ * Say something under the message, in the right colour. A class rather
443
+ * than `style.color`, so the colour follows the theme.
444
+ *
445
+ * @param {string} text
446
+ * @param {'ok'|'bad'|'quiet'} [tone]
447
+ * @returns {void}
251
448
  */
252
- function field(label, value, placeholder = '') {
253
- const wrap = document.createElement('div');
254
- const labelEl = document.createElement('label');
255
- labelEl.className = 'form-label';
256
- labelEl.textContent = label;
257
- const input = document.createElement('input');
258
- input.type = 'text';
259
- input.className = 'form-input';
260
- input.value = value ?? '';
261
- input.placeholder = placeholder;
262
- wrap.appendChild(labelEl);
263
- wrap.appendChild(input);
264
- form.appendChild(wrap);
265
- return input;
449
+ function say(text, tone = 'quiet') {
450
+ statusText.value = text ?? '';
451
+ statusClass.value = 'text-sm mail-status '
452
+ + ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
453
+ }
454
+
455
+ // ---- Fields ------------------------------------------------------------
456
+
457
+ toInput.value = addressesToText(draft.to);
458
+ ccInput.value = addressesToText(draft.cc);
459
+ bccInput.value = addressesToText(draft.bcc);
460
+ subjectInput.value = draft.subject ?? '';
461
+ prioritySelect.value = ['high', 'low'].includes(draft.priority) ? draft.priority : 'normal';
462
+ receiptInput.checked = draft.readReceipt === true;
463
+
464
+ /**
465
+ * Does this identity have a signature that would have been added?
466
+ *
467
+ * @param {object|null} entry
468
+ * @returns {boolean}
469
+ */
470
+ function hasSignatureFor(entry) {
471
+ return Boolean(entry?.signatureHtml || entry?.signatureText);
266
472
  }
267
473
 
268
474
  // The From picker, and only when there is a choice to make. One identity
269
475
  // is not a choice, and a row saying so on every message is a row nobody
270
476
  // reads.
271
- let fromSelect = null;
272
- if (identities.length > 1) {
273
- const wrap = document.createElement('div');
274
- wrap.className = 'form-group';
275
- const label = document.createElement('label');
276
- label.className = 'form-label';
277
- label.textContent = 'From';
278
- fromSelect = document.createElement('select');
279
- fromSelect.className = 'form-select';
280
-
477
+ const hasFrom = identities.length > 1;
478
+ if (hasFrom) {
281
479
  for (const entry of identities) {
282
480
  const option = document.createElement('option');
283
481
  option.value = entry.id;
@@ -286,7 +484,7 @@ export function openCompose({
286
484
  // recipient will see, and those are the two different questions
287
485
  // "which of these am I?" asks.
288
486
  const shown = identityFrom(entry, mailboxAddress);
289
- option.textContent = entry.label === shown ? shown : `${entry.label} \u2014 ${shown}`;
487
+ option.textContent = entry.label === shown ? shown : `${entry.label} — ${shown}`;
290
488
  fromSelect.appendChild(option);
291
489
  }
292
490
  fromSelect.value = currentIdentity?.id ?? identities[0].id;
@@ -307,104 +505,7 @@ export function openCompose({
307
505
  }
308
506
  }
309
507
  });
310
-
311
- wrap.appendChild(label);
312
- wrap.appendChild(fromSelect);
313
- form.appendChild(wrap);
314
- }
315
-
316
- /**
317
- * Does this identity have a signature that would have been added?
318
- *
319
- * @param {object|null} entry
320
- * @returns {boolean}
321
- */
322
- function hasSignatureFor(entry) {
323
- return Boolean(entry?.signatureHtml || entry?.signatureText);
324
- }
325
-
326
- const toInput = field('To', addressesToText(draft.to), 'someone@example.com');
327
- const ccInput = field('Cc', addressesToText(draft.cc));
328
- const bccInput = field('Bcc', addressesToText(draft.bcc));
329
-
330
- // Folded away with Cc and Bcc: it belongs with the things most messages
331
- // do not need, and a picker sitting permanently above every message
332
- // invites fiddling with a setting whose whole meaning is that it is
333
- // unusual.
334
- const priorityWrap = document.createElement('div');
335
- const priorityLabel = document.createElement('label');
336
- priorityLabel.className = 'form-label';
337
- priorityLabel.textContent = 'Priority';
338
- const prioritySelect = document.createElement('select');
339
- prioritySelect.className = 'form-select';
340
- for (const [value, text] of [['normal', 'Normal'], ['high', 'High'], ['low', 'Low']]) {
341
- const option = document.createElement('option');
342
- option.value = value;
343
- option.textContent = text;
344
- prioritySelect.appendChild(option);
345
508
  }
346
- prioritySelect.value = ['high', 'low'].includes(draft.priority) ? draft.priority : 'normal';
347
- priorityWrap.appendChild(priorityLabel);
348
- priorityWrap.appendChild(prioritySelect);
349
-
350
- // A request, not a mechanism. Every client is free to ignore it and many
351
- // do, so the hint says so rather than letting someone rely on silence
352
- // meaning "not read".
353
- const receiptLabel = document.createElement('label');
354
- receiptLabel.className = 'form-check mt-2';
355
- const receiptInput = document.createElement('input');
356
- receiptInput.type = 'checkbox';
357
- receiptInput.className = 'form-check-input';
358
- receiptInput.checked = draft.readReceipt === true;
359
- const receiptText = document.createElement('span');
360
- receiptText.className = 'form-check-label';
361
- receiptText.textContent = 'Ask for a read receipt';
362
- receiptLabel.appendChild(receiptInput);
363
- receiptLabel.appendChild(receiptText);
364
- const receiptHint = document.createElement('div');
365
- receiptHint.className = 'form-help text-xs text-muted';
366
- receiptHint.textContent = 'Many mail clients ignore this, or ask the recipient first. No receipt does not mean unread.';
367
- priorityWrap.appendChild(receiptLabel);
368
- priorityWrap.appendChild(receiptHint);
369
-
370
- form.appendChild(priorityWrap);
371
-
372
- const subjectInput = field('Subject', draft.subject ?? '');
373
-
374
- // Cc and Bcc are folded away until wanted. Most messages use neither, and
375
- // two empty fields between the recipient and the subject is two rows of
376
- // nothing in the part of the window someone looks at first.
377
- //
378
- // Opened automatically when the draft already has one, because a reply-all
379
- // or a reopened draft with a hidden Cc would be a message quietly going
380
- // somewhere the sender cannot see.
381
- const ccRow = ccInput.closest('div');
382
- const bccRow = bccInput.closest('div');
383
- // Opened when the draft already carries any of them, priority included -
384
- // a reopened urgent draft with the picker folded away would look ordinary.
385
- let extrasOpen = Boolean(
386
- draft.cc?.length || draft.bcc?.length || ['high', 'low'].includes(draft.priority)
387
- || draft.readReceipt === true
388
- );
389
-
390
- const extrasToggle = document.createElement('button');
391
- extrasToggle.type = 'button';
392
- extrasToggle.className = 'btn btn-sm btn-ghost mail-extras-toggle';
393
- const paintExtras = () => {
394
- ccRow.hidden = !extrasOpen;
395
- bccRow.hidden = !extrasOpen;
396
- priorityWrap.hidden = !extrasOpen;
397
- extrasToggle.textContent = extrasOpen ? 'Hide Cc and Bcc' : 'Cc / Bcc';
398
- extrasToggle.setAttribute('aria-expanded', String(extrasOpen));
399
- };
400
- extrasToggle.addEventListener('click', () => {
401
- extrasOpen = !extrasOpen;
402
- paintExtras();
403
- if (extrasOpen) ccInput.focus();
404
- });
405
- // Directly under To, which is where someone reaches for it.
406
- toInput.closest('div').appendChild(extrasToggle);
407
- paintExtras();
408
509
 
409
510
  // Suggest recipients from the Contacts plugin, if this site has it. The
410
511
  // address book is passed in rather than fetched here: the view already
@@ -419,78 +520,46 @@ export function openCompose({
419
520
  }
420
521
  }
421
522
 
422
- const bodyWrap = document.createElement('div');
423
- bodyWrap.className = 'mail-compose-body';
424
- const bodyLabel = document.createElement('label');
425
- bodyLabel.className = 'form-label';
426
- bodyLabel.textContent = 'Message';
427
- bodyWrap.appendChild(bodyLabel);
428
-
429
523
  // Canned responses, and only when this mailbox has any. A picker offering
430
524
  // nothing is a picker that has to explain itself.
431
- if (templates.length) {
432
- const pickerRow = document.createElement('div');
433
- pickerRow.className = 'flex items-center gap-2 mail-template-row';
434
-
435
- const picker = document.createElement('select');
436
- picker.className = 'form-select form-select-sm mail-template-picker';
437
-
438
- const placeholder = document.createElement('option');
439
- placeholder.value = '';
440
- placeholder.textContent = 'Insert a template\u2026';
441
- picker.appendChild(placeholder);
442
-
443
- for (const template of templates) {
444
- const option = document.createElement('option');
445
- option.value = template.id;
446
- option.textContent = template.name;
447
- picker.appendChild(option);
448
- }
449
-
450
- picker.addEventListener('change', () => {
451
- const chosen = templates.find(t => t.id === picker.value);
452
- // Back to the placeholder straight away: the picker is an action,
453
- // not a setting, and leaving the last-used one showing reads as
454
- // "this message is that template".
455
- picker.value = '';
456
- if (!chosen) return;
457
-
458
- const expanded = expandTemplate(chosen, {
459
- to: textToAddresses(toInput.value),
460
- subject: subjectInput.value,
461
- identity: currentIdentity,
462
- fromAddress: mailboxAddress
463
- });
464
-
465
- const current = {
466
- subject: subjectInput.value,
467
- html: editor ? editor.getValue() : '',
468
- body: editor ? editor.getText() : bodyInput.value
469
- };
470
- const next = applyTemplate(current, expanded);
471
-
472
- subjectInput.value = next.subject;
473
- if (editor) editor.setValue(next.html);
474
- else bodyInput.value = next.body;
525
+ const hasTemplates = templates.length > 0;
526
+ for (const template of templates) {
527
+ const option = document.createElement('option');
528
+ option.value = template.id;
529
+ option.textContent = template.name;
530
+ picker.appendChild(option);
531
+ }
532
+ picker.addEventListener('change', () => {
533
+ const chosen = templates.find(t => t.id === picker.value);
534
+ // Back to the placeholder straight away: the picker is an action, not
535
+ // a setting, and leaving the last-used one showing reads as "this
536
+ // message is that template".
537
+ picker.value = '';
538
+ if (!chosen) return;
539
+
540
+ const expanded = expandTemplate(chosen, {
541
+ to: textToAddresses(toInput.value),
542
+ subject: subjectInput.value,
543
+ identity: currentIdentity,
544
+ fromAddress: mailboxAddress
475
545
  });
476
546
 
477
- const hint = document.createElement('span');
478
- hint.className = 'text-xs text-muted';
479
- hint.textContent = 'Added above what is already written.';
547
+ const current = {
548
+ subject: subjectInput.value,
549
+ html: editor ? editor.getValue() : '',
550
+ body: editor ? editor.getText() : bodyInput.value
551
+ };
552
+ const next = applyTemplate(current, expanded);
480
553
 
481
- pickerRow.appendChild(picker);
482
- pickerRow.appendChild(hint);
483
- bodyWrap.appendChild(pickerRow);
484
- }
554
+ subjectInput.value = next.subject;
555
+ if (editor) editor.setValue(next.html);
556
+ else bodyInput.value = next.body;
557
+ });
485
558
 
486
559
  // Domma's own editor, in rich mode. A plain textarea is kept as the
487
560
  // fallback rather than assumed away: the editor is a component that may
488
561
  // not be present in an older Domma, and a compose window that cannot type
489
562
  // is worse than one without bold.
490
- const editorHost = document.createElement('div');
491
- editorHost.className = 'mail-compose-editor';
492
- bodyWrap.appendChild(editorHost);
493
-
494
563
  const bodyInput = document.createElement('textarea');
495
564
  bodyInput.className = 'form-textarea mail-compose-textarea';
496
565
  bodyInput.value = draft.body ?? '';
@@ -504,124 +573,18 @@ export function openCompose({
504
573
  editor = null;
505
574
  }
506
575
  }
507
- if (!editor) {
508
- editorHost.remove();
509
- bodyWrap.appendChild(bodyInput);
510
- }
511
- form.appendChild(bodyWrap);
576
+ if (!editor) editorHost.replaceWith(bodyInput);
512
577
 
513
-
514
- // Attachments
515
- // Seeded from the draft, so reopening one keeps the files that were
516
- // attached to it - normalised to the picker's shape on the way in. The
517
- // server calls the length `size` and the picker calls it `bytes`, and
518
- // seeding the server's shape verbatim renders "notes.txt (undefined)" with
519
- // a NaN running total. Falling back to the base64 length is the honest
520
- // measure anyway: that is what will actually be sent.
521
- const attachments = (draft.attachments ?? []).map(file => ({
522
- filename: file.filename,
523
- contentType: file.contentType,
524
- content: file.content,
525
- bytes: file.bytes ?? file.size ?? Math.floor(String(file.content ?? '').length * 3 / 4)
526
- }));
527
-
528
- const attachRow = document.createElement('div');
529
- attachRow.className = 'flex items-center gap-2 flex-wrap';
530
-
531
- const fileInput = document.createElement('input');
532
- fileInput.type = 'file';
533
- fileInput.multiple = true;
534
- fileInput.style.display = 'none';
535
-
536
- const attachBtn = document.createElement('button');
537
- attachBtn.className = 'btn btn-sm btn-ghost';
538
- attachBtn.textContent = 'Attach files';
539
- attachBtn.addEventListener('click', () => fileInput.click());
540
-
541
- const attachTotal = document.createElement('span');
542
- attachTotal.className = 'text-xs text-muted';
543
-
544
- attachRow.appendChild(attachBtn);
545
- attachRow.appendChild(attachTotal);
546
- form.appendChild(attachRow);
547
- form.appendChild(fileInput);
548
-
549
- const attachList = document.createElement('div');
550
- attachList.className = 'mail-attach-list';
551
- form.appendChild(attachList);
552
-
553
- const status = document.createElement('div');
554
- status.className = 'text-sm mail-status';
555
- form.appendChild(status);
556
-
557
- /**
558
- * Say something under the message, in the right colour.
559
- *
560
- * A class rather than `style.color`: an inline colour cannot read a
561
- * `--dm-*` token a theme override redefines, so a status line set that
562
- * way stays the same shade in every theme.
563
- *
564
- * @param {string} text
565
- * @param {'ok'|'bad'|'quiet'} [tone]
566
- * @returns {void}
567
- */
568
- function say(text, tone = 'quiet') {
569
- status.textContent = text ?? '';
570
- status.className = 'text-sm mail-status '
571
- + ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
572
- }
573
-
574
- /**
575
- * Redraw the attachment list and the running total.
576
- *
577
- * The total is shown as it grows rather than only on send, because being
578
- * told a message is too large after writing it is a poor moment to find
579
- * out.
580
- *
581
- * @returns {void}
582
- */
583
- function paintAttachments() {
584
- attachList.textContent = '';
585
- const total = attachments.reduce((n, a) => n + a.bytes, 0);
586
-
587
- attachTotal.textContent = attachments.length
588
- ? `${attachments.length} file${attachments.length === 1 ? '' : 's'}, ${formatSize(total)} of ${formatSize(maxAttachmentBytes)}`
589
- : '';
590
- // Red once the total is over the limit, which is the only reason
591
- // this number is on screen while a message is being written.
592
- attachTotal.className = total > maxAttachmentBytes
593
- ? 'text-xs text-danger'
594
- : 'text-xs text-muted';
595
-
596
- for (const file of attachments) {
597
- const row = document.createElement('div');
598
- row.className = 'mail-box-row';
599
-
600
- const name = document.createElement('span');
601
- name.className = 'flex-1 text-sm mail-ellipsis';
602
- name.textContent = `${file.filename} (${formatSize(file.bytes)})`;
603
- row.appendChild(name);
604
-
605
- const remove = document.createElement('button');
606
- remove.className = 'btn btn-sm btn-ghost';
607
- remove.textContent = 'Remove';
608
- remove.addEventListener('click', () => {
609
- attachments.splice(attachments.indexOf(file), 1);
610
- paintAttachments();
611
- });
612
- row.appendChild(remove);
613
- attachList.appendChild(row);
614
- }
615
-
616
- send.disabled = total > maxAttachmentBytes;
617
- }
578
+ // ---- Attachments -------------------------------------------------------
618
579
 
619
580
  fileInput.addEventListener('change', async () => {
620
581
  const chosen = [...fileInput.files];
621
582
  fileInput.value = '';
583
+ const added = [];
622
584
  for (const file of chosen) {
623
585
  try {
624
- attachments.push({
586
+ added.push({
587
+ key: `a${nextKey++}`,
625
588
  filename: file.name,
626
589
  contentType: file.type || 'application/octet-stream',
627
590
  content: await readAsBase64(file),
@@ -631,59 +594,25 @@ export function openCompose({
631
594
  E.toast(err.message, {type: 'error'});
632
595
  }
633
596
  }
634
- paintAttachments();
597
+ if (added.length) attachments.value = [...attachments.peek(), ...added];
635
598
  });
636
599
 
637
- const actions = document.createElement('div');
638
- actions.className = 'flex justify-end gap-2';
600
+ /** @param {string} key */
601
+ function removeAttachment(key) {
602
+ attachments.value = attachments.peek().filter(a => a.key !== key);
603
+ }
604
+
605
+ // ---- Draft and close guard ---------------------------------------------
639
606
 
640
607
  // The UID of this draft's previous version, once it has been saved once,
641
608
  // so a second save replaces rather than accumulates.
642
609
  let savedUid = draft.savedUid ?? null;
643
610
 
644
- const saveDraftBtn = document.createElement('button');
645
- saveDraftBtn.className = 'btn btn-sm btn-ghost';
646
- saveDraftBtn.textContent = 'Save draft';
647
- /**
648
- * Save this message to the mailbox's Drafts folder - the button, and what
649
- * leaving the screen does with a message that has changed.
650
- *
651
- * @returns {Promise<object|null>} the server's answer, or null if it failed
652
- */
653
- async function saveDraft() {
654
- saveDraftBtn.disabled = true;
655
- say('Saving\u2026');
656
- try {
657
- const params = new URLSearchParams({account: accountId});
658
- const result = await api(`${base}/drafts?${params}`, 'POST', {
659
- ...collect(),
660
- replaces: savedUid
661
- });
662
- savedUid = result.uid ?? savedUid;
663
- // Saved is the new baseline: closing now loses nothing.
664
- savedSnapshot = snapshot();
665
- say(`Saved to ${result.savedTo}.`, 'ok');
666
- // The Drafts folder now holds a different set of UIDs, so a list
667
- // showing it is out of date whether or not the window closes.
668
- onSaved(result);
669
- return result;
670
- } catch (err) {
671
- say(err.message, 'bad');
672
- return null;
673
- } finally {
674
- saveDraftBtn.disabled = false;
675
- }
676
- }
677
- saveDraftBtn.addEventListener('click', () => { saveDraft(); });
678
-
679
- const cancel = document.createElement('button');
680
- cancel.className = 'btn btn-sm btn-ghost';
681
- cancel.textContent = 'Discard';
682
611
  /** What the message says now - compared with the last saved (or opened) state. */
683
612
  function snapshot() {
684
613
  const current = editor ? editor.getText() : bodyInput.value;
685
614
  return JSON.stringify([subjectInput.value.trim(), current.trim(), toInput.value.trim(),
686
- ccInput.value.trim(), bccInput.value.trim(), attachments.length]);
615
+ ccInput.value.trim(), bccInput.value.trim(), attachments.peek().length]);
687
616
  }
688
617
  let savedSnapshot = null; // set once the window is built (below)
689
618
 
@@ -699,10 +628,6 @@ export function openCompose({
699
628
  return savedSnapshot !== null && snapshot() !== savedSnapshot;
700
629
  }
701
630
 
702
- // Discard, the X, Escape and the backdrop all go through slideover.close,
703
- // which asks (see the guard at the end) - so this button just closes.
704
- cancel.addEventListener('click', () => slideover.close());
705
-
706
631
  /** The question the close guard asks, for what would be lost. */
707
632
  function discardQuestion() {
708
633
  return savedUid
@@ -710,7 +635,6 @@ export function openCompose({
710
635
  : 'Discard this message? It has not been saved as a draft.';
711
636
  }
712
637
 
713
-
714
638
  /**
715
639
  * The message as it currently stands.
716
640
  *
@@ -738,36 +662,44 @@ export function openCompose({
738
662
  html,
739
663
  inReplyTo: draft.inReplyTo ?? null,
740
664
  references: draft.references ?? [],
741
- attachments: attachments.map(({filename, contentType, content}) =>
665
+ attachments: attachments.peek().map(({filename, contentType, content}) =>
742
666
  ({filename, contentType, content}))
743
667
  };
744
668
  }
745
669
 
746
- const send = document.createElement('button');
747
- send.className = 'btn btn-sm btn-primary';
748
-
749
670
  /**
750
- * Label the Send button for what it will actually do.
751
- *
752
- * Three different things wear this button: send now, hold briefly so it
753
- * can be taken back, and send at a chosen time. Calling all three "Send"
754
- * is how someone finds out about the ten second hold by watching a
755
- * message not arrive.
671
+ * Save this message to the mailbox's Drafts folder - the button, and what
672
+ * leaving the screen does with a message that has changed.
756
673
  *
757
- * @returns {void}
674
+ * @returns {Promise<object|null>} the server's answer, or null if it failed
758
675
  */
759
- function paintSend() {
760
- if (scheduledAt) {
761
- send.textContent = `Send ${describeWhen(scheduledAt)}`;
762
- send.title = new Date(scheduledAt).toLocaleString();
763
- } else {
764
- send.textContent = 'Send';
765
- send.title = undoSeconds > 0
766
- ? `Held for ${undoSeconds} seconds so you can take it back.`
767
- : '';
676
+ async function saveDraft() {
677
+ saving.value = true;
678
+ say('Saving…');
679
+ try {
680
+ const params = new URLSearchParams({account: accountId});
681
+ const result = await api(`${base}/drafts?${params}`, 'POST', {
682
+ ...collect(),
683
+ replaces: savedUid
684
+ });
685
+ savedUid = result.uid ?? savedUid;
686
+ // Saved is the new baseline: closing now loses nothing.
687
+ savedSnapshot = snapshot();
688
+ say(`Saved to ${result.savedTo}.`, 'ok');
689
+ // The Drafts folder now holds a different set of UIDs, so a list
690
+ // showing it is out of date whether or not the window closes.
691
+ onSaved(result);
692
+ return result;
693
+ } catch (err) {
694
+ say(err.message, 'bad');
695
+ return null;
696
+ } finally {
697
+ saving.value = false;
768
698
  }
769
699
  }
770
700
 
701
+ // ---- Sending -----------------------------------------------------------
702
+
771
703
  /**
772
704
  * Choose a time to send at.
773
705
  *
@@ -777,85 +709,62 @@ export function openCompose({
777
709
  * @returns {void}
778
710
  */
779
711
  function openScheduler() {
780
- const panel = document.createElement('div');
781
- panel.className = 'flex flex-col gap-2';
782
-
783
- const choices = document.createElement('div');
784
- choices.className = 'flex flex-wrap gap-1';
785
-
786
712
  const modal = E.modal({title: 'Send later', size: 'sm'});
787
-
788
- /**
789
- * @param {string|null} at
790
- * @returns {void}
791
- */
713
+ const panel = document.createElement('div');
714
+ panel.innerHTML = `
715
+ <div class="flex flex-col gap-2">
716
+ <div class="flex flex-wrap gap-1" data-ref="choices"></div>
717
+ <label class="form-label">Or pick a time</label>
718
+ <input type="datetime-local" class="form-input" data-ref="exact">
719
+ <div class="flex justify-end gap-2 mt-2">
720
+ <button type="button" class="btn btn-sm btn-ghost" data-ref="clear" hidden>Send normally instead</button>
721
+ <button type="button" class="btn btn-sm btn-primary" data-ref="use">Use this time</button>
722
+ </div>
723
+ </div>`;
724
+ const pick = (name) => panel.querySelector(`[data-ref="${name}"]`);
725
+
726
+ /** @param {string|null} at */
792
727
  const choose = (at) => {
793
- scheduledAt = at;
794
- paintSend();
728
+ scheduledAt.value = at;
795
729
  modal.close();
796
730
  };
797
731
 
798
732
  for (const option of quickTimes()) {
799
733
  const button = document.createElement('button');
734
+ button.type = 'button';
800
735
  button.className = 'btn btn-sm btn-ghost';
801
736
  button.textContent = option.label;
802
737
  button.title = new Date(option.at).toLocaleString();
803
738
  button.addEventListener('click', () => choose(option.at));
804
- choices.appendChild(button);
805
- }
806
- panel.appendChild(choices);
807
-
808
- const exactLabel = document.createElement('label');
809
- exactLabel.className = 'form-label';
810
- exactLabel.textContent = 'Or pick a time';
811
- const exact = document.createElement('input');
812
- exact.type = 'datetime-local';
813
- exact.className = 'form-input';
814
- panel.appendChild(exactLabel);
815
- panel.appendChild(exact);
816
-
817
- const row = document.createElement('div');
818
- row.className = 'flex justify-end gap-2 mt-2';
819
-
820
- if (scheduledAt) {
821
- const clear = document.createElement('button');
822
- clear.className = 'btn btn-sm btn-ghost';
823
- clear.textContent = 'Send normally instead';
824
- clear.addEventListener('click', () => choose(null));
825
- row.appendChild(clear);
739
+ pick('choices').appendChild(button);
826
740
  }
827
741
 
828
- const confirm = document.createElement('button');
829
- confirm.className = 'btn btn-sm btn-primary';
830
- confirm.textContent = 'Use this time';
831
- confirm.addEventListener('click', () => {
742
+ const clear = pick('clear');
743
+ clear.hidden = !scheduledAt.peek();
744
+ clear.addEventListener('click', () => choose(null));
745
+
746
+ const exact = pick('exact');
747
+ pick('use').addEventListener('click', () => {
832
748
  if (!exact.value) return;
833
749
  // `datetime-local` has no zone, so it is read in the browser's -
834
750
  // which is the one the person picking it is in.
835
751
  choose(new Date(exact.value).toISOString());
836
752
  });
837
- row.appendChild(confirm);
838
- panel.appendChild(row);
839
753
 
840
754
  modal.element.appendChild(panel);
841
755
  modal.open();
842
756
  }
843
757
 
844
- const later = document.createElement('button');
845
- later.className = 'btn btn-sm btn-ghost';
846
- later.textContent = 'Later\u2026';
847
- later.title = 'Send this at a time you choose';
848
- later.addEventListener('click', openScheduler);
849
-
850
- send.addEventListener('click', async () => {
851
- send.disabled = true;
852
- say(scheduledAt ? 'Scheduling\u2026' : 'Sending\u2026');
758
+ async function send() {
759
+ const at = scheduledAt.peek();
760
+ sending.value = true;
761
+ say(at ? 'Scheduling…' : 'Sending…');
853
762
 
854
763
  try {
855
764
  const params = new URLSearchParams({account: accountId});
856
765
  const result = await api(`${base}/send?${params}`, 'POST', {
857
766
  ...collect(),
858
- sendAt: scheduledAt,
767
+ sendAt: at,
859
768
  // So a sent draft does not stay in Drafts. Saved again since
860
769
  // opening, this is the latest version's UID, not the original.
861
770
  replaces: savedUid
@@ -882,23 +791,67 @@ export function openCompose({
882
791
  }
883
792
  } catch (err) {
884
793
  say(err.message, 'bad');
885
- send.disabled = false;
794
+ sending.value = false;
795
+ }
796
+ }
797
+
798
+ // ---- Bind --------------------------------------------------------------
799
+
800
+ // Rows and help markers before binding: data-each needs its row template
801
+ // in place, and helpPopovers needs the markers where they will stay.
802
+ form.querySelector('.mail-attach-list').innerHTML = ATTACH_ROW;
803
+ const stopHelp = helpPopovers(form, {position: 'bottom'});
804
+
805
+ const bindings = M.applyBindings({
806
+ hasFrom, hasTemplates, extrasOpen, extrasLabel, extrasAria, attachRows, attachSummary, attachTone,
807
+ statusText, statusClass, saving, cannotSend, sendLabel, sendTitle
808
+ }, form, {
809
+ methods: {
810
+ toggleExtras: () => {
811
+ extrasOpen.value = !extrasOpen.peek();
812
+ if (extrasOpen.peek()) ccInput.focus();
813
+ },
814
+ pickFiles: () => fileInput.click(),
815
+ // Discard, the X, Escape and the backdrop all go through
816
+ // slideover.close, which asks (see the guard below).
817
+ discard: () => { slideover.close(); },
818
+ saveDraft: () => { saveDraft(); },
819
+ later: () => openScheduler(),
820
+ send: () => { send(); }
886
821
  }
887
822
  });
888
823
 
889
- actions.appendChild(cancel);
890
- actions.appendChild(saveDraftBtn);
891
- actions.appendChild(later);
892
- actions.appendChild(send);
893
- form.appendChild(actions);
894
- paintSend();
824
+ // Attachment actions: right-click, the ⋮, Shift+F10 or Delete on a row.
825
+ const attachList = form.querySelector('.mail-attach-list');
826
+ const attachMenu = typeof E?.contextMenu === 'function'
827
+ ? E.contextMenu(attachList, {
828
+ cursor: 'glyph', match: '.mail-attach-row', inherit: false, density: 'compact',
829
+ items: (row) => [
830
+ {label: 'Remove', icon: 'x', action: () => removeAttachment(row.dataset.key)},
831
+ {type: 'divider'},
832
+ {label: 'Remove all attachments', icon: 'trash', danger: true,
833
+ action: () => { attachments.value = []; }}
834
+ ]
835
+ })
836
+ : null;
837
+ const onAttachClick = (e) => {
838
+ const kebab = e.target.closest('.mail-attach-kebab');
839
+ if (kebab) openMenuAt(kebab);
840
+ };
841
+ const onAttachKey = (e) => {
842
+ const row = e.target.closest('.mail-attach-row');
843
+ if (!row || e.target !== row) return;
844
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
845
+ else if (e.key === 'Delete') { e.preventDefault(); removeAttachment(row.dataset.key); }
846
+ };
847
+ attachList.addEventListener('click', onAttachClick);
848
+ attachList.addEventListener('keydown', onAttachKey);
895
849
 
896
850
  slideover.element.appendChild(form);
897
851
  const stopResizing = makeResizable(slideover, {
898
852
  storageKey: 'domma-mail.compose-width',
899
853
  defaultWidth: 700
900
854
  });
901
- paintAttachments();
902
855
  slideover.open();
903
856
 
904
857
  // A reply already knows who it is going to; a new message does not.
@@ -952,6 +905,12 @@ export function openCompose({
952
905
  editor?.destroy?.();
953
906
  stopResizing();
954
907
  for (const suggester of suggesters) suggester.destroy?.();
908
+ bindings?.dispose?.();
909
+ effects.forEach(stop => { try { stop(); } catch { /* gone */ } });
910
+ attachMenu?.destroy?.();
911
+ attachList.removeEventListener('click', onAttachClick);
912
+ attachList.removeEventListener('keydown', onAttachKey);
913
+ try { stopHelp?.(); } catch { /* gone */ }
955
914
  closedListeners.splice(0).forEach(fn => { try { fn(); } catch { /* ignore */ } });
956
915
  // Domma hides a closed slideover rather than removing it: every message
957
916
  // written would otherwise leave its window behind in the page.