domma-cms 0.78.1 → 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.
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/related.js +1 -0
- package/admin/js/views/contacts.js +11 -10
- package/admin/js/views/notes.js +7 -6
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +389 -430
- package/plugins/_lib/admin/mail/diagnostics-section.js +14 -41
- package/server/routes/api/contacts.js +4 -0
- package/server/routes/api/notes.js +3 -0
- package/server/routes/api/related.js +76 -0
- package/server/routes/api/todo.js +34 -0
- package/server/server.js +3 -0
- package/server/services/hooks.js +98 -0
- package/server/services/plugins.js +7 -0
|
@@ -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
|
|
244
|
-
|
|
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
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* @
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
272
|
-
if (
|
|
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}
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
picker.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
placeholder
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
+
if (added.length) attachments.value = [...attachments.peek(), ...added];
|
|
635
598
|
});
|
|
636
599
|
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
*
|
|
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 {
|
|
674
|
+
* @returns {Promise<object|null>} the server's answer, or null if it failed
|
|
758
675
|
*/
|
|
759
|
-
function
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
790
|
-
|
|
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
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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.
|