Files
pl_mail/assets/controllers/compose/compose_controller.js
T
Paul LütznerandClaude Opus 5.5 76bf97c82e Answer from the address a mail came in for, under its own name, and take a signature as HTML
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>
2026-10-13 12:39:40 +02:00

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();
}
}