mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +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
+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"
|
||||
|
||||
Reference in new issue
Block a user