mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(compose): the send pill is the cancel, in every editor
A send used to answer by taking the window away and offering the way back
somewhere else — an Undo in a toast for the dock, a countdown bar in the
thread for an inline reply — plus a "Cancel" link that appeared beside the
pill while the request was out. Three surfaces for one act, none of them where
the user had just clicked, two of them in a window that no longer existed.
The window now stays exactly where it is for the cancel window. Its Send
button reads "Sending…" with living dots and "click to cancel" under it, and a
second click on that same button calls the send off. Both faces are stacked in
one grid cell and only toggle visibility, so the button cannot change size when
a send starts, when it ends, or on any frame of the animation.
The server's answer no longer re-renders anything: it hands the live window the
two URLs it cannot address by itself (compose/_sending.stream), and when the
offer expires the window asks ComposeController::sent() to file the message and
close it. A forward starts its own conversation, so the settle streams close the
frame unconditionally and touch the thread only when it is the thread on screen
— without that a forwarded mail left the composer reading "Sending…" for ever,
addressing ids that were nowhere in the page.
A toast now says the message went, because a composer that simply vanished was
the one moment in the flow with no feedback at all. Cancelling says nothing: the
draft coming back with everything in it is the answer, and the dock's old "Send
cancelled" toast was the last asymmetry between the two composers.
Also here, because they are the same window:
• The inline composer gets a reduced header — the To field itself, typable,
with From/Cc/Bcc/Subject folded behind the chevron. A forward opens needing
exactly one thing and used to open with a label where that thing goes.
• The suggestion panel opens on typing and on nothing else. `openOnFocus` is
read at focus time and a forward's caret beats the code that sets it, so an
empty query is refused at the moment the panel opens instead.
• It overlays the body rather than reserving room, which in a header meant
growing the header and shoving the editor down on every keystroke.
• `mode` moved onto ComposeContext. As a render-time extra it was set by one
action and lost by every other render, so a cancelled forward came back as a
plain new message and asked whether you meant to send an empty mail.
compose/_send_toast, compose/_inline_send.stream, compose--undo-send and
compose--inline-send are kept, unwired and documented as such, while the
replacement is reviewed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bea74d7c71
commit
35cf17f05d
28 files changed
+1574
-283
No files matched your search
@@ -2,44 +2,20 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Presses the cancel window's own control the moment it renders.
|
||||
* The compose window is the only place a send is announced, and the only place
|
||||
* one is called off.
|
||||
*
|
||||
* MODULE scope, not controller state, and that is the mechanism rather than a
|
||||
* style choice: the stream that announces the cancel window REPLACES the
|
||||
* compose element (inline reply becomes the countdown bar; the dock window
|
||||
* closes into a toast), so a watcher held by the controller would be torn
|
||||
* down in the same breath that renders the thing it is waiting for. Six
|
||||
* seconds covers a slow answer; past that, the click is dropped exactly the
|
||||
* way a late manual cancel is.
|
||||
* 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.
|
||||
*/
|
||||
let cancelWatcher = null;
|
||||
|
||||
function armCancelWatcher() {
|
||||
const deadline = Date.now() + 6_000;
|
||||
clearInterval(cancelWatcher);
|
||||
|
||||
cancelWatcher = setInterval(() => {
|
||||
const control = document.querySelector(
|
||||
'[data-action*="compose--inline-send#cancel"], [data-action*="compose--undo-send#abort"]',
|
||||
);
|
||||
|
||||
if (null !== control) {
|
||||
clearInterval(cancelWatcher);
|
||||
cancelWatcher = null;
|
||||
control.click();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
clearInterval(cancelWatcher);
|
||||
cancelWatcher = null;
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
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', 'sendCancel', 'errors', 'plainBody', 'plainToggle', 'plainCheck', 'plainWarning', 'plainWarningConfirm', 'sendWarning', 'sendWarningBody', 'sendWarningConfirm', 'richOnly'];
|
||||
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)';
|
||||
@@ -154,7 +130,12 @@ export default class extends Controller {
|
||||
|
||||
// On a forward the body is already written - the quote - and the
|
||||
// recipient is the one thing the mail cannot leave without.
|
||||
if ('forward' === this.modeValue) {
|
||||
//
|
||||
// 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();
|
||||
@@ -334,6 +315,22 @@ export default class extends Controller {
|
||||
select.plmailTamed = true;
|
||||
select.settings.openOnFocus = false;
|
||||
|
||||
// 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 —
|
||||
@@ -408,6 +405,18 @@ export default class extends Controller {
|
||||
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) {
|
||||
@@ -441,6 +450,15 @@ export default class extends Controller {
|
||||
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;
|
||||
@@ -479,6 +497,8 @@ export default class extends Controller {
|
||||
disconnect() {
|
||||
clearTimeout(this.#autosaveTimer);
|
||||
clearInterval(this._sendingTicker);
|
||||
clearTimeout(this._sendHoldTimer);
|
||||
clearTimeout(this._sendFallback);
|
||||
|
||||
for (const release of this._panelReleases ?? []) {
|
||||
release();
|
||||
@@ -740,8 +760,13 @@ export default class extends Controller {
|
||||
// ── Address / subject fields ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inline replies keep From/To/Cc/Bcc/Subject folded away — you rarely
|
||||
* retarget a reply — behind the recipient summary in the header.
|
||||
* 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) {
|
||||
@@ -755,6 +780,26 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1846,6 +1891,16 @@ export default class extends Controller {
|
||||
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; }
|
||||
|
||||
@@ -2150,6 +2205,23 @@ export default class extends Controller {
|
||||
.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();
|
||||
@@ -2318,6 +2390,19 @@ export default class extends Controller {
|
||||
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();
|
||||
@@ -2337,94 +2422,345 @@ export default class extends Controller {
|
||||
// happens next, nothing is being abandoned.
|
||||
this._unsaved = false;
|
||||
|
||||
// TWO in the DOM, one on screen: the send pill is rendered twice from
|
||||
// one partial — `md:hidden` in the window header for phones, `hidden
|
||||
// md:flex` in the action bar for the desktop — and only ever one of
|
||||
// them is displayed. A target LIST rather than a single target is what
|
||||
// makes that safe: both get disabled and relabelled, so the copy the
|
||||
// user cannot see can never come back armed. The fallback below covers
|
||||
// the settings signature editor, which renders this toolbar with no
|
||||
// pill at all.
|
||||
const sendButtons = this.hasSendBtnTarget
|
||||
? this.sendBtnTargets
|
||||
: Array.from(this.element.querySelectorAll('[type="submit"]'));
|
||||
// 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');
|
||||
|
||||
sendButtons.forEach((button) => {
|
||||
button.disabled = true;
|
||||
// The icon button has no text to replace — keep its markup.
|
||||
if ('' !== button.textContent.trim()) {
|
||||
button.dataset.sendLabel ??= button.textContent;
|
||||
}
|
||||
});
|
||||
|
||||
// "Sending" with living dots — one, two, three, over again. A static
|
||||
// label reads as a hang; motion is what says the request is out and
|
||||
// something is happening to it.
|
||||
const base = this._t('sending', 'Sending…').replace(/[….]+\s*$/, '');
|
||||
let ticks = 0;
|
||||
|
||||
const paint = () => {
|
||||
const dots = '.'.repeat(1 + (ticks++ % 3));
|
||||
sendButtons.forEach((button) => {
|
||||
if (undefined !== button.dataset.sendLabel) {
|
||||
button.textContent = base + dots;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
paint();
|
||||
clearInterval(this._sendingTicker);
|
||||
this._sendingTicker = setInterval(paint, 400);
|
||||
|
||||
// The way out while the request is in flight — see _send_pill.
|
||||
this.sendCancelTargets.forEach((button) => { button.hidden = false; });
|
||||
|
||||
setTimeout(() => {
|
||||
this._submitting = false;
|
||||
this._settleSendUi(sendButtons);
|
||||
}, 15_000);
|
||||
this._beginSending(false === scheduling);
|
||||
}
|
||||
|
||||
/** Put the send pill back to rest: label, dots ticker, cancel link. */
|
||||
_settleSendUi(sendButtons) {
|
||||
clearInterval(this._sendingTicker);
|
||||
this._sendingTicker = null;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
(sendButtons ?? (this.hasSendBtnTarget ? this.sendBtnTargets : [])).forEach((button) => {
|
||||
button.disabled = false;
|
||||
if (false === this.hasSendProgressTarget) {
|
||||
this._plainSubmitButtons().forEach((button) => { button.disabled = true; });
|
||||
}
|
||||
|
||||
if (undefined !== button.dataset.sendLabel) {
|
||||
button.textContent = button.dataset.sendLabel;
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
if (this.hasSendCancelTarget) {
|
||||
this.sendCancelTargets.forEach((button) => { button.hidden = true; });
|
||||
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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Cancel" pressed while the send request is still in flight.
|
||||
* 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.
|
||||
*
|
||||
* There is nothing to abort client-side that the server has not already
|
||||
* received, but every send lands in a cancel window (CANCEL_WINDOW_MS) and
|
||||
* announces it — the inline bar, or the toast's undo. So the click is
|
||||
* remembered and the moment that announcement renders, its own cancel is
|
||||
* pressed on the user's behalf. If the window never renders (network
|
||||
* error) or is already spent, this fails exactly the way a late manual
|
||||
* click would — honestly.
|
||||
* 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.
|
||||
*/
|
||||
cancelInFlight(event) {
|
||||
const button = event.currentTarget;
|
||||
armSendHold({ undoUrl, settleUrl, delay }) {
|
||||
clearTimeout(this._sendFallback);
|
||||
|
||||
// Pressed has to look pressed: the actual cancellation lands when the
|
||||
// server's cancel window renders and the watcher clicks it, which can
|
||||
// be a beat away — a button that neither disables nor changes reads
|
||||
// as a click that fell on the floor.
|
||||
button.disabled = true;
|
||||
button.textContent = '…';
|
||||
this._undoUrl = undoUrl;
|
||||
this._settleUrl = settleUrl;
|
||||
|
||||
armCancelWatcher();
|
||||
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' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[compose] cancel failed', error);
|
||||
this._cancelFailed();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === response.ok) {
|
||||
console.error('[compose] cancel refused', response.status);
|
||||
this._cancelFailed();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Turbo.renderStreamMessage(await response.text());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -2441,26 +2777,30 @@ export default class extends Controller {
|
||||
/**
|
||||
* A blinking caret in a typable To — a forward's first missing piece.
|
||||
*
|
||||
* Two obstacles stand between connect() and that caret, and both are
|
||||
* handled here rather than hoped away. The inline zone opens with the
|
||||
* address rows folded behind the "to …" summary, and a hidden field
|
||||
* cannot take focus — so the rows are unfolded first, the same move
|
||||
* toggleFields makes. And Tom Select owns the row only once its
|
||||
* enhancement has run, which connect() can beat — so the focus retries
|
||||
* briefly for the enhanced input and falls back to the raw widget rather
|
||||
* than give up silently.
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (true === this.hasFieldsTarget && this.fieldsTarget.classList.contains('hidden')) {
|
||||
this.fieldsTarget.classList.remove('hidden');
|
||||
|
||||
if (this.hasFieldsChevronTarget) {
|
||||
this.fieldsChevronTarget.classList.add('rotate-180');
|
||||
}
|
||||
// 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;
|
||||
@@ -2867,6 +3207,7 @@ export default class extends Controller {
|
||||
// ── Cc / Bcc ──────────────────────────────────────────────────────
|
||||
|
||||
showCc() {
|
||||
this._revealFields();
|
||||
this.ccFieldTarget.classList.remove('hidden');
|
||||
if (this.hasCcBtnTarget) {
|
||||
this.ccBtnTarget.classList.add('hidden');
|
||||
@@ -2874,6 +3215,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
showBcc() {
|
||||
this._revealFields();
|
||||
this.bccFieldTarget.classList.remove('hidden');
|
||||
if (this.hasBccBtnTarget) {
|
||||
this.bccBtnTarget.classList.add('hidden');
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* **UNWIRED.** Nothing renders the bar this drives any more: the composer now
|
||||
* stays open through the cancel window and its own Send pill is the cancel, in
|
||||
* the thread and in the dock alike. See compose--compose#armSendHold and
|
||||
* compose/_sent.stream.html.twig. Kept while that is reviewed.
|
||||
*
|
||||
* The "Sending… Ns — click to cancel" bar that replaces the reply buttons
|
||||
* after an inline send. Mirrors the fetch shape of undo_send_controller, but
|
||||
* lives in the thread instead of a toast.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* The send response's only job: tell the window that sent it how to call the
|
||||
* send off, and how to tidy up when the offer expires.
|
||||
*
|
||||
* An empty, hidden element appended into the compose frame — see
|
||||
* compose/_sending.stream.html.twig. It exists because the two URLs it carries
|
||||
* cannot be known when the window is rendered: a brand-new message has no id
|
||||
* until the send mints one, and both `undo` and `settle` are addressed by it.
|
||||
* A turbo-stream is how the server speaks to a page it is not re-rendering,
|
||||
* and a Stimulus controller is how that stream reaches a controller that is
|
||||
* already alive.
|
||||
*
|
||||
* It hands the values over and removes itself. Everything after that —- the
|
||||
* countdown, the cancel, the settle — belongs to compose--compose, because the
|
||||
* surface all three act on is its pill.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
undoUrl: String,
|
||||
settleUrl: String,
|
||||
// The CANCEL window, not the send delay. The gap between the two is
|
||||
// deliberate: see ComposeController::CANCEL_WINDOW_MS.
|
||||
delay: { type: Number, default: 8000 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
// The frame is the window's own root element, so the controller is on
|
||||
// the element this was appended next to — not an ancestor of it.
|
||||
const frame = this.element.closest("turbo-frame") ?? document;
|
||||
const root = frame.querySelector('[data-controller~="compose--compose"]');
|
||||
|
||||
if (null === root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const compose = this.application.getControllerForElementAndIdentifier(
|
||||
root,
|
||||
"compose--compose",
|
||||
);
|
||||
|
||||
compose?.armSendHold({
|
||||
undoUrl: this.undoUrlValue,
|
||||
settleUrl: this.settleUrlValue,
|
||||
delay: this.delayValue,
|
||||
});
|
||||
|
||||
// Out of connect(), not inside it: removing the element a controller
|
||||
// is connecting to, mid-connect, is a teardown racing its own setup.
|
||||
requestAnimationFrame(() => this.element.remove());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
/**
|
||||
* **UNWIRED.** The dock's "Sending… — Undo" toast that this drives is no
|
||||
* longer rendered — the window stays open and its Send pill is the cancel.
|
||||
* See compose/_send_toast.html.twig, which is kept alongside it while the
|
||||
* replacement is reviewed.
|
||||
*/
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
url: String,
|
||||
|
||||
@@ -339,6 +339,17 @@
|
||||
right: -1rem;
|
||||
}
|
||||
|
||||
/* Except in the inline composer's header, where there is no gutter to bleed
|
||||
into. The bleed is measured against the dock's `w-10 text-right` label
|
||||
column; the reduced header puts a plain "To" in front of the field instead,
|
||||
so -4rem hangs the panel a good 20px off the left edge of the window. Same
|
||||
attribute the controller reads to leave the panel overlaying rather than
|
||||
reserving room for it — see compose/_to_row.html.twig. */
|
||||
[data-compose-overlay-panel] .ts-wrapper.multi .ts-dropdown {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Anchored to <body> instead of to the control, because every place ui--select
|
||||
* is used clips: the modal body scrolls, the settings pane scrolls, the
|
||||
* calendar event sheet scrolls, and a panel that is a child of any of them is
|
||||
|
||||
@@ -173,15 +173,24 @@ class ComposeController extends AbstractController
|
||||
$account = $original->account ?? $this->window->defaultAccountFor($this->currentUser());
|
||||
$draft = $this->replyDrafts->forward($original);
|
||||
|
||||
$form = $this->formFactory->create($draft, $ctx, $this->currentUser());
|
||||
$form->get('account')->setData($this->senders->token($account));
|
||||
$this->addressFields->hydrate($form, $draft, $this->getUser());
|
||||
|
||||
// The window behaves differently for a forward — the caret belongs in
|
||||
// To (the quote IS the content; the missing piece is who gets it), and
|
||||
// the empty-body question would be asked about a body that is not
|
||||
// empty. The mode is how the client knows which case it is in.
|
||||
return $this->renderWindow($form, $draft, $ctx, ['mode' => 'forward']);
|
||||
//
|
||||
// On the CONTEXT rather than as a render-time extra, and that is the
|
||||
// fix for a real bug: an extra is set by this action and lost by every
|
||||
// other render of the same window. Cancel a forward and the window
|
||||
// that came back was a plain new message, so sending it again asked
|
||||
// whether you meant to send an empty mail — about the forward in it.
|
||||
// The context rides in the URL, so it survives the trip.
|
||||
$ctx = $ctx->withMode(ComposeContext::MODE_FORWARD);
|
||||
|
||||
$form = $this->formFactory->create($draft, $ctx, $this->currentUser());
|
||||
$form->get('account')->setData($this->senders->token($account));
|
||||
$this->addressFields->hydrate($form, $draft, $this->getUser());
|
||||
|
||||
return $this->renderWindow($form, $draft, $ctx);
|
||||
}
|
||||
|
||||
#[Route('/draft', name: 'form_new', methods: ['POST'])]
|
||||
@@ -470,7 +479,7 @@ class ComposeController extends AbstractController
|
||||
// faded and the draft was filed with no way back to it — while the
|
||||
// cancel above had already gone through. Cancelling a send has to
|
||||
// *reopen* what was being written, which is the whole point of it.
|
||||
return $this->renderTurboStream('compose/_undo_toast.html.twig', [
|
||||
return $this->renderTurboStream('compose/_dock_undo.stream.html.twig', [
|
||||
'form' => $form,
|
||||
'message' => $message,
|
||||
'ctx' => $ctx,
|
||||
@@ -586,35 +595,72 @@ class ComposeController extends AbstractController
|
||||
|
||||
return $this->renderTurboStream('compose/_undo_too_late.html.twig', [
|
||||
'message' => $message,
|
||||
'thread' => $message->thread,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline sends skip the toast: the message is appended to the open thread
|
||||
* and the reply bar becomes a countdown the user can click to cancel.
|
||||
* A send changes nothing on screen — the window stays put and becomes its
|
||||
* own cancel.
|
||||
*
|
||||
* This used to branch: the dock got a toast with an Undo link and had its
|
||||
* window closed underneath it, the inline composer was replaced by a
|
||||
* countdown bar in the thread. Two surfaces, two lifetimes, and in both
|
||||
* cases the button the user had just pressed was gone by the time they
|
||||
* looked for a way back. Now there is one answer for both, and it only
|
||||
* hands the live window the URLs it cannot address by itself — see
|
||||
* compose/_sending.stream.html.twig.
|
||||
*
|
||||
* The toast is not deleted, only unused: compose/_send_toast.html.twig and
|
||||
* compose--undo-send are still here while this shape is reviewed.
|
||||
*/
|
||||
private function sendResponse(Message $message, ComposeContext $ctx): Response
|
||||
{
|
||||
if (false === $ctx->inline) {
|
||||
return $this->renderTurboStream('compose/_send_toast.html.twig', [
|
||||
'message' => $message,
|
||||
'cancelWindow' => self::CANCEL_WINDOW_MS,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->renderTurboStream('compose/_inline_send.stream.html.twig', [
|
||||
'message' => $message,
|
||||
'thread' => $message->thread,
|
||||
return $this->renderTurboStream('compose/_sending.stream.html.twig', [
|
||||
'ctx' => $ctx,
|
||||
'undoUrl' => $this->generateUrl(
|
||||
'app_compose_mail_undo',
|
||||
$ctx->urlParams() + ['id' => $message->id],
|
||||
),
|
||||
// The cancel window, NOT the send delay. Offering the whole delay
|
||||
'settleUrl' => $this->generateUrl(
|
||||
'app_compose_mail_sent',
|
||||
$ctx->urlParams() + ['id' => $message->id],
|
||||
),
|
||||
// The CANCEL window, not the send delay. Offering the whole delay
|
||||
// is what let a click land after the worker had already claimed
|
||||
// the message — see CANCEL_WINDOW_MS.
|
||||
'delay' => self::CANCEL_WINDOW_MS,
|
||||
'cancelWindow' => self::CANCEL_WINDOW_MS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cancel window has run out: file the message and close the window.
|
||||
*
|
||||
* Deliberately a separate request rather than something the send response
|
||||
* could have done, because it happens eight seconds after that response
|
||||
* and the difference between the two moments is the entire feature. Until
|
||||
* it runs, the composer is still on screen with the message in it and one
|
||||
* click calls the send off.
|
||||
*
|
||||
* Nothing here writes. It is a POST because it is an instruction and not a
|
||||
* resource — nothing should replay it out of a cache or a prefetch — and
|
||||
* it answers the same streams a too-late cancel does, because "this
|
||||
* message has gone" is the same fact either way.
|
||||
*
|
||||
* Safe to lose. If the browser is closed inside the window the mail still
|
||||
* goes; only the tidying is skipped, and the next render of the thread
|
||||
* shows the message where this would have put it.
|
||||
*/
|
||||
#[Route('/sent/{id}', name: 'mail_sent', methods: ['POST'])]
|
||||
public function sent(ComposeContext $ctx, Message $message): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $message);
|
||||
|
||||
return $this->renderTurboStream('compose/_sent.stream.html.twig', [
|
||||
'message' => $message,
|
||||
'thread' => $message->thread,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,11 +57,32 @@ final readonly class ComposeContext
|
||||
*/
|
||||
private const string INLINE_FRAME_PATTERN = '/^compose_(inline|draft_\d+)$/';
|
||||
|
||||
/**
|
||||
* What the window is FOR, and it has to survive the round trip.
|
||||
*
|
||||
* Only 'forward' means anything to the client so far, and it means three
|
||||
* things: the caret starts in To, the quoted original may be left
|
||||
* unfolded, and the "this message has no text" question is not asked about
|
||||
* a body whose whole content is the quote.
|
||||
*
|
||||
* It used to be a render-time extra — `renderWindow(..., ['mode' =>
|
||||
* 'forward'])`, set by exactly one action — so every OTHER render of the
|
||||
* same window lost it. Cancel a forward and the window that came back was
|
||||
* a 'new' message: send it again and it asked whether you really meant to
|
||||
* send an empty mail, about the forward sitting in it. A server-side
|
||||
* refusal (a bad address) re-rendered the window and did the same. Here it
|
||||
* rides in the URL with the frame and the thread, so a window cannot come
|
||||
* back not knowing what it is.
|
||||
*/
|
||||
public const string MODE_NEW = 'new';
|
||||
public const string MODE_FORWARD = 'forward';
|
||||
|
||||
public function __construct(
|
||||
public bool $inline,
|
||||
public string $frame,
|
||||
public ?int $thread = null,
|
||||
public ?int $replyTo = null,
|
||||
public string $mode = self::MODE_NEW,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -71,8 +92,12 @@ final readonly class ComposeContext
|
||||
* is this class's rule, and asking each caller to apply it is how two of
|
||||
* them would come to disagree.
|
||||
*/
|
||||
public static function forFrame(?string $frame, ?int $thread = null, ?int $replyTo = null): self
|
||||
{
|
||||
public static function forFrame(
|
||||
?string $frame,
|
||||
?int $thread = null,
|
||||
?int $replyTo = null,
|
||||
?string $mode = null,
|
||||
): self {
|
||||
$frame = null === $frame || '' === $frame ? self::DOCK_FRAME : $frame;
|
||||
$inline = 1 === preg_match(self::INLINE_FRAME_PATTERN, $frame);
|
||||
|
||||
@@ -81,6 +106,10 @@ final readonly class ComposeContext
|
||||
frame: $inline ? $frame : self::DOCK_FRAME,
|
||||
thread: $thread,
|
||||
replyTo: $replyTo,
|
||||
// Anything unrecognised is a plain window. The value reaches this
|
||||
// from a query string, so it is user input and gets the same
|
||||
// treatment as any other: an allow-list, not a cast.
|
||||
mode: self::MODE_FORWARD === $mode ? self::MODE_FORWARD : self::MODE_NEW,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,13 +142,25 @@ final readonly class ComposeContext
|
||||
$params['reply_to'] = $this->replyTo;
|
||||
}
|
||||
|
||||
// Only when it is not the default, so ordinary windows keep the URLs
|
||||
// they had and the parameter appears exactly where it means something.
|
||||
if (self::MODE_NEW !== $this->mode) {
|
||||
$params['mode'] = $this->mode;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/** The same window, told which message it is answering. */
|
||||
public function withReplyTo(?int $replyTo): self
|
||||
{
|
||||
return new self($this->inline, $this->frame, $this->thread, $replyTo);
|
||||
return new self($this->inline, $this->frame, $this->thread, $replyTo, $this->mode);
|
||||
}
|
||||
|
||||
/** The same window, told what it is for. See {@see MODE_FORWARD}. */
|
||||
public function withMode(string $mode): self
|
||||
{
|
||||
return new self($this->inline, $this->frame, $this->thread, $this->replyTo, $mode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +173,6 @@ final readonly class ComposeContext
|
||||
*/
|
||||
public function withThread(?int $thread): self
|
||||
{
|
||||
return new self($this->inline, $this->frame, $thread, $this->replyTo);
|
||||
return new self($this->inline, $this->frame, $thread, $this->replyTo, $this->mode);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ final readonly class ComposeContextResolver implements ValueResolverInterface
|
||||
$request->query->get('frame'),
|
||||
$request->query->has('thread') ? $request->query->getInt('thread') : null,
|
||||
$request->query->has('reply_to') ? $request->query->getInt('reply_to') : null,
|
||||
$request->query->get('mode'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use Twig\TwigFunction;
|
||||
*
|
||||
* A function rather than a controller variable, for the reason
|
||||
* `read_receipt_decision` is one: compose/_window.html.twig is included from
|
||||
* three places (the two routes that render it, plus _undo_toast and
|
||||
* three places (the two routes that render it, plus _dock_undo.stream and
|
||||
* _inline_undo, both of which include it `only`), and threading one more
|
||||
* variable through all of them means the next caller has to remember to. Here
|
||||
* forgetting would mean the From switch quietly stops changing the signature
|
||||
|
||||
+10
-12
@@ -1,8 +1,15 @@
|
||||
{#
|
||||
compose/_undo_toast.html.twig
|
||||
compose/_dock_undo.stream.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Cancelling a send from the dock: say so, and put the window back exactly as it
|
||||
was — recipients, subject, body and attachments included.
|
||||
Cancelling a send from the dock: put the window back exactly as it was —
|
||||
recipients, subject, body and attachments included.
|
||||
|
||||
**No toast.** There was one — "Send cancelled — back to your draft" — and it
|
||||
was the last asymmetry left between the two composers: the inline cancel never
|
||||
had one, because there the draft reappearing in the thread was the answer. It
|
||||
is the answer in the dock too. The window coming back with everything in it
|
||||
says more than a line of text can, and the button that was clicked has already
|
||||
said the rest.
|
||||
|
||||
`replace` rather than `append`, and `only` rather than an inherited context:
|
||||
|
||||
@@ -19,15 +26,6 @@ was — recipients, subject, body and attachments included.
|
||||
|
||||
Variables: form, message, ctx, pickerIntegrations
|
||||
#}
|
||||
<turbo-stream action="append" target="toast-region">
|
||||
<template>
|
||||
{% include '_partials/_toast.html.twig' with {
|
||||
type: 'info',
|
||||
message: 'toast.send_cancelled'|trans,
|
||||
} only %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
<turbo-stream action="replace" target="{{ ctx.frame }}">
|
||||
<template>
|
||||
{% include 'compose/_window.html.twig' with {
|
||||
@@ -1,8 +1,15 @@
|
||||
{#
|
||||
compose/_inline_send.stream.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Response to sending from the inline reply card. No toast: the message joins
|
||||
the open conversation and the reply bar turns into a cancel countdown.
|
||||
Response to sending from the inline reply card. **NOT RENDERED ANY MORE.**
|
||||
|
||||
The other half of the pair the send rewrite replaced — where the dock got a
|
||||
toast, the thread got a countdown bar in its reply zone, and both took the
|
||||
composer away to do it. One surface now: the window stays and its Send pill is
|
||||
the cancel. What this did on send, compose/_sent.stream.html.twig now does
|
||||
when the cancel window runs out.
|
||||
|
||||
Kept, unwired, while that is reviewed. compose--inline-send goes with it.
|
||||
|
||||
Variables: message, thread (MessageThread|null), undoUrl, delay
|
||||
#}
|
||||
|
||||
@@ -78,27 +78,68 @@ Variables: message, params (from the including context); placement.
|
||||
}),
|
||||
}|json_encode|e('html_attr') }}">
|
||||
|
||||
{# Hidden until a send is in flight. While the request is out the pill
|
||||
says "Sending" with living dots, and this is the way out: a click here
|
||||
marks the send as unwanted, and the moment the server answers with its
|
||||
cancel window (every send gets one — see CANCEL_WINDOW_MS) the undo is
|
||||
fired on the user's behalf. Honest about the race: if the window is
|
||||
already gone the cancel simply fails the way a late click on the
|
||||
countdown bar does. #}
|
||||
<button type="button" hidden
|
||||
data-compose--compose-target="sendCancel"
|
||||
data-action="click->compose--compose#cancelInFlight"
|
||||
class="flex items-center justify-center text-xs text-ink-muted underline
|
||||
px-2 py-1.5 max-md:min-h-11 hover:text-ink transition-colors">
|
||||
{{ 'compose.sending_cancel'|trans }}
|
||||
</button>
|
||||
{# ── Send, and while a send is in flight, the way out of it ──────────
|
||||
|
||||
ONE control, two faces. The window used to grow a second button beside
|
||||
the pill while a send was out ("Cancel"), and the dock answered a send
|
||||
by closing itself and putting an Undo in a toast — so at any moment
|
||||
there were two different places a cancel might live and neither was
|
||||
where the user had just clicked. The pill IS the cancel now: it says
|
||||
"Sending" with living dots, "click to cancel" under it, and a click on
|
||||
it calls the send off. Nothing else appears anywhere.
|
||||
|
||||
**The two faces are stacked in one grid cell, and that is the whole
|
||||
sizing story.** Both spans sit at `col-start-1 row-start-1`, so the cell
|
||||
— and therefore the button — is as wide and as tall as the LARGER of
|
||||
them, at rest and in flight alike. Switching between them toggles
|
||||
`invisible` (visibility, which keeps the box) rather than `hidden`
|
||||
(display, which does not), so the button cannot change size when a send
|
||||
starts, when it ends, or on any frame of the animation in between. The
|
||||
dots are three real characters that are always there and only change
|
||||
opacity, for the same reason: a label that grows a character per tick is
|
||||
a button that jitters four times a second.
|
||||
|
||||
The resting pill is a few pixels taller than it was, because it now
|
||||
reserves the two-line face it will need. Below md that is invisible
|
||||
anyway — `min-h-11` was already taller than both. #}
|
||||
<div class="flex items-center rounded-full overflow-hidden bg-accent">
|
||||
<button type="submit"
|
||||
data-compose--compose-target="sendBtn"
|
||||
class="flex items-center justify-center text-white text-sm font-semibold
|
||||
data-action="click->compose--compose#sendOrCancel"
|
||||
class="grid items-center justify-items-center cursor-pointer
|
||||
text-white text-sm font-semibold
|
||||
px-4 py-1.5 max-md:min-h-11 hover:bg-white/10 transition-colors">
|
||||
{{ 'compose.send'|trans }}
|
||||
<span data-compose--compose-target="sendLabel"
|
||||
class="col-start-1 row-start-1 leading-5 whitespace-nowrap">
|
||||
{{- 'compose.send'|trans -}}
|
||||
</span>
|
||||
|
||||
{# `invisible`, not `hidden` — see above. Kept out of the
|
||||
accessibility tree while it is invisible by visibility:hidden
|
||||
itself, so the button reads "Send" at rest and "Sending… click
|
||||
to cancel" in flight, with no aria juggling. #}
|
||||
<span data-compose--compose-target="sendProgress"
|
||||
class="col-start-1 row-start-1 invisible text-center leading-tight">
|
||||
<span class="block whitespace-nowrap">
|
||||
{# The word without its ellipsis: the three dots after it
|
||||
are the animation, and a label that already ended in one
|
||||
would make four. #}
|
||||
{{- 'compose.sending'|trans|replace({'…': '', '...': ''})|trim -}}
|
||||
{#- Three dots that never leave; only their opacity moves.
|
||||
Whitespace-trimmed on both sides, or the template's own
|
||||
indentation lands between the word and them and the
|
||||
button reads "Sending ." -#}
|
||||
<span data-compose--compose-target="sendDots" aria-hidden="true"
|
||||
><span>.</span><span>.</span><span>.</span></span>
|
||||
</span>
|
||||
{# The instruction, not a second button. Hidden (again by
|
||||
visibility) for a scheduled send, which has nothing to call
|
||||
off from here — the menu owns that. #}
|
||||
<span data-compose--compose-target="sendCancelHint"
|
||||
class="block text-[10px] font-normal opacity-80 whitespace-nowrap">
|
||||
{{- 'compose.click_to_cancel'|trans -}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
{#
|
||||
compose/_send_toast.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The dock's "Sending… — Undo" toast.
|
||||
The dock's "Sending… — Undo" toast. **NOT RENDERED ANY MORE.**
|
||||
|
||||
Kept, unwired, while the replacement is reviewed. The dock no longer closes
|
||||
when a send goes out: the window stays put and its Send pill becomes the
|
||||
cancel — one surface, in the place the user just clicked, in every editor. See
|
||||
compose/_sending.stream.html.twig, compose/_sent.stream.html.twig and
|
||||
ComposeController::sendResponse(). compose--undo-send, this template's
|
||||
controller, is unwired with it.
|
||||
|
||||
Everything below is the old behaviour, left verbatim.
|
||||
|
||||
|
||||
`hide_after` is ComposeController::CANCEL_WINDOW_MS rather than a literal, and
|
||||
that matters: this toast's lifetime IS the cancel window, because the Undo link
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
compose/_sending.stream.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The answer to a send, and it changes nothing on screen.
|
||||
|
||||
That is the point of it. Every earlier version of this response took the
|
||||
window away — the dock's closed into a toast carrying an Undo link, the
|
||||
inline one was replaced by a countdown bar in the thread — so the cancel was
|
||||
always offered somewhere other than where the user had just clicked, and the
|
||||
window had to be rebuilt from scratch to get it back. The window now stays,
|
||||
its Send pill reads "Sending… click to cancel" for the whole cancel window,
|
||||
and this stream's only job is to hand it the two things it cannot know by
|
||||
itself:
|
||||
|
||||
• `undoUrl` — how to call the send off. Addressed by the message id, which
|
||||
for a message composed and sent without an autosave is
|
||||
minted by the send itself, so it cannot be baked into the
|
||||
window at render time.
|
||||
• `settleUrl` — how to tidy up when the offer expires: put the sent message
|
||||
in the conversation and take the window away. See
|
||||
ComposeController::sent().
|
||||
|
||||
Appended into the frame rather than replacing anything, and it takes itself
|
||||
back out as soon as it has spoken — see compose--send-hold.
|
||||
|
||||
Variables: ctx, undoUrl, settleUrl, cancelWindow
|
||||
#}
|
||||
<turbo-stream action="append" target="{{ ctx.frame }}">
|
||||
<template>
|
||||
<div hidden
|
||||
data-controller="compose--send-hold"
|
||||
data-compose--send-hold-undo-url-value="{{ undoUrl }}"
|
||||
data-compose--send-hold-settle-url-value="{{ settleUrl }}"
|
||||
data-compose--send-hold-delay-value="{{ cancelWindow }}"></div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
@@ -0,0 +1,86 @@
|
||||
{#
|
||||
compose/_sent.stream.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The cancel window is over: the message is where it belongs, the composer is
|
||||
gone, and a toast says so.
|
||||
|
||||
Rendered when the composer asks for it (ComposeController::sent(), driven by
|
||||
compose--compose#_settleSend) and when a cancel arrives too late to take —
|
||||
both are the same fact, "this message has gone", and they must leave the same
|
||||
page behind.
|
||||
|
||||
Everything here used to happen the instant the send was accepted, which is
|
||||
what made the window's own pill useless as a cancel surface: it was not on
|
||||
screen any more. Deferring it by the cancel window is the whole shape of this
|
||||
round.
|
||||
|
||||
**The conversation is only touched when it is the conversation on screen**,
|
||||
and that condition is not decoration — it is the forward bug. A reply is
|
||||
filed in the thread it answers, so `ctx.thread` (the thread the composer was
|
||||
opened against) and the sent message's thread are the same and the message can
|
||||
simply join the conversation below. A FORWARD starts a new conversation: the
|
||||
sent message lands in a thread that is nowhere on the page, so the three
|
||||
streams below addressed `thread_messages_…` and `reply_zone_…` ids that did
|
||||
not exist, every one of them was silently dropped, and the composer sat there
|
||||
saying "Sending…" for ever. Closing the frame is therefore unconditional and
|
||||
comes first; the conversation is updated only when there is one to update.
|
||||
|
||||
A forward's mail is not shown anywhere on this page as a result, because it is
|
||||
not part of this conversation — it is in Sent, and the toast is what says it
|
||||
went.
|
||||
|
||||
Variables: message, thread (MessageThread|null), ctx, notify (default true)
|
||||
#}
|
||||
{# The too-late answer includes this template for the tidying and brings its
|
||||
own toast — an error one, which is the news that matters there. Two toasts
|
||||
stacked, one saying the send failed to stop and one saying it went, is the
|
||||
same fact told twice with the second half burying the first. #}
|
||||
{% set notify = notify|default(true) %}
|
||||
{# Is the sent message part of the conversation this composer was opened in?
|
||||
Both halves are needed: the dock carries `thread` too (a reply opened below
|
||||
md lands there), and a message can have no thread at all. #}
|
||||
{% set inConversation = thread is not null and ctx.thread is not null and thread.id == ctx.thread %}
|
||||
|
||||
{# The composer goes, wherever it was. For an inline window that frame IS the
|
||||
window's root; for the dock it is the dock. Emptying it disconnects
|
||||
compose--compose, which is what gives a thread its Reply / Forward buttons
|
||||
back — see the `composing` class in app.css. #}
|
||||
<turbo-stream action="update" target="{{ ctx.frame }}">
|
||||
<template></template>
|
||||
</turbo-stream>
|
||||
|
||||
{% if inConversation %}
|
||||
{# The draft's row is already in the conversation — hidden while the editor
|
||||
was open — so it is dropped and the sent message re-appended at the end,
|
||||
which is where it now belongs. #}
|
||||
<turbo-stream action="remove" target="thread_message_{{ message.id }}">
|
||||
</turbo-stream>
|
||||
|
||||
<turbo-stream action="append" target="thread_messages_{{ thread.id }}">
|
||||
<template>
|
||||
{% include 'mail/_thread_message.html.twig' with { message: message, expanded: true, draft: false } only %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
{# Redrawn without the composer and without the countdown bar this used to
|
||||
carry: the pill was the only cancel there was, and it is spent. #}
|
||||
<turbo-stream action="replace" target="reply_zone_{{ thread.id }}">
|
||||
<template>
|
||||
{% include 'mail/_reply_zone.html.twig' with { thread: thread } only %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
|
||||
{# The one piece of feedback the send gets that is not the button itself.
|
||||
Without it a mail sent from the dock was a window that simply vanished, and
|
||||
a forward was a composer that closed with nothing said at all. #}
|
||||
{% if notify %}
|
||||
<turbo-stream action="append" target="toast-region">
|
||||
<template>
|
||||
{% include '_partials/_toast.html.twig' with {
|
||||
type: 'success',
|
||||
message: 'toast.sent'|trans,
|
||||
} only %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{#
|
||||
compose/_to_row.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The To row, and directly under it the refusal that belongs to it.
|
||||
|
||||
Rendered in ONE of two places, never both, and which one depends on where the
|
||||
window lives:
|
||||
|
||||
• `compact: true` — in an inline window's HEADER, where the recipient
|
||||
summary used to be. That summary was a button reading "to Someone" (or
|
||||
"No recipients") that unfolded the whole address block, and on a forward —
|
||||
which opens with no recipient at all and needs exactly one thing typed
|
||||
into it — it was a label standing in front of the only field that
|
||||
mattered. It IS the field now: same widget, same target, same everything
|
||||
below, just laid out for a header row. The chevron beside it still unfolds
|
||||
From/Cc/Bcc/Subject for the rarer case.
|
||||
|
||||
• `compact: false` — first in the address block of a dock window, where it
|
||||
has always been.
|
||||
|
||||
One partial rather than two markups because everything that reads this row
|
||||
addresses it by target — the Tom Select taming, the panel-room reservation,
|
||||
the tab order, the aria wiring, the invalid marking — and a second copy is a
|
||||
second thing for those to drift out of step with.
|
||||
|
||||
The <label for> is load-bearing, not decoration: Tom Select hides the <select>
|
||||
and builds its own text input, and copies the accessible name across only when
|
||||
a label is bound to the select's id. The controller mirrors it onto the
|
||||
generated input as well, belt with braces.
|
||||
|
||||
Variables: form, compact (default false)
|
||||
#}
|
||||
{% set compact = compact|default(false) %}
|
||||
|
||||
<div class="{{ compact ? 'min-w-0 flex-1' : 'shrink-0' }}">
|
||||
<div data-compose--compose-target="toField"
|
||||
{# The suggestion panel OVERLAYS from here rather than pushing the
|
||||
window about. In the dock the panel is given real room — it hangs
|
||||
over the Subject row otherwise, and a click aimed at Subject
|
||||
commits a contact nobody chose (see
|
||||
compose--compose#_reserveRoomForPanel). Here there is no Subject
|
||||
below to cover: the row is the header, and reserving room for the
|
||||
panel grew the header and shoved the whole editor down every time
|
||||
a result arrived. This attribute is what tells the controller which
|
||||
of the two rows it is looking at. #}
|
||||
{% if compact %}data-compose-overlay-panel{% endif %}
|
||||
{# `select-text`: the inline header carries `select-none` so the
|
||||
window chrome cannot be dragged into a text selection, and it is
|
||||
inherited — which in a header that now contains a typing surface
|
||||
would fight the caret. #}
|
||||
class="flex items-center gap-3 min-h-[36px] select-text
|
||||
{{ compact ? '' : 'px-4 py-1.5' }}">
|
||||
<label for="{{ form.toAddresses.vars.id }}"
|
||||
id="{{ form.toAddresses.vars.id }}_label"
|
||||
class="text-xs font-medium text-ink-faint shrink-0
|
||||
{{ compact ? '' : 'w-10 text-right' }}">{{ 'compose.to'|trans }}</label>
|
||||
<div class="flex-1 min-w-0">
|
||||
{{ form_widget(form.toAddresses) }}
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button type="button"
|
||||
data-compose--compose-target="ccBtn"
|
||||
data-action="click->compose--compose#showCc"
|
||||
{# title, not aria-label: the button already has an
|
||||
accessible name — the word "Cc" on its face — and
|
||||
an aria-label would replace it rather than add to
|
||||
it, leaving the visible label and the spoken one
|
||||
saying different things (WCAG 2.5.3). #}
|
||||
title="{{ 'compose.add_cc'|trans }}"
|
||||
class="text-xs text-ink-faint hover:text-accent transition-colors">
|
||||
{{ 'compose.cc'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-compose--compose-target="bccBtn"
|
||||
data-action="click->compose--compose#showBcc"
|
||||
title="{{ 'compose.add_bcc'|trans }}"
|
||||
class="text-xs text-ink-faint hover:text-accent transition-colors">
|
||||
{{ 'compose.bcc'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Why a send was refused. Rendered from the form rather than from a
|
||||
toast so it sits with the field it is about, and so a refusal
|
||||
that came from the server reads the same as one the window
|
||||
caught itself. It travels WITH the row: in a compact header the
|
||||
address block below is folded away, and a refusal nobody can see is
|
||||
the oldest bug in this window. #}
|
||||
<div data-compose--compose-target="errors"
|
||||
class="{{ compact ? '' : 'px-4' }} {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
|
||||
role="alert" aria-live="polite">
|
||||
<p class="text-xs text-danger">
|
||||
{%- for error in form.toAddresses.vars.errors %}{{ error.message }}{% endfor -%}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@ compose/_undo_too_late.html.twig
|
||||
The cancel arrived after SendMessageHandler had already claimed the message.
|
||||
|
||||
This template exists because the alternative was lying. Every undo used to
|
||||
answer with _undo_toast.html.twig — "send cancelled", window restored — whether
|
||||
answer with the dock-undo stream — "send cancelled", window restored — whether
|
||||
or not the cancel had actually taken, and a cancel clicked in the last fraction
|
||||
of the hold does not take. The user was told the one thing that was not true
|
||||
about the one action that cannot be reversed.
|
||||
@@ -14,10 +14,17 @@ not happen; and no window, because there is no draft to reopen. Restoring the
|
||||
composer here would put an editable copy of an already-sent mail on screen, and
|
||||
the next autosave would file it as a draft standing beside the sent copy.
|
||||
|
||||
The dock is emptied for the same reason the send toast empties it — whatever
|
||||
window this cancel came from is describing a message that has gone.
|
||||
And the page is left exactly as a send that ran its course leaves it, by
|
||||
including the very same partial: whatever window this cancel came from is
|
||||
describing a message that has gone, so it closes, and the message takes its
|
||||
place in the conversation. That used to be a bare "empty the dock", which was
|
||||
right only because the inline composer had already been replaced by a
|
||||
countdown bar before this could be reached. It is not any more — the window is
|
||||
still open, still holding a copy of a mail that is on the wire — so saying
|
||||
"too late" without also closing it would leave the user an editable draft of
|
||||
something already sent.
|
||||
|
||||
Variables: message, ctx
|
||||
Variables: message, thread (MessageThread|null), ctx
|
||||
#}
|
||||
<turbo-stream action="append" target="toast-region">
|
||||
<template>
|
||||
@@ -29,8 +36,12 @@ Variables: message, ctx
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
{% if false == ctx.inline %}
|
||||
<turbo-stream action="update" target="compose_dock">
|
||||
<template></template>
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
{# `notify: false` — the error above IS this send's notification, and a
|
||||
"Message sent" toast stacked under "too late to stop it" tells the same
|
||||
news twice with the reassuring half on top. #}
|
||||
{% include 'compose/_sent.stream.html.twig' with {
|
||||
message: message,
|
||||
thread: thread,
|
||||
ctx: ctx,
|
||||
notify: false,
|
||||
} only %}
|
||||
@@ -19,7 +19,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
fallbacks had drifted from the ones the controller used, which is how a
|
||||
window could render against 'compose_dock' while the server thought it was
|
||||
inline. It is a value object now: all three renders of this template
|
||||
(renderWindow, _undo_toast, _inline_undo) construct one, so the fields are
|
||||
(renderWindow, _dock_undo, _inline_undo) construct one, so the fields are
|
||||
there by type. #}
|
||||
{% set inline = ctx.inline %}
|
||||
{% set frame = ctx.frame %}
|
||||
@@ -46,7 +46,6 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
saved: 'compose.status.saved'|trans,
|
||||
saveFailed: 'compose.status.save_failed'|trans,
|
||||
uploading: 'compose.status.uploading'|trans,
|
||||
sending: 'compose.sending'|trans,
|
||||
newMessage: 'compose.new_message'|trans,
|
||||
attachFailed: 'compose.attach_failed'|trans,
|
||||
attachRemoveFailed: 'compose.attach_remove_failed'|trans,
|
||||
@@ -62,7 +61,6 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
|
||||
imageFailed: 'compose.image_insert_failed'|trans,
|
||||
noSignature: 'compose.signature_none'|trans,
|
||||
cancelSend: 'compose.sending_cancel'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# The toolbar controller's own vocabulary. Same arrangement and same
|
||||
reason as compose--compose's above: the link popover writes words
|
||||
@@ -83,8 +81,14 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
data-compose--compose-inline-value="{{ inline ? 'true' : 'false' }}"
|
||||
{# 'forward' moves the caret to To, spares the quote-only body the
|
||||
empty-mail question, and is the case the quote-collapse setting
|
||||
below is about. See ComposeController::forwardMessage(). #}
|
||||
data-compose--compose-mode-value="{{ mode|default('new') }}"
|
||||
below is about. See ComposeController::forwardMessage().
|
||||
|
||||
From the CONTEXT, not from a render-time `mode` variable. It was
|
||||
the latter, passed by the forward action alone, so every other
|
||||
render of this window — the undo reopen, a server-side refusal —
|
||||
forgot what it was looking at and started asking a forward whether
|
||||
it was an empty mail. ctx travels in the URL. #}
|
||||
data-compose--compose-mode-value="{{ ctx.mode }}"
|
||||
data-compose--compose-collapse-forward-quote-value="{{ app.user and false == app.user.getSetting(constant('App\\Entity\\User\\User::SETTING_COMPOSE_FORWARD_QUOTE_COLLAPSED'), true) ? 'false' : 'true' }}"
|
||||
data-compose--compose-min-chars-value="{{ min_chars|default(5) }}"
|
||||
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeAttachmentController::MAX_ATTACHMENT_BYTES') }}"
|
||||
@@ -144,23 +148,27 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
</button>
|
||||
|
||||
{% if inline %}
|
||||
{# Replying rarely means retargeting: the address and subject
|
||||
rows stay folded behind this summary. #}
|
||||
{% set recipients = [] %}
|
||||
{% for entry in message.toAddresses ?? [] %}
|
||||
{% set recipients = recipients|merge([entry.name|default('') ?: entry.address]) %}
|
||||
{% endfor %}
|
||||
{# A REDUCED header, not a summary of one. What stood here was
|
||||
a button reading "to Someone" that unfolded the address
|
||||
block when clicked — fine for a reply, wrong for a forward,
|
||||
which opens with no recipient and needs exactly one thing
|
||||
typed before it can go anywhere. The line that said "No
|
||||
recipients" was a label in front of the field that would
|
||||
have fixed it. It is that field now. Everything else —
|
||||
From, Cc, Bcc, Subject — stays folded behind the chevron,
|
||||
because retargeting the rest really is the rare case.
|
||||
|
||||
See compose/_to_row.html.twig; the dock renders the same
|
||||
partial in its address block. #}
|
||||
{{ include('compose/_to_row.html.twig', { compact: true }) }}
|
||||
|
||||
<button type="button"
|
||||
data-action="click->compose--compose#toggleFields"
|
||||
class="flex items-center gap-2 min-w-0 flex-1 md:flex-none text-sm text-ink-soft
|
||||
hover:text-ink transition-colors cursor-pointer">
|
||||
<span class="text-xs text-ink-faint shrink-0">{{ 'message.details.to_label'|trans }}</span>
|
||||
<span class="truncate max-w-[420px]">
|
||||
{{- recipients is empty ? ('compose.no_recipients'|trans) : recipients|join(', ') -}}
|
||||
</span>
|
||||
title="{{ 'compose.more_fields'|trans }}"
|
||||
aria-label="{{ 'compose.more_fields'|trans }}"
|
||||
class="{{ hc }}">
|
||||
<i data-compose--compose-target="fieldsChevron"
|
||||
class="fa-solid fa-chevron-down text-[10px] text-ink-faint shrink-0 transition-transform duration-150"></i>
|
||||
class="fa-solid fa-chevron-down text-[10px] text-ink-faint transition-transform duration-150"></i>
|
||||
</button>
|
||||
<span class="hidden" data-compose--compose-target="title"></span>
|
||||
{% else %}
|
||||
@@ -342,55 +350,12 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
</div>
|
||||
|
||||
{# ── To ───────────────────────────────────────────────── #}
|
||||
{# A real <label for>, not a <span>: it is what gives the recipient
|
||||
combobox an accessible name. Tom Select hides the <select> and
|
||||
builds its own text input, and copies the name across only when
|
||||
there is a label bound to the select's id — so the label is
|
||||
load-bearing rather than decoration. The controller mirrors it
|
||||
onto the generated input as well, for the same reason a belt
|
||||
goes with braces. #}
|
||||
<div data-compose--compose-target="toField"
|
||||
class="flex items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
|
||||
<label for="{{ form.toAddresses.vars.id }}"
|
||||
id="{{ form.toAddresses.vars.id }}_label"
|
||||
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.to'|trans }}</label>
|
||||
<div class="flex-1 min-w-0">
|
||||
{{ form_widget(form.toAddresses) }}
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button type="button"
|
||||
data-compose--compose-target="ccBtn"
|
||||
data-action="click->compose--compose#showCc"
|
||||
{# title, not aria-label: the button already has an
|
||||
accessible name — the word "Cc" on its face — and
|
||||
an aria-label would replace it rather than add to
|
||||
it, leaving the visible label and the spoken one
|
||||
saying different things (WCAG 2.5.3). #}
|
||||
title="{{ 'compose.add_cc'|trans }}"
|
||||
class="text-xs text-ink-faint hover:text-accent transition-colors">
|
||||
{{ 'compose.cc'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-compose--compose-target="bccBtn"
|
||||
data-action="click->compose--compose#showBcc"
|
||||
title="{{ 'compose.add_bcc'|trans }}"
|
||||
class="text-xs text-ink-faint hover:text-accent transition-colors">
|
||||
{{ 'compose.bcc'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Why a send was refused. Rendered from the form rather than from a
|
||||
toast so it sits with the field it is about, and so a refusal
|
||||
that came from the server reads the same as one the window
|
||||
caught itself. #}
|
||||
<div data-compose--compose-target="errors"
|
||||
class="px-4 {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
|
||||
role="alert" aria-live="polite">
|
||||
<p class="text-xs text-danger">
|
||||
{%- for error in form.toAddresses.vars.errors %}{{ error.message }}{% endfor -%}
|
||||
</p>
|
||||
</div>
|
||||
{# The dock only. An inline window renders the same partial in its
|
||||
header — one To row per window, wherever it sits, because every
|
||||
target the controller reaches for is on it. #}
|
||||
{% if not inline %}
|
||||
{{ include('compose/_to_row.html.twig', { compact: false }) }}
|
||||
{% endif %}
|
||||
|
||||
{# ── Cc ───────────────────────────────────────────────── #}
|
||||
<div data-compose--compose-target="ccField"
|
||||
|
||||
@@ -9,7 +9,11 @@ outer #reply_zone_{id} element is part of this template.
|
||||
|
||||
Variables:
|
||||
thread MessageThread
|
||||
sending null | { messageId, undoUrl, delay } — post-send countdown state
|
||||
sending null | { messageId, undoUrl, delay } — post-send countdown state.
|
||||
**Nothing passes this any more.** A send no longer replaces the
|
||||
composer with a countdown bar here; the composer stays open and its
|
||||
Send pill is the cancel (see compose/_sending.stream.html.twig).
|
||||
The block is left in place, unreached, while that is reviewed.
|
||||
|
||||
The action buttons hide themselves while a compose card is open; see the
|
||||
[data-reply-zone] rule in assets/styles/app.css.
|
||||
|
||||
@@ -72,10 +72,24 @@ final class UndoSendTest extends WebTestCase
|
||||
$client->request('POST', '/compose/undo/' . $id);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
// The composer coming back IS the confirmation. There used to be a
|
||||
// "Send cancelled" toast beside it, and this asserted on that string —
|
||||
// but only the dock ever raised one, the inline cancel answered with
|
||||
// the draft alone, and the window returning with everything in it says
|
||||
// more than the line did. So the assertion is on the thing that
|
||||
// matters: a cancel that won hands the composer back.
|
||||
$body = (string) $client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString(
|
||||
'Send cancelled',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
'the cancel won, so the composer should confirm it',
|
||||
'compose-window',
|
||||
$body,
|
||||
'the cancel won, so the composer should come back',
|
||||
);
|
||||
self::assertStringNotContainsString(
|
||||
'Too late',
|
||||
$body,
|
||||
'the cancel won — it must not be reported as having lost',
|
||||
);
|
||||
|
||||
// The envelope still comes due — cancelling does not unqueue anything.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test, expect, type Page } from "./support/test";
|
||||
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* The inline composer's reduced header.
|
||||
*
|
||||
* An inline window keeps its address block folded away — you rarely retarget a
|
||||
* reply — and what stood in the header was a summary of it: a button reading
|
||||
* "to Someone", or "No recipients" when there was nobody yet. On a forward that
|
||||
* was exactly backwards. A forward opens with the body already written and the
|
||||
* recipient the one thing missing, so the header showed a label where the only
|
||||
* field that mattered should have been, and typing an address meant unfolding
|
||||
* From, Cc, Bcc and Subject to get at it.
|
||||
*
|
||||
* The header now carries the real To field, and nothing else: From, Cc, Bcc and
|
||||
* Subject stay behind the chevron. See compose/_to_row.html.twig, which the
|
||||
* dock renders too — one row, one set of targets, either place.
|
||||
*/
|
||||
|
||||
const INLINE = "#compose_inline";
|
||||
const FIELDS = `${INLINE} [data-compose--compose-target="fields"]`;
|
||||
const TO_PANEL = "#compose_inline_toAddresses-ts-dropdown";
|
||||
|
||||
test.beforeAll(() => {
|
||||
seed("seed-mail", "clear-drafts");
|
||||
});
|
||||
|
||||
async function openForward(page: Page): Promise<void> {
|
||||
await page.goto("/mail/inbox");
|
||||
await mailRow(page, INBOX_SUBJECTS.read).click();
|
||||
await page.getByRole("link", { name: "Forward", exact: true }).first().click();
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The To field's own text input, as Tom Select built it. */
|
||||
function toInput(page: Page) {
|
||||
return page.locator(`${INLINE} [data-compose--compose-target="toField"] .ts-control input`);
|
||||
}
|
||||
|
||||
test.describe("the inline composer's reduced header", () => {
|
||||
test("a forward opens with a typable To in the header and everything else folded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openForward(page);
|
||||
|
||||
// The To field is IN the header — the row above the editor, not inside
|
||||
// the block the chevron unfolds.
|
||||
const header = page.locator(`${INLINE} .compose-window form > div`).first();
|
||||
await expect(header.locator('[data-compose--compose-target="toField"]')).toBeVisible();
|
||||
|
||||
await expect(page.locator(FIELDS)).toHaveClass(/hidden/);
|
||||
|
||||
// And the caret is already in it: a forward's first missing piece.
|
||||
await expect(toInput(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("what is typed there becomes a real recipient", async ({ page }) => {
|
||||
await openForward(page);
|
||||
|
||||
await toInput(page).fill("forward-header@example.test");
|
||||
await toInput(page).press("Enter");
|
||||
|
||||
const chips = page
|
||||
.locator(`${INLINE} [data-compose--compose-target="toField"] .ts-control`)
|
||||
.locator(".item");
|
||||
|
||||
await expect(chips).toHaveCount(1);
|
||||
await expect(chips.first()).toContainText("forward-header@example.test");
|
||||
|
||||
// Still reduced: committing an address does not unfold the rest.
|
||||
await expect(page.locator(FIELDS)).toHaveClass(/hidden/);
|
||||
});
|
||||
|
||||
test("the chevron is what unfolds From, Cc, Bcc and Subject", async ({ page }) => {
|
||||
await openForward(page);
|
||||
|
||||
await page.locator(INLINE).getByRole("button", { name: /Show From/ }).click();
|
||||
|
||||
await expect(page.locator(FIELDS)).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator(`${INLINE} [data-compose--compose-target="subject"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Cc and Bcc are asked for from the To row, which is in the header, while
|
||||
* the rows they reveal are in the block below it. Without the unfold they
|
||||
* un-hid a row inside something still hidden — a button that did nothing.
|
||||
*/
|
||||
test("Cc from the header unfolds the block it lives in", async ({ page }) => {
|
||||
await openForward(page);
|
||||
|
||||
// "Cc" on its face is the accessible name; the title enlarges on it
|
||||
// rather than replacing it, which is the point of the pair.
|
||||
await page.locator(INLINE).getByRole("button", { name: "Cc", exact: true }).click();
|
||||
|
||||
await expect(page.locator(FIELDS)).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator(`${INLINE} [data-compose--compose-target="ccField"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* The suggestion panel opens on TYPING and on nothing else.
|
||||
*
|
||||
* Tom Select reopens its panel whenever the field takes focus, and a
|
||||
* forward puts the caret in To the moment it opens — so the window used to
|
||||
* appear with an empty panel hanging over the body, offering "No results
|
||||
* found" about a query nobody had made. `openOnFocus: false` alone did not
|
||||
* settle it: it is read at focus time, and the focus can beat the code that
|
||||
* sets it. The panel now refuses to open on an empty query.
|
||||
*/
|
||||
test("the suggestion panel stays shut until something is typed", async ({ page }) => {
|
||||
await openForward(page);
|
||||
|
||||
// Focused, settled, and still nothing on screen.
|
||||
await expect(toInput(page)).toBeFocused();
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.locator(TO_PANEL)).toBeHidden();
|
||||
|
||||
// Clicking the field is not typing either.
|
||||
await toInput(page).click();
|
||||
await expect(page.locator(TO_PANEL)).toBeHidden();
|
||||
|
||||
// Typing is.
|
||||
await toInput(page).pressSequentially("forward", { delay: 20 });
|
||||
await expect(page.locator(TO_PANEL)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* …and when it does open, it overlays rather than pushes.
|
||||
*
|
||||
* The dock gives the panel real room — it hangs over the Subject row
|
||||
* otherwise, and a click aimed at Subject commits a contact nobody chose.
|
||||
* In the header there is no Subject below to cover, only the message body,
|
||||
* so the same reservation bought nothing and cost everything: the header
|
||||
* grew by the panel's height and the whole editor jumped down the page
|
||||
* every time a result arrived or left.
|
||||
*/
|
||||
test("the panel overlays the body rather than growing the window", async ({ page }) => {
|
||||
await openForward(page);
|
||||
|
||||
const header = page.locator(`${INLINE} .compose-window form > div`).first();
|
||||
const editor = page.locator(`${INLINE} [data-compose--compose-toolbar-target="editor"]`);
|
||||
|
||||
const headerBefore = await header.boundingBox();
|
||||
const editorBefore = await editor.boundingBox();
|
||||
|
||||
await toInput(page).pressSequentially("forward", { delay: 20 });
|
||||
await expect(page.locator(TO_PANEL)).toBeVisible();
|
||||
|
||||
expect(await header.boundingBox()).toMatchObject({ height: headerBefore!.height });
|
||||
expect(await editor.boundingBox()).toMatchObject({ y: editorBefore!.y });
|
||||
|
||||
// Overlaying, and inside the window it belongs to: the dock's panel
|
||||
// bleeds 4rem left into its label gutter, which the reduced header does
|
||||
// not have — uncorrected, that hung the panel off the window's edge.
|
||||
const panel = await page.locator(TO_PANEL).boundingBox();
|
||||
const window_ = await page.locator(`${INLINE} .compose-window`).boundingBox();
|
||||
|
||||
expect(panel!.x).toBeGreaterThanOrEqual(window_!.x);
|
||||
expect(panel!.x + panel!.width).toBeLessThanOrEqual(window_!.x + window_!.width + 1);
|
||||
});
|
||||
});
|
||||
@@ -330,6 +330,47 @@ test.describe("the To field's feedback", () => {
|
||||
* target, which is exactly the condition under test, so it would have
|
||||
* reported this bug as a timeout instead of as the wrong recipient.
|
||||
*/
|
||||
/**
|
||||
* A panel nobody asked for is not a suggestion.
|
||||
*
|
||||
* Tom Select reopens its dropdown on every focus, and after Enter it refreshes
|
||||
* it against the query it has just turned into a chip — so the window used to
|
||||
* grow an empty panel over the Subject row whenever the field was touched,
|
||||
* offering "No results found" about nothing. The dock is the calmer of the two
|
||||
* cases (the inline forward opens with the caret already in To, which is where
|
||||
* this was loudest — see compose-inline-header.spec.ts), and the rule is the
|
||||
* same in both: the panel opens on a query, and a query means typing.
|
||||
*/
|
||||
test.describe("the suggestion panel opens on typing and nothing else", () => {
|
||||
function toInput(page: Page) {
|
||||
return page.locator(`${DOCK} .ts-control input`).first();
|
||||
}
|
||||
|
||||
test("focusing the field does not open it", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
await toInput(page).click();
|
||||
await expect(toInput(page)).toBeFocused();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator(TO_PANEL)).toBeHidden();
|
||||
});
|
||||
|
||||
test("typing opens it, and committing the chip closes it again", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
await toInput(page).pressSequentially(OTHER, { delay: 20 });
|
||||
await expect(page.locator(TO_PANEL)).toBeVisible();
|
||||
|
||||
await toInput(page).press("Enter");
|
||||
await expect(chips(page)).toHaveCount(1);
|
||||
|
||||
// The query that produced the chip is spent, and the field still has
|
||||
// focus — which is precisely when the panel used to spring back.
|
||||
await expect(page.locator(TO_PANEL)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("clicking out of the To field", () => {
|
||||
const SUBJECT = `${DOCK} input[id$="subject"]`;
|
||||
|
||||
|
||||
@@ -405,16 +405,27 @@ test.describe("compose safety", () => {
|
||||
* `pickerIntegrations`, and the cancel flag was already flushed by then —
|
||||
* so the send was called off, the toast faded, and the draft was filed with
|
||||
* no way back to it.
|
||||
*
|
||||
* There is no toast to wait for any more. The window stays open through
|
||||
* the cancel window and its own Send pill becomes the way out — one
|
||||
* surface, in every editor — so the cancel is a second click on the button
|
||||
* that was just pressed.
|
||||
*/
|
||||
test("undo reopens the window with the recipient intact", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await fillMessage(page, VALID);
|
||||
|
||||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||||
// Addressed by placement, not by role: the pill's accessible name
|
||||
// changes to "Sending… click to cancel" as it changes jobs, which is
|
||||
// exactly what a getByRole("Send") locator would stop matching.
|
||||
const send = page.locator(
|
||||
`${DOCK} [data-compose-send-pill="bar"] [data-compose--compose-target="sendBtn"]`,
|
||||
);
|
||||
await send.click();
|
||||
|
||||
const undo = page.locator("[data-controller='compose--undo-send']");
|
||||
await expect(undo).toBeVisible({ timeout: 10_000 });
|
||||
await undo.click();
|
||||
// The same button, now offering the way back.
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
await send.click();
|
||||
|
||||
// The window is back…
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { test, expect, type Page } from "./support/test";
|
||||
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* One send, one cancel, one button.
|
||||
*
|
||||
* The window used to answer a send by taking itself away and offering the way
|
||||
* back somewhere else — an Undo link in a toast for the dock, a countdown bar
|
||||
* in the thread for an inline reply — plus a "Cancel" link that appeared
|
||||
* beside the pill while the request was out. Three surfaces for one act, none
|
||||
* of them where the user had just clicked, and two of them in a window that no
|
||||
* longer existed. The Send pill is all of it now: it says "Sending… click to
|
||||
* cancel" for the cancel window, a second click on it calls the send off, and
|
||||
* when the window expires the composer closes on its own.
|
||||
*
|
||||
* These pin the things that shape has to keep true:
|
||||
* • the pill IS the cancel, in the dock and inline alike;
|
||||
* • nothing else offers one — no toast, no countdown bar;
|
||||
* • a send that runs its course says so, once, in a toast. The button
|
||||
* carries the whole story up to that point, but a composer that simply
|
||||
* vanished was the one moment with no feedback at all;
|
||||
* • a CANCEL says nothing, because the draft coming back is the answer;
|
||||
* • the button does not change size, at rest, in flight, or on any frame of
|
||||
* the animation. That last one is why the two faces are stacked in one
|
||||
* grid cell rather than swapped — see compose/_send_pill.html.twig.
|
||||
*/
|
||||
|
||||
const DOCK = "#compose_dock";
|
||||
const INLINE = "#compose_inline";
|
||||
const VALID = "send-cancel@example.test";
|
||||
|
||||
test.beforeAll(() => {
|
||||
seed("seed-mail", "clear-drafts");
|
||||
});
|
||||
|
||||
/**
|
||||
* The desktop Send pill, by placement rather than by role.
|
||||
*
|
||||
* Its accessible name is "Send" at rest and "Sending… click to cancel" in
|
||||
* flight — that IS the feature — so a getByRole("Send") locator stops matching
|
||||
* the button in the middle of the gesture under test. `bar` is the desktop
|
||||
* copy; the `header` one is display:none above md.
|
||||
*/
|
||||
function sendPill(page: Page, scope: string) {
|
||||
return page.locator(`${scope} [data-compose-send-pill="bar"] [data-compose--compose-target="sendBtn"]`);
|
||||
}
|
||||
|
||||
async function openDock(page: Page): Promise<void> {
|
||||
await page.goto("/mail/inbox");
|
||||
await page.getByRole("link", { name: "Compose" }).first().click();
|
||||
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
|
||||
|
||||
const to = page.locator(`${DOCK} .ts-control input`).first();
|
||||
await to.fill(VALID);
|
||||
await to.press("Enter");
|
||||
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).fill("Send cancel subject");
|
||||
await page
|
||||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||||
.fill("Send cancel body, long enough to save.");
|
||||
}
|
||||
|
||||
async function openInlineReply(page: Page, subject: string): Promise<void> {
|
||||
await page.goto("/mail/inbox");
|
||||
await mailRow(page, subject).click();
|
||||
await page.getByRole("link", { name: "Reply", exact: true }).first().click();
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible();
|
||||
await page
|
||||
.locator(`${INLINE} [data-compose--compose-toolbar-target="editor"]`)
|
||||
.fill("Inline body, long enough to save.");
|
||||
}
|
||||
|
||||
test.describe("the send pill is the cancel", () => {
|
||||
test("a dock send keeps its window, and the pill offers the way back", async ({ page }) => {
|
||||
await openDock(page);
|
||||
|
||||
const send = sendPill(page, DOCK);
|
||||
await send.click();
|
||||
|
||||
// The window is still here — that is the whole change. It used to be
|
||||
// replaced by a toast before the user could look for a way out.
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible();
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
|
||||
// And nowhere else offers one.
|
||||
await expect(page.locator("[data-controller='compose--undo-send']")).toHaveCount(0);
|
||||
await expect(page.locator("[data-controller='compose--inline-send']")).toHaveCount(0);
|
||||
|
||||
// The body cannot be edited while a copy of a sent message is in it.
|
||||
await expect(
|
||||
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
|
||||
).toHaveJSProperty("isContentEditable", true);
|
||||
expect(
|
||||
await page
|
||||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||||
.evaluate((el) => el.closest("[inert]") !== null),
|
||||
).toBe(true);
|
||||
|
||||
// Second click on the same button: the draft comes back, intact.
|
||||
await send.click();
|
||||
await expect(page.locator(`${DOCK} [data-compose--compose-target="subject"]`)).toHaveValue(
|
||||
"Send cancel subject",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(
|
||||
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
|
||||
).toContainText("Send cancel body");
|
||||
|
||||
// And says nothing about it. The dock used to raise "Send cancelled —
|
||||
// back to your draft" while the inline cancel raised nothing, which
|
||||
// was the last asymmetry between the two composers; the window
|
||||
// returning with everything in it is the answer in both.
|
||||
await expect(page.locator("#toast-region")).toHaveText("");
|
||||
});
|
||||
|
||||
/**
|
||||
* The one thing the button cannot say, because by then it is gone.
|
||||
*/
|
||||
test("a send that runs its course closes the dock and says the message went", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDock(page);
|
||||
|
||||
await sendPill(page, DOCK).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect(page.locator("#toast-region").getByText("Message sent.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an inline send keeps its window too — no countdown bar in the thread", async ({ page }) => {
|
||||
await openInlineReply(page, INBOX_SUBJECTS.read);
|
||||
|
||||
const send = sendPill(page, INLINE);
|
||||
await send.click();
|
||||
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible();
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
await expect(page.locator("[data-controller='compose--inline-send']")).toHaveCount(0);
|
||||
|
||||
await send.click();
|
||||
await expect(
|
||||
page.locator(`${INLINE} [data-compose--compose-toolbar-target="editor"]`),
|
||||
).toContainText("Inline body", { timeout: 10_000 });
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Left alone, the offer expires and the composer tidies itself away —
|
||||
* ComposeController::sent(), asked for by the window when its own countdown
|
||||
* runs out. The reply buttons coming back are what says the reply zone was
|
||||
* redrawn rather than the window merely hidden.
|
||||
*/
|
||||
test("left alone, the window closes itself and the thread gets its reply bar back", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openInlineReply(page, INBOX_SUBJECTS.star);
|
||||
|
||||
const messages = page.locator('[data-controller="mail--thread-message"]');
|
||||
const before = await messages.count();
|
||||
|
||||
await sendPill(page, INLINE).click();
|
||||
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect(page.getByRole("link", { name: "Reply", exact: true }).first()).toBeVisible();
|
||||
|
||||
// The reply joined the conversation, and the toast said so.
|
||||
await expect(messages).toHaveCount(before + 1);
|
||||
await expect(page.locator("#toast-region").getByText("Message sent.")).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* A forward is a new conversation, and that is what broke it.
|
||||
*
|
||||
* The settle streams address the sent message's thread — `thread_messages_…`
|
||||
* and `reply_zone_…` — and a reply's thread is the one on screen, so they
|
||||
* landed. A forward's does not: it starts its own, so every stream was
|
||||
* addressed to an id that was nowhere in the page, every one was silently
|
||||
* dropped, and the composer sat there reading "Sending…" for ever with no
|
||||
* feedback of any kind. Closing the frame is unconditional now, and the
|
||||
* conversation is only touched when it is the conversation on screen.
|
||||
*/
|
||||
test("a forwarded message closes its composer even though it starts a new thread", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await mailRow(page, INBOX_SUBJECTS.archive).click();
|
||||
await page.getByRole("link", { name: "Forward", exact: true }).first().click();
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible();
|
||||
|
||||
const to = page.locator(`${INLINE} [data-compose--compose-target="toField"] .ts-control input`);
|
||||
await to.fill("forwarded@example.test");
|
||||
await to.press("Enter");
|
||||
|
||||
await sendPill(page, INLINE).click();
|
||||
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect(page.locator("#toast-region").getByText("Message sent.")).toBeVisible();
|
||||
|
||||
// The thread's own buttons are back — the composer did not merely go
|
||||
// invisible, the reply zone knows it closed.
|
||||
await expect(page.getByRole("link", { name: "Forward", exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* A window that comes back has to come back as what it was.
|
||||
*
|
||||
* `mode` was a render-time extra set by the forward action alone, so every
|
||||
* OTHER render of the same window forgot it — and the undo reopen is one.
|
||||
* A cancelled forward came back as a plain new message, and sending it
|
||||
* again asked "this message has no text, send anyway?" about the forwarded
|
||||
* mail sitting in the body. It rides in the URL with the frame and the
|
||||
* thread now (ComposeContext::MODE_FORWARD), so it survives the trip.
|
||||
*/
|
||||
test("a cancelled forward is still a forward, and does not ask if it is empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await mailRow(page, INBOX_SUBJECTS.trash).click();
|
||||
await page.getByRole("link", { name: "Forward", exact: true }).first().click();
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible();
|
||||
|
||||
const to = page.locator(`${INLINE} [data-compose--compose-target="toField"] .ts-control input`);
|
||||
await to.fill("forward-again@example.test");
|
||||
await to.press("Enter");
|
||||
|
||||
const send = sendPill(page, INLINE);
|
||||
|
||||
await send.click();
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
await send.click();
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The reopened window knows what it is…
|
||||
await expect(page.locator(`${INLINE} [data-compose--compose-mode-value]`)).toHaveAttribute(
|
||||
"data-compose--compose-mode-value",
|
||||
"forward",
|
||||
);
|
||||
|
||||
// …so the second send goes straight out, with nothing asked about a
|
||||
// body whose whole content is the quote.
|
||||
const again = sendPill(page, INLINE);
|
||||
|
||||
await again.click();
|
||||
await expect(page.getByText("This message has no text")).toHaveCount(0);
|
||||
await expect(again).toContainText("click to cancel", { timeout: 10_000 });
|
||||
});
|
||||
|
||||
/**
|
||||
* The size, held.
|
||||
*
|
||||
* This is the assertion the markup exists for. "Sending" plus a growing run
|
||||
* of dots plus a second line is wider and taller than "Send", so a button
|
||||
* that swapped one label for the other would jump when the send starts and
|
||||
* jitter four times a second while it runs. Both faces occupy the same grid
|
||||
* cell and only their visibility changes, so the box is the larger of the
|
||||
* two from the moment the window renders and never moves again.
|
||||
*/
|
||||
test("the pill does not change size — not on send, not between dots", async ({ page }) => {
|
||||
await openDock(page);
|
||||
|
||||
const send = sendPill(page, DOCK);
|
||||
const resting = await send.boundingBox();
|
||||
|
||||
await send.click();
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Four ticks of the 400ms animation, which is every dot count twice.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const box = await send.boundingBox();
|
||||
seen.add(`${Math.round(box!.width)}x${Math.round(box!.height)}`);
|
||||
await page.waitForTimeout(220);
|
||||
}
|
||||
|
||||
expect([...seen]).toEqual([`${Math.round(resting!.width)}x${Math.round(resting!.height)}`]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
|
||||
* A window "reopened" is not enough to assert with toBeVisible(): an element in
|
||||
* normal flow below the fold is still visible to Playwright. These check where
|
||||
* it actually is.
|
||||
*
|
||||
* ── What "undo" means now ────────────────────────────────────────────────────
|
||||
* There is no toast and no countdown bar to find. A send leaves the window
|
||||
* exactly where it was and turns its Send pill into "Sending… click to cancel"
|
||||
* for the cancel window; clicking that pill is the undo, in the dock and in the
|
||||
* thread alike. sendThenCancel() below is that gesture, and the reopen it has
|
||||
* to survive is the same one it always was — the frame is still replaced
|
||||
* wholesale by the undo response.
|
||||
*/
|
||||
|
||||
const DOCK = "#compose_dock";
|
||||
@@ -43,6 +51,31 @@ async function openCompose(page: Page): Promise<void> {
|
||||
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop Send pill. Addressed by its placement rather than by role,
|
||||
* deliberately: its accessible name is "Send" at rest and "Sending… click to
|
||||
* cancel" in flight — that IS the feature — so a getByRole("Send") locator
|
||||
* stops matching the button halfway through the very gesture being tested.
|
||||
* `bar` is the desktop copy; the `header` one is display:none above md.
|
||||
*/
|
||||
function sendPill(page: Page, scope: string) {
|
||||
return page.locator(`${scope} [data-compose-send-pill="bar"] [data-compose--compose-target="sendBtn"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send, then call it back — both clicks on the same button.
|
||||
*
|
||||
* The wait between them is an assertion, not a sleep: until the pill says it
|
||||
* has become the cancel, a second click would be a second send.
|
||||
*/
|
||||
async function sendThenCancel(page: Page, scope: string): Promise<void> {
|
||||
const send = sendPill(page, scope);
|
||||
|
||||
await send.click();
|
||||
await expect(send).toContainText("click to cancel", { timeout: 10_000 });
|
||||
await send.click();
|
||||
}
|
||||
|
||||
async function fillDock(page: Page): Promise<void> {
|
||||
await toInput(page).fill(VALID);
|
||||
await toInput(page).press("Enter");
|
||||
@@ -83,11 +116,7 @@ test.describe("compose undo reopens where it was sent from", () => {
|
||||
expect(await dockWindowPlacement(page)).toMatchObject({ onScreen: true, atBottomRight: true });
|
||||
|
||||
await fillDock(page);
|
||||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||||
|
||||
const undo = page.locator("[data-controller='compose--undo-send']");
|
||||
await expect(undo).toBeVisible({ timeout: 10_000 });
|
||||
await undo.click();
|
||||
await sendThenCancel(page, DOCK);
|
||||
|
||||
// The window is back…
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||||
@@ -109,11 +138,7 @@ test.describe("compose undo reopens where it was sent from", () => {
|
||||
test("the dock survives an undo — the next Compose still opens on screen", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await fillDock(page);
|
||||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||||
|
||||
const undo = page.locator("[data-controller='compose--undo-send']");
|
||||
await expect(undo).toBeVisible({ timeout: 10_000 });
|
||||
await undo.click();
|
||||
await sendThenCancel(page, DOCK);
|
||||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Close, then open a fresh Compose — WITHOUT a reload. This is the second
|
||||
@@ -140,14 +165,8 @@ test.describe("compose undo reopens where it was sent from", () => {
|
||||
.locator(`${INLINE} [data-compose--compose-toolbar-target="editor"]`)
|
||||
.fill("Inline reply body, long enough to save.");
|
||||
|
||||
// Inline send: the reply bar becomes a "click to cancel" countdown.
|
||||
// getByRole skips the display:none mobile Send button, leaving the
|
||||
// desktop pill.
|
||||
await page.locator(INLINE).getByRole("button", { name: "Send", exact: true }).click();
|
||||
|
||||
const cancel = page.locator("[data-controller='compose--inline-send']");
|
||||
await expect(cancel).toBeVisible({ timeout: 10_000 });
|
||||
await cancel.getByRole("button").click();
|
||||
// Inline send: the window stays put and its own pill becomes the cancel.
|
||||
await sendThenCancel(page, INLINE);
|
||||
|
||||
// Reopened where it was written — inline, in the thread…
|
||||
await expect(page.locator(`${INLINE} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -1218,6 +1218,7 @@ compose:
|
||||
url_label: Webadresse
|
||||
url_placeholder: "beispiel.de"
|
||||
minimize: Minimieren
|
||||
more_fields: Absender, Cc, Bcc und Betreff anzeigen
|
||||
more_options: Weitere Optionen
|
||||
new_message: Neue Nachricht
|
||||
no_recipients: Keine Empfänger
|
||||
@@ -1303,6 +1304,7 @@ header:
|
||||
|
||||
inbox:
|
||||
empty: Keine Nachrichten in diesem Tab.
|
||||
empty_account: In diesem Konto ist noch nichts angekommen.
|
||||
|
||||
insights:
|
||||
card:
|
||||
@@ -2752,6 +2754,7 @@ toast:
|
||||
schedule_cancelled: Geplanter Versand abgebrochen.
|
||||
send_cancelled: Senden abgebrochen — zurück zu deinem Entwurf.
|
||||
send_too_late: Zu spät — diese Nachricht wurde bereits gesendet.
|
||||
sent: Nachricht gesendet.
|
||||
undo: Rückgängig
|
||||
error:
|
||||
no_archive_mailbox: Es gibt kein Archiv-Postfach.
|
||||
|
||||
@@ -1207,6 +1207,7 @@ compose:
|
||||
url_label: Web address
|
||||
url_placeholder: "example.com"
|
||||
minimize: Minimize
|
||||
more_fields: Show From, Cc, Bcc and subject
|
||||
more_options: More options
|
||||
new_message: New Message
|
||||
no_recipients: No recipients
|
||||
@@ -1294,6 +1295,7 @@ header:
|
||||
|
||||
inbox:
|
||||
empty: No messages in this tab.
|
||||
empty_account: Nothing has arrived in this account yet.
|
||||
|
||||
insights:
|
||||
card:
|
||||
@@ -2725,6 +2727,7 @@ toast:
|
||||
# message has gone and nothing here can bring it back, and the previous
|
||||
# behaviour — claiming it was cancelled — was worse than saying nothing.
|
||||
send_too_late: Too late — that message had already been sent.
|
||||
sent: Message sent.
|
||||
undo: Undo
|
||||
error:
|
||||
no_archive_mailbox: There is no archive mailbox.
|
||||
|
||||
@@ -934,6 +934,7 @@ compose:
|
||||
url_label: Web berth
|
||||
url_placeholder: "example.com"
|
||||
minimize: Batten down
|
||||
more_fields: Show the rest o' the manifest
|
||||
more_options: More riggin'
|
||||
new_message: A Fresh Scroll
|
||||
no_recipients: Nobody to send it to
|
||||
@@ -1019,6 +1020,7 @@ header:
|
||||
|
||||
inbox:
|
||||
empty: No bottles in this here tab.
|
||||
empty_account: Nothin' has washed ashore for this account yet.
|
||||
|
||||
insights:
|
||||
card:
|
||||
@@ -2374,6 +2376,7 @@ toast:
|
||||
schedule_cancelled: The castin' off be belayed.
|
||||
send_cancelled: Castin' off belayed — back to yer scroll.
|
||||
send_too_late: Too late, matey — that message has already sailed.
|
||||
sent: Message away!
|
||||
undo: Belay that
|
||||
error:
|
||||
no_archive_mailbox: There be no hold on this ship.
|
||||
|
||||
Reference in new issue
Block a user