mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Three things about who a mail is from, grouped as v0.3.5. The sender name (#70). EmailAlias::$displayName was shown in the From selector and published as the JMAP identity name, and nothing sent it: MessageSendService built From with the account's name whatever the address. There was also no field to set it in the browser. Account::senderNameFor() the alias's name, else the account's; the selector, the draft row and the From header all read it Settings -> Aliases a sender name field under each address EmailSubmission/set writes fromName with the address it resolves The address a reply goes out from (#41, the reply half). Every window was pre-set from the account alone, so a reply to mail for an alias opened on the primary address, and a draft saved from an alias reopened on the primary and was stored that way by the next autosave. ReplyFromResolver the first of From (own mail), To, Cc, then Delivered-To / X-Original-To / Envelope-To that the account may send as SenderResolver::tokenFor() opens a draft on the address it carries catch-all domains an account setting, not an alias row: any address at one may be answered from, is left out of a reply-all, and signs with the account signature. ComposeType admits such a token on submit by the same rule. Not offered for Gmail and Microsoft accounts. Typing an arbitrary From into a new message is not in this. Signatures as HTML (#32). Pasting source into the contenteditable gave the source back, and a linked image never showed, the page's img-src being 'self' data: blob:. Rendered | HTML a textarea beside the editor. Rendered asks the server (signature/preview) and shows what a save would keep, with the elements and attributes the sanitiser dropped named underneath SignatureImporter sanitises, and fetches https images through ImageProxyFetcher into data: sources, 256 KB each and 768 KB a signature; the rest stay linked and are counted DataImageEmbedder on send, a data: image becomes an inline part and a cid: reference, since Gmail and Outlook draw no data URI. It reads the entity-encoded form the sanitiser stores (= for the padding). No migration: the catch-all domains live in Account::$settings and are carried by a config backup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3839 lines
151 KiB
JavaScript
3839 lines
151 KiB
JavaScript
// assets/controllers/compose_controller.js
|
|
import { Controller } from '@hotwired/stimulus'
|
|
import { forgetPendingCancel, markPendingCancel } from "../../compose/pending_cancel.js";
|
|
import { requestFailed } from "../../request_errors.js";
|
|
import { csrfToken } from "../../csrf.js";
|
|
import {
|
|
fillMarkers,
|
|
fillTokens,
|
|
hasOpenTokens,
|
|
markersAsTokens,
|
|
recipientValues,
|
|
} from "../../compose/template_variables.js";
|
|
|
|
/**
|
|
* The compose window is the only place a send is announced, and the only place
|
|
* one is called off.
|
|
*
|
|
* There used to be a module-scope watcher here that clicked whatever cancel
|
|
* control the server's answer happened to render — a countdown bar in the
|
|
* thread, or an Undo link in a toast — because the send response REPLACED the
|
|
* window, so the thing that had been clicked no longer existed to finish the
|
|
* job. That is the shape this round removes. The window now stays on screen
|
|
* for the whole cancel window with its own pill as the cancel surface, the
|
|
* server's answer only hands it the two URLs it needs (see
|
|
* compose--send-hold), and nothing has to hunt the DOM for a button to press.
|
|
*/
|
|
/**
|
|
* Markup that never belongs in a mail body, removed with its contents.
|
|
*
|
|
* <style> is the one that matters: pasting from a page brings its stylesheet
|
|
* along, and it would restyle the whole composer rather than only the pasted
|
|
* text. <meta> and <link> are how Word announces itself. The rest cannot do
|
|
* anything useful in mail and can do plenty elsewhere.
|
|
*/
|
|
const DROPPED_ON_PASTE = 'style, script, link, meta, iframe, object, embed, form, input, button, textarea, select';
|
|
|
|
export default class extends Controller {
|
|
static targets = ['toField', 'ccField', 'bccField', 'subject', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow', 'fields', 'fieldsChevron', 'fileInput', 'imageInput', 'attachments', 'scroller', 'formatBar', 'formatToggle', 'sendBtn', 'sendLabel', 'sendProgress', 'sendDots', 'sendCancelHint', 'errors', 'plainBody', 'plainToggle', 'plainCheck', 'plainWarning', 'plainWarningConfirm', 'sendWarning', 'sendWarningBody', 'sendWarningConfirm', 'richOnly'];
|
|
|
|
/** Below this the dock window is the whole screen — matches Tailwind's md. */
|
|
static MOBILE_QUERY = '(max-width: 767px)';
|
|
/**
|
|
* Below this many pixels, a visual viewport shorter than the layout one is
|
|
* a rounding artefact or a browser toolbar mid-animation rather than a
|
|
* keyboard. Deliberately far below the shortest thing that IS one: iOS's
|
|
* bare accessory bar, the ~55px strip an external keyboard on an iPad
|
|
* leaves behind, covers the dock's send row as thoroughly as a full
|
|
* keyboard does and has to count.
|
|
*/
|
|
static KEYBOARD_INSET_MIN = 24;
|
|
/**
|
|
* An address the window will accept as a chip.
|
|
*
|
|
* Deliberately the same shape as the `createFilter` ContactAutocompleteField
|
|
* hands Tom Select, because they are two enforcements of one rule and a
|
|
* window that chipped what the field would refuse — or the other way round
|
|
* — is worse than either rule alone.
|
|
*/
|
|
static ADDRESS = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
|
static values = {
|
|
// Every user-facing string the controller writes, translated
|
|
// server-side. Defaults are English so a window rendered without the
|
|
// attribute still says something, but the template always supplies it.
|
|
i18n: { type: Object, default: {} },
|
|
draftUrl: String,
|
|
sendUrl: String,
|
|
// The send pill's "send later" target. Kept here beside the other two
|
|
// because the three go stale together — see _adoptSavedUrls().
|
|
scheduleUrl: String,
|
|
autosaveDelay: { type: Number, default: 2000 },
|
|
// 'new', 'reply' or 'forward'. A forward opens with the caret in To
|
|
// (the quote is the content; the recipient is the missing piece),
|
|
// skips the empty-body question for a body that holds the quote, and
|
|
// is the case collapseForwardQuote is about.
|
|
mode: { type: String, default: 'new' },
|
|
// Settings -> Compose: fold a forward's quoted original behind the
|
|
// "show quoted text" pill, or show it open from the start.
|
|
collapseForwardQuote: { type: Boolean, default: true },
|
|
minimized: { type: Boolean, default: false },
|
|
expanded: { type: Boolean, default: false },
|
|
// Rendered inline at the bottom of a thread rather than in the
|
|
// floating dock: no fullscreen, no mobile auto-expand, pop-out button,
|
|
// and the address/subject rows start folded away.
|
|
inline: { type: Boolean, default: false },
|
|
// Body characters needed before an autosave may create a draft —
|
|
// every stray keystroke used to mint one.
|
|
minChars: { type: Number, default: 5 },
|
|
// The conversation this window belongs to, for the draft row.
|
|
thread: Number,
|
|
// Mirrors ComposeController::MAX_ATTACHMENT_BYTES so an oversized file
|
|
// is refused here rather than after the whole upload.
|
|
maxAttachmentBytes: { type: Number, default: 25 * 1024 * 1024 },
|
|
// Every From option's signature block, keyed by the same
|
|
// "accountId|address" token the hidden account <select> carries — so
|
|
// switching From swaps the signature without asking the server.
|
|
signatures: { type: Object, default: {} },
|
|
}
|
|
|
|
#autosaveTimer = null
|
|
|
|
/**
|
|
* The id of the turbo-frame this window was rendered into.
|
|
*
|
|
* Read once, here, because it has to survive the window being taken off
|
|
* the page: `closest('turbo-frame')` answers null the moment the frame is
|
|
* emptied, and the one place that needs the id — _cancelSend() answering a
|
|
* request that outlived its window — is by definition past that point.
|
|
*/
|
|
#frameId = null
|
|
|
|
connect() {
|
|
const input = this.element.querySelector('.compose-to[data-prototype]');
|
|
this._ensureEntry(input);
|
|
this.#frameId = this.element.closest('turbo-frame')?.id ?? null;
|
|
this._submitting = false;
|
|
this._boundHandleSubmit = this._handleSubmit.bind(this);
|
|
this._boundAutosave = this._scheduleAutosave.bind(this);
|
|
this._boundHandleTab = this._handleTab.bind(this);
|
|
|
|
this._boundHandleEnter = this._handleEnter.bind(this);
|
|
|
|
// Bubbles up from the Tom Select textboxes, so it runs after Tom
|
|
// Select's own key handler has had its say — see _handleTab().
|
|
this.element.addEventListener('keydown', this._boundHandleTab);
|
|
this.element.addEventListener('keydown', this._boundHandleEnter);
|
|
|
|
// The integration picker lives in the modal frame, which is outside
|
|
// this element entirely, so it reports back on window rather than
|
|
// through the DOM tree.
|
|
this._boundIntegrationAttached = this._handleIntegrationAttached.bind(this);
|
|
window.addEventListener('plmail:integration-attached', this._boundIntegrationAttached);
|
|
|
|
// Guarded like every other lookup of it in this file. The form is
|
|
// unconditional in _window.html.twig, so this is belt and braces —
|
|
// but the two lifecycle methods were the only places that assumed it,
|
|
// and they are the two where the element is most likely to be
|
|
// mid-teardown.
|
|
const form = this.element.querySelector('form');
|
|
|
|
if (null !== form) {
|
|
form.action = this.sendUrlValue;
|
|
form.addEventListener('submit', this._boundHandleSubmit);
|
|
form.addEventListener('input', this._boundAutosave);
|
|
}
|
|
|
|
// Mirror subject into header title
|
|
const subjectInput = this.element.querySelector('[name$="[subject]"]');
|
|
if (null !== subjectInput) {
|
|
this._updateTitle(subjectInput.value);
|
|
subjectInput.addEventListener('input', () => this._updateTitle(subjectInput.value));
|
|
}
|
|
|
|
// Close from-dropdown when clicking outside
|
|
this._boundCloseDropdown = this._closeFromDropdown.bind(this);
|
|
|
|
// Fullscreen on a phone — both the dock and an inline reply.
|
|
this._mq = window.matchMedia(this.constructor.MOBILE_QUERY);
|
|
this._boundBreakpoint = this._applyMobile.bind(this);
|
|
this._boundViewport = this._trackViewport.bind(this);
|
|
this._mq.addEventListener('change', this._boundBreakpoint);
|
|
this._applyMobile();
|
|
|
|
if (this.hasBodyTarget) {
|
|
// A forward can opt out of the fold (Settings -> Compose); the
|
|
// pill is otherwise the default for anything carrying a quote.
|
|
if ('forward' !== this.modeValue || true === this.collapseForwardQuoteValue) {
|
|
this._collapseQuotedContent();
|
|
}
|
|
|
|
// On a forward the body is already written - the quote - and the
|
|
// recipient is the one thing the mail cannot leave without.
|
|
//
|
|
// Unless it already has one. A forward is re-rendered whenever it
|
|
// comes back from something — a cancelled send, a refusal — and by
|
|
// then the recipient is usually the part that IS done, so putting
|
|
// the caret back in To would be taking it away from the writing.
|
|
if ('forward' === this.modeValue && false === this._alreadyAddressed()) {
|
|
this._focusRecipients();
|
|
} else {
|
|
this._focusCursorAtTop();
|
|
}
|
|
|
|
// Pasted and dropped images become real parts instead of data:
|
|
// URIs — see _handleBodyPaste(). Listeners rather than
|
|
// data-action because both have to be able to preventDefault
|
|
// before the browser inserts its own version.
|
|
this._boundBodyPaste = this._handleBodyPaste.bind(this);
|
|
this._boundBodyDrop = this._handleBodyDrop.bind(this);
|
|
this._boundBodyDragOver = this._allowBodyDrop.bind(this);
|
|
|
|
this.bodyTarget.addEventListener('paste', this._boundBodyPaste);
|
|
this.bodyTarget.addEventListener('drop', this._boundBodyDrop);
|
|
this.bodyTarget.addEventListener('dragover', this._boundBodyDragOver);
|
|
}
|
|
|
|
// Tom Select is built by the autocomplete bundle, which may connect
|
|
// after this controller does — hence both the immediate pass and the
|
|
// event.
|
|
this._boundNameAddressFields = this._nameAddressFields.bind(this);
|
|
this.element.addEventListener('autocomplete:connect', this._boundNameAddressFields);
|
|
requestAnimationFrame(this._boundNameAddressFields);
|
|
|
|
// A draft that arrives with text and no HTML is already a plain-text
|
|
// message (the template works that out), so the format controls have to
|
|
// start hidden — otherwise a bar of buttons that do nothing sits over a
|
|
// textarea.
|
|
//
|
|
// The same three consequences the switch applies, applied to a window
|
|
// that was rendered already in the mode: the bar folded, the "Aa"
|
|
// toggle gone, the HTML input off the wire — plus the rich-only
|
|
// affordances greyed, which is what makes coming back out of the mode
|
|
// symmetric rather than approximate.
|
|
if (true === this._isPlainText()) {
|
|
this._setFormatBar(false);
|
|
|
|
if (this.hasFormatToggleTarget) {
|
|
this.formatToggleTarget.classList.add('hidden');
|
|
}
|
|
|
|
this._setRichAffordances(false);
|
|
|
|
const hidden = this.element.querySelector('[data-compose--compose-toolbar-target="hiddenInput"]');
|
|
|
|
if (hidden) {
|
|
hidden.disabled = true;
|
|
}
|
|
}
|
|
|
|
// Leaving with writing that has not reached the server yet.
|
|
//
|
|
// The threshold above closes the common case; this closes the rest of
|
|
// it. Between the last keystroke and the debounced save there is always
|
|
// a window in which the draft on the server is older than the one on
|
|
// screen, and a reload inside that window used to take the difference
|
|
// with it in silence — no prompt, nothing in Drafts.
|
|
//
|
|
// Deliberately the NATIVE dialog, which is the opposite of the choice
|
|
// made for the send confirmations: `beforeunload` is the only hook a
|
|
// page gets before a reload, and the browser will not show anything of
|
|
// ours in its place. The text is the browser's too; a custom string has
|
|
// been ignored by every engine for years.
|
|
this._boundBeforeUnload = (event) => {
|
|
if (false === this._hasUnsavedWriting()) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
};
|
|
|
|
window.addEventListener('beforeunload', this._boundBeforeUnload);
|
|
|
|
// A draft saved with a template variable still open comes back with
|
|
// the marker in its body and, usually, the recipient it was waiting
|
|
// for already in To. Nothing fires an event for either, so ask once.
|
|
this._fillTemplateVariables();
|
|
|
|
// Inline: the thread's reply buttons step aside while we're open.
|
|
// Cached — by the time disconnect() runs the card is already detached
|
|
// and closest() would find nothing.
|
|
this._zone = this._replyZone();
|
|
this._zone?.classList.add('composing');
|
|
}
|
|
|
|
/**
|
|
* Give each recipient combobox the name its label already carries.
|
|
*
|
|
* Tom Select replaces the <select> with a text input of its own making,
|
|
* and an accessible name does not follow across that swap on its own — so
|
|
* the To field reported as a bare "combobox" with nothing to say what it
|
|
* was for. The <label for> in the template is the source of truth; this
|
|
* copies it onto whatever Tom Select actually built.
|
|
*/
|
|
_nameAddressFields() {
|
|
for (const row of this._addressRows()) {
|
|
const label = row.querySelector('label');
|
|
const input = row.querySelector('.ts-control input');
|
|
|
|
if (null === label || null === input) {
|
|
continue;
|
|
}
|
|
|
|
input.setAttribute('aria-label', label.textContent.trim());
|
|
|
|
// The original <select> too. Tom Select leaves it in the DOM under
|
|
// `ts-hidden-accessible` — visually hidden, still in the
|
|
// accessibility tree — and the <label for> in the template stops
|
|
// reaching it once the widget is built, so it reported as an unnamed
|
|
// combobox. The audit found it as the second of two.
|
|
const original = row.querySelector('select.ts-hidden-accessible, select');
|
|
|
|
if (null !== original) {
|
|
original.setAttribute('aria-label', label.textContent.trim());
|
|
}
|
|
|
|
// The error is about the FIELD, so it has to point at the field.
|
|
// Announced-but-unassociated is how this shipped: the alert was
|
|
// read out and the combobox itself still reported aria-invalid
|
|
// null, so anyone arriving at the input afterwards got no hint that
|
|
// it was the thing being complained about. The calendar form does
|
|
// this properly; this is the compose window catching up.
|
|
input.setAttribute('aria-describedby', this._errorsId());
|
|
|
|
this._tameAddressField(this._tomSelectFor(row.querySelector('.ts-wrapper')), input);
|
|
}
|
|
|
|
this._markInvalid(this.hasErrorsTarget && false === this.errorsTarget.classList.contains('hidden'));
|
|
}
|
|
|
|
/** The id of the shared refusal line, minted once so it can be pointed at. */
|
|
_errorsId() {
|
|
if (false === this.hasErrorsTarget) {
|
|
return '';
|
|
}
|
|
|
|
this.errorsTarget.id ||= `${this.element.id || 'compose'}_address_errors`;
|
|
|
|
return this.errorsTarget.id;
|
|
}
|
|
|
|
/** Flag or unflag every address combobox as the one being complained about. */
|
|
_markInvalid(invalid) {
|
|
for (const row of this._addressRows()) {
|
|
const input = row.querySelector('.ts-control input');
|
|
|
|
if (null === input) {
|
|
continue;
|
|
}
|
|
|
|
if (true === invalid) {
|
|
input.setAttribute('aria-invalid', 'true');
|
|
} else {
|
|
input.removeAttribute('aria-invalid');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the suggestion panel from taking the window over.
|
|
*
|
|
* Two faults, one cause. Tom Select reopens the dropdown whenever the field
|
|
* takes focus, and after Enter it refreshes it against the query that has
|
|
* just been turned into a chip. So committing an address made the panel
|
|
* spring back — covering the Subject row and the top of the body, and
|
|
* offering the contradictory pair "Add test@…" and "No results found" about
|
|
* an address that was by then already a chip sitting inches away. Escape
|
|
* dismissed it and the next focus brought it back, which left Subject
|
|
* unreachable by mouse.
|
|
*
|
|
* Typing still opens it; that is a different code path (`refreshOptions` on
|
|
* input) and it is the one the feature is actually for. What is switched off
|
|
* is opening a panel nobody asked for.
|
|
*/
|
|
_tameAddressField(select, input) {
|
|
if (null === select || true === select.plmailTamed) {
|
|
return;
|
|
}
|
|
|
|
select.plmailTamed = true;
|
|
select.settings.openOnFocus = false;
|
|
|
|
// NOT reparented to <body>, and that is a decision rather than an
|
|
// omission.
|
|
//
|
|
// Reported: the suggestion panel shifts the compose window instead of
|
|
// overlaying it — which is true, because Tom Select renders the panel
|
|
// as a sibling of the field, in flow, so the window grows to make room.
|
|
// `dropdownParent: 'body'` fixes that in one line, and it was tried.
|
|
//
|
|
// It reintroduces a worse bug. Subject sits directly under To, so an
|
|
// overlaid panel covers it — and a click aimed at Subject then lands on
|
|
// whichever suggestion is under the pointer and enrols it. That is a
|
|
// real report, not a hypothetical: it silently added a third party to a
|
|
// message addressed to somebody else, and three specs in
|
|
// compose-recipients.spec.ts exist to keep it fixed.
|
|
//
|
|
// So the panel stays in flow, and the window growing is the price. The
|
|
// way out is not a parent change: it is for the panel to open where it
|
|
// cannot cover the next field, which is a layout question this line
|
|
// cannot answer.
|
|
// And the same rule enforced where it cannot be raced. `openOnFocus`
|
|
// is a setting read at focus time, so it only holds if this ran before
|
|
// the field was focused — and on a forward it does not: connect()
|
|
// moves the caret into To immediately (see _focusRecipients), while
|
|
// this arrives with the `autocomplete:connect` event that Tom Select
|
|
// fires when the bundle has finished building the widget. Whichever
|
|
// wins, an empty panel is not something anyone asked for, so it is
|
|
// refused at the moment it opens rather than at the moment it is
|
|
// asked to. Typing still opens it — the query is non-empty by then,
|
|
// which is the whole distinction the user is after.
|
|
select.on('dropdown_open', () => {
|
|
if ('' === (select.control_input?.value ?? '').trim()) {
|
|
select.close();
|
|
}
|
|
});
|
|
|
|
// Remember what is being typed, because by the time Send is handled it
|
|
// is already gone. Clicking the button blurs the field, and Tom Select
|
|
// empties its textbox on blur when the contents cannot become an item —
|
|
// which is precisely the case worth reporting. Reading `input.value` in
|
|
// the submit handler therefore always found an empty box, so a typed
|
|
// address was thrown away and then reported as "no recipient".
|
|
//
|
|
// Listened for on the INPUT rather than through Tom Select's own `type`
|
|
// event: that event comes out of its key handlers, so a value set any
|
|
// other way never reaches it, and "any other way" includes both a paste
|
|
// and how the tests drive the field.
|
|
input?.addEventListener('input', () => {
|
|
select.plmailTyped = input.value;
|
|
});
|
|
|
|
select.on('item_add', () => {
|
|
// The query that produced this chip is spent. Left in place it is
|
|
// what the reopened panel searches for.
|
|
select.setTextboxValue('');
|
|
select.plmailTyped = '';
|
|
select.close();
|
|
|
|
// The complaint is over the moment the thing complained about is
|
|
// fixed. It used to sit there indefinitely, pushing the Subject row
|
|
// down, until the next Send happened to clear it.
|
|
this._clearError();
|
|
|
|
// A recipient now exists, which is what an inserted template's
|
|
// open variables were waiting for. Asked on every row rather than
|
|
// only To: the function reads To itself, and a row that is not To
|
|
// simply finds nothing new to fill.
|
|
this._fillTemplateVariables();
|
|
});
|
|
|
|
this._reserveRoomForPanel(select, input);
|
|
}
|
|
|
|
/**
|
|
* Keep the suggestion panel from being painted over the Subject row.
|
|
*
|
|
* This is the mis-addressing bug, and it is a layout bug rather than
|
|
* anything to do with blur. `.ts-dropdown` is `position: absolute`, so it
|
|
* takes up no room and hangs over whatever follows — and what follows the
|
|
* To row is Subject, then the body. Measured on this window: the Subject
|
|
* input occupies y=346..366 and the open panel y=331..412. It covers it
|
|
* completely, label and all, so `elementFromPoint` at the middle of the
|
|
* Subject field answers `.option` or `.create`, not the input.
|
|
*
|
|
* Everything the tester saw follows from that one fact. A click aimed at
|
|
* Subject lands on the panel instead:
|
|
*
|
|
* • on the "Add <typed>" row it commits what was typed and, because
|
|
* Tom Select keeps focus in the control after a selection, leaves the
|
|
* caret in the To field — so the subject someone then types goes into
|
|
* the recipient box, and a second click is needed to escape. That is
|
|
* the "click-out is swallowed" report.
|
|
* • on a highlighted suggestion it commits THAT CONTACT — an address
|
|
* nobody chose, on a message the user believes they are addressing
|
|
* elsewhere. That is the "adds a recipient nobody chose" report, and
|
|
* it is the reason this is the priority in the batch.
|
|
*
|
|
* So the panel is given real room instead of being allowed to overhang:
|
|
* while it is open the row grows by its height, Subject and the body move
|
|
* down, and the click always reaches whatever the user can actually see.
|
|
* Nothing here second-guesses which suggestion was "really" meant, because
|
|
* with nothing hidden there is no longer a wrong guess to make.
|
|
*
|
|
* Measured rather than assumed: the panel's height changes as results load
|
|
* and as the create row appears and disappears, so a ResizeObserver keeps
|
|
* the reservation in step. The floating variant (`ui--select`, anchored to
|
|
* <body> for the clipping reasons in tom-select.css) is left alone — it is
|
|
* not in this flow and reserving space for it would move the wrong box.
|
|
*/
|
|
_reserveRoomForPanel(select, input) {
|
|
const row = this._addressRows().find((candidate) => candidate.contains(input));
|
|
|
|
if (undefined === row) {
|
|
return;
|
|
}
|
|
|
|
// The inline window's To row is its HEADER, and everything above is
|
|
// about a panel covering the field that comes next. In the header
|
|
// nothing comes next — the row is followed by the message body, not by
|
|
// Subject — so there is no click to protect, and reserving the room
|
|
// anyway grew the header by the panel's height and pushed the whole
|
|
// editor down every time a result arrived or left. It overlays there,
|
|
// the way a combobox popover normally does. See
|
|
// compose/_to_row.html.twig.
|
|
if (true === row.hasAttribute('data-compose-overlay-panel')) {
|
|
return;
|
|
}
|
|
|
|
const panel = select.dropdown;
|
|
|
|
if (null === panel || undefined === panel) {
|
|
return;
|
|
}
|
|
|
|
const clear = () => {
|
|
this._panelObserver?.disconnect();
|
|
row.style.paddingBottom = '';
|
|
};
|
|
|
|
// Collapsing the reservation is itself a layout change, and doing it
|
|
// the instant the panel closes moves the form out from under a click
|
|
// that is still happening: mousedown on Subject closes the panel, the
|
|
// row shrinks by its height, and mouseup lands on whatever slid up into
|
|
// that space — so the gesture completes somewhere the user never
|
|
// pressed. Holding the space until the button is released keeps the
|
|
// page still for the whole of the click, which is the point.
|
|
const release = () => {
|
|
if (false === this._pointerDown) {
|
|
clear();
|
|
|
|
return;
|
|
}
|
|
|
|
document.addEventListener('pointerup', clear, { once: true });
|
|
};
|
|
|
|
const reserve = () => {
|
|
if (true === panel.classList.contains('ts-dropdown-floating')) {
|
|
return;
|
|
}
|
|
|
|
// The empty-query guard in _tameAddressField closes the panel from
|
|
// inside the same `dropdown_open` it opened on, and handlers run in
|
|
// registration order — so this one can be reached with the panel
|
|
// already shut. Reserving room for a panel nobody will see would
|
|
// leave a gap under the row until the next pointerup.
|
|
if (false === select.isOpen) {
|
|
return;
|
|
}
|
|
|
|
// offsetHeight, not getBoundingClientRect: the window animates in,
|
|
// and a transformed rect would reserve a scaled-down height.
|
|
const height = panel.offsetHeight;
|
|
|
|
row.style.paddingBottom = 0 === height ? '' : `${height + 8}px`;
|
|
};
|
|
|
|
select.on('dropdown_open', () => {
|
|
reserve();
|
|
|
|
this._panelObserver ??= new ResizeObserver(reserve);
|
|
this._panelObserver.observe(panel);
|
|
});
|
|
|
|
select.on('dropdown_close', release);
|
|
|
|
// The window can be torn down with the panel still open — sending from
|
|
// a half-typed address does exactly that — and a stale observer on a
|
|
// detached node would keep the callback alive.
|
|
this._panelReleases ??= [];
|
|
this._panelReleases.push(clear);
|
|
|
|
// One pair of listeners for the window, however many address rows it
|
|
// has. Capture, so a handler that stops the event lower down cannot
|
|
// leave `_pointerDown` stuck true and the reservation held open.
|
|
if (undefined === this._boundPointerDown) {
|
|
this._pointerDown = false;
|
|
this._boundPointerDown = () => { this._pointerDown = true; };
|
|
this._boundPointerUp = () => { this._pointerDown = false; };
|
|
|
|
document.addEventListener('pointerdown', this._boundPointerDown, { capture: true });
|
|
document.addEventListener('pointerup', this._boundPointerUp, { capture: true });
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
clearTimeout(this.#autosaveTimer);
|
|
clearInterval(this._sendingTicker);
|
|
clearTimeout(this._sendHoldTimer);
|
|
clearTimeout(this._sendFallback);
|
|
|
|
for (const release of this._panelReleases ?? []) {
|
|
release();
|
|
}
|
|
|
|
if (undefined !== this._boundPointerDown) {
|
|
document.removeEventListener('pointerdown', this._boundPointerDown, { capture: true });
|
|
document.removeEventListener('pointerup', this._boundPointerUp, { capture: true });
|
|
}
|
|
this.element.removeEventListener('keydown', this._boundHandleTab);
|
|
this.element.removeEventListener('keydown', this._boundHandleEnter);
|
|
this.element.removeEventListener('autocomplete:connect', this._boundNameAddressFields);
|
|
|
|
if (this.hasBodyTarget && undefined !== this._boundBodyPaste) {
|
|
this.bodyTarget.removeEventListener('paste', this._boundBodyPaste);
|
|
this.bodyTarget.removeEventListener('drop', this._boundBodyDrop);
|
|
this.bodyTarget.removeEventListener('dragover', this._boundBodyDragOver);
|
|
}
|
|
|
|
window.removeEventListener('beforeunload', this._boundBeforeUnload);
|
|
window.removeEventListener('plmail:integration-attached', this._boundIntegrationAttached);
|
|
const form = this.element.querySelector('form');
|
|
|
|
// Guarded, and the indentation above is what gave it away: an `if` had
|
|
// been lost here, leaving the one unguarded lookup in a method full of
|
|
// guarded ones. Nothing could reach it — the form is unconditional in
|
|
// the template — but everything below runs AFTER it, so a TypeError
|
|
// here would have skipped the listener removals, the overflow reset
|
|
// and the zone class, leaving a phone with a body stuck at
|
|
// `overflow: hidden` and no way to scroll.
|
|
if (null !== form) {
|
|
form.removeEventListener('input', this._boundAutosave);
|
|
form.removeEventListener('submit', this._boundHandleSubmit);
|
|
}
|
|
|
|
document.removeEventListener('click', this._boundCloseDropdown, { capture: true });
|
|
this._closePlainWarning();
|
|
|
|
// Both warnings, not just one. This popover binds its own Escape
|
|
// handler to `document` and only ever unbound it from its two answer
|
|
// buttons — so navigating away with "send without a subject?" still on
|
|
// screen left a keydown listener holding this controller alive, and an
|
|
// Escape afterwards called cancelSendAnyway() on a detached instance.
|
|
// Its sibling above was already handled here, which is the whole
|
|
// reason this was easy to miss.
|
|
this._closeSendWarning();
|
|
|
|
this._mq?.removeEventListener('change', this._boundBreakpoint);
|
|
this._unwatchViewport();
|
|
document.body.style.overflow = '';
|
|
this._zone?.classList.remove('composing');
|
|
}
|
|
|
|
// ── Fullscreen on a phone ─────────────────────────────────────────
|
|
|
|
/**
|
|
* True while this window owns the whole screen.
|
|
*
|
|
* Inline replies included: on a phone there is no room to wedge a compose
|
|
* card into a thread, so replying gets the same fullscreen window as
|
|
* composing. Above md an inline card stays an inline card.
|
|
*/
|
|
_isMobile() {
|
|
return true === (this._mq?.matches ?? false);
|
|
}
|
|
|
|
/**
|
|
* Enter or leave fullscreen. Called on connect and again whenever the
|
|
* breakpoint flips, so a rotation lands in the right mode rather than
|
|
* keeping the layout it was opened in.
|
|
*
|
|
* The layout itself is CSS (see the root element's `md:` classes); what
|
|
* has to happen here is everything CSS cannot express — dropping the dock
|
|
* chrome's state, locking the page behind, and tracking the virtual
|
|
* keyboard.
|
|
*
|
|
* Folding the formatting bar away used to be on that list too: below md it
|
|
* was closed on arrival and had to be asked for with "Aa". It is open at
|
|
* every width now — it wraps onto a second row instead of scrolling, which
|
|
* is what made it affordable, and a bar you have to know about is a bar
|
|
* most people never find. "Aa" still folds it for whoever wants the room
|
|
* back; crossing the breakpoint restores the default, which is the same
|
|
* thing this function has always done to the rest of the window's state.
|
|
*/
|
|
_applyMobile() {
|
|
const mobile = this._isMobile();
|
|
|
|
if (mobile === this._mobileApplied) {
|
|
return;
|
|
}
|
|
|
|
this._mobileApplied = mobile;
|
|
|
|
// Not in plain-text mode, where the bar has nothing to act on. The
|
|
// guard rather than an ordering assumption: connect() calls this
|
|
// BEFORE it reads the rendered mode.
|
|
if (false === this._isPlainText()) {
|
|
this._setFormatBar(true);
|
|
}
|
|
|
|
if (true === mobile) {
|
|
// Minimize and expand are dock concepts, and expand leaves inline
|
|
// styles behind that would fight the fullscreen rules.
|
|
this.minimizedValue = false;
|
|
this.expandedValue = false;
|
|
this.element.style.cssText = '';
|
|
this._resetExpandedBody();
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
this._watchViewport();
|
|
|
|
return;
|
|
}
|
|
|
|
// Every property the phone branch of _trackViewport sets, or the
|
|
// desktop window keeps a phone's width the next time the viewport
|
|
// crosses the breakpoint.
|
|
this.element.style.width = '';
|
|
this.element.style.height = '';
|
|
this.element.style.transform = '';
|
|
this.element.style.paddingBottom = '';
|
|
document.body.style.overflow = '';
|
|
|
|
// Still watching, though. The dock window is covered by the keyboard
|
|
// on a tablet exactly as the fullscreen one was on a phone — it just
|
|
// needs moving rather than resizing, which is what the desktop half of
|
|
// _trackViewport does. Re-adding a listener that is already on with the
|
|
// same bound function is a no-op, so crossing the breakpoint twice does
|
|
// not stack them.
|
|
this._watchViewport();
|
|
}
|
|
|
|
_watchViewport() {
|
|
// Both events fire: `resize` when the keyboard opens or closes,
|
|
// `scroll` when the page is panned while it is open.
|
|
window.visualViewport?.addEventListener('resize', this._boundViewport);
|
|
window.visualViewport?.addEventListener('scroll', this._boundViewport);
|
|
this._trackViewport();
|
|
}
|
|
|
|
_unwatchViewport() {
|
|
window.visualViewport?.removeEventListener('resize', this._boundViewport);
|
|
window.visualViewport?.removeEventListener('scroll', this._boundViewport);
|
|
|
|
// --keyboard-inset is read by markup outside this element — the dock
|
|
// wrapper in app.html.twig — so it has to go when the window does, or
|
|
// the dock stays hitched up over a keyboard that closed with it.
|
|
//
|
|
// Only when it is the LAST one, though. Above md a dock window and an
|
|
// inline reply are open at once by design (ComposeContext namespaces
|
|
// the inline form's ids for exactly that), and both publish. Clearing
|
|
// the property on behalf of the survivor drops the dock back under the
|
|
// keyboard with nothing to put it back: _trackViewport runs on
|
|
// visualViewport events only, and the keyboard is still up, so no
|
|
// resize and no scroll is coming.
|
|
//
|
|
// That is not a hypothetical. _settleSend() empties the inline frame
|
|
// from a timer 8 seconds after a send — no gesture, no blur, keyboard
|
|
// untouched — so a dock window opened during the cancel window would
|
|
// have lost its lift mid-sentence.
|
|
//
|
|
// Read from the DOM rather than counted in a variable: querySelectorAll
|
|
// returns only connected elements, a controller can be connected twice
|
|
// for one element across a re-render, and a count that drifts is a dock
|
|
// that never comes down again.
|
|
const survivors = Array.from(
|
|
document.querySelectorAll('[data-controller~="compose--compose"]'),
|
|
).filter((el) => el !== this.element);
|
|
|
|
if (0 === survivors.length) {
|
|
document.documentElement.style.removeProperty('--keyboard-inset');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How much of the layout viewport the virtual keyboard has taken.
|
|
*
|
|
* `window.innerHeight` is the LAYOUT viewport — the box a fixed element is
|
|
* placed against — and iOS leaves it at full height when the keyboard comes
|
|
* up. Whatever it has over the visual viewport, once that viewport's own
|
|
* offset is taken off, is keyboard.
|
|
*
|
|
* Android answers ~0 here and that is correct rather than a failure to
|
|
* detect: `interactive-widget=resizes-content` (see app.html.twig) shrinks
|
|
* the layout viewport too, so the two agree, fixed elements already end up
|
|
* above the keyboard, and there is nothing left to correct. It is why the
|
|
* phone layout has always looked right there and wrong on iOS.
|
|
*/
|
|
_keyboardInset(viewport) {
|
|
// Pinch zoom first, because at any scale but 1 the subtraction below
|
|
// measures something else entirely. `innerHeight` is zoom-invariant
|
|
// while `visualViewport.height` is not, so zooming to 2x on a tablet
|
|
// reports about half the screen as keyboard with no keyboard anywhere —
|
|
// and `scroll` is subscribed as well as `resize`, so panning would
|
|
// resize the card continuously under the user's finger. There is no
|
|
// arithmetic that separates keyboard from zoom here; not correcting is
|
|
// the honest answer, and the viewport is the user's to place while they
|
|
// are holding it. Zoom is deliberately available (see the viewport meta
|
|
// in app.html.twig — WCAG 1.4.4), so this is a reachable state, not a
|
|
// corner.
|
|
if (Math.abs((viewport.scale ?? 1) - 1) > 0.01) {
|
|
return 0;
|
|
}
|
|
|
|
const inset = window.innerHeight - viewport.height - viewport.offsetTop;
|
|
|
|
// Under the threshold it is a fractional-pixel artefact or a browser
|
|
// toolbar mid-animation, not a keyboard, and acting on it would twitch
|
|
// the dock every time the URL bar moved.
|
|
return inset < this.constructor.KEYBOARD_INSET_MIN ? 0 : Math.round(inset);
|
|
}
|
|
|
|
/**
|
|
* Answer the keyboard. Two shapes, because the window has two.
|
|
*
|
|
* The virtual keyboard shrinks the visual viewport but not the layout
|
|
* viewport a fixed element is measured against, so anything anchored to the
|
|
* bottom of the screen keeps its bottom rows — the action bar, the send
|
|
* button — underneath the keyboard. That is one fault with two faces:
|
|
*
|
|
* • On a phone the window IS the screen. It FILLS THE LAYOUT VIEWPORT and
|
|
* pads its own bottom by however much of that is covered.
|
|
*
|
|
* Filling the layout viewport is what makes it impossible to leave the
|
|
* page showing: a `position: fixed` element is placed against the
|
|
* layout viewport, and the mail list behind this one is laid out to the
|
|
* same box, so a window that fills it covers the list by construction.
|
|
* Sizing to `visualViewport.height` instead — which is what this did —
|
|
* is right only while the layout viewport still includes the keyboard,
|
|
* and it does not always: `interactive-widget=resizes-content` asks the
|
|
* browser to shrink the layout viewport too, and which browsers honour
|
|
* it has moved since that meta was written. Where it is honoured, the
|
|
* visual viewport stops higher than the layout viewport does and the
|
|
* mail list showed through the difference — a strip of somebody else's
|
|
* inbox between the action bar and the keys, in a screenshot from an
|
|
* iPhone.
|
|
*
|
|
* The padding is then the same subtraction, and it does not care what
|
|
* is doing the covering. There is no threshold here and nothing that
|
|
* asks which platform this is, which is the point: the previous
|
|
* version had to classify a shortfall as "keyboard" or "accessory bar"
|
|
* by its size, and got it wrong on a phone nobody here owns.
|
|
*
|
|
* Width and offsetLeft matter as much as height: with the keyboard up
|
|
* the browser can pan and scale the visual viewport, and a window that
|
|
* only tracked the vertical axis left a strip of the page showing down
|
|
* the right edge too. Rounded up, because these are fractional CSS
|
|
* pixels — flooring, or leaving them fractional, is what turns a
|
|
* rounding error into a visible hairline.
|
|
*
|
|
* • On a tablet the window is the 520px dock card, and sizing it to the
|
|
* viewport would be wrong — it has to *move*, and cap its height so the
|
|
* move does not push its title bar off the top. That is published as
|
|
* `--keyboard-inset` and read by the dock wrapper and the .compose-dock
|
|
* rules in app.css, rather than written here as inline styles, because
|
|
* the element that has to move is the wrapper this window sits in and
|
|
* because expandedValueChanged() wipes this element's inline styles
|
|
* wholesale every time the window is expanded or restored.
|
|
*/
|
|
_trackViewport() {
|
|
const viewport = window.visualViewport;
|
|
|
|
if (null === viewport || undefined === viewport) {
|
|
return;
|
|
}
|
|
|
|
const keyboard = this._keyboardInset(viewport);
|
|
|
|
document.documentElement.style.setProperty('--keyboard-inset', `${keyboard}px`);
|
|
|
|
if (true === this._isMobile()) {
|
|
// How much of the window is not visible: the keyboard, the browser
|
|
// chrome above it, whatever else the platform has put there. One
|
|
// subtraction, and it never has to be told WHICH of those it is.
|
|
const covered = Math.max(
|
|
0,
|
|
Math.round(window.innerHeight - viewport.offsetTop - viewport.height),
|
|
);
|
|
|
|
this.element.style.width = `${Math.ceil(viewport.width)}px`;
|
|
this.element.style.height = `${Math.ceil(window.innerHeight - viewport.offsetTop)}px`;
|
|
this.element.style.transform = `translate(${viewport.offsetLeft}px, ${viewport.offsetTop}px)`;
|
|
|
|
// The padding is the whole of the keyboard answer, and it replaces
|
|
// the safe-area inset while it is non-zero: the home indicator is
|
|
// behind the keyboard, so reserving room for it as well would be
|
|
// reserving the same room twice.
|
|
this.element.style.paddingBottom = 0 === covered ? '' : `${covered}px`;
|
|
}
|
|
|
|
// Half the screen just became keyboard: put the caret back on screen.
|
|
// True of the shortened dock card as much as of the phone window.
|
|
requestAnimationFrame(() => this._revealCaret());
|
|
}
|
|
|
|
/** Scroll the caret back into the visible part of the body scroller. */
|
|
_revealCaret() {
|
|
if (false === this.hasScrollerTarget) {
|
|
return;
|
|
}
|
|
|
|
const selection = window.getSelection();
|
|
|
|
if (null === selection || 0 === selection.rangeCount) {
|
|
return;
|
|
}
|
|
|
|
if (false === this.scrollerTarget.contains(selection.anchorNode)) {
|
|
return;
|
|
}
|
|
|
|
const caret = selection.getRangeAt(0).getBoundingClientRect();
|
|
const box = this.scrollerTarget.getBoundingClientRect();
|
|
|
|
// A range with no box at all (some browsers, empty editor) tells us
|
|
// nothing — leave the scroll where it is.
|
|
if (0 === caret.top && 0 === caret.bottom) {
|
|
return;
|
|
}
|
|
|
|
if (caret.bottom > box.bottom) {
|
|
this.scrollerTarget.scrollTop += caret.bottom - box.bottom + 24;
|
|
} else if (caret.top < box.top) {
|
|
this.scrollerTarget.scrollTop -= box.top - caret.top + 24;
|
|
}
|
|
}
|
|
|
|
// ── Formatting bar ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* "Aa" — fold the rich-text bar away, or bring it back.
|
|
*
|
|
* It starts open at every width. The bar wraps onto a second row rather
|
|
* than scrolling, so showing it costs a predictable ~32px rather than
|
|
* hiding half of itself off the side; what "Aa" buys now is room, for
|
|
* whoever wants it, rather than access to controls that were unreachable
|
|
* without it.
|
|
*/
|
|
toggleFormatBar() {
|
|
this._setFormatBar(false === this._isFormatBarOpen());
|
|
}
|
|
|
|
/**
|
|
* Is the bar showing? Read from the inline style _setFormatBar writes, not
|
|
* from a class or from offsetHeight — a window that has never been touched
|
|
* has no inline display at all, and that is the open state.
|
|
*/
|
|
_isFormatBarOpen() {
|
|
return this.hasFormatBarTarget
|
|
&& 'none' !== this.formatBarTarget.style.display;
|
|
}
|
|
|
|
_setFormatBar(open) {
|
|
if (false === this.hasFormatBarTarget) {
|
|
return;
|
|
}
|
|
|
|
// '' rather than a display value: the element's own responsive
|
|
// classes decide whether it is a wrapping row or a scroller.
|
|
this.formatBarTarget.style.display = true === open ? '' : 'none';
|
|
|
|
if (true === this.hasFormatToggleTarget) {
|
|
this.formatToggleTarget.classList.toggle('bg-hover', open);
|
|
this.formatToggleTarget.classList.toggle('text-ink', open);
|
|
this.formatToggleTarget.setAttribute('aria-pressed', true === open ? 'true' : 'false');
|
|
}
|
|
|
|
if (true === open) {
|
|
this._revealCaret();
|
|
}
|
|
}
|
|
|
|
/** The thread reply zone this card lives in, when rendered inline. */
|
|
_replyZone() {
|
|
return true === this.inlineValue
|
|
? this.element.closest('[data-reply-zone]')
|
|
: null;
|
|
}
|
|
|
|
/** The message id this window is editing, once the draft exists. */
|
|
_messageId() {
|
|
return this.sendUrlValue.match(/\/send\/(\d+)/)?.[1] ?? null;
|
|
}
|
|
|
|
// ── Address / subject fields ──────────────────────────────────────
|
|
|
|
/**
|
|
* Inline replies keep From/Cc/Bcc/Subject folded away behind the chevron
|
|
* in the header — you rarely retarget a reply.
|
|
*
|
|
* To is NOT in there any more. It sits in the header itself, typable, and
|
|
* that is the point of the reduced header: a forward opens needing exactly
|
|
* one thing, and it used to open with a label where that thing goes. See
|
|
* compose/_to_row.html.twig.
|
|
*/
|
|
toggleFields() {
|
|
if (false === this.hasFieldsTarget) {
|
|
return;
|
|
}
|
|
|
|
const hidden = this.fieldsTarget.classList.toggle('hidden');
|
|
|
|
if (this.hasFieldsChevronTarget) {
|
|
this.fieldsChevronTarget.classList.toggle('rotate-180', false === hidden);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unfold the address block, if it is folded.
|
|
*
|
|
* Cc and Bcc are asked for from the To row, and in an inline window that
|
|
* row is in the HEADER while the fields they reveal are in the block below
|
|
* it — so without this, pressing "Cc" un-hid a row inside something still
|
|
* hidden and looked like a button that did nothing.
|
|
*/
|
|
_revealFields() {
|
|
if (false === this.hasFieldsTarget || false === this.fieldsTarget.classList.contains('hidden')) {
|
|
return;
|
|
}
|
|
|
|
this.fieldsTarget.classList.remove('hidden');
|
|
|
|
if (this.hasFieldsChevronTarget) {
|
|
this.fieldsChevronTarget.classList.add('rotate-180');
|
|
}
|
|
}
|
|
|
|
// ── Keyboard ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Tab out of an address field.
|
|
*
|
|
* With its dropdown open Tom Select consumes the keystroke itself
|
|
* (selectOnTab: commit the highlighted suggestion, or the typed address)
|
|
* and calls preventDefault — nothing left to do here. Closed, the browser
|
|
* would move focus to the little Cc/Bcc buttons that follow the To row in
|
|
* the DOM, which is never where you want to go next: send it to the next
|
|
* visible address row, or to the subject.
|
|
*/
|
|
_handleTab(event) {
|
|
if ('Tab' !== event.key || true === event.shiftKey || true === event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
const wrapper = event.target.closest?.('.ts-wrapper');
|
|
|
|
if (null === wrapper || undefined === wrapper) {
|
|
return;
|
|
}
|
|
|
|
if (true === this._focusAfterAddressRow(wrapper)) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enter must never be a send.
|
|
*
|
|
* This is the accidental-send bug, and the mechanism is plain implicit form
|
|
* submission. A single-line <input> inside a <form> submits that form on
|
|
* Enter unless something calls preventDefault, and this form's action is
|
|
* the send URL — so any Enter the address field did not consume sent the
|
|
* message, with whatever was (or was not) typed.
|
|
*
|
|
* Tom Select consumes the key only when it has a use for it: an active
|
|
* suggestion to commit, or a createItem() that succeeds. Type something
|
|
* with no `@` — it matches no contact, and createFilter refuses to make a
|
|
* chip of it — and neither applies, so the keystroke fell through to the
|
|
* browser and the mail went out to nobody with no subject and no body.
|
|
* That is why every existing test missed it: they all type real addresses,
|
|
* which Tom Select always swallows.
|
|
*
|
|
* So: Enter is claimed here unconditionally (bar the explicit Ctrl/Cmd
|
|
* shortcut), and *then* asked what it should have meant. In an address
|
|
* field that is "commit a chip", and a value that cannot become one is
|
|
* reported rather than silently dropped.
|
|
*
|
|
* Runs on the container so Tom Select's own handler goes first;
|
|
* defaultPrevented is how we know it already dealt with the key.
|
|
*/
|
|
_handleEnter(event) {
|
|
if ('Enter' !== event.key || true === event.isComposing) {
|
|
return;
|
|
}
|
|
|
|
// The one deliberate keyboard send, Gmail's shortcut. Explicit, so it
|
|
// is not the footgun the bare key was.
|
|
if (true === event.ctrlKey || true === event.metaKey) {
|
|
event.preventDefault();
|
|
this._requestSend();
|
|
|
|
return;
|
|
}
|
|
|
|
// Tom Select committed a chip, or opened its menu — either way it has
|
|
// already stopped this reaching the form.
|
|
if (true === event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
|
|
// The body is a contenteditable, where Enter is a newline and there is
|
|
// no implicit submission to guard against.
|
|
if (true === target?.isContentEditable) {
|
|
return;
|
|
}
|
|
|
|
if (false === (target instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
|
|
// Nothing below this line may fall through to the browser.
|
|
event.preventDefault();
|
|
|
|
// An address row, specifically — not merely a Tom Select. The typeface
|
|
// and size pickers are Tom Selects too now, and a filter string typed
|
|
// into one of those is not an address anybody failed to enter.
|
|
const row = this._addressRows().find((candidate) => candidate.contains(target));
|
|
|
|
if (undefined === row) {
|
|
// Subject, and anything else single-line: Enter moves on rather
|
|
// than sending. Gmail does the same.
|
|
this._focusBody();
|
|
|
|
return;
|
|
}
|
|
|
|
this._commitTypedAddress(target.closest('.ts-wrapper'), target);
|
|
}
|
|
|
|
/**
|
|
* Turn what is typed in an address field into a chip, or say why not.
|
|
*
|
|
* Tom Select is asked to do it rather than being worked around: createItem
|
|
* applies the same createFilter the field is configured with, so the answer
|
|
* here and the answer to a blur or a Tab are the same answer.
|
|
*/
|
|
_commitTypedAddress(wrapper, input) {
|
|
const typed = input.value.trim();
|
|
|
|
if ('' === typed || null === wrapper) {
|
|
return;
|
|
}
|
|
|
|
const select = this._tomSelectFor(wrapper);
|
|
|
|
if (false === this.constructor.ADDRESS.test(typed)) {
|
|
this._reportError(this._t('invalidAddress', '"%s" is not a valid email address').replace('%s', typed));
|
|
|
|
return;
|
|
}
|
|
|
|
this._clearError();
|
|
|
|
if (null === select) {
|
|
return;
|
|
}
|
|
|
|
select.createItem(typed, false);
|
|
select.setTextboxValue('');
|
|
select.focus();
|
|
}
|
|
|
|
/** The Tom Select instance behind a rendered `.ts-wrapper`. */
|
|
_tomSelectFor(wrapper) {
|
|
// The bundle hangs the instance off the original <select>, which Tom
|
|
// Select leaves in the DOM (hidden) next to the wrapper it built.
|
|
const select = wrapper.parentElement?.querySelector('select');
|
|
|
|
return select?.tomselect ?? null;
|
|
}
|
|
|
|
/** The To/Cc/Bcc rows, in tab order. */
|
|
_addressRows() {
|
|
return [
|
|
this.hasToFieldTarget ? this.toFieldTarget : null,
|
|
this.hasCcFieldTarget ? this.ccFieldTarget : null,
|
|
this.hasBccFieldTarget ? this.bccFieldTarget : null,
|
|
].filter((row) => null !== row);
|
|
}
|
|
|
|
/** Focus the first visible address row after `wrapper`, else the subject. */
|
|
_focusAfterAddressRow(wrapper) {
|
|
const rows = this._addressRows();
|
|
const index = rows.findIndex((row) => row.contains(wrapper));
|
|
|
|
if (-1 === index) {
|
|
return false;
|
|
}
|
|
|
|
for (const row of rows.slice(index + 1)) {
|
|
if (true === row.classList.contains('hidden')) {
|
|
continue;
|
|
}
|
|
|
|
const input = row.querySelector('.ts-control input');
|
|
|
|
if (null !== input) {
|
|
input.focus();
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (false === this.hasSubjectTarget) {
|
|
return false;
|
|
}
|
|
|
|
this.subjectTarget.focus();
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Quoted content ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wraps every top-level blockquote and forwarded-message div in a
|
|
* collapsible toggle so the compose window doesn't grow unbounded.
|
|
*/
|
|
_collapseQuotedContent() {
|
|
const editor = this.bodyTarget;
|
|
|
|
// [data-quoted] is what buildQuotedHtml emits today; the other two
|
|
// match drafts saved before that marker existed.
|
|
const quoted = Array.from(editor.querySelectorAll(
|
|
':scope > [data-quoted], :scope > blockquote, :scope > div[style*="border-top"]',
|
|
));
|
|
|
|
if (quoted.length === 0) {
|
|
return;
|
|
}
|
|
|
|
quoted.forEach((node) => {
|
|
if (node.dataset.quoteWrapped) {
|
|
return;
|
|
}
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.dataset.quoteWrapped = '1';
|
|
wrapper.style.cssText = 'margin-top: 0.5em;';
|
|
|
|
const toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.contentEditable = 'false';
|
|
toggle.dataset.quoteToggle = '1';
|
|
toggle.textContent = `··· (${this._t('quoteShow', 'show quoted text')})`;
|
|
toggle.style.cssText = [
|
|
'display: inline-block',
|
|
'margin-bottom: 0.4em',
|
|
'padding: 0.1em 0.6em',
|
|
'font-size: 0.75em',
|
|
'border: 1px solid #d1d5db',
|
|
'border-radius: 9999px',
|
|
'background: transparent',
|
|
'color: #6b7280',
|
|
'cursor: pointer',
|
|
'user-select: none',
|
|
].join(';');
|
|
|
|
// Prevent clicks on the toggle from moving the editor cursor.
|
|
toggle.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
|
|
toggle.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const isHidden = node.style.display === 'none';
|
|
node.style.display = isHidden ? '' : 'none';
|
|
toggle.textContent = isHidden
|
|
? `··· (${this._t('quoteHide', 'hide quoted text')})`
|
|
: `··· (${this._t('quoteShow', 'show quoted text')})`;
|
|
});
|
|
|
|
node.parentNode.insertBefore(wrapper, node);
|
|
wrapper.appendChild(toggle);
|
|
wrapper.appendChild(node);
|
|
|
|
// Start collapsed.
|
|
node.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Places the cursor at the very start of the editor (before any
|
|
* quoted content) and scrolls the editor to the top.
|
|
*/
|
|
_focusCursorAtTop() {
|
|
const editor = this.bodyTarget;
|
|
|
|
// preventScroll: inside the thread pane, a plain focus() yanks the
|
|
// conversation to the top the moment the card mounts.
|
|
editor.focus({ preventScroll: true });
|
|
|
|
// Fullscreen is fixed to the viewport — there is nothing to scroll to.
|
|
if (true === this.inlineValue && false === this._isMobile()) {
|
|
this.element.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
const firstNode = this._firstEditableNode(editor);
|
|
if (firstNode === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.setStart(firstNode, 0);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
} catch (_) {
|
|
// Silently ignore — editor is still focused.
|
|
}
|
|
|
|
// The address rows and the editor share one scroll region, so the
|
|
// thing to rewind is that region, not the editor.
|
|
if (this.hasScrollerTarget) {
|
|
this.scrollerTarget.scrollTop = 0;
|
|
}
|
|
|
|
editor.scrollTop = 0;
|
|
}
|
|
|
|
/** Returns the first child node of `editor` that is not a quote wrapper. */
|
|
_firstEditableNode(editor) {
|
|
for (const child of editor.childNodes) {
|
|
if (child.dataset && child.dataset.quoteWrapped) {
|
|
continue;
|
|
}
|
|
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
return child;
|
|
}
|
|
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
const inner = child.firstChild;
|
|
if (inner !== null) {
|
|
return inner;
|
|
}
|
|
|
|
return child;
|
|
}
|
|
}
|
|
|
|
return editor.firstChild;
|
|
}
|
|
|
|
// ── Minimize ──────────────────────────────────────────────────────
|
|
|
|
toggleMinimize() {
|
|
// Fullscreen has nothing to minimize into — the dock is not on screen.
|
|
if (true === this._isMobile()) {
|
|
return;
|
|
}
|
|
|
|
// Can't minimize while expanded — collapse first
|
|
if (this.expandedValue) {
|
|
this.expandedValue = false;
|
|
return;
|
|
}
|
|
this.minimizedValue = !this.minimizedValue;
|
|
}
|
|
|
|
minimizedValueChanged() {
|
|
const minimized = this.minimizedValue;
|
|
|
|
if (this.hasCollapsibleTarget) {
|
|
this.collapsibleTarget.style.display = minimized ? 'none' : '';
|
|
}
|
|
|
|
if (this.hasMinimizeIconTarget) {
|
|
this.minimizeIconTarget.className = minimized
|
|
? 'fa-solid fa-chevron-up text-xs'
|
|
: 'fa-solid fa-minus text-xs';
|
|
}
|
|
|
|
// Must track the header's rounded-t-pane: both follow the theme radius,
|
|
// so a fixed value here disagrees at every setting but the default.
|
|
this.element.classList.toggle('rounded-b-pane', minimized);
|
|
}
|
|
|
|
// ── Expand / fullscreen ───────────────────────────────────────────
|
|
|
|
toggleExpand() {
|
|
if (true === this._isMobile()) {
|
|
return;
|
|
}
|
|
|
|
this.expandedValue = !this.expandedValue;
|
|
}
|
|
|
|
expandedValueChanged() {
|
|
// Mobile is already fullscreen and sizes itself from the visual
|
|
// viewport; the inline styles below would fight that.
|
|
if (true === this.inlineValue || true === this._isMobile()) {
|
|
return;
|
|
}
|
|
|
|
const expanded = this.expandedValue;
|
|
const el = this.element;
|
|
|
|
if (expanded) {
|
|
this.minimizedValue = false;
|
|
|
|
// `bottom` after `inset`, and `max-height: none` after the
|
|
// .compose-dock cap in app.css would otherwise apply: expanded, the
|
|
// window is anchored top and bottom, so clearing the keyboard is a
|
|
// matter of lifting its bottom edge and letting `height: auto` take
|
|
// the difference — no cap needed, and a cap would only leave a
|
|
// second gap under it.
|
|
//
|
|
// `--keyboard-lift` rather than `--keyboard-inset`, and a
|
|
// `min-height` under it, for the reason app.css gives where the two
|
|
// are defined: on a screen short enough that the lift leaves less
|
|
// room than the window's own header and action bar, a box anchored
|
|
// top and bottom spills those out of its bottom edge instead of
|
|
// shrinking them. `min-height` over-constrains the box on purpose —
|
|
// `bottom` is the declaration that loses, which is the right one to
|
|
// lose, since the alternative is a title bar off the top.
|
|
el.style.cssText = `
|
|
position: fixed;
|
|
inset: 1rem;
|
|
bottom: calc(1rem + var(--keyboard-lift, 0px));
|
|
width: auto;
|
|
max-width: none;
|
|
height: auto;
|
|
max-height: none;
|
|
min-height: 20rem;
|
|
margin: 0;
|
|
z-index: 50;
|
|
display: flex;
|
|
flex-direction: column;
|
|
`;
|
|
|
|
if (this.hasBodyTarget) {
|
|
this.bodyTarget.closest('div').style.flex = '1';
|
|
this.bodyTarget.style.flex = '1';
|
|
this.bodyTarget.style.height = '0';
|
|
}
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
} else {
|
|
el.style.cssText = '';
|
|
this._resetExpandedBody();
|
|
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
this._syncExpandIcon(expanded);
|
|
}
|
|
|
|
/** Undo the inline sizing the expanded dock puts on the editor. */
|
|
_resetExpandedBody() {
|
|
if (false === this.hasBodyTarget) {
|
|
return;
|
|
}
|
|
|
|
this.bodyTarget.closest('div').style.flex = '';
|
|
this.bodyTarget.style.flex = '';
|
|
this.bodyTarget.style.height = '';
|
|
}
|
|
|
|
_syncExpandIcon(expanded) {
|
|
if (this.hasExpandIconTarget) {
|
|
this.expandIconTarget.className = expanded
|
|
? 'fa-solid fa-down-left-and-up-right-to-center text-[10px]'
|
|
: 'fa-solid fa-up-right-and-down-left-from-center text-[10px]';
|
|
}
|
|
}
|
|
|
|
// ── From dropdown ─────────────────────────────────────────────────
|
|
|
|
toggleFromDropdown() {
|
|
const open = !this.fromDropdownTarget.classList.contains('hidden');
|
|
if (open) {
|
|
this._closeFromDropdown();
|
|
} else {
|
|
this.fromDropdownTarget.classList.remove('hidden');
|
|
this.fromChevronTarget.style.transform = 'rotate(180deg)';
|
|
document.addEventListener('click', this._boundCloseDropdown, { capture: true, once: true });
|
|
}
|
|
}
|
|
|
|
selectAccount(event) {
|
|
const btn = event.currentTarget;
|
|
const value = btn.dataset.value;
|
|
const label = btn.dataset.label;
|
|
|
|
this.accountSelectTarget.value = value;
|
|
this.fromLabelTarget.textContent = label;
|
|
|
|
this.fromDropdownTarget.querySelectorAll('button').forEach(b => {
|
|
const selected = b.dataset.value === value;
|
|
b.classList.toggle('bg-accent-soft', selected);
|
|
b.classList.toggle('text-accent', selected);
|
|
});
|
|
|
|
// The signature follows the address, and ONLY the signature: the swap
|
|
// is scoped to the [data-pl-signature] block, so a paragraph already
|
|
// typed above it is untouched. Changing From must never cost someone
|
|
// what they have written.
|
|
this._swapSignature(value);
|
|
|
|
this._closeFromDropdown();
|
|
}
|
|
|
|
// ── Signature ─────────────────────────────────────────────────────
|
|
//
|
|
// One block, marked with `data-pl-signature`, living in the body above the
|
|
// quote. Everything here is the same operation seen from three angles:
|
|
// there is at most one signature block, and writing a signature means
|
|
// replacing that block's contents or creating it where the caret is.
|
|
|
|
/**
|
|
* The signature the current From selection signs with, as HTML, or '' when
|
|
* that address signs with nothing.
|
|
*/
|
|
_signatureFor(token) {
|
|
const map = this.hasSignaturesValue ? this.signaturesValue : {};
|
|
|
|
// An address at a catch-all domain has no entry of its own — the
|
|
// server cannot list every local part — so it signs with the account's
|
|
// wildcard entry, `<accountId>|*`. See SignatureProvider::tokenMap().
|
|
const wildcard = `${String(token).split('|')[0]}|*`;
|
|
|
|
return map?.[token] ?? map?.[wildcard] ?? '';
|
|
}
|
|
|
|
_currentSignature() {
|
|
return this._signatureFor(
|
|
this.hasAccountSelectTarget ? this.accountSelectTarget.value : '',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A click in the empty space below the content lands in writeable text,
|
|
* never in the signature.
|
|
*
|
|
* The bug this fixes: a new message opens as `<p><br></p>` + the signature
|
|
* block, so the signature is the LAST thing in the editor and everything
|
|
* below it is the editor's own padding. A contenteditable resolves a click
|
|
* on its padding to the nearest position in the nearest child — the end of
|
|
* the signature line — so the first sentence a person wrote came out as
|
|
* "-- SIG-OUTLOOK Paul" with their text welded onto it, no separator, and
|
|
* no way to get above it with the mouse at all.
|
|
*
|
|
* `event.target === bodyTarget` is what makes this precise rather than
|
|
* clever. A click that lands ON any content — the signature text included,
|
|
* which people legitimately edit — has that element as its target and is
|
|
* left completely alone. Only a click on the editor's own box, which is by
|
|
* definition a click on empty space, is redirected.
|
|
*
|
|
* It goes UP, to the writing space above the signature, rather than opening
|
|
* a paragraph below it. Below the sign-off is not where the message goes,
|
|
* and the seeded `<p><br></p>` is already sitting there for exactly this.
|
|
*/
|
|
claimWritingSpace(event) {
|
|
if (false === this.hasBodyTarget || event.target !== this.bodyTarget) {
|
|
return;
|
|
}
|
|
|
|
const signature = this._signatureBlock();
|
|
|
|
if (null === signature) {
|
|
return;
|
|
}
|
|
|
|
this._focusWritingSpace(signature);
|
|
}
|
|
|
|
/**
|
|
* Put the caret at the end of the last writeable block above `signature`,
|
|
* making one if the body has none left.
|
|
*/
|
|
_focusWritingSpace(signature) {
|
|
let space = null;
|
|
|
|
for (const child of this.bodyTarget.children) {
|
|
if (child === signature) {
|
|
break;
|
|
}
|
|
|
|
// A quote is no more writeable than the signature is: landing at
|
|
// the end of a quoted reply is the same bug wearing a hat.
|
|
if (null === child.closest('[data-quote-wrapped], [data-quoted], blockquote')) {
|
|
space = child;
|
|
}
|
|
}
|
|
|
|
if (null === space) {
|
|
space = document.createElement('p');
|
|
space.appendChild(document.createElement('br'));
|
|
this.bodyTarget.insertBefore(space, signature);
|
|
}
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(space);
|
|
range.collapse(false);
|
|
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
this.bodyTarget.focus();
|
|
}
|
|
|
|
/** The signature block currently in the body, if there is one. */
|
|
_signatureBlock() {
|
|
return this.hasBodyTarget
|
|
? this.bodyTarget.querySelector('[data-pl-signature]')
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Toolbar button: put the signature in the body.
|
|
*
|
|
* Replaces the block in place when one is already there rather than
|
|
* appending a second — clicking twice is a thing people do, and two
|
|
* sign-offs is not what either click asked for.
|
|
*/
|
|
insertSignature() {
|
|
if (false === this.hasBodyTarget) { return; }
|
|
|
|
const html = this._currentSignature();
|
|
|
|
if ('' === html) {
|
|
this._setStatus(this._t('noSignature', 'No signature set for this address'), 'text-ink-faint');
|
|
|
|
return;
|
|
}
|
|
|
|
const existing = this._signatureBlock();
|
|
|
|
if (existing) {
|
|
existing.outerHTML = html;
|
|
this._afterBodyEdit();
|
|
|
|
return;
|
|
}
|
|
|
|
// No block yet: build one and hand it to the toolbar, which knows
|
|
// where the caret was before this button stole the focus.
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
|
|
const node = wrapper.firstElementChild;
|
|
|
|
if (!node) { return; }
|
|
|
|
const toolbar = this._toolbar();
|
|
|
|
if (toolbar) {
|
|
toolbar._insertAtCaret(node);
|
|
|
|
return;
|
|
}
|
|
|
|
this.bodyTarget.appendChild(node);
|
|
this._afterBodyEdit();
|
|
}
|
|
|
|
/**
|
|
* Swap the signature block for the newly selected identity's, and change
|
|
* nothing else.
|
|
*
|
|
* The fiddly case is a body that has no block at all — a draft written
|
|
* before the sender had a signature, or one the user deleted the block out
|
|
* of. Nothing is inserted then: an absent signature is a decision, and
|
|
* changing From is not the moment to overrule it.
|
|
*/
|
|
_swapSignature(token) {
|
|
const existing = this._signatureBlock();
|
|
|
|
if (!existing) { return; }
|
|
|
|
// A signature a template chose by name stays the one it chose. The
|
|
// server marks it (SignatureProvider::block()), and following From
|
|
// here would replace exactly the thing the template's author pinned.
|
|
// The toolbar button still replaces it: that is somebody asking.
|
|
if (existing.hasAttribute('data-pl-signature-pinned')) { return; }
|
|
|
|
const html = this._signatureFor(token);
|
|
|
|
if ('' === html) {
|
|
existing.remove();
|
|
} else {
|
|
existing.outerHTML = html;
|
|
}
|
|
|
|
this._afterBodyEdit();
|
|
}
|
|
|
|
/** The toolbar controller sharing this window's element, if it is up. */
|
|
_toolbar() {
|
|
return this.application?.getControllerForElementAndIdentifier(
|
|
this.element,
|
|
'compose--compose-toolbar',
|
|
) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Mirror a scripted body change into the hidden input and wake the
|
|
* autosave. Nothing the DOM is changed by script fires `input` on its own,
|
|
* so without this the change is on screen and gone again on reload.
|
|
*/
|
|
_afterBodyEdit() {
|
|
this._toolbar()?._syncHiddenInput();
|
|
|
|
if (this.hasBodyTarget) {
|
|
this.bodyTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
// ── Templates ─────────────────────────────────────────────────────
|
|
//
|
|
// compose--template-picker fetches a template the server has already
|
|
// filled in as far as it can (TemplateRenderer) and hands it to
|
|
// insertTemplate(). What is left open is the recipient, and closing that
|
|
// is this window's job because this window is the only thing that sees
|
|
// the To field change. assets/compose/template_variables.js holds the
|
|
// rules; these methods only decide WHEN.
|
|
|
|
/**
|
|
* Put a rendered template into the message.
|
|
*
|
|
* The subject only ever fills an empty subject line. A template dropped
|
|
* into a reply must not rename the conversation, and a subject somebody
|
|
* has already typed is theirs.
|
|
*
|
|
* @param {{subject: string, html: string}} template
|
|
*/
|
|
insertTemplate({ subject, html }) {
|
|
if (false === this.hasBodyTarget) { return; }
|
|
|
|
if ('' !== (subject ?? '') && this.hasSubjectTarget && '' === this.subjectTarget.value.trim()) {
|
|
this.subjectTarget.value = subject;
|
|
this.subjectTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
const holder = document.createElement('div');
|
|
holder.innerHTML = html;
|
|
|
|
if (true === this._isPlainText()) {
|
|
this._insertPlainText(this._bodyAsText(holder));
|
|
} else {
|
|
this._insertTemplateNodes(holder);
|
|
}
|
|
|
|
this._fillTemplateVariables();
|
|
}
|
|
|
|
/**
|
|
* The rich half of insertTemplate().
|
|
*
|
|
* ONE SIGNATURE. A template that brings a signature replaces the one the
|
|
* window opened with, for the reason insertSignature() replaces rather
|
|
* than appends: two sign-offs is not what anyone asked for. A template
|
|
* without one leaves the window's alone.
|
|
*
|
|
* WHERE IT GOES. At the caret — but a template is paragraphs, and the
|
|
* caret is nearly always inside one. Paragraphs inserted into a paragraph
|
|
* are something the DOM tolerates and HTML does not: the body is
|
|
* serialised on every save and re-parsed on every reopen, and the parser
|
|
* would split them apart again with an empty paragraph left over each
|
|
* time. So an empty paragraph is replaced by the template, and a
|
|
* paragraph with writing in it gets the template after it.
|
|
*/
|
|
_insertTemplateNodes(holder) {
|
|
if (null !== holder.querySelector('[data-pl-signature]')) {
|
|
this._signatureBlock()?.remove();
|
|
}
|
|
|
|
const nodes = [...holder.childNodes];
|
|
|
|
if (0 === nodes.length) { return; }
|
|
|
|
const anchor = document.createElement('span');
|
|
const toolbar = this._toolbar();
|
|
|
|
if (toolbar) {
|
|
toolbar._insertAtCaret(anchor);
|
|
} else {
|
|
this.bodyTarget.appendChild(anchor);
|
|
}
|
|
|
|
const block = anchor.parentElement;
|
|
|
|
if (block !== this.bodyTarget && block?.parentElement === this.bodyTarget && 'P' === block.tagName) {
|
|
anchor.remove();
|
|
|
|
if ('' === block.textContent.trim() && null === block.querySelector('img')) {
|
|
block.replaceWith(...nodes);
|
|
} else {
|
|
block.after(...nodes);
|
|
}
|
|
} else {
|
|
anchor.replaceWith(...nodes);
|
|
}
|
|
|
|
// The caret after what was inserted, where the next sentence goes —
|
|
// but never after the signature, which is the bug claimWritingSpace()
|
|
// documents.
|
|
const last = nodes.findLast((node) => false === (node instanceof Element && node.hasAttribute('data-pl-signature')))
|
|
?? nodes[nodes.length - 1];
|
|
const range = document.createRange();
|
|
|
|
range.selectNodeContents(last);
|
|
range.collapse(false);
|
|
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
this._afterBodyEdit();
|
|
}
|
|
|
|
/** The plain half: text at the textarea's caret. */
|
|
_insertPlainText(text) {
|
|
const textarea = this.plainBodyTarget;
|
|
const start = textarea.selectionStart ?? textarea.value.length;
|
|
const end = textarea.selectionEnd ?? start;
|
|
|
|
textarea.setRangeText(text, start, end, 'end');
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
textarea.focus();
|
|
}
|
|
|
|
/**
|
|
* What the first To recipient's chip says, or '' with nobody in To.
|
|
*
|
|
* The first, and only To: a template speaks to one person, and with three
|
|
* recipients "Hi Dana," is at least true of the one the sender thought of
|
|
* first. Whoever disagrees edits the text — once inserted it is only text.
|
|
*/
|
|
_firstRecipientLabel() {
|
|
if (false === this.hasToFieldTarget) {
|
|
return '';
|
|
}
|
|
|
|
const wrapper = this.toFieldTarget.querySelector('.ts-wrapper');
|
|
const select = null === wrapper ? null : this._tomSelectFor(wrapper);
|
|
const value = select?.items?.[0];
|
|
|
|
if (undefined !== value && null !== value) {
|
|
return String(select.options?.[value]?.text ?? value);
|
|
}
|
|
|
|
// Tom Select not built yet — a draft reopened with its recipient
|
|
// already selected. The server's markup has the same text.
|
|
return this.toFieldTarget.querySelector('select option:checked')?.textContent?.trim() ?? '';
|
|
}
|
|
|
|
/**
|
|
* Close every open template variable the current recipient can answer.
|
|
*
|
|
* Safe to call at any time and from anywhere: with no template in the
|
|
* message, or no recipient, it finds nothing and changes nothing. Markers
|
|
* are also made non-editable here rather than by the server, because the
|
|
* attribute is the editor's business and the sanitiser drops it from the
|
|
* stored draft — so a reopened draft gets it back on the first pass.
|
|
*/
|
|
_fillTemplateVariables() {
|
|
const values = recipientValues(this._firstRecipientLabel());
|
|
|
|
if (this.hasSubjectTarget) {
|
|
const filled = fillTokens(this.subjectTarget.value, values);
|
|
|
|
if (filled !== this.subjectTarget.value) {
|
|
this.subjectTarget.value = filled;
|
|
this.subjectTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
if (true === this._isPlainText()) {
|
|
const filled = fillTokens(this.plainBodyTarget.value, values);
|
|
|
|
if (filled !== this.plainBodyTarget.value) {
|
|
this.plainBodyTarget.value = filled;
|
|
this.plainBodyTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (false === this.hasBodyTarget) {
|
|
return;
|
|
}
|
|
|
|
this.bodyTarget.querySelectorAll('[data-pl-var]').forEach((marker) => {
|
|
marker.contentEditable = 'false';
|
|
});
|
|
|
|
if (true === fillMarkers(this.bodyTarget, values)) {
|
|
this._afterBodyEdit();
|
|
}
|
|
}
|
|
|
|
/** Is a template variable still waiting for a recipient? */
|
|
_hasOpenTemplateVariables() {
|
|
if (this.hasSubjectTarget && true === hasOpenTokens(this.subjectTarget.value)) {
|
|
return true;
|
|
}
|
|
|
|
if (true === this._isPlainText()) {
|
|
return hasOpenTokens(this.plainBodyTarget.value);
|
|
}
|
|
|
|
return this.hasBodyTarget && null !== this.bodyTarget.querySelector('[data-pl-var]');
|
|
}
|
|
|
|
// ── Plain text mode ───────────────────────────────────────────────
|
|
|
|
_isPlainText() {
|
|
return this.hasPlainBodyTarget && false === this.plainBodyTarget.disabled;
|
|
}
|
|
|
|
/**
|
|
* Switch the body between the rich editor and a plain textarea.
|
|
*
|
|
* WHAT HAPPENS TO FORMATTING. Going to plain text flattens the HTML, and
|
|
* that is not reversible by re-parsing text — so it is warned about first
|
|
* (a confirm the user can decline) and it is undone by REMEMBERING: the
|
|
* HTML is kept, untouched, in the hidden bodyHtml input and in the editor
|
|
* that is merely hidden, so switching back restores exactly what was there.
|
|
* The cost of that choice is that the round trip is only free until the
|
|
* draft is saved in plain mode — the save stores an empty bodyHtml, and
|
|
* reopening the draft later genuinely has no HTML left to come back to.
|
|
* Which is honest: at that point the message really is a plain-text
|
|
* message, and pretending otherwise would be the lie.
|
|
*
|
|
* The mode itself is not stored anywhere. A message with text and no HTML
|
|
* IS a plain-text message — that is the same pair MessageSendService reads
|
|
* — so the window re-derives the mode on render and needs no column.
|
|
*/
|
|
togglePlainText() {
|
|
if (false === this.hasPlainBodyTarget || false === this.hasBodyTarget) {
|
|
return;
|
|
}
|
|
|
|
const plain = false === this._isPlainText();
|
|
|
|
// Going plain, with formatting that flattening would destroy: ask
|
|
// first. The question is a popover over the editor rather than
|
|
// `window.confirm()` — same rule, same reversibility, but a dialog the
|
|
// app drew rather than one the browser did, which on a phone is a
|
|
// system sheet dropped over a half-written message.
|
|
if (true === plain && true === this._plainTextLosesFormatting()) {
|
|
this._openPlainWarning();
|
|
|
|
return;
|
|
}
|
|
|
|
this._setPlainText(plain);
|
|
}
|
|
|
|
/**
|
|
* Is there formatting here that going plain would throw away?
|
|
*
|
|
* Unchanged from when this guarded the `confirm()`: an empty body, or one
|
|
* that is already nothing but text, converts silently. Lifted out only so
|
|
* that the question and the answer are no longer inside the function that
|
|
* performs the switch.
|
|
*/
|
|
_plainTextLosesFormatting() {
|
|
const text = this._bodyAsText(this.bodyTarget);
|
|
|
|
return this.bodyTarget.innerHTML.trim() !== text.replace(/\n/g, '')
|
|
&& text.trim().length > 0;
|
|
}
|
|
|
|
_openPlainWarning() {
|
|
if (false === this.hasPlainWarningTarget) {
|
|
// No popover in this window (the settings signature editor renders
|
|
// the toolbar without one): fall back to switching, which is what
|
|
// declining could only ever postpone.
|
|
this._setPlainText(true);
|
|
|
|
return;
|
|
}
|
|
|
|
this.plainWarningTarget.hidden = false;
|
|
|
|
this._boundPlainWarningOutside ??= (event) => {
|
|
if (false === this.plainWarningTarget.contains(event.target)) {
|
|
this.cancelPlainText();
|
|
}
|
|
};
|
|
this._boundPlainWarningEscape ??= (event) => {
|
|
if ('Escape' === event.key) {
|
|
event.stopPropagation();
|
|
this.cancelPlainText();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('click', this._boundPlainWarningOutside, { capture: true });
|
|
document.addEventListener('keydown', this._boundPlainWarningEscape);
|
|
|
|
// Focus lands on Continue so the dialog is operable from the keyboard
|
|
// at all; Escape and Cancel are both a decline, which is the safe
|
|
// default this guard exists to protect.
|
|
if (this.hasPlainWarningConfirmTarget) {
|
|
this.plainWarningConfirmTarget.focus();
|
|
}
|
|
}
|
|
|
|
_closePlainWarning() {
|
|
if (false === this.hasPlainWarningTarget) {
|
|
return;
|
|
}
|
|
|
|
this.plainWarningTarget.hidden = true;
|
|
|
|
document.removeEventListener('click', this._boundPlainWarningOutside, { capture: true });
|
|
document.removeEventListener('keydown', this._boundPlainWarningEscape);
|
|
}
|
|
|
|
/** "Continue" — the switch the warning was standing in front of. */
|
|
confirmPlainText() {
|
|
this._closePlainWarning();
|
|
this._setPlainText(true);
|
|
}
|
|
|
|
/** "Cancel", Escape, or a click outside. The body is left exactly as it was. */
|
|
cancelPlainText() {
|
|
this._closePlainWarning();
|
|
}
|
|
|
|
_setPlainText(plain) {
|
|
const textarea = this.plainBodyTarget;
|
|
const editor = this.bodyTarget;
|
|
|
|
if (true === plain) {
|
|
textarea.value = this._bodyAsText(editor);
|
|
} else {
|
|
// Coming back: the editor still holds the HTML it always did, so
|
|
// there is nothing to restore unless the user typed into the
|
|
// textarea in the meantime — in which case that text wins, because
|
|
// it is the newer writing.
|
|
if (textarea.value !== this._bodyAsText(editor)) {
|
|
editor.innerHTML = this._textAsHtml(textarea.value);
|
|
}
|
|
}
|
|
|
|
textarea.disabled = false === plain;
|
|
textarea.hidden = false === plain;
|
|
textarea.classList.toggle('hidden', false === plain);
|
|
editor.classList.toggle('hidden', true === plain);
|
|
|
|
// The HTML body has to go on the wire empty for the send path to emit
|
|
// text only — that branch is `if ($message->bodyHtml)` in
|
|
// MessageSendService::buildEmail(). The editor keeps its content; only
|
|
// what is submitted changes.
|
|
const hidden = this.element.querySelector('[data-compose--compose-toolbar-target="hiddenInput"]');
|
|
|
|
if (hidden) {
|
|
hidden.disabled = true === plain;
|
|
}
|
|
|
|
// Formatting controls have nothing to act on in plain text — so going
|
|
// plain folds the bar, and coming back UNFOLDS it. That second half is
|
|
// what was missing: the switch wrote `display: none` on the way in and
|
|
// nothing on the way out, leaving a window in rich mode with no
|
|
// formatting bar and no obvious way to work out why. ("Aa" would have
|
|
// brought it back, but only for someone who already knew that.)
|
|
//
|
|
// Restored to what it was rather than simply opened: whether the bar is
|
|
// open in rich mode is the user's own choice, and a round trip through
|
|
// plain text must not quietly overrule it. `?? true` covers the window
|
|
// that went plain before anyone touched "Aa" — open is the default.
|
|
if (true === plain) {
|
|
this._formatBarWasOpen = this._isFormatBarOpen();
|
|
this._setFormatBar(false);
|
|
} else {
|
|
this._setFormatBar(this._formatBarWasOpen ?? true);
|
|
}
|
|
|
|
if (this.hasFormatToggleTarget) {
|
|
this.formatToggleTarget.classList.toggle('hidden', true === plain);
|
|
}
|
|
|
|
// Link, emoji, inline image and signature all write into the rich
|
|
// editor, which in plain text is hidden — they were live buttons that
|
|
// silently did nothing. Greyed while plain, and back exactly as they
|
|
// were on the way out. The encrypt button is NOT in this set: it is
|
|
// permanently disabled for reasons of its own and re-enabling it here
|
|
// would be the one lie a mail client must not tell.
|
|
this._setRichAffordances(false === plain);
|
|
|
|
if (this.hasPlainToggleTarget) {
|
|
this.plainToggleTarget.setAttribute('aria-pressed', true === plain ? 'true' : 'false');
|
|
}
|
|
|
|
if (this.hasPlainCheckTarget) {
|
|
this.plainCheckTarget.classList.toggle('invisible', false === plain);
|
|
}
|
|
|
|
(true === plain ? textarea : editor).focus();
|
|
this._scheduleAutosave();
|
|
}
|
|
|
|
/**
|
|
* Enable or grey the toolbar buttons that only the rich editor can serve.
|
|
*
|
|
* Marked in the template (`richOnly`) rather than found by selector, so
|
|
* that adding a button to the icon row does not silently opt it in — or,
|
|
* worse, opt the permanently-disabled encrypt button in with it.
|
|
*/
|
|
_setRichAffordances(enabled) {
|
|
if (false === this.hasRichOnlyTarget) {
|
|
return;
|
|
}
|
|
|
|
this.richOnlyTargets.forEach((button) => {
|
|
button.disabled = false === enabled;
|
|
button.classList.toggle('opacity-40', false === enabled);
|
|
button.classList.toggle('cursor-not-allowed', false === enabled);
|
|
});
|
|
}
|
|
|
|
/** The editor's content as text, block boundaries becoming newlines. */
|
|
_bodyAsText(editor) {
|
|
// An open template variable is a span in the editor and cannot be one
|
|
// in a textarea, so it crosses as its token — which is the shape
|
|
// _fillTemplateVariables() looks for in text. As its label it would
|
|
// become the words "First name" in the message with nothing left to
|
|
// say they were a placeholder.
|
|
const clone = markersAsTokens(editor);
|
|
|
|
clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
|
clone.querySelectorAll('p, div, li, tr').forEach((block) => block.append('\n'));
|
|
|
|
return clone.textContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
/** Text back into the editor, escaped — never as markup. */
|
|
_textAsHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
|
|
return div.innerHTML.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
_closeFromDropdown() {
|
|
if (this.hasFromDropdownTarget) {
|
|
this.fromDropdownTarget.classList.add('hidden');
|
|
}
|
|
if (this.hasFromChevronTarget) {
|
|
this.fromChevronTarget.style.transform = '';
|
|
}
|
|
}
|
|
|
|
// ── Close ─────────────────────────────────────────────────────────
|
|
|
|
async close() {
|
|
document.body.style.overflow = '';
|
|
|
|
const frame = this.element.closest('turbo-frame');
|
|
const id = this._messageId();
|
|
|
|
// In-place draft: the frame IS the row, so put the row markup back
|
|
// rather than leaving a hole in the conversation. Rendered server
|
|
// side so the snippet reflects what was just saved.
|
|
if (null !== id && true === await this._restoreDraftRow(frame, id)) {
|
|
return;
|
|
}
|
|
|
|
// Reply box: a draft the autosave created has no row yet — add one,
|
|
// otherwise it only turns up after a reload. Keyed on the thread
|
|
// rather than on `inline`, because a reply below md is a dock window
|
|
// that still belongs to the conversation behind it.
|
|
if (null !== id && true === this.hasThreadValue) {
|
|
this._insertDraftRow(id);
|
|
}
|
|
|
|
frame.innerHTML = '';
|
|
}
|
|
|
|
/**
|
|
* Really delete the draft — the trash button used to just close the
|
|
* window and leave it behind.
|
|
*/
|
|
async discard() {
|
|
const id = this._messageId();
|
|
|
|
if (null === id) {
|
|
this.close();
|
|
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this.#autosaveTimer);
|
|
|
|
const frame = this.element.closest('turbo-frame');
|
|
const params = new URLSearchParams({ frame: frame?.id ?? 'compose_dock' });
|
|
|
|
if (this.hasThreadValue) {
|
|
params.set('thread', String(this.threadValue));
|
|
}
|
|
|
|
// Which list is behind the window. The Drafts view shows a row per
|
|
// draft, so a conversation that loses its last one has to lose its row
|
|
// there — and only there.
|
|
//
|
|
// Read off the list FRAME rather than off the #message-list pane it
|
|
// sits in: the pane is outside the frame, so after a frame navigation
|
|
// its copy of the scope names the folder that was open before the
|
|
// click. See _layout/_mailbox.html.twig.
|
|
const scope = document.getElementById('inbox-list-frame')?.dataset.syncScope;
|
|
|
|
if (undefined !== scope) {
|
|
params.set('scope', scope);
|
|
}
|
|
|
|
let response;
|
|
|
|
try {
|
|
response = await fetch(`/compose/discard/${id}?${params}`, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken() },
|
|
});
|
|
} catch {
|
|
requestFailed(null);
|
|
|
|
return;
|
|
}
|
|
|
|
// The draft is still there and the window stays open on it; the next
|
|
// keystroke arms the autosave again. The toast says why the trash
|
|
// button did nothing.
|
|
if (requestFailed(response)) {
|
|
return;
|
|
}
|
|
|
|
Turbo.renderStreamMessage(await response.text());
|
|
}
|
|
|
|
_rowUrl(id) {
|
|
const params = this.hasThreadValue ? `?thread=${this.threadValue}` : '';
|
|
|
|
return `/compose/draft-row/${id}${params}`;
|
|
}
|
|
|
|
async _fetchRow(id) {
|
|
// Null on a network failure too: every caller already has a fallback
|
|
// for "no row", and a rejection here would stop close() closing.
|
|
try {
|
|
const response = await fetch(this._rowUrl(id), {
|
|
headers: { 'X-Requested-With': 'fetch' },
|
|
});
|
|
|
|
return true === response.ok ? await response.text() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Append the row for a freshly created draft to the open conversation. */
|
|
async _insertDraftRow(id) {
|
|
const list = this.hasThreadValue
|
|
? document.getElementById(`thread_messages_${this.threadValue}`)
|
|
: null;
|
|
|
|
if (null === list || null !== document.getElementById(`thread_message_${id}`)) {
|
|
return;
|
|
}
|
|
|
|
const html = await this._fetchRow(id);
|
|
|
|
if (null !== html) {
|
|
list.insertAdjacentHTML('beforeend', html);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move the inline draft into the floating dock. The draft has to be saved
|
|
* first so the dock can load it by id — saveDraft() rewrites sendUrlValue
|
|
* to /compose/send/{id}, which is where the id comes from.
|
|
*/
|
|
async popOut() {
|
|
await this.saveDraft(null, { force: true });
|
|
|
|
const id = this.sendUrlValue.match(/\/send\/(\d+)/)?.[1];
|
|
const dock = document.querySelector('turbo-frame#compose_dock');
|
|
|
|
if (undefined === id || null === dock) {
|
|
return;
|
|
}
|
|
|
|
dock.src = `/compose/edit/${id}`;
|
|
|
|
// Put the row back rather than emptying the frame. Popping out moves
|
|
// where the draft is edited; it does not remove the draft from the
|
|
// conversation, and the row is how the conversation shows it.
|
|
//
|
|
// Emptying was invisible until the window was closed: close() restores
|
|
// the row only when its own frame is still compose_draft_*, and by then
|
|
// the editor lives in the dock. So the draft survived, its row did not,
|
|
// and the thread only told the truth again after a reload.
|
|
const frame = this.element.closest('turbo-frame');
|
|
|
|
if (false === await this._restoreDraftRow(frame, id)) {
|
|
frame.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Swap an in-place editor back for the draft's row.
|
|
*
|
|
* Server-rendered so the snippet reflects what was just saved. Answers
|
|
* false when this is not an in-place editor, or the row or the fetch is not
|
|
* there — the caller then decides what to leave behind.
|
|
*/
|
|
async _restoreDraftRow(frame, id) {
|
|
if (false === frame?.id.startsWith('compose_draft_')) {
|
|
return false;
|
|
}
|
|
|
|
const row = frame.closest('[id^="thread_message_"]');
|
|
const html = await this._fetchRow(id);
|
|
|
|
if (null === row || null === html) {
|
|
return false;
|
|
}
|
|
|
|
row.outerHTML = html;
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Save draft ────────────────────────────────────────────────────
|
|
|
|
_scheduleAutosave() {
|
|
clearTimeout(this.#autosaveTimer);
|
|
|
|
// Set BEFORE the threshold check, on purpose. Writing that is too short
|
|
// to autosave is exactly the writing that would otherwise vanish
|
|
// without trace, so it is the case the unload guard most needs to know
|
|
// about.
|
|
this._unsaved = this._contentLength() > 0;
|
|
|
|
if (false === this._worthSaving()) {
|
|
this._reportPending();
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.hasSaveStatusTarget && this.saveStatusTarget.textContent.includes('to save')) {
|
|
this.saveStatusTarget.textContent = '';
|
|
}
|
|
|
|
this.#autosaveTimer = setTimeout(
|
|
() => this.saveDraft(),
|
|
this.autosaveDelayValue,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `force` covers the deliberate saves — the close button (which passes its
|
|
* click event) and pop-out — so only the debounced autosave is gated on
|
|
* the body having real content. `allowEmpty` additionally lets an empty
|
|
* draft be created: attaching a file needs a row to hang it off.
|
|
*/
|
|
async saveDraft(event = null, { force = false, allowEmpty = false } = {}) {
|
|
event?.preventDefault();
|
|
|
|
// Not while a send is out. What is on screen during the cancel window
|
|
// is a copy of a message that has already left, and the header is
|
|
// still live — so "save and close" is reachable, and without this it
|
|
// would write the sent message back over itself as a draft. The body
|
|
// is `inert` for the same reason; this is the half of it that a click
|
|
// on the chrome can still reach.
|
|
if (true === this._sending) {
|
|
return;
|
|
}
|
|
|
|
const form = this.element.querySelector('form');
|
|
if (!form) { return; }
|
|
|
|
if (false === force && null === event && false === this._worthSaving()) {
|
|
return;
|
|
}
|
|
|
|
// Nothing written and nothing saved yet: closing an untouched reply
|
|
// box must not leave an empty draft behind.
|
|
//
|
|
// "Nothing written" means the subject as well as the body — this gate
|
|
// asked about the body alone, so a draft whose only content was a
|
|
// subject was refused here even once the threshold above had let it
|
|
// through, and the save never happened. See _contentLength().
|
|
if (false === allowEmpty && 0 === this._contentLength() && null === this._messageId()) {
|
|
return;
|
|
}
|
|
|
|
const url = this.hasDraftUrlValue ? this.draftUrlValue : form.action;
|
|
const status = this.hasSaveStatusTarget ? this.saveStatusTarget : null;
|
|
this._setStatus(this._t('saving', 'Saving…'), 'text-ink-faint');
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: new FormData(form),
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (true === response.ok) {
|
|
// Outside the status-target check on purpose. Reading the new
|
|
// URLs used to be conditional on there being somewhere to print
|
|
// "Draft saved", which tied whether the window's routes were
|
|
// correct to whether it had a status line — two unrelated
|
|
// things. A window without one still needs to know its own id.
|
|
const html = await response.text();
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
|
|
// `response.ok` is not the question. A form the server REFUSED
|
|
// — a stale CSRF token, a validation failure — comes back as a
|
|
// re-rendered window carrying the errors, with HTTP 200, and
|
|
// this used to read that as success and say "Draft saved" about
|
|
// writing that had just been discarded. The marker is the
|
|
// server saying it accepted the save; see _window.html.twig.
|
|
if (null === doc.querySelector('[data-compose-saved]')) {
|
|
throw new Error('Draft refused');
|
|
}
|
|
|
|
this._adoptSavedUrls(doc);
|
|
|
|
if (status) {
|
|
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
|
|
}
|
|
|
|
// What is on screen is now what is on the server, so leaving
|
|
// costs nothing and must not be interrupted.
|
|
this._unsaved = false;
|
|
} else {
|
|
throw new Error('Server error');
|
|
}
|
|
} catch (_) {
|
|
this._setStatus(this._t('saveFailed', 'Could not save the draft'), 'text-danger');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take the id-bearing URLs out of an autosave response.
|
|
*
|
|
* A window opens with no message id, so every route it knows is the
|
|
* "create" one. The first autosave mints the id and answers with a window
|
|
* that knows all the id-bearing routes; this is where the open window
|
|
* catches up with it.
|
|
*
|
|
* The schedule URL is the one that was missing, and its absence was a
|
|
* permanent duplicate draft rather than a broken button. The send pill's
|
|
* items are submit buttons carrying `formaction`, rendered once when the
|
|
* window opened, so they went on pointing at `/compose/schedule` with no
|
|
* id. ComposeController::schedule() takes a null message there and does
|
|
* exactly what it is told — builds a NEW one. The result was two identical
|
|
* rows in Drafts from one composition, one of them scheduled and one of
|
|
* them stranded, and the stranded one never went away because nothing
|
|
* afterwards knew it was a twin.
|
|
*
|
|
* Belt and braces on the form action too: `formaction` on a submit button
|
|
* beats `action` on the form, so both have to be right.
|
|
*/
|
|
_adoptSavedUrls(doc) {
|
|
const fresh = doc.querySelector('[data-compose--compose-draft-url-value]');
|
|
|
|
if (!fresh) { return; }
|
|
|
|
this.draftUrlValue = fresh.getAttribute('data-compose--compose-draft-url-value');
|
|
this.sendUrlValue = fresh.getAttribute('data-compose--compose-send-url-value');
|
|
|
|
const scheduleUrl = fresh.getAttribute('data-compose--compose-schedule-url-value');
|
|
|
|
if (scheduleUrl) {
|
|
this.scheduleUrlValue = scheduleUrl;
|
|
|
|
// Every schedule submit button in this window: the presets, and the
|
|
// custom picker's confirm. There are two send pills (the header one
|
|
// below md and the action-bar one above it) and both are live.
|
|
for (const button of this.element.querySelectorAll(
|
|
'[data-compose-send-pill] button[formaction]',
|
|
)) {
|
|
button.setAttribute('formaction', scheduleUrl);
|
|
}
|
|
}
|
|
|
|
const form = this.element.querySelector('form');
|
|
|
|
if (form) {
|
|
form.action = this.sendUrlValue;
|
|
}
|
|
}
|
|
|
|
/** Writing on screen that the server has not been told about yet. */
|
|
_hasUnsavedWriting() {
|
|
return true === this._unsaved && this._contentLength() > 0;
|
|
}
|
|
|
|
/** The status line next to the window controls. */
|
|
_setStatus(text, className) {
|
|
if (false === this.hasSaveStatusTarget) {
|
|
return;
|
|
}
|
|
|
|
const status = this.saveStatusTarget;
|
|
|
|
status.textContent = text;
|
|
status.classList.remove('text-ink-faint', 'text-danger', 'text-success');
|
|
status.classList.add(className);
|
|
}
|
|
|
|
/**
|
|
* Typed body length, ignoring the quoted original — replying to a mail
|
|
* always starts with a screenful of quote that isn't the user's writing.
|
|
*/
|
|
_worthSaving() {
|
|
if (false === this.hasBodyTarget) {
|
|
return true;
|
|
}
|
|
|
|
return this._contentLength() >= this.minCharsValue;
|
|
}
|
|
|
|
/**
|
|
* Everything the user has written that a draft would keep — the subject as
|
|
* well as the body.
|
|
*
|
|
* The threshold used to look at the body alone, so a subject typed on its
|
|
* own was never worth saving no matter how long it got. A tester typed
|
|
* "TEST-RELOAD halbfertig" into Subject, waited past the autosave delay,
|
|
* reloaded, and it was gone: not in the composer, not in Drafts, and no
|
|
* warning on the way out.
|
|
*
|
|
* Counting the subject does not weaken what the threshold is FOR. Its job
|
|
* is to stop a composer that was merely opened from littering the Drafts
|
|
* list — and a composer that was merely opened has an empty subject. The
|
|
* only drafts this newly saves are ones with something deliberately typed
|
|
* in them.
|
|
*/
|
|
_contentLength() {
|
|
const subject = this.hasSubjectTarget ? this.subjectTarget.value.trim().length : 0;
|
|
|
|
return subject + this._typedLength();
|
|
}
|
|
|
|
/** Characters the user has actually written, quote excluded. */
|
|
_typedLength() {
|
|
// Plain-text mode: the textarea IS the body, and none of the markup
|
|
// this method strips below can exist in it.
|
|
if (true === this._isPlainText()) {
|
|
return this.plainBodyTarget.value.trim().length;
|
|
}
|
|
|
|
if (false === this.hasBodyTarget) {
|
|
return 0;
|
|
}
|
|
|
|
const clone = this.bodyTarget.cloneNode(true);
|
|
|
|
// The last two selectors cover drafts written before buildQuotedHtml
|
|
// marked the quote with data-quoted.
|
|
clone.querySelectorAll(
|
|
'[data-quote-wrapped], [data-quoted], blockquote, div[style*="border-top"], div[style*="font-size:0.85em"]',
|
|
).forEach((node) => node.remove());
|
|
|
|
// The signature is dropped for the same reason the quote is: it is not
|
|
// the user's writing. A new window opens with it already in the body,
|
|
// and counting it would put every fresh draft over minChars — so
|
|
// opening the composer and closing it again would leave an autosaved
|
|
// draft containing nothing but a sign-off.
|
|
//
|
|
// ONLY WHILE IT IS UNTOUCHED, though, and that qualifier is the fix.
|
|
// Text typed into the signature block still counts, because it is still
|
|
// text the user wrote. Dropping the block unconditionally is what made
|
|
// Send insist "this message has no text" about a message the user could
|
|
// read on screen — the caret had landed in the signature (see
|
|
// claimWritingSpace) and every word they typed was being subtracted
|
|
// again before the count.
|
|
clone.querySelectorAll('[data-pl-signature]').forEach((node) => {
|
|
if (node.textContent.trim() === this._pristineSignatureText()) {
|
|
node.remove();
|
|
}
|
|
});
|
|
|
|
return clone.textContent.trim().length;
|
|
}
|
|
|
|
/**
|
|
* The current identity's signature as plain text — what an UNEDITED
|
|
* signature block in the body reads as.
|
|
*
|
|
* Text rather than HTML because the comparison has to survive the round
|
|
* trip through contenteditable, which normalises markup freely (attribute
|
|
* order, `<br>` versus `<br/>`, whitespace between blocks) without anyone
|
|
* having typed a thing. The words are what "untouched" means here.
|
|
*/
|
|
_pristineSignatureText() {
|
|
const html = this._currentSignature();
|
|
|
|
if ('' === html) {
|
|
return '';
|
|
}
|
|
|
|
const probe = document.createElement('div');
|
|
probe.innerHTML = html;
|
|
|
|
return probe.textContent.trim();
|
|
}
|
|
|
|
/**
|
|
* Below the threshold the status line counts down instead of going quiet,
|
|
* so it is obvious the draft is not being kept yet.
|
|
*/
|
|
_reportPending() {
|
|
if (false === this.hasSaveStatusTarget) {
|
|
return;
|
|
}
|
|
|
|
const missing = this.minCharsValue - this._contentLength();
|
|
const status = this.saveStatusTarget;
|
|
|
|
status.classList.remove('text-danger', 'text-success');
|
|
status.classList.add('text-ink-faint');
|
|
status.textContent = missing <= 0
|
|
? ''
|
|
: this._pendingText(missing);
|
|
}
|
|
|
|
/**
|
|
* The countdown line, in the right plural form.
|
|
*
|
|
* Two keys rather than one plural string: the catalogue's pluralisation is
|
|
* Symfony's, evaluated server-side, and there is no count to evaluate it
|
|
* against until the user is typing.
|
|
*/
|
|
_pendingText(missing) {
|
|
return 1 === missing
|
|
? this._t('pendingOne', 'one more character to save')
|
|
: this._t('pendingMany', '%d more characters to save').replace('%d', String(missing));
|
|
}
|
|
|
|
/** A translated string, falling back to English if the value is absent. */
|
|
_t(key, fallback) {
|
|
return this.i18nValue?.[key] ?? fallback;
|
|
}
|
|
|
|
/** Show a refusal next to the address rows. */
|
|
_reportError(text) {
|
|
if (false === this.hasErrorsTarget) {
|
|
this._setStatus(text, 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
this.errorsTarget.classList.remove('hidden');
|
|
this.errorsTarget.querySelector('p').textContent = text;
|
|
this._errorsId();
|
|
this._markInvalid(true);
|
|
}
|
|
|
|
_clearError() {
|
|
if (true === this.hasErrorsTarget) {
|
|
this.errorsTarget.classList.add('hidden');
|
|
this.errorsTarget.querySelector('p').textContent = '';
|
|
}
|
|
|
|
this._markInvalid(false);
|
|
}
|
|
|
|
/** Move the caret into the body — where Enter on a header row leads. */
|
|
_focusBody() {
|
|
if (true === this.hasBodyTarget) {
|
|
this.bodyTarget.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The committed recipients across To, Cc and Bcc.
|
|
*
|
|
* Counted per address row rather than by sweeping the window for
|
|
* `.ts-control .item`: the typeface and size pickers are Tom Selects too
|
|
* now, and each renders its *selected option* as an `.item`. Swept, an
|
|
* empty To read as two recipients and the send guard waved it through.
|
|
*/
|
|
_recipientCount() {
|
|
return this._addressRows()
|
|
.reduce((total, row) => total + row.querySelectorAll('.ts-control .item').length, 0);
|
|
}
|
|
|
|
/**
|
|
* Does this window arrive with a recipient already on it?
|
|
*
|
|
* Read off the <select> rather than off the chips, and that is timing
|
|
* rather than taste: this is asked from connect(), and the chips are
|
|
* Tom Select's — they do not exist until the autocomplete bundle has built
|
|
* the widget, which connect() can beat. The selected options are in the
|
|
* server's markup from the first byte.
|
|
*/
|
|
_alreadyAddressed() {
|
|
if (false === this.hasToFieldTarget) {
|
|
return false;
|
|
}
|
|
|
|
return null !== this.toFieldTarget.querySelector('select option:checked');
|
|
}
|
|
|
|
/** Ask the form to submit, going through every guard in _handleSubmit. */
|
|
_requestSend() {
|
|
this.element.querySelector('form')?.requestSubmit();
|
|
}
|
|
|
|
/**
|
|
* The last gate before a message leaves.
|
|
*
|
|
* Everything here is a question the user would rather be asked than have
|
|
* answered for them — and the first one is not a question at all, because
|
|
* a send with no recipient cannot be what anyone meant. The server refuses
|
|
* the same three things (ComposeType's `send` group, and
|
|
* ComposeController::send); this is only the half that can still be
|
|
* friendly about it.
|
|
*/
|
|
_guardSend(submitter) {
|
|
// What is half-typed in an address field is an address the user
|
|
// believes they have entered. Committing it here is what stops Send
|
|
// from reporting "no recipient" about a field with an address visibly
|
|
// in it — and, when the address is malformed, what makes the refusal
|
|
// say so instead of blaming the count.
|
|
if (false === this._commitPendingAddresses()) {
|
|
return false;
|
|
}
|
|
|
|
if (0 === this._recipientCount()) {
|
|
this._reportError(this._t('recipientRequired', 'Add at least one recipient before sending.'));
|
|
|
|
return false;
|
|
}
|
|
|
|
this._clearError();
|
|
|
|
// Answered already, on the panel below. One question, one answer.
|
|
if (true === this._sendConfirmed) {
|
|
this._sendConfirmed = false;
|
|
|
|
return true;
|
|
}
|
|
|
|
const missing = [];
|
|
|
|
if ('' === (this.hasSubjectTarget ? this.subjectTarget.value.trim() : 'x')) {
|
|
missing.push(this._t('confirmNoSubject', 'This message has no subject.'));
|
|
}
|
|
|
|
// On a forward the quoted original IS the message - asking "send an
|
|
// empty mail?" about it told the user their content did not count.
|
|
if (0 === this._typedLength() && false === this._forwardCarryingQuote()) {
|
|
missing.push(this._t('confirmNoBody', 'This message has no text.'));
|
|
}
|
|
|
|
// One last chance to fill them — a recipient committed by
|
|
// _commitPendingAddresses() a moment ago is one the item_add hook has
|
|
// already seen, but a draft reopened with its recipient in place has
|
|
// had no event at all.
|
|
this._fillTemplateVariables();
|
|
|
|
if (true === this._hasOpenTemplateVariables()) {
|
|
missing.push(this._t('confirmOpenVariables', 'A template placeholder has not been filled in.'));
|
|
}
|
|
|
|
if (0 === missing.length) {
|
|
return true;
|
|
}
|
|
|
|
this._openSendWarning(missing, submitter);
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Turn anything typed-but-uncommitted in To/Cc/Bcc into a chip.
|
|
*
|
|
* @returns {boolean} false when something typed is not an address, having
|
|
* said so — and having LEFT IT IN THE FIELD. Tom Select
|
|
* drops an uncommittable string on blur, so pressing Send
|
|
* with a malformed address in the box used to throw the
|
|
* text away and then complain there were no recipients:
|
|
* two wrongs, and the user could no longer see what they
|
|
* had typed to fix it.
|
|
*/
|
|
_commitPendingAddresses() {
|
|
for (const row of this._addressRows()) {
|
|
const input = row.querySelector('.ts-control input');
|
|
const select = this._tomSelectFor(row.querySelector('.ts-wrapper'));
|
|
|
|
// The box first, then what was last typed into it — see the `type`
|
|
// handler in _tameAddressField for why the box is usually empty by
|
|
// the time we get here.
|
|
const typed = (input?.value.trim() || select?.plmailTyped?.trim()) ?? '';
|
|
|
|
if ('' === typed) {
|
|
continue;
|
|
}
|
|
|
|
if (false === this.constructor.ADDRESS.test(typed)) {
|
|
this._reportError(
|
|
this._t('invalidAddress', '"%s" is not a valid email address').replace('%s', typed),
|
|
);
|
|
|
|
// Put it back where the user left it. A refusal that also
|
|
// deletes the thing being refused leaves nothing to correct.
|
|
select?.setTextboxValue(typed);
|
|
input?.focus();
|
|
|
|
return false;
|
|
}
|
|
|
|
if (null !== select) {
|
|
select.createItem(typed, false);
|
|
select.setTextboxValue('');
|
|
select.plmailTyped = '';
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** The in-app replacement for the two `window.confirm()` calls. */
|
|
_openSendWarning(reasons, submitter) {
|
|
if (false === this.hasSendWarningTarget) {
|
|
return;
|
|
}
|
|
|
|
// Held so the retry submits through the SAME button. The schedule
|
|
// buttons carry `formaction`, and re-submitting without the submitter
|
|
// would quietly turn "send on Monday" into "send now".
|
|
this._pendingSubmitter = submitter ?? null;
|
|
|
|
this.sendWarningBodyTarget.textContent =
|
|
`${reasons.join(' ')} ${this._t('confirmSendAnyway', 'Send it anyway?')}`;
|
|
this.sendWarningTarget.hidden = false;
|
|
|
|
this._boundSendWarningEscape ??= (event) => {
|
|
if ('Escape' === event.key) {
|
|
event.stopPropagation();
|
|
this.cancelSendAnyway();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', this._boundSendWarningEscape);
|
|
|
|
if (this.hasSendWarningConfirmTarget) {
|
|
this.sendWarningConfirmTarget.focus();
|
|
}
|
|
}
|
|
|
|
_closeSendWarning() {
|
|
if (true === this.hasSendWarningTarget) {
|
|
this.sendWarningTarget.hidden = true;
|
|
}
|
|
|
|
document.removeEventListener('keydown', this._boundSendWarningEscape);
|
|
}
|
|
|
|
/** "Send anyway" — go back through the same submit, past the question. */
|
|
confirmSendAnyway() {
|
|
const submitter = this._pendingSubmitter;
|
|
|
|
this._closeSendWarning();
|
|
this._sendConfirmed = true;
|
|
this._pendingSubmitter = null;
|
|
|
|
const form = this.element.querySelector('form');
|
|
|
|
if (null !== form) {
|
|
form.requestSubmit(submitter ?? undefined);
|
|
}
|
|
}
|
|
|
|
/** "Cancel" or Escape. Nothing is sent and nothing is lost. */
|
|
cancelSendAnyway() {
|
|
this._closeSendWarning();
|
|
this._sendConfirmed = false;
|
|
this._pendingSubmitter = null;
|
|
}
|
|
|
|
/**
|
|
* A send has left the window. Everything from here to `_settleSend()` is
|
|
* one state, and the pill is the only thing that shows it.
|
|
*
|
|
* The old shape closed the window the moment the server answered and put
|
|
* the way back somewhere else — a toast in the dock, a countdown bar in
|
|
* the thread — which is how the app came to have two cancel surfaces that
|
|
* disagreed, plus a hidden button beside the pill that made a third. The
|
|
* window now stays exactly where it is, with the pill reading "Sending…
|
|
* click to cancel", until one of two things happens: the user clicks it,
|
|
* or the cancel window runs out and `_settleSend()` asks the server to
|
|
* tidy up. See _send_pill.html.twig for why that costs no layout shift.
|
|
*/
|
|
_handleSubmit(event) {
|
|
if (true === this._submitting) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (false === this._guardSend(event.submitter)) {
|
|
event.preventDefault();
|
|
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this.#autosaveTimer);
|
|
this._submitting = true;
|
|
|
|
// The form is on its way to the server with everything in it. Whatever
|
|
// happens next, nothing is being abandoned.
|
|
this._unsaved = false;
|
|
|
|
// A scheduled send comes through this same handler — the menu's items
|
|
// are real submit buttons with a `formaction` — and it has no cancel
|
|
// window to offer: the hold itself is the affordance, called off from
|
|
// the menu or the row for as long as it lasts. So it gets the sending
|
|
// face without the invitation to click it.
|
|
const scheduling = true === event.submitter?.hasAttribute('formaction');
|
|
|
|
this._beginSending(false === scheduling);
|
|
}
|
|
|
|
/**
|
|
* Put the pill into its sending face and start the dots.
|
|
*
|
|
* TWO pills in the DOM, one on screen: the partial is rendered twice —
|
|
* `md:hidden` in the header for phones, `hidden md:flex` in the action bar
|
|
* for the desktop — so every one of these is a target LIST. The copy the
|
|
* user cannot see has to be in the same state as the one they can, or a
|
|
* resize mid-send would reveal a button still saying "Send".
|
|
*
|
|
* The fallback covers the settings signature editor, which renders this
|
|
* toolbar with no pill at all: there is nothing there to animate and
|
|
* nothing to cancel, so its submit buttons are simply disabled the way
|
|
* they always were.
|
|
*/
|
|
_beginSending(cancellable) {
|
|
this._sending = true;
|
|
this._cancellable = cancellable;
|
|
this._cancelRequested = false;
|
|
this._undoUrl = null;
|
|
this._settleUrl = null;
|
|
|
|
if (false === this.hasSendProgressTarget) {
|
|
this._plainSubmitButtons().forEach((button) => { button.disabled = true; });
|
|
}
|
|
|
|
this.sendLabelTargets.forEach((span) => { span.classList.add('invisible'); });
|
|
this.sendProgressTargets.forEach((span) => { span.classList.remove('invisible'); });
|
|
this.sendCancelHintTargets.forEach((span) => {
|
|
span.classList.toggle('invisible', false === cancellable);
|
|
});
|
|
|
|
this.sendBtnTargets.forEach((button) => {
|
|
button.setAttribute('aria-busy', 'true');
|
|
// Not disabled: while a send is in flight this button IS the
|
|
// cancel, and a disabled button takes no clicks. Re-submission is
|
|
// stopped by `_submitting` in the handler above and by
|
|
// sendOrCancel() swallowing the click, not by the attribute.
|
|
button.disabled = false === cancellable;
|
|
});
|
|
|
|
this._dotTick = 0;
|
|
this._paintDots();
|
|
clearInterval(this._sendingTicker);
|
|
this._sendingTicker = setInterval(() => this._paintDots(), 400);
|
|
|
|
// The message is gone; what is on screen is a copy of it. Editing it
|
|
// would be writing into something already sent, and the next autosave
|
|
// would file those edits against the sent row — so the body goes out of
|
|
// reach, keyboard included, for as long as the send is out.
|
|
this._freeze();
|
|
|
|
// If the answer never comes — a dropped connection, a 500 that Turbo
|
|
// renders somewhere else — the window must not sit there animating for
|
|
// ever. The hold, when it arrives, clears this.
|
|
clearTimeout(this._sendFallback);
|
|
this._sendFallback = setTimeout(() => {
|
|
this._submitting = false;
|
|
this._settleSendUi();
|
|
}, 20_000);
|
|
}
|
|
|
|
/** One, two, three dots, over again — by opacity, never by width. */
|
|
_paintDots() {
|
|
const lit = 1 + (this._dotTick++ % 3);
|
|
|
|
for (const group of this.sendDotsTargets) {
|
|
Array.from(group.children).forEach((dot, index) => {
|
|
dot.style.opacity = index < lit ? '1' : '0';
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Submit buttons in a window that has no send pill (the signature editor). */
|
|
_plainSubmitButtons() {
|
|
return this.hasSendBtnTarget
|
|
? this.sendBtnTargets
|
|
: Array.from(this.element.querySelectorAll('[type="submit"]'));
|
|
}
|
|
|
|
/**
|
|
* The send pill was clicked. Which of its two jobs that is depends on
|
|
* whether a send is already out.
|
|
*
|
|
* At rest this does nothing at all and the button submits the form the way
|
|
* a submit button does. In flight it is the cancel, and the click must not
|
|
* also reach the form — hence preventDefault on the click itself rather
|
|
* than a guard in the submit handler, which would fire after the browser
|
|
* had already begun the submission.
|
|
*/
|
|
sendOrCancel(event) {
|
|
if (true !== this._sending) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (false === this._cancellable || true === this._cancelRequested) {
|
|
return;
|
|
}
|
|
|
|
this._cancelRequested = true;
|
|
|
|
// Remembered OUTSIDE this controller as well, because this controller
|
|
// may not be here when the answer arrives. The undo URL does not exist
|
|
// yet (see below), so the only way to honour this press is to hand it
|
|
// to whatever turns up holding that URL — and if the reader closes the
|
|
// window in the same gap, the thing that turns up finds no composer at
|
|
// all. See assets/compose/pending_cancel.js.
|
|
markPendingCancel(this.#frameId);
|
|
|
|
// Pressed has to look pressed. The cancel may not be sendable yet —
|
|
// the undo URL only exists once the server has answered with the
|
|
// message's id — so this is often a beat ahead of the request, and a
|
|
// button that showed nothing would read as a click that fell on the
|
|
// floor. Deliberately no text change: the label is what the button is
|
|
// sized to, and this round is about a button that never resizes.
|
|
this.sendBtnTargets.forEach((button) => {
|
|
button.classList.add('opacity-60', 'pointer-events-none');
|
|
});
|
|
|
|
if (null !== this._undoUrl) {
|
|
this._cancelSend();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The server has answered the send: here is how to call it off, here is
|
|
* how to tidy up, and here is how long the offer stands.
|
|
*
|
|
* Called by compose--send-hold, which rides in on the send response — see
|
|
* compose/_sending.stream.html.twig. A cancel clicked BEFORE this arrives
|
|
* is not lost: `_cancelRequested` is standing, and it is honoured the
|
|
* moment there is a URL to send it to.
|
|
*/
|
|
armSendHold({ undoUrl, settleUrl, delay }) {
|
|
// This composer is alive and owns the request from here, so the copy
|
|
// kept for the case where it is not must go — a cancel honoured twice
|
|
// would land the second time on whatever this frame sent next.
|
|
forgetPendingCancel(this.#frameId);
|
|
|
|
clearTimeout(this._sendFallback);
|
|
|
|
this._undoUrl = undoUrl;
|
|
this._settleUrl = settleUrl;
|
|
|
|
if (true === this._cancelRequested) {
|
|
this._cancelSend();
|
|
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this._sendHoldTimer);
|
|
this._sendHoldTimer = setTimeout(() => this._settleSend(), delay);
|
|
}
|
|
|
|
/**
|
|
* Call the send off.
|
|
*
|
|
* Honest about the race it can lose: the window closes a couple of seconds
|
|
* before the worker picks the envelope up (CANCEL_WINDOW_MS against
|
|
* SEND_DELAY_MS), but a slow request inside those seconds can still arrive
|
|
* late — and the server says so rather than confirming a cancel it did not
|
|
* make. Either answer is a stream: the draft reopened where it was, or an
|
|
* error toast and the window settled as sent.
|
|
*/
|
|
async _cancelSend() {
|
|
clearTimeout(this._sendHoldTimer);
|
|
|
|
let response;
|
|
|
|
try {
|
|
response = await fetch(this._undoUrl, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken() },
|
|
});
|
|
} catch (error) {
|
|
requestFailed(null);
|
|
this._cancelFailed();
|
|
|
|
return;
|
|
}
|
|
|
|
// The pill going back to "sending" is not enough on its own to say
|
|
// the cancel was lost; the toast is.
|
|
if (requestFailed(response)) {
|
|
this._cancelFailed();
|
|
|
|
return;
|
|
}
|
|
|
|
// The answer to a cancel is a compose window, and by the time it gets
|
|
// here the reader may have put that window away themselves — the held
|
|
// shape leaves it on screen for the whole cancel window, so "Save
|
|
// draft and close" is one click away for every second this request is
|
|
// out. Rendering it then reopens, unasked, a composer that was
|
|
// deliberately shut a beat earlier. Nothing is lost by dropping it:
|
|
// the cancel has already happened server-side, and the message is a
|
|
// draft again — which is exactly what close() was asking for.
|
|
const html = await response.text();
|
|
|
|
Turbo.renderStreamMessage(
|
|
this._stillOnScreen() ? html : this._withoutReopen(html),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Is the window this cancel was clicked in still the window on screen?
|
|
*
|
|
* `isConnected` rather than a flag set in close(): close() empties the
|
|
* frame, discard() takes the row away with it, pop-out moves the editor to
|
|
* the dock and a fresh Compose navigates the frame — four gestures, all of
|
|
* which take this element out of the document, and all of which mean the
|
|
* same thing here. A flag would have covered whichever one it was written
|
|
* for.
|
|
*/
|
|
_stillOnScreen() {
|
|
return true === this.element.isConnected;
|
|
}
|
|
|
|
/**
|
|
* The same stream message with the part that would put this window back
|
|
* removed from it.
|
|
*
|
|
* Only the streams addressed at this window's own frame go. An inline
|
|
* cancel also takes the sent message out of the conversation and redraws
|
|
* the reply zone (compose/_inline_undo.stream.html.twig): that tidying is
|
|
* about the thread, which is still on screen and still wrong until it
|
|
* runs. Dropping the whole message would leave a sent mail sitting in a
|
|
* conversation it was called back out of.
|
|
*
|
|
* Parsed rather than matched with a regex — the target is an attribute in
|
|
* HTML the server wrote, and a pattern over it would be one quoting change
|
|
* away from silently dropping nothing at all.
|
|
*/
|
|
_withoutReopen(html) {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
|
|
for (const stream of doc.querySelectorAll('turbo-stream')) {
|
|
if (stream.getAttribute('target') === this.#frameId) {
|
|
stream.remove();
|
|
}
|
|
}
|
|
|
|
return doc.body.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* The cancel never reached the server. The send is still going, so the
|
|
* window goes back to sending rather than pretending to be an editor
|
|
* again — and the hold is restarted so the message still gets tidied out
|
|
* of the window when its time is up.
|
|
*/
|
|
_cancelFailed() {
|
|
this._cancelRequested = false;
|
|
|
|
this.sendBtnTargets.forEach((button) => {
|
|
button.classList.remove('opacity-60', 'pointer-events-none');
|
|
});
|
|
|
|
this._sendHoldTimer = setTimeout(() => this._settleSend(), 2_000);
|
|
}
|
|
|
|
/**
|
|
* The cancel window is over. Ask the server to put the sent message where
|
|
* it belongs and take this window away.
|
|
*
|
|
* A POST rather than a GET: it changes nothing on the server, but it is a
|
|
* one-shot instruction rather than a resource, and nothing should ever
|
|
* replay it out of a cache or a prefetch.
|
|
*/
|
|
async _settleSend() {
|
|
if (null === this._settleUrl || undefined === this._settleUrl) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this._settleUrl, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
|
|
if (true === response.ok) {
|
|
Turbo.renderStreamMessage(await response.text());
|
|
|
|
return;
|
|
}
|
|
|
|
console.error('[compose] settle failed', response.status);
|
|
} catch (error) {
|
|
console.error('[compose] settle failed', error);
|
|
}
|
|
|
|
// The mail has gone either way; only the tidying failed. Give the
|
|
// window back rather than leaving a frozen editor on screen.
|
|
this._submitting = false;
|
|
this._settleSendUi();
|
|
}
|
|
|
|
/** Put the pill back to rest: faces, dots, and the body's editability. */
|
|
_settleSendUi() {
|
|
clearInterval(this._sendingTicker);
|
|
clearTimeout(this._sendHoldTimer);
|
|
clearTimeout(this._sendFallback);
|
|
|
|
this._sendingTicker = null;
|
|
this._sending = false;
|
|
this._cancelRequested = false;
|
|
|
|
this._plainSubmitButtons().forEach((button) => { button.disabled = false; });
|
|
|
|
this.sendLabelTargets.forEach((span) => { span.classList.remove('invisible'); });
|
|
this.sendProgressTargets.forEach((span) => { span.classList.add('invisible'); });
|
|
this.sendCancelHintTargets.forEach((span) => { span.classList.remove('invisible'); });
|
|
|
|
this.sendBtnTargets.forEach((button) => {
|
|
button.removeAttribute('aria-busy');
|
|
button.classList.remove('opacity-60', 'pointer-events-none');
|
|
});
|
|
|
|
this._thaw();
|
|
}
|
|
|
|
/**
|
|
* Take the window's body out of reach while a send is in flight — every
|
|
* part of it EXCEPT the send pill, which is the one live control left.
|
|
*
|
|
* `inert` on the whole collapsible region was the obvious move and the
|
|
* wrong one: on the desktop the pill lives IN that region, first in the
|
|
* action bar, so inerting its ancestor made the cancel unclickable —
|
|
* pointer events land on the form behind it and nothing happens. Inert
|
|
* cannot be lifted again on a descendant, so the exception has to be made
|
|
* on the way down: anything containing the send button is descended into
|
|
* rather than frozen, and only the button itself is left alone.
|
|
*
|
|
* The BUTTON, not the pill it sits in. The chevron beside it and the whole
|
|
* send-later menu behind that are frozen with everything else, because
|
|
* scheduling a message that has already gone out would post the draft a
|
|
* second time — a hold set on a sent mail is not a feature, it is a
|
|
* duplicate.
|
|
*
|
|
* The elements are remembered rather than re-derived, because what has to
|
|
* be thawed is exactly what was frozen — a second walk after the DOM has
|
|
* moved on would miss some and un-freeze things that were never ours. The
|
|
* dimming is remembered separately for the same reason: some of these
|
|
* elements are already greyed for their own reasons (the rich-only
|
|
* affordances in plain-text mode), and un-dimming those on the way out
|
|
* would leave the window looking wrong about something else entirely.
|
|
*/
|
|
_freeze() {
|
|
this._frozen = [];
|
|
this._dimmed = [];
|
|
|
|
if (false === this.hasCollapsibleTarget) {
|
|
return;
|
|
}
|
|
|
|
const live = '[data-compose--compose-target="sendBtn"]';
|
|
|
|
const walk = (root) => {
|
|
for (const child of root.children) {
|
|
if (true === child.matches(live)) {
|
|
continue;
|
|
}
|
|
|
|
if (null !== child.querySelector(live)) {
|
|
walk(child);
|
|
|
|
continue;
|
|
}
|
|
|
|
child.inert = true;
|
|
this._frozen.push(child);
|
|
|
|
if (false === child.classList.contains('opacity-60')) {
|
|
child.classList.add('opacity-60', 'transition-opacity');
|
|
this._dimmed.push(child);
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(this.collapsibleTarget);
|
|
}
|
|
|
|
/** Give the body back. */
|
|
_thaw() {
|
|
for (const element of this._frozen ?? []) {
|
|
element.inert = false;
|
|
}
|
|
|
|
for (const element of this._dimmed ?? []) {
|
|
element.classList.remove('opacity-60');
|
|
}
|
|
|
|
this._frozen = [];
|
|
this._dimmed = [];
|
|
}
|
|
|
|
/** Whether this window is a forward whose body still carries the quote. */
|
|
_forwardCarryingQuote() {
|
|
if ('forward' !== this.modeValue || false === this.hasBodyTarget) {
|
|
return false;
|
|
}
|
|
|
|
return null !== this.bodyTarget.querySelector(
|
|
'[data-quote-wrapped], [data-quoted], blockquote, div[style*="border-top"]',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A blinking caret in a typable To — a forward's first missing piece.
|
|
*
|
|
* One obstacle is left between connect() and that caret. Tom Select owns
|
|
* the row only once its enhancement has run, and connect() can beat it —
|
|
* so the focus retries briefly for the enhanced input and falls back to
|
|
* the raw widget rather than give up silently.
|
|
*
|
|
* The other used to be the fold: the inline window kept every address row
|
|
* behind a "to …" summary, and a hidden field cannot take focus, so this
|
|
* had to unfold the lot on the way past. The To row is in the header now
|
|
* and needs no unfolding — the guard below is what is left of that, and it
|
|
* is still needed for a dock window, where the block is the only place the
|
|
* row lives.
|
|
*/
|
|
_focusRecipients() {
|
|
if (false === this.hasToFieldTarget) {
|
|
return;
|
|
}
|
|
|
|
// Only when the field is actually behind the fold. It no longer is in
|
|
// an inline window — the To row moved into the header precisely so a
|
|
// forward opens with a typable recipient and nothing else unfolded —
|
|
// and unfolding From, Cc, Bcc and Subject on the way to it would undo
|
|
// the reduced header on every forward.
|
|
if (true === this.hasFieldsTarget && this.fieldsTarget.contains(this.toFieldTarget)) {
|
|
this._revealFields();
|
|
}
|
|
|
|
const deadline = Date.now() + 1500;
|
|
|
|
const attempt = () => {
|
|
const input = this.toFieldTarget.querySelector('.ts-control input');
|
|
|
|
if (null !== input) {
|
|
input.focus();
|
|
|
|
return;
|
|
}
|
|
|
|
if (Date.now() > deadline) {
|
|
this.toFieldTarget.querySelector('select, input')?.focus();
|
|
|
|
return;
|
|
}
|
|
|
|
setTimeout(attempt, 80);
|
|
};
|
|
|
|
attempt();
|
|
}
|
|
|
|
// ── Attachments ───────────────────────────────────────────────────
|
|
|
|
openFilePicker() {
|
|
if (true === this.hasFileInputTarget) {
|
|
this.fileInputTarget.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a connected service's file picker.
|
|
*
|
|
* The draft is force-saved first for the same reason uploadFiles() does
|
|
* it: attachments are rows hanging off a Message, so one has to exist
|
|
* before the picker can add to it. Saving here rather than inside the
|
|
* picker keeps the id in the URL correct from the moment the modal opens.
|
|
*/
|
|
async openIntegrationPicker(event) {
|
|
const button = event.currentTarget;
|
|
const integrationId = button.dataset.integrationId;
|
|
|
|
if (undefined === integrationId) {
|
|
return;
|
|
}
|
|
|
|
await this.saveDraft(null, { force: true, allowEmpty: true });
|
|
|
|
const id = this._messageId();
|
|
|
|
if (null === id) {
|
|
this._setStatus(this._t('attachFailed', 'Could not attach'), 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
// The modal controller takes its URL from the trigger's own values, and
|
|
// the draft id is only known once the save above has run — so the
|
|
// button carries data-controller="ui--modal" and gets pointed at the right
|
|
// URL here, immediately before being opened.
|
|
button.setAttribute('data-ui--modal-src-value', `/integrations/${integrationId}/browse?draft=${id}`);
|
|
|
|
this.application
|
|
.getControllerForElementAndIdentifier(button, 'ui--modal')
|
|
?.open();
|
|
}
|
|
|
|
/**
|
|
* Result of an integration pick: the re-rendered attachment strip, plus
|
|
* any share links to drop into the body.
|
|
*
|
|
* Every open compose window hears this, so windows that are not the one
|
|
* that opened the picker have to ignore it — the picker names the draft it
|
|
* acted on and that is the only window allowed to act.
|
|
*/
|
|
_handleIntegrationAttached(event) {
|
|
const { attachmentsHtml, links, draftId } = event.detail ?? {};
|
|
|
|
if (undefined !== draftId && String(draftId) !== String(this._messageId())) {
|
|
return;
|
|
}
|
|
|
|
if (undefined !== attachmentsHtml && true === this.hasAttachmentsTarget) {
|
|
this.attachmentsTarget.innerHTML = attachmentsHtml;
|
|
}
|
|
|
|
if (0 < (links ?? []).length) {
|
|
this._insertLinks(links);
|
|
}
|
|
|
|
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
|
|
|
|
// The links went into the body directly, so the form has not fired an
|
|
// input event — without this the draft would keep the pre-link body
|
|
// until the next keystroke.
|
|
this.saveDraft(null, { force: true, allowEmpty: true });
|
|
}
|
|
|
|
/** Append share links as anchors at the top of the user's own writing. */
|
|
_insertLinks(links) {
|
|
if (false === this.hasBodyTarget) {
|
|
return;
|
|
}
|
|
|
|
const block = document.createElement('div');
|
|
|
|
links.forEach(({ name, url }) => {
|
|
const line = document.createElement('div');
|
|
const anchor = document.createElement('a');
|
|
|
|
anchor.href = url;
|
|
anchor.textContent = name;
|
|
anchor.rel = 'noopener noreferrer';
|
|
|
|
line.append(anchor);
|
|
block.append(line);
|
|
});
|
|
|
|
// Before any quoted original, so the links sit with the reply rather
|
|
// than being buried under the thread history.
|
|
this.bodyTarget.prepend(block);
|
|
}
|
|
|
|
/**
|
|
* Attachments are rows hanging off the draft, so an unsaved window has to
|
|
* become one first — hence the forced save before the upload.
|
|
*/
|
|
async uploadFiles(event) {
|
|
const input = event.currentTarget;
|
|
const files = Array.from(input.files ?? []);
|
|
|
|
if (0 === files.length) {
|
|
return;
|
|
}
|
|
|
|
// Clearing it early keeps the picked files out of every later autosave
|
|
// (the input sits inside the form) and lets the same file be picked
|
|
// again after a removal.
|
|
input.value = '';
|
|
|
|
const tooBig = files.find((file) => file.size > this.maxAttachmentBytesValue);
|
|
|
|
if (undefined !== tooBig) {
|
|
const limit = Math.round(this.maxAttachmentBytesValue / (1024 * 1024));
|
|
this._setStatus(
|
|
this._t('tooLarge', '%s is over %d MB')
|
|
.replace('%s', tooBig.name)
|
|
.replace('%d', String(limit)),
|
|
'text-danger',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
await this.saveDraft(null, { force: true, allowEmpty: true });
|
|
|
|
const id = this._messageId();
|
|
|
|
if (null === id) {
|
|
this._setStatus(this._t('attachFailed', 'Could not attach'), 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
const body = new FormData();
|
|
files.forEach((file) => body.append('files[]', file));
|
|
|
|
this._setStatus(this._t('uploading', 'Uploading…'), 'text-ink-faint');
|
|
|
|
await this._renderAttachments(
|
|
fetch(`/compose/attachments/${id}`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken() },
|
|
}),
|
|
this._t('attachFailed', 'Could not attach'),
|
|
);
|
|
}
|
|
|
|
async removeAttachment(event) {
|
|
const partId = event.currentTarget.dataset.partId;
|
|
|
|
if (undefined === partId) {
|
|
return;
|
|
}
|
|
|
|
await this._renderAttachments(
|
|
fetch(`/compose/attachment/${partId}/remove`, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken() },
|
|
}),
|
|
this._t('attachRemoveFailed', 'Could not remove attachment'),
|
|
);
|
|
}
|
|
|
|
/** Swap in the server-rendered attachment strip, or report why not. */
|
|
async _renderAttachments(request, failureMessage) {
|
|
let response;
|
|
|
|
try {
|
|
response = await request;
|
|
} catch (_) {
|
|
this._setStatus(failureMessage, 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
if (false === response.ok) {
|
|
this._setStatus(await this._errorText(response, failureMessage), 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
if (true === this.hasAttachmentsTarget) {
|
|
this.attachmentsTarget.innerHTML = await response.text();
|
|
}
|
|
|
|
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
|
|
}
|
|
|
|
/**
|
|
* The server states why an upload was refused in plain text. Anything else
|
|
* — an HTML error page, a proxy cutting the request off — falls back to the
|
|
* generic message rather than dumping markup into the status line.
|
|
*/
|
|
async _errorText(response, fallback) {
|
|
if (false === (response.headers.get('Content-Type') ?? '').startsWith('text/plain')) {
|
|
return fallback;
|
|
}
|
|
|
|
const detail = (await response.text()).trim();
|
|
|
|
return '' === detail ? fallback : detail;
|
|
}
|
|
|
|
// ── Inline images ─────────────────────────────────────────────────
|
|
|
|
openImagePicker() {
|
|
if (true === this.hasImageInputTarget) {
|
|
this.imageInputTarget.click();
|
|
}
|
|
}
|
|
|
|
/** The hidden image input's own change event. */
|
|
async uploadInlineImage(event) {
|
|
const input = event.currentTarget;
|
|
const files = Array.from(input.files ?? []);
|
|
|
|
// Cleared early for the same reason uploadFiles() does it: the input
|
|
// sits inside the form, and a file left on it rides along on every
|
|
// later autosave.
|
|
input.value = '';
|
|
|
|
for (const file of files) {
|
|
await this._placeImage(file);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An image pasted into the body.
|
|
*
|
|
* Without this the browser drops a data: URI into the contenteditable, and
|
|
* every autosave from then on posts the whole picture back as base64 text
|
|
* inside bodyHtml — which is how a screenshot turns a 2 KB draft into a
|
|
* 4 MB one that no mail server will take.
|
|
*/
|
|
_handleBodyPaste(event) {
|
|
const files = Array.from(event.clipboardData?.files ?? [])
|
|
.filter((file) => file.type.startsWith('image/'));
|
|
|
|
if (0 === files.length) {
|
|
this._pasteMarkup(event);
|
|
|
|
return;
|
|
}
|
|
|
|
// Only the images are ours; text pasted alongside them still goes
|
|
// through the browser's own handling.
|
|
event.preventDefault();
|
|
|
|
this._placeImages(files);
|
|
}
|
|
|
|
/**
|
|
* HTML from the clipboard, reduced to the markup a mail should carry.
|
|
*
|
|
* The browser's own paste keeps everything: three sentences out of Word or
|
|
* a web page bring that document's fonts, its background colours, its
|
|
* nested tables and its class names into the body. That is the whole "why
|
|
* does my mail look like this" effect, and the recipient sees it in full,
|
|
* because this goes on the wire.
|
|
*
|
|
* Whitelisted rather than flattened to plain text, and that is the choice
|
|
* worth defending. Normalising to text/plain is what several clients do and
|
|
* it is simpler — it also throws away the thing people are usually pasting
|
|
* FOR. A list stays a list, a link stays a link, bold stays bold. What goes
|
|
* is everything presentational: style, class, id, width, bgcolor, font,
|
|
* every alignment attribute.
|
|
*
|
|
* Shift-paste is left alone. The browser's own "paste as plain text" is
|
|
* already the escape hatch, and reimplementing it would take it away from
|
|
* the people who know it.
|
|
*
|
|
* This is a convenience, not a security boundary. The server sanitises
|
|
* every composed body on save (MailBodySanitizer::sanitizeComposedBody),
|
|
* which is what actually stops a paste carrying script onto the wire — and
|
|
* it has to, because nothing here can be relied on to have run.
|
|
*/
|
|
_pasteMarkup(event) {
|
|
const html = event.clipboardData?.getData('text/html');
|
|
|
|
if (!html) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
|
|
doc.body.querySelectorAll(DROPPED_ON_PASTE).forEach((node) => node.remove());
|
|
|
|
doc.body.querySelectorAll('*').forEach((node) => {
|
|
for (const name of [...node.attributes].map((attribute) => attribute.name)) {
|
|
// href, src and alt survive on the elements that mean them. The
|
|
// server has the final say on the schemes.
|
|
const keep = ('href' === name && 'A' === node.tagName)
|
|
|| (('src' === name || 'alt' === name) && 'IMG' === node.tagName);
|
|
|
|
if (false === keep) {
|
|
node.removeAttribute(name);
|
|
}
|
|
}
|
|
});
|
|
|
|
// insertHTML rather than writing to the node: it keeps this a single
|
|
// undoable step, so ctrl+z after a paste undoes the paste rather than
|
|
// the last thing typed before it.
|
|
document.execCommand('insertHTML', false, doc.body.innerHTML);
|
|
}
|
|
|
|
/** Same again for a file dragged onto the body. */
|
|
_handleBodyDrop(event) {
|
|
const files = Array.from(event.dataTransfer?.files ?? [])
|
|
.filter((file) => file.type.startsWith('image/'));
|
|
|
|
if (0 === files.length) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
// The caret follows the pointer on a drop, and the toolbar's saved
|
|
// range is what the insertion uses — so record where this landed
|
|
// before the upload's await gives focus away.
|
|
this._toolbar()?._saveRange();
|
|
|
|
this._placeImages(files);
|
|
}
|
|
|
|
/** Dropping onto a contenteditable is refused unless dragover says yes. */
|
|
_allowBodyDrop(event) {
|
|
if (Array.from(event.dataTransfer?.items ?? []).some((item) => 'file' === item.kind)) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
async _placeImages(files) {
|
|
for (const file of files) {
|
|
await this._placeImage(file);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload one image and put it where the cursor is.
|
|
*
|
|
* The forced save is the same rule attachments follow: a part is a row
|
|
* hanging off a Message, so the draft has to exist before there is
|
|
* anything to hang it off.
|
|
*/
|
|
async _placeImage(file) {
|
|
if (file.size > this.maxAttachmentBytesValue) {
|
|
const limit = Math.round(this.maxAttachmentBytesValue / (1024 * 1024));
|
|
|
|
this._setStatus(
|
|
this._t('tooLarge', '%s is over %d MB')
|
|
.replace('%s', file.name)
|
|
.replace('%d', String(limit)),
|
|
'text-danger',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
await this.saveDraft(null, { force: true, allowEmpty: true });
|
|
|
|
const id = this._messageId();
|
|
|
|
if (null === id) {
|
|
this._setStatus(this._t('imageFailed', 'Could not insert the image'), 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
const body = new FormData();
|
|
body.append('file', file);
|
|
|
|
this._setStatus(this._t('uploading', 'Uploading…'), 'text-ink-faint');
|
|
|
|
const failure = this._t('imageFailed', 'Could not insert the image');
|
|
let payload = null;
|
|
|
|
try {
|
|
const response = await fetch(`/compose/inline-image/${id}`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-Token': csrfToken(),
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Anything that is not our JSON — a proxy's error page, a request
|
|
// PHP discarded whole for exceeding post_max_size — is a failure
|
|
// we cannot explain, and a parser's complaint is not an
|
|
// explanation to show anybody.
|
|
payload = await response.json().catch(() => null);
|
|
|
|
if (false === response.ok || 'string' !== typeof payload?.url) {
|
|
this._setStatus(payload?.error ?? failure, 'text-danger');
|
|
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
this._setStatus(failure, 'text-danger');
|
|
|
|
return;
|
|
}
|
|
|
|
this._toolbar()?.insertInlineImage(payload);
|
|
|
|
// Saved straight away rather than left to the debounced autosave. The
|
|
// part row already exists; if the body that references it does not, a
|
|
// window closed in the next two seconds leaves a draft with an orphan
|
|
// image in it and no picture.
|
|
await this.saveDraft(null, { force: true, allowEmpty: true });
|
|
}
|
|
|
|
/**
|
|
* The toolbar controller on this same window — it owns the editor's saved
|
|
* selection, so every caret operation goes through it rather than being
|
|
* written twice.
|
|
*/
|
|
_toolbar() {
|
|
return this.application.getControllerForElementAndIdentifier(
|
|
this.element,
|
|
'compose--compose-toolbar',
|
|
);
|
|
}
|
|
|
|
// ── Cc / Bcc ──────────────────────────────────────────────────────
|
|
|
|
showCc() {
|
|
this._revealFields();
|
|
this.ccFieldTarget.classList.remove('hidden');
|
|
if (this.hasCcBtnTarget) {
|
|
this.ccBtnTarget.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
showBcc() {
|
|
this._revealFields();
|
|
this.bccFieldTarget.classList.remove('hidden');
|
|
if (this.hasBccBtnTarget) {
|
|
this.bccBtnTarget.classList.add('hidden');
|
|
}
|
|
}
|
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
_updateTitle(value) {
|
|
if (this.hasTitleTarget) {
|
|
this.titleTarget.textContent = value.trim() || this._t('newMessage', 'New Message');
|
|
}
|
|
}
|
|
|
|
_ensureEntry(collection) {
|
|
if (!collection || collection.children.length > 0) { return; }
|
|
|
|
const index = collection.dataset.index ?? 0;
|
|
const prototype = collection.dataset.prototype
|
|
.replace(/__cc__|__bcc__|__to__/g, index);
|
|
|
|
collection.dataset.index = parseInt(index) + 1;
|
|
collection.insertAdjacentHTML('beforeend', prototype);
|
|
collection.querySelector('input')?.focus();
|
|
}
|
|
}
|