Files
pl_mail/templates/_layout/_mailbox.html.twig
T
550dd9cf89 Pull the mail list down to sync on a phone (#163)
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged.

Takes over #115.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:29:01 +02:00

488 lines
29 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# A refresh of the list gets the list, a navigation of the list frame gets the
document with only the list in its body, and an ordinary visit gets the whole
page. All three render the same `list_frame` block below, so the answers
cannot drift apart — see App\Twig\ListFragmentGlobal for what each caller
reads, and for why the middle one keeps its head. #}
{% extends listFragment.poll ? '_layout/_list_fragment.html.twig' : '_layout/app.html.twig' %}
{# The list/reading pane switch belongs to these pages alone: its targets are
further down this file, and it throws on connect wherever they are not.
Spelled out rather than `parent() ~ mail--mail-pane`, because the parent is
conditional above and the fragment layout has no such block to inherit. #}
{#
A mail list is never restored from Turbo's snapshot cache.
Opening a conversation is an ordinary Drive visit, so Turbo snapshots the list
as it leaves — and it leaves BEFORE the conversation is marked read, because
mail--thread-read waits two seconds and then posts. The stream that post renders
back targets a row that is not on the thread page, so nothing updates the
snapshot either. Pressing the browser's back button then restored it: the row
still bold, the unread count still counting it, and no request made that could
have corrected either.
A poll does correct it, a few seconds later, which is what made this "sometimes"
— and what makes it easy to write a test that passes against the bug. The list
is stale at the moment it is shown, and that is the whole of the complaint.
`no-cache` means back re-fetches. That costs one request on a screen whose only
purpose is to show current state, and whose contents may have changed for a
dozen other reasons while the conversation was open.
#}
{% block turbo_cache_control %}
<meta name="turbo-cache-control" content="no-cache">
{% endblock %}
{# The base layout puts the Inbox's unread count in front of every title, which
is right for settings or the calendar and wrong here: a mail folder says its
own number or none (the bin counts a total, Sent and Archive count nothing),
and the Inbox says its own in its own title block. Off by default for all of
them, and each page that does count names its key again, as before. #}
{% block title_prefix %}{% endblock %}
{% block title_count_key %}{% endblock %}
{# mail--dnd is here rather than in the base layout for the same reason
mail--mail-pane is: it is about a mail LIST. It listens on <body> because
the two ends of a drag — the rows in the list frame and the folders in the
sidebar, of which there are two copies — have no closer common ancestor. #}
{# `parent()`, and not the base list written out again. Written out, it was
one controller short: the base layout mounts `mail--jobs` beside
`core--mercure`, this block replaced both names with its own three, and the
topbar's background-work indicator was never told to look again on any mail
page — which is every page a bulk action is started from. It showed what the
server rendered at page load and then sat there, for as long as it existed
(#42 is where it was finally noticed). Whatever the base layout mounts, the
mailbox mounts too. #}
{% block body_controllers %}{{ parent() }} mail--mail-pane mail--dnd{% endblock %}
{% block body_values %}
{# The list to draw beside a message that was opened by its own address.
A thread page has no list behind it — the route knows a conversation, not
the folder and page it was opened from — so with the reading pane beside
the list, a reload or a bookmark would otherwise leave an empty column
next to the message. The Inbox is the one list that always exists. #}
data-mail--mail-pane-inbox-url-value="{{ path('app_mail_inbox') }}"
{# Two whole URLs rather than a base the controller appends an action to.
A route is the router's to spell, and `path()` cannot be asked for a
prefix — building one by trimming the end off a generated path is a
string operation that keeps working right up until the route changes. #}
data-mail--dnd-move-url-value="{{ path('app_status_bulk_run', { action: 'move' }) }}"
data-mail--dnd-label-url-value="{{ path('app_status_bulk_run', { action: 'label' }) }}"
data-mail--dnd-category-url-value="{{ path('app_status_bulk_run', { action: 'category' }) }}"
data-mail--dnd-read-url-value="{{ path('app_status_bulk_run', { action: 'read' }) }}"
data-mail--dnd-star-url-value="{{ path('app_status_bulk_run', { action: 'star' }) }}"
{# The placeholder is left in deliberately — the controller substitutes it.
Same shape as the calendar grid's pending/reverted announcements. Always
plural because the pill it fills is only drawn for two or more; a single
conversation drags a picture of its own row. #}
data-mail--dnd-dragging-value="{{ 'dnd.dragging'|trans }}"
{% endblock %}
{# The one part of a view that varies within its scope: the category tab for the
inbox, the label id for a label view. Empty for every list where the scope is
the whole answer. Defined here so `block('list_scope_value')` resolves for
every list rather than only the two that care. #}
{% block list_scope_value %}{% endblock %}
{% set selectedMessage = selectedMessage|default(null) %}
{#
Subclasses can override these blocks:
inbox_tabs — tabs / heading row above the toolbar (renders before toolbar)
inbox_toolbar — override the entire toolbar (rarely needed)
message_list — the <li> items inside the <ul>
list_scope — space-separated special-use roles this view shows, so a
Mercure mailbox.synced update knows whether to refresh
("inbox", "sent", … or "*" for every mailbox; default "*")
reading_pane — the reading pane content
pane_open — "true"/"false" string (default false)
list_hidden — extra CSS classes to hide list on direct-load pages
reading_hidden — extra CSS classes (default: "hidden")
Pagination variables expected from the controller (all optional, fallback to 0/1):
page, total, per_page
#}
{#
Which of the two documents this is.
A frame navigation of the list — a folder in the sidebar, a category tab, the
pager, the sort menu, a search — has Turbo take `inbox-list-frame` out of the
response and drop everything else in the body on the floor. Rendering the
sidebar, the topbar and the calendar pane for it is work whose output is
measured, parsed and discarded — 21–24 database statements on a fifty-row
list against the 9–13 the frame alone costs, spent on a navigation that cannot
have changed any of it.
The HEAD is not dropped, which is why this is a fork inside `body` rather than
a second parent layout. Turbo reads that head to decide whether the navigation
renders anything at all, and then merges it into the live document, removing
what the response leaves out — so it has to stay exactly what app.html.twig
renders for a full visit. Cut it down to the <title> and the tab stops
following the folder you clicked; cut it down further and the page loses its
csrf token. Both measured: see App\Twig\ListFragmentGlobal.
#}
{% block body %}{% if listFragment.navigation %}{{ block('list_frame') }}{% else %}{{ block('mailbox_page') }}{% endif %}{% endblock %}
{% block mailbox_page %}
{#
sidebar-drawer wraps the entire page so the controller can manage
the drawer state from any trigger (burger button in the topbar, backdrop).
#}
<div
class="h-full overflow-hidden app-bg"
data-controller="ui--sidebar-drawer"
>
{# ── Mobile backdrop ──────────────────────────────────────────────── #}
<div
data-ui--sidebar-drawer-target="backdrop"
data-action="click->ui--sidebar-drawer#backdropClick"
class="md:hidden fixed inset-0 z-30 bg-black/40 backdrop-blur-sm
opacity-0 pointer-events-none transition-opacity duration-200"
aria-hidden="true"
></div>
{# ── Mobile sidebar drawer ────────────────────────────────────────── #}
{#
On mobile: fixed, full-height, slides in from the left.
On md+: hidden (the normal sidebar is in the flex row below).
#}
{# `data-surface` — see app.css. Each of the three marks a wrapper that
re-points the density tokens at that surface's own resolved pair, so
a dense list can sit beside a comfortable reading pane. On the
wrappers rather than inside the partials because the cascade reaches
the same elements either way and _sidebar.html.twig is a file this
change deliberately leaves alone. Both sidebar copies carry it: the
drawer is the same sidebar on a phone, and a setting that applied to
one and not the other would be a bug nobody could describe. #}
<div
data-ui--sidebar-drawer-target="drawer" id="sidebar-drawer" inert
data-surface="sidebar"
class="md:hidden fixed inset-y-0 left-0 z-40 w-64 p-3
-translate-x-full transition-transform duration-200 ease-out"
aria-label="{{ 'sidebar.nav.inbox'|trans }}"
>
{% include '_partials/_sidebar.html.twig' with { _drawerMode: true } %}
</div>
{# ui--split spans the topbar as well as the panes: the calendar
toggle lives in the topbar, and a Stimulus action only reaches a
controller mounted on an ancestor. #}
<div class="shell-frame flex h-full flex-col p-gutter gap-gutter"
{% if app.user %}
style="--calendar-pane-width: {{ app.user.calendarPaneWidth }}px"
data-controller="ui--split"
data-ui--split-state-url-value="{{ path('app_calendar_pane_state') }}"
data-ui--split-token-value="{{ csrf_token('calendar_pane_state') }}"
data-ui--split-min-value="{{ constant('App\\Entity\\User\\User::CALENDAR_PANE_MIN_WIDTH') }}"
data-ui--split-max-value="{{ constant('App\\Entity\\User\\User::CALENDAR_PANE_MAX_WIDTH') }}"
data-ui--split-mode-value="{{ app.user.calendarPaneMode.value }}"
{# Settings → Calendar → Behaviour. Which position the switch skips
(none, split or calendar); the label in the topbar is drawn from
the same setting. #}
data-ui--split-skip-value="{{ app.user.calendarSwitchSkip.value }}"
{# The attribute drives every layout rule below, and it is rendered
as the STORED mode. It used to be rendered as `mail` and
corrected by the controller on connect — which painted every
reload as mail first and the remembered view second, a flash on
every refresh. The one reader the stored mode can be wrong for
is a phone, which cannot hold split or calendar; the controller
still demotes there on connect, so the correction flash is
confined to the case that actually needs correcting. #}
data-calendar-mode="{{ app.user.calendarPaneMode.value }}"
{# …and marked as remembered rather than chosen in this window, which
is what keeps the phone's first paint clean. Below lg every
remembered position opens on the mail, and the pane is already
`hidden` there, so until the controller has confirmed the mode
the mail must not step aside for a calendar that is not drawn
(see [data-calendar-restored] in app.css). Split is the default
now, so without this every phone would open on a blank row and
wait for JavaScript to put the mail back. connect() removes it. #}
data-calendar-restored
{# The pane reloads its body through a Turbo Frame, and the view it
lands on decides how much room it wants — see
CalendarView::minimumPaneWidth(). turbo:frame-load bubbles, so
the boundary's own controller can hear it from up here. #}
data-action="turbo:frame-load->ui--split#paneLoaded"
{% endif %}
>
{# Full-width topbar. `calendarRestored`, because its calendar
switch has to be drawn in the same remembered position as the
layout above — and, like the layout, as `mail` below lg. See
"The first paint" in the topbar. #}
{% include '_partials/_topbar.html.twig' with { calendarRestored: true } %}
{# Sidebar + content #}
<div class="flex flex-1 gap-gutter overflow-hidden">
{# Sidebar — desktop only (hidden on mobile, shown in drawer) #}
<div class="hidden md:flex" data-surface="sidebar">
{% include '_partials/_sidebar.html.twig' %}
</div>
{# ── The mail column: the insight card, then the mail card ──
Two cards, not one card with a band inside it. The strip is a
SIBLING of .main-pane here rather than a child of the list
pane, which is the only way it can carry its own border and
sit on the app background the way the sidebar and the
calendar pane do — a child can only ever be a box drawn on
the mail card's surface.
`data-mail-column` exists for the two rules in app.css that
hide the mail side outright: calendar mode, and split below
lg. They select .main-pane, and a wrapper around it would
survive both and leave an insight card floating beside a
calendar. The wrapper answers to the same two rules.
The strip is still LAZY and still renders nothing when there
is nothing to say — being its own card costs the mailbox no
more than being a band did. #}
{# No `gap-gutter` here, and that is load-bearing. A gap is
paid between CHILDREN, and the frame below is a child even
with nothing in it — before its lazy fetch lands, on a day
with no insight, and after the strip is dismissed. The mail
pane then sat a gutter lower than the sidebar beside it, for
a strip that was not on screen. The separation is the
STRIP's margin instead (see insight/_pane.html.twig), so it
exists exactly when the strip does. #}
<div data-mail-column
class="flex flex-col flex-1 min-w-0 overflow-hidden">
<turbo-frame id="insight_pane"
class="shrink-0"
src="{{ path('app_insight_pane') }}"
loading="lazy"></turbo-frame>
{# Main content. Targeted by ui--split so the drag can enforce a
minimum on this side too — the pane knows how much room it is
taking only by measuring what is left. #}
{#
The reading pane position, drawn from the first paint.
`data-reading-pane` is the STORED mode and nothing the browser has
measured: whether the message actually goes beside the list is a
question about this card's width, answered in app.css by a
container query, so a window the calendar has squeezed falls back
to one pane at a time without a script having to notice. It is
rendered here rather than applied on connect for the reason the
calendar mode is — a reload that painted one pane and then
rearranged itself would flash on every visit.
`data-reading-open` is the one thing that varies within a mode: a
message is open. The thread page renders it directly; everywhere
else mail--mail-pane sets and clears it as messages open and
close, so the placeholder and the message never both show.
#}
{% set readingPane = app.user ? app.user.readingPaneMode : enum('App\\Domain\\Enum\\Mail\\ReadingPaneMode').Off %}
<div class="flex flex-1 overflow-hidden relative main-pane"
data-reading-pane="{{ readingPane.value }}"
{% if 'true' == block('pane_open')|trim %}data-reading-open{% endif %}
{% if app.user %}
data-ui--split-target="main"
data-controller="mail--reading-split"
style="--reading-pane-pct: {{ app.user.readingPaneWidthPct }}%"
data-mail--reading-split-state-url-value="{{ path('app_appearance_reading_pane_state') }}"
data-mail--reading-split-token-value="{{ csrf_token('reading_pane_state') }}"
data-mail--reading-split-min-value="{{ constant('App\\Entity\\User\\User::READING_PANE_MIN_PCT') }}"
data-mail--reading-split-max-value="{{ constant('App\\Entity\\User\\User::READING_PANE_MAX_PCT') }}"
data-mail--reading-split-default-value="{{ constant('App\\Entity\\User\\User::READING_PANE_DEFAULT_PCT') }}"
data-mail--reading-split-width-value="{{ app.user.readingPaneWidthPct }}"
{% endif %}>
{# List pane #}
<div
id="message-list"
data-surface="list"
data-mail--mail-pane-target="list"
data-mail--reading-split-target="list"
{# Confirms to the server that the rows below reached the
screen. Mounted on the container rather than on each
row so fifty rows are one report, and here rather than
inside the frame so it survives a fragment swap and
watches for one. #}
{# One data-controller attribute: a second one is dropped by the parser. The
pull gesture is outside the frame, so a refresh cannot take it away, and
it reaches the topbar's sync button by lookup, not by DOM position. #}
{% if app.user %}
data-controller="mail--new-marker mail--pull-to-refresh"
data-mail--new-marker-url-value="{{ path('app_mail_threads_listed') }}"
{% else %}
data-controller="mail--pull-to-refresh"
{% endif %}
{# @container, so the rows below switch to their stacked
layout on the LIST's width. They used to switch on the
viewport's, which is a different number entirely once
the calendar pane is docked: a 1440px window with a
wide pane leaves the list narrower than a phone, and
every row stayed on one line and truncated to nothing. #}
class="@container relative flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
>
{# The pull to refresh indicator. Here, beside the frame and not inside it, so a
refresh never replaces it. Its words are the visual states only: the sync
toast is what announces the run, so this stays out of the accessibility tree. #}
<div data-mail--pull-to-refresh-target="indicator" data-state="idle" aria-hidden="true" class="mail-pull-indicator">
<i class="fa-solid fa-rotate" data-mail--pull-to-refresh-target="icon" aria-hidden="true"></i>
<span data-label-for="pulling">{{ 'pull_to_refresh.pull'|trans }}</span>
<span data-label-for="release">{{ 'pull_to_refresh.release'|trans }}</span>
</div>
{{ block('list_frame') }}
</div>
{#
The divider between list and message. Drawn only when the
reading pane is beside the list (see app.css), which is also
how mail--mail-pane tells that it is: a handle that is
`display: none` means one pane at a time.
A separator rather than a button, because it is a window
splitter in the ARIA sense: it has a value, a range and arrow
keys, and a screen reader announces it as such. The value is
the MESSAGE's share of the card, in percent, which is what is
stored and what the keys step.
#}
{% if app.user %}
<div
data-reading-handle
data-mail--reading-split-target="handle"
data-action="pointerdown->mail--reading-split#start keydown->mail--reading-split#nudge dblclick->mail--reading-split#reset"
role="separator"
aria-orientation="vertical"
aria-valuemin="{{ constant('App\\Entity\\User\\User::READING_PANE_MIN_PCT') }}"
aria-valuemax="{{ constant('App\\Entity\\User\\User::READING_PANE_MAX_PCT') }}"
aria-valuenow="{{ app.user.readingPaneWidthPct }}"
aria-label="{{ 'reading_pane.handle'|trans }}"
tabindex="0"
></div>
{% endif %}
{# Reading pane #}
<div
data-mail--mail-pane-target="reading"
data-mail--reading-split-target="reading"
data-surface="reading"
class="flex flex-col flex-1 overflow-hidden {% block reading_hidden %}hidden{% endblock %}"
>
{% block reading_pane %}
{% if selectedMessage is not null %}
{% include 'mail/_message_content.html.twig' with { message: selectedMessage } %}
{% endif %}
{% endblock %}
</div>
{#
What the right-hand pane says while no message is open. A
sibling of the reading pane and not content inside it,
because mail--mail-pane REPLACES the reading pane's
innerHTML with each message it loads — a hint placed in it
would be wiped by the first click and never come back.
Hidden everywhere the reading pane is not beside the list.
#}
<div data-reading-placeholder aria-hidden="true">
<i class="fa-regular fa-envelope-open" aria-hidden="true"></i>
<p>{{ 'reading_pane.placeholder'|trans }}</p>
</div>
</div>
</div>{# /mail column #}
{# Calendar — a sibling of .main-pane, so the handle between
them splits the row's width rather than shrinking one into
the other. Below lg it is absent and the sidebar link goes
to the full page instead. #}
{% if app.user %}
{% include '_partials/_calendar_pane.html.twig' %}
{% endif %}
</div>
</div>
</div>
{% endblock %}
{#
The list itself, in one place.
Its own block because it is rendered two ways: inside the page above on a
visit, and alone as the whole response when the poll refreshes it. The page
templates fill `message_list`, `inbox_tabs` and the toolbar without caring
which of the two is happening.
#}
{% block list_frame %}
{# Whether this page actually put a list in the frame. The thread page does
not — it has no list to render — and the pane controller reads this to
know that going Back has to fetch one rather than uncover an empty
frame. See mail_pane_controller#_listNeedsRendering. #}
{# `data-sync-scope` is ON THE FRAME, which is a change of address rather
than of meaning: it used to sit on #message-list, one element further
out. That element is outside the frame, so it survives a frame
navigation — and a scope that survives the navigation is a scope that
describes the folder you just left. Switch from the inbox to Sent and
the list went on refreshing itself for inbox syncs and ignoring sent
ones, which shows up as new mail not appearing until something else
redraws. The scope belongs where the list it describes is rendered.
Read by mail--mail-pane#_affectsCurrentView and by compose--compose
when it discards a draft. #}
<turbo-frame id="inbox-list-frame" data-turbo-action="advance" class="contents"
data-list-rendered="{% block list_rendered %}1{% endblock %}"
data-sync-scope="{% block list_scope %}*{% endblock %}"
data-list-scope-value="{{ block('list_scope_value') }}">
{# ── Gmail-style toolbar ──────────────────────────────────────────── #}
{% block inbox_toolbar %}
{# list_scope is the same block the sync scope uses, so a view
names itself once. list_scope_value carries the one thing that
varies within a scope: the category tab for the inbox, the label
id for a label view. Both go to the server's closed set — see
App\Service\Mail\ListViewResolver. #}
{% include '_partials/_list_toolbar.html.twig' with {
page: page ?? 1,
total: total ?? 0,
per_page: per_page ?? 50,
list_scope: block('list_scope'),
list_scope_value: block('list_scope_value'),
unread_only: unread_only ?? false,
} %}
{% endblock %}
{# Optional tabs row (inbox only).
The wrapper is always here even when the row is not, because it is
what a refresh writes into: `data-list-region` marks the parts of
this frame a background refresh is allowed to replace, and a region
that disappears when it is empty cannot be filled again. Only the
inbox renders tabs, and only when more than one category has mail —
see mail/inbox.html.twig — so the empty case is the common one.
`contents`, so the wrapper adds no box of its own and the tabs stay
a direct child of the frame's flex column. #}
<div data-list-region="tabs" class="contents">{% block inbox_tabs %}{% endblock %}</div>
{# The rows are a region too. A refresh brings them up to date by
morphing rather than replacing — see mail--mail-pane#_morphRows.
`data-enter-children`, not `data-enter`: the whole-list gesture is
the ROWS arriving, not this element. A list that fades as one block
tells you a grey rectangle changed; fifty rows dropping in, each one
a frame behind the one above it, tells you what changed. motion.js
gives each row the entrance named here and marks it as spoken for, so
the entrance the row carries for itself — the long drop that means
one new mail, see _partials/_thread_row.html.twig — cannot fire on
top. Same direction as that one, and nothing else in common: this is
30ms a row, and what is seen is the cascade rather than the fall.
This element is only ever FILLED by a refresh, never replaced, so it
fires on exactly the thing it should mean: a genuinely different list
arriving, from the sidebar, a category tab, the next page or a search.
The stagger is capped in CSS at eight rows and that cap is doing real
work here. Fifty rows at 16ms would be four fifths of a second before
the last one was legible; capped, a list of fifty and a list of six
both finish in about 150ms and read as one cascade. #}
<ul data-list-region="rows"
data-enter-children="slide-down"
data-enter-stagger
class="flex-1 overflow-y-auto divide-y divide-line">
{% block message_list %}{% endblock %}
</ul>
</turbo-frame>
{% endblock %}