Merge fix/compose-editor-polish: in-app popovers, and a picker that fits

Replaces the native prompt and confirm with popovers: a link editor with URL
and text prefilled from the selection, a link viewer with Open/Change/Remove,
and a plain-text warning. Preset times now honour the user's 12/24 setting,
which formatWallClock never consulted.

Two real display bugs behind the emoji picker's black borders: --rgb-line was
spent without its alpha, so every hairline resolved to pure black, and the
component set color-scheme from the OS rather than the app theme. Also fixes
a more-options menu that was clipped to nothing on a phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-12 22:05:00 +02:00
commit cf9ac15e35
17 files changed
+1745 -36

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
{#
compose/_link_popover.html.twig
─────────────────────────────────────────────────────────────────────────────
The insert-link popover, for any surface driven by `compose--compose-toolbar`.
What it replaces is `prompt('Enter URL:')` — one unthemed field, no way to say
what the link should READ as, and a bare "example.com" going in as a relative
link to a path inside the app.
ONE element with two faces rather than two popovers. They are the same object
at two moments — "what is this link" and "what should this link be" — and
Change is a switch between them rather than a different window. The controller
drives both; it is also what holds the saved selection the URL is applied to,
which is why focusing a field in here does not lose the user's place. See the
note on #savedRange in compose_toolbar_controller.js.
A partial rather than markup in the compose window, because the settings
signature editor runs the SAME controller and had the same link button — and
therefore had the same prompt. One popover, two hosts, no second copy to
forget when one of them changes.
The POSITIONING is the caller's, passed in as `class`: the compose window
anchors it above a toolbar block that spans the window and never scrolls (the
button itself lives in a sideways scroller and would drag the popover off
screen with it); the signature editor anchors it under its own small bar.
Variables:
id string unique suffix for the two field ids
class string positioning and width classes for the popover box
#}
<div data-compose--compose-toolbar-target="linkPopover" hidden
role="dialog"
aria-label="{{ 'compose.link.dialog'|trans }}"
data-action="keydown->compose--compose-toolbar#linkKeydown"
class="{{ class|default('absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80') }}
z-40 popover p-3 text-sm">
{# ── Editing face ──────────────────────────────────────────── #}
<div data-compose--compose-toolbar-target="linkEdit" class="space-y-2">
<p data-compose--compose-toolbar-target="linkHeading"
class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.link.add'|trans }}
</p>
<div>
<label for="link-url-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
{{ 'compose.link.url_label'|trans }}
</label>
{# `text`, not `url`: a bare "example.com" is what people type and
is exactly what the controller normalises to https://example.com.
type=url would have the browser refuse it before we saw it. #}
<input type="text" inputmode="url"
id="link-url-{{ id }}"
data-compose--compose-toolbar-target="linkUrl"
autocomplete="off" spellcheck="false"
placeholder="{{ 'compose.link.url_placeholder'|trans }}"
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
text-sm text-ink placeholder:text-ink-faint outline-none
focus:ring-2 focus:ring-accent">
</div>
<div>
<label for="link-text-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
{{ 'compose.link.text_label'|trans }}
</label>
<input type="text"
id="link-text-{{ id }}"
data-compose--compose-toolbar-target="linkText"
autocomplete="off"
placeholder="{{ 'compose.link.text_placeholder'|trans }}"
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
text-sm text-ink placeholder:text-ink-faint outline-none
focus:ring-2 focus:ring-accent">
</div>
<p data-compose--compose-toolbar-target="linkError" hidden
role="alert" class="text-xs text-danger"></p>
<div class="flex items-center gap-2 pt-0.5">
<button type="button"
data-action="click->compose--compose-toolbar#applyLink"
class="rounded-full bg-accent text-white text-sm font-semibold
px-3 py-1.5 hover:bg-white/10 transition-colors">
{{ 'compose.link.apply'|trans }}
</button>
<button type="button"
data-action="click->compose--compose-toolbar#closeLink"
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors">
{{ 'compose.link.cancel'|trans }}
</button>
{# Only while an existing link is being edited — there is nothing
to unlink otherwise. #}
<button type="button" hidden
data-compose--compose-toolbar-target="linkUnlink"
data-action="click->compose--compose-toolbar#removeLink"
class="ml-auto text-xs text-danger hover:underline">
{{ 'compose.link.remove'|trans }}
</button>
</div>
</div>
{# ── Viewing face ──────────────────────────────────────────────
A click in a contenteditable only places a caret, so a link the user
just wrote is otherwise unreachable without sending the message first.
This is the Gmail affordance: the address, and the three things you can
do to it. #}
<div data-compose--compose-toolbar-target="linkView" hidden class="space-y-2">
<p class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.link.heading'|trans }}
</p>
{# A real anchor, so the browser's own "copy link address" and
middle-click work on it. #}
<a data-compose--compose-toolbar-target="linkHref"
href="#" target="_blank" rel="noopener noreferrer"
class="block truncate text-sm text-accent hover:underline"></a>
<div class="flex items-center gap-1">
<button type="button"
data-action="click->compose--compose-toolbar#openLink"
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
hover:text-ink transition-colors">
{{ 'compose.link.open'|trans }}
</button>
<button type="button"
data-action="click->compose--compose-toolbar#changeLink"
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
hover:text-ink transition-colors">
{{ 'compose.link.change'|trans }}
</button>
<button type="button"
data-action="click->compose--compose-toolbar#removeLink"
class="px-2 py-1 rounded text-sm text-danger hover:bg-hover
transition-colors">
{{ 'compose.link.remove'|trans }}
</button>
</div>
</div>
</div>
+110 -5
View File
@@ -55,7 +55,16 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
imageFailed: 'compose.image_insert_failed'|trans,
noSignature: 'compose.signature_none'|trans,
confirmPlainText: 'compose.confirm.plain_text'|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
onto the screen from JavaScript, and a string baked into the
controller is a string that stays English in a German window. #}
data-compose--compose-toolbar-i18n-value="{{ {
linkAdd: 'compose.link.add'|trans,
linkEdit: 'compose.link.edit'|trans,
linkInvalid: 'compose.link.invalid'|trans,
linkUnsafe: 'compose.link.unsafe'|trans,
}|json_encode|e('html_attr') }}"
{# Every From option's signature, keyed by the same "accountId|address"
token the hidden account <select> carries. Handed over in one go so
@@ -417,7 +426,67 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</div>
{# ── Toolbar ──────────────────────────────────────────── #}
<div class="px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
{# `relative` so the two popovers below can anchor to THIS block
rather than to a button. Both the format bar and the icon row
scroll sideways on a phone (.compose-scroll-x), and anything
anchored to a control inside one of them opens wherever that
control happens to have been scrolled to — which is how the emoji
panel came to open past the right edge of the screen. This
wrapper spans the window and never scrolls, so a popover pinned
to it is on screen by construction at every width. #}
<div class="relative px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
{# ── Link popover ─────────────────────────────────────
Shared with the settings signature editor, which runs the
same toolbar controller and had the same `prompt()`. The
positioning is passed in because it is the one thing the two
hosts disagree about: here it is anchored to the toolbar
BLOCK, which spans the window and never scrolls — the link
button itself lives in a sideways scroller and would drag
the popover off the screen with it. #}
{{ include('compose/_link_popover.html.twig', {
id: form.vars.id,
class: 'absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80',
}, with_context = false) }}
{# ── Plain-text warning ───────────────────────────────
Was `window.confirm()`. The rule it enforces has not changed
— it only appears when there is formatting that flattening
would actually destroy, and the switch stays reversible for
as long as the draft has not been saved in plain mode (see
_setPlainText) — but a native dialog is the one thing in this
window the theme never reached, and on a phone it is a
system sheet over an app that was mid-sentence. #}
<div data-compose--compose-target="plainWarning" hidden
role="alertdialog"
aria-labelledby="{{ form.vars.id }}_plain_warning_title"
aria-describedby="{{ form.vars.id }}_plain_warning_body"
class="absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80
z-40 popover p-3 text-sm">
<p id="{{ form.vars.id }}_plain_warning_title"
class="text-sm font-semibold text-ink">
{{ 'compose.plain_warning.title'|trans }}
</p>
<p id="{{ form.vars.id }}_plain_warning_body"
class="mt-1 text-xs text-ink-muted">
{{ 'compose.confirm.plain_text'|trans }}
</p>
<div class="mt-2.5 flex items-center gap-2">
<button type="button"
data-compose--compose-target="plainWarningConfirm"
data-action="click->compose--compose#confirmPlainText"
class="rounded-full bg-accent text-white text-sm font-semibold
px-3 py-1.5 hover:bg-white/10 transition-colors">
{{ 'compose.plain_warning.continue'|trans }}
</button>
<button type="button"
data-action="click->compose--compose#cancelPlainText"
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors">
{{ 'compose.plain_warning.cancel'|trans }}
</button>
</div>
</div>
{# One line that scrolls sideways below md — wrapping it would
eat three rows of a phone screen. The controller hides it
@@ -730,8 +799,18 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<input type="file" multiple class="hidden"
data-compose--compose-target="fileInput"
data-action="change->compose--compose#uploadFiles">
{# `data-compose--compose-toolbar-action` is load-bearing
here, not decoration: it is what the controller hangs a
mousedown preventDefault on, and that is the only reason
the editor keeps the user's selection while this button
is pressed. Without it the selection collapses before
the popover has had a chance to read it, and the text
field prefills empty. #}
<button type="button" data-compose--compose-toolbar-action
data-compose--compose-toolbar-target="linkBtn"
data-action="click->compose--compose-toolbar#insertLink"
aria-haspopup="dialog"
aria-expanded="false"
title="{{ 'compose.insert_link'|trans }}"
aria-label="{{ 'compose.insert_link'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
@@ -873,9 +952,29 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
aria-label="{{ 'compose.more_options'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical" aria-hidden="true"></i></button>
{# Below md this menu is pinned to the VIEWPORT rather
than to its button, exactly as the emoji panel is and
for exactly the same reason — except that here it was
not merely mispositioned, it was unreachable. The
button sits in `.compose-scroll-x`, whose
`overflow-x: auto` computes `overflow-y: auto` as
well, so a menu opening UPWARDS out of a 36px strip
was clipped to nothing: on a phone the three controls
in here (plain text, priority, read receipt) could be
tapped open and never seen. A fixed element is not
clipped by an overflow ancestor — its containing
block is .compose-window, which below md is the
screen — which is the whole trick.
`bottom-20` clears the action bar; `w-auto` releases
the `w-64` so left-2/right-2 can set the width.
compose-mobile.spec.ts measures the box against the
viewport at 393px and at 320px. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 left-0 md:right-auto z-20
w-64 max-w-[calc(100vw-2rem)] popover py-1">
class="absolute bottom-full mb-1 left-0 md:right-auto z-30
w-64 max-w-[calc(100vw-2rem)] popover py-1
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
max-md:bottom-20 max-md:w-auto max-md:max-w-none">
{# Plain text. A toggle, not a submit: what it
changes is which surface the body lives in, and
@@ -950,8 +1049,14 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
data-action="click->ui--dropdown#toggle"
title="{{ 'integration.picker.attach_from_service'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-cloud-arrow-down"></i></button>
{# Pinned to the viewport below md, same as the more-
options menu above and for the same reason: this
button is in the sideways scroller too, and a
menu opening upwards out of it is clipped away. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-20 min-w-48 popover py-1">
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-30 min-w-48 popover py-1
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
max-md:bottom-20 max-md:min-w-0">
{% for integration in pickerIntegrations %}
<button type="button"
data-controller="ui--modal"