mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
308 lines
18 KiB
Twig
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>
|