Files
Paul LütznerandClaude Opus 5.5 8f7d2cb073 Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.

A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.

The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.

A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.

A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.

Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.

Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.

Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:39 +02:00

308 lines
18 KiB
Twig

{# templates/compose/_send_pill.html.twig #}
{#
The Send pill, and behind its chevron, send later.
Included TWICE by _window.html.twig and rendered ONCE at any given width:
• `placement: 'header'` — `md:hidden`, in the window header, where the phone
expects it. This is the spot the old plain Send ICON held before the last
round removed it; the pill itself lives there now, so scheduling is a thing
a phone can do and there is still exactly one Send on screen.
• `placement: 'bar'` — `hidden md:flex`, first in the action bar, which is
where the desktop has always had it and where it stays.
The two are the same markup on purpose. The previous shape of this — a header
icon that could only send and a bar pill that could also schedule — was two
Send buttons that disagreed about what Send meant. One partial cannot drift
into that.
**Both directions of the menu are here.** The bar pill opens UPWARD
(`bottom-full`) out of a window that already sits at the bottom of the screen,
anchored `left-0` because the pill is the first thing in the bar and a
right-anchored menu hung 150px off the window's left edge. The header pill is
the mirror image: it opens DOWNWARD (`top-full`, or the menu clears the top of
the phone) and anchors `right-0`, because in the header the pill is the LAST
thing on the row and a left-anchored `w-64` runs off the right of a 320px
screen. `max-w` keeps the narrow end honest either way.
The positioning box is OUTSIDE the pill because the pill is `overflow-hidden`
(that is what rounds the two buttons into one shape) and a menu absolutely
positioned inside it would be clipped to a 30px strip. Same shape as the
integration picker: relative wrapper, ui--dropdown, a `menu` target that starts
hidden.
Both halves take a 44px minimum below md. The chevron in particular was an 11px
hit area at its natural size, which is a control you aim at rather than press.
Every item is a real submit button pointed at the schedule route with
`formaction`, so choosing a time goes through the same submit path as Send —
the same guards in compose--compose#_handleSubmit, the same CSRF token, the
same form body. See compose--schedule.
Variables: message, params (from the including context); placement.
#}
{% set placement = placement|default('bar') %}
{% set header = 'header' == placement %}
{% set scheduleUrl = null == message.id
? path('app_compose_mail_schedule', params)
: path('app_compose_mail_schedule_draft', params|merge({'id': message.id})) %}
{# The hold already in place, if any. sentAt guards it: on a sent message
submissionSendAt is a record of when it was due, not a schedule anyone can
still call off. #}
{% set scheduledAt = (message.id and message.sentAt is null)
? message.submissionSendAt : null %}
<div class="relative shrink-0 {{ header ? 'flex md:hidden ml-1' : 'hidden md:flex mr-2' }}"
data-compose-send-pill="{{ placement }}"
data-controller="ui--dropdown compose--schedule"
data-action="ui--dropdown:opened->compose--schedule#refresh"
{# The zone every other timestamp in this app is drawn in — the user's
setting, not the browser's guess. The menu computes "tomorrow morning"
against it and posts a wall clock; ScheduledSendResolver turns that into
an instant with the tz database in hand. #}
data-compose--schedule-timezone-value="{{ clock.timezone }}"
data-compose--schedule-max-days-value="{{ constant('App\\Service\\Mail\\ScheduledSendResolver::MAX_SECONDS') // 86400 }}"
{# The floor, from the same constant the resolver enforces. The picker used
to check only "has this passed", so every time under a minute out — which
is exactly what a person types to test the feature — passed here and was
refused there. #}
data-compose--schedule-min-seconds-value="{{ constant('App\\Service\\Mail\\ScheduledSendResolver::MIN_SECONDS') }}"
data-compose--schedule-i18n-value="{{ {
scheduleUnreadable: 'compose.schedule.error.unreadable'|trans,
schedulePast: 'compose.schedule.error.past'|trans,
scheduleTooSoon: 'compose.schedule.error.too_soon'|trans,
scheduleTooFar: 'compose.schedule.error.too_far'|trans({
'%days%': constant('App\\Service\\Mail\\ScheduledSendResolver::MAX_SECONDS') // 86400,
}),
}|json_encode|e('html_attr') }}">
{# ── 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"
data-action="click->compose--compose#sendOrCancel"
{# Inset, and white rather than accent: the two halves of this
pill are square-cornered boxes rounded by the group's
`overflow-hidden`, so a ring drawn outside one of them is
clipped away with the corner it belongs to — the same fault
the attachment chip was reported for, and that file carries
the long version — including why `rounded-l-full` is half the
fix and not decoration: an inset ring on a square half is
still a square ring, and the clip bites its corners off. An
accent ring would also be an accent ring on an accent
button. #}
class="grid items-center justify-items-center cursor-pointer
text-white text-sm font-semibold rounded-l-full
px-4 py-1.5 max-md:min-h-11 hover:bg-white/10 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white">
<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"
title="{{ 'compose.send_options'|trans }}"
aria-label="{{ 'compose.send_options'|trans }}"
class="flex items-center justify-center text-white border-l border-white/30
px-2.5 py-1.5 max-md:min-h-11 max-md:min-w-11 rounded-r-full
hover:bg-white/10 transition-colors text-xs
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white">
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
{# See the note at the top for why the direction and the anchor both flip
with the placement. compose-scheduled-send.spec.ts measures the box that
comes out of it at 393px and at 320px.
The ENTRANCE flips with them, for the same reason: a menu should travel
away from the pill it came out of. The bar pill opens UPWARD, so the menu
rises into place from where the pill is; the header pill opens DOWNWARD,
so it comes down from above. A menu that slides the wrong way reads as a
second, unrelated thing appearing beside the one that was asked for.
Six pixels at Full and none at all at Minimal. #}
<div data-ui--dropdown-target="menu" hidden
data-enter="{{ header ? 'slide-down' : 'rise' }}"
class="absolute z-20 w-64 has-[[data-compose--schedule-target=custom]:not([hidden])]:w-80
max-w-[calc(100vw-2rem)] popover py-1
{{ header ? 'top-full mt-1 right-0 left-auto' : 'bottom-full mb-1 left-0 right-auto' }}">
{% if scheduledAt is not null %}
{# A draft that is already on a hold. The menu is where the schedule
was set, so it is where it can be called off — and unlike the
toast, this is still here tomorrow. #}
<div class="px-3 py-2 border-b border-line">
<p class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.schedule.scheduled_for'|trans }}
</p>
<p class="text-sm text-ink mt-0.5">
{{ 'compose.schedule.when'|trans({
'%date%': scheduledAt|intl_date('dMMM', clock.timezone),
'%time%': scheduledAt|date(clock.time, clock.timezone),
}) }}
</p>
<button type="button"
data-cancel-url="{{ path('app_compose_mail_undo', params|merge({'id': message.id})) }}"
data-action="click->compose--schedule#cancel click->ui--dropdown#close"
class="mt-1 text-xs text-danger hover:underline cursor-pointer">
{{ 'compose.schedule.cancel'|trans }}
</button>
</div>
{% endif %}
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.schedule.heading'|trans }}
</p>
{# Hidden until the controller has a time to put on it: a preset showing
its label and no hour is a button whose effect you cannot read.
`style="display:none"` as well as the attribute, and not belt and
braces — `[hidden]` lives in the UA stylesheet, so ANY author rule
that sets display wins over it, and `flex` below is exactly such a
rule. The attribute stays for the semantics an assistive technology
reads; the inline style is what actually keeps it off the screen.
Kept in step by compose--schedule. #}
{% for key in ['tomorrow_morning', 'tomorrow_afternoon', 'monday_morning'] %}
<button type="submit" hidden style="display:none" formnovalidate
formaction="{{ scheduleUrl }}"
data-compose--schedule-target="option"
data-schedule-key="{{ key }}"
data-action="click->compose--schedule#choose"
class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors text-left">
<span>{{ ('compose.schedule.' ~ key)|trans }}</span>
<span data-schedule-when class="text-xs text-ink-faint tabular-nums"></span>
</button>
{% endfor %}
<div class="my-1 border-t border-line"></div>
<button type="button"
data-compose--schedule-target="toggle"
data-action="click->compose--schedule#showCustom"
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors text-left">
<i class="fa-regular fa-calendar w-4 text-center" aria-hidden="true"></i>
<span>{{ 'compose.schedule.custom'|trans }}</span>
</button>
{# No picker library — the same one the calendar event editor draws,
with one card: days, then the hour, then the minute. The field is
hidden and still compose--schedule's: it seeds it, bounds it (`min`
is now, `max` the end of the hold — the picker greys out what lies
outside) and confirms it. The menu widens while this is open; a
six-day strip does not fit sixteen rem. #}
{% import 'calendar/_when.html.twig' as when %}
<div data-compose--schedule-target="custom" hidden class="px-3 py-2 space-y-2">
<div {{ when.attrs('now'|date('Y-m-d')) }} class="flex flex-col gap-2">
{# `input` as well as `change`, so a refusal goes the moment the
time that earned it is changed. #}
<input type="hidden"
data-calendar--when-target="start"
data-compose--schedule-target="input"
data-action="input->compose--schedule#revalidate change->compose--schedule#revalidate">
<div data-calendar--when-target="startCard" tabindex="-1"
role="status" aria-label="{{ 'compose.schedule.custom'|trans }}"
class="flex flex-col gap-0.5 rounded-xl border px-3 py-2 focus:outline-none">
<span class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'calendar.when.send_at'|trans }}
</span>
<span class="flex items-baseline gap-2">
<span data-when-part="day" class="text-[13px] text-ink-muted"></span>
<span data-when-part="time" class="text-lg font-semibold tracking-tight text-ink tabular-nums"></span>
</span>
</div>
<div data-calendar--when-target="panel"
class="flex flex-col gap-2.5 rounded-xl border border-line bg-sunken p-2.5"></div>
</div>
{# Named, because a bare time is ambiguous the moment the machine and
the setting disagree — which is exactly when it matters. #}
<p class="text-[11px] text-ink-faint">
{{ 'compose.schedule.timezone_hint'|trans({ '%zone%': clock.timezone }) }}
</p>
{# role="alert" because this is the only thing that happens when a
time is refused: the menu stays put and a line appears. Without it
a screen reader is told nothing at all, and "nothing happened" is
precisely the complaint this whole round is about. #}
<p data-compose--schedule-target="error" hidden
role="alert" aria-live="assertive"
class="text-xs text-danger"></p>
<button type="submit" formnovalidate
formaction="{{ scheduleUrl }}"
data-action="click->compose--schedule#confirm"
class="w-full rounded-full bg-accent text-accent-ink text-sm font-semibold
px-3 py-1.5 hover:bg-accent-strong transition-colors">
{{ 'compose.schedule.confirm'|trans }}
</button>
</div>
{# The only field of the lot that is submitted. The datetime-local above
is deliberately unnamed — the value that counts is the one a preset or
the confirm button writes here.
**`disabled` until a time is actually chosen**, which is new with the
second placement. There are two of these in the form now, both named
`schedule_at`, and a disabled control is not submitted at all: without
it PHP would take the LAST `schedule_at` in document order, which is
the pill the user did not touch, and every schedule set from the
header would post an empty time. compose--schedule arms its own field
as it writes to it. #}
<input type="hidden" name="schedule_at" value="" disabled
data-compose--schedule-target="field">
</div>
</div>