Files
pl_mail/templates/_partials/_list_toolbar.html.twig
Walid ManiandClaude Opus 5.5 f19818fa50 Put the Delete forever buttons behind a Disposal setting, off by default (#71)
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.

A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.

The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.

The choice lives in the user's settings bag and a config backup carries it. No migration.

Continues #56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:01:23 +02:00

543 lines
29 KiB
Twig
Raw Permalink 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.
{#
_list_toolbar.html.twig
─────────────────────────────────────────────────────────────────────────────
Gmail-style toolbar above the message list, inside #inbox-list-frame.
Variables (passed via `with` from _mailbox.html.twig):
page int current page (1-based)
total int total thread count
per_page int items per page
Inherited from the rendering context rather than passed (the include above is
not `only`):
sort SearchSortOrder|null set by the search page alone; its presence is
what puts the order switch on the toolbar
#}
{% set total = total ?? 0 %}
{% set per_page = per_page ?? 50 %}
{% set page = page ?? 1 %}
{% set last_page = max(1, (total / per_page)|round(0, 'ceil')) %}
{% set page_start = total > 0 ? (page - 1) * per_page + 1 : 0 %}
{% set page_end = min(page * per_page, total) %}
{% set has_prev = page > 1 %}
{% set has_next = page < last_page %}
{% set base_params = app.request.attributes.get('_route_params')|default({})|merge(app.request.query.all) %}
{#
tbtn macro — icon button with tooltip.
Tooltip is a sibling <span> inside the relative-positioned <button>.
Uses plain `group` + `group-hover:` (no named variant) so it works
with any Tailwind v4 setup regardless of CLI version.
The button itself carries `group` and `relative`.
#}
{% macro tbtn(action, icon, label, extra_classes) %}
<button
type="button"
data-action="{{ action }}"
aria-label="{{ label }}"
title="{{ label }}"
class="flex items-center justify-center
w-8 h-8 rounded-full
text-ink-muted
hover:text-ink
hover:bg-hover
transition-colors duration-100 cursor-pointer {{ extra_classes ?? '' }}"
>
<i class="{{ icon }} text-[15px]" aria-hidden="true"></i>
</button>
{% endmacro %}
{#
mrow macro — one row of the "More" menu above a selection.
The same `data-action` a toolbar button would carry, so a shortcut finds the
control by what it does and not by where it is drawn, followed by the one that
closes the menu: ui--dropdown closes on a click OUTSIDE it, and a row is inside.
`flyout` names the panel a row hands over to — see openFlyout in the controller.
#}
{% macro mrow(action, icon, label, extra_classes, flyout) %}
<button
type="button"
role="menuitem"
data-action="{{ action }} click->ui--dropdown#close"
{% if flyout ?? false %}data-mail--list-toolbar-flyout-param="{{ flyout }}"{% endif %}
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left whitespace-nowrap
text-ink-soft hover:bg-hover transition-colors cursor-pointer {{ extra_classes ?? '' }}"
>
<i class="{{ icon }} w-4 text-center text-[13px] text-ink-muted" aria-hidden="true"></i>
{{ label }}
</button>
{% endmacro %}
{# Same treatment for pagination nav links (they're <a> not <button>) #}
{% macro pgnav(href, icon, label, disabled) %}
{% if disabled %}
<span class="flex items-center justify-center w-8 h-8 rounded-full
text-ink-faint cursor-not-allowed"
aria-disabled="true">
<i class="{{ icon }} text-[13px]" aria-hidden="true"></i>
</span>
{% else %}
<a
href="{{ href }}"
data-turbo-frame="inbox-list-frame"
data-turbo-action="advance"
aria-label="{{ label }}"
title="{{ label }}"
class="flex items-center justify-center w-8 h-8 rounded-full
text-ink-muted
hover:text-ink
hover:bg-hover
transition-colors duration-100"
>
<i class="{{ icon }} text-[13px]" aria-hidden="true"></i>
</a>
{% endif %}
{% endmacro %}
{% import _self as m %}
<div
{# A COLUMN, and the controls are the row inside it.
The select-all-in-view banner used to be `absolute top-full` against this
bar, which put it over whatever came next in the frame — on the inbox
that is the category tabs, so the offer to select 18,000 conversations
landed on top of Primary / Social / Promotions / Updates and hid them.
In flow it takes its own height and the tabs move down for it, which is
what a banner should do. #}
class="flex flex-col px-3 py-1
border-b border-line
bg-raised
shrink-0"
data-controller="mail--list-toolbar"
data-mail--list-toolbar-total-value="{{ total }}"
{# What this list is showing, in the vocabulary the list template already
uses for itself. The server resolves it against a closed set — see
App\Service\Mail\ListViewResolver, which explains why the view is named
rather than posted as the current URL. #}
data-mail--list-toolbar-view-scope-value="{{ list_scope|default('') }}"
data-mail--list-toolbar-view-value-value="{{ list_scope_value|default('') }}"
data-mail--list-toolbar-unread-only-value="{{ unread_only|default(false) ? 'true' : 'false' }}"
{# The list, page number aside. A selection survives "next page" and does
not survive going to a different list, and this is how the two are told
apart — see assets/mail_selection.js. Route and parameters rather than
the scope above, because a search has no scope and is told from another
search only by its query. #}
data-mail--list-toolbar-view-key-value="{{ app.request.attributes.get('_route') }}?{{ base_params|filter((v, k) => k != 'page')|url_encode }}"
data-mail--list-toolbar-i18n-value="{{ {
selectAll: 'toolbar.view_select.offer'|trans,
selectAllAction: 'toolbar.view_select.offer_action'|trans,
allSelected: 'toolbar.view_select.selected'|trans,
clear: 'toolbar.view_select.clear'|trans,
purgeConfirmOne: 'toolbar.purge_confirm.one'|trans,
purgeConfirmMany: 'toolbar.purge_confirm.many'|trans,
}|json_encode }}"
>
{# The row the toolbar has always been. Wrapped now only so the banner
below can be a sibling of it rather than an overlay on the tabs. #}
<div data-toolbar-row class="flex items-center min-h-[var(--pane-header-h)]">
{# ── Checkbox + chevron ───────────────────────────────────────────────── #}
<div class="flex items-center shrink-0 mr-2">
{# The visible checkbox box — styled and toggled entirely by JS.
We do NOT use a real <input type="checkbox"> with peer-* because
the checkmark SVG is a child of the label, not a peer sibling,
so peer-checked: cannot reach it. JS drives everything. #}
<button
type="button"
role="checkbox"
aria-checked="false"
data-mail--list-toolbar-target="checkboxBtn"
data-action="click->mail--list-toolbar#toggleAll"
{# border-field-border, not border-field: the latter is the FILL
token — white at 0.6 alpha — so this box was drawing a white
border on a white surface and reading as a smudge next to the
row checkboxes below it. #}
class="flex items-center justify-center w-4 h-4 rounded
border border-field-border
bg-field
cursor-pointer transition-colors duration-100
hover:border-accent focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-accent"
aria-label="{{ 'toolbar.select_all'|trans }}"
>
{# Checkmark — shown when all selected #}
<svg data-mail--list-toolbar-target="checkmark"
class="hidden w-2.5 h-2.5 text-accent-ink pointer-events-none"
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2.2">
<polyline points="1.5,5 3.8,7.5 8.5,2.5"/>
</svg>
{# Dash — shown when some (but not all) selected #}
<svg data-mail--list-toolbar-target="indeterminate"
class="hidden w-2.5 h-2.5 text-accent-ink pointer-events-none"
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2.2">
<line x1="2" y1="5" x2="8" y2="5"/>
</svg>
</button>
{# Chevron — opens select-type dropdown #}
<div class="relative">
<button
type="button"
data-action="click->mail--list-toolbar#toggleSelectMenu"
data-mail--list-toolbar-target="selectMenuBtn"
aria-label="{{ 'toolbar.select_menu'|trans }}"
aria-haspopup="true"
class="ml-0.5 p-0.5 rounded
text-gray-400 hover:text-ink-soft
transition-colors cursor-pointer"
>
<svg class="w-2.5 h-2.5" viewBox="0 0 10 10" fill="none"
stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="2,3.5 5,6.5 8,3.5"/>
</svg>
</button>
{# Dropdown #}
{# The same entrance as every other menu in the app — spelt out
because this one is opened by the toolbar controller rather than
by ui--dropdown, and a menu that behaves differently from the ten
beside it for no reason is worse than one that does not move at
all. Safe on first-paint markup: it is `hidden`, and a
display:none element has no running animation. #}
<div
data-mail--list-toolbar-target="selectMenu"
data-enter="slide-down"
class="hidden absolute left-0 top-full mt-1 z-30 w-max min-w-36
bg-surface
border border-line
rounded-pane shadow-lg overflow-hidden py-1 text-sm"
>
{% for key, label in {
'selectAll': 'toolbar.select.all',
'selectNone': 'toolbar.select.none',
'selectRead': 'toolbar.select.read',
'selectUnread': 'toolbar.select.unread',
'selectStarred': 'toolbar.select.starred',
} %}
<button type="button"
data-action="click->mail--list-toolbar#{{ key }}"
{# `block`: the menu is as wide as its widest
entry now, and five inline-block buttons in a
max-content box sit side by side to work that
width out. #}
class="block w-full text-left px-3 py-1.5
text-ink-soft
hover:bg-hover
transition-colors cursor-pointer
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent">
{{ label|trans }}
</button>
{% endfor %}
{# The whole view, from the menu where selecting is done (#37).
It was only ever offered by the banner below, and the banner
only appears once every row on the page is ticked — so the
one way to select a folder was hidden behind having already
selected a page of it, and was reported as missing.
Only where it differs from "All": on a list that fits on one
page the two are the same selection, and offering both would
be asking somebody to work out that they are.
The wrapper is the pagination region's sibling in spirit:
`total` moves when mail arrives or a bulk action runs, and
the controller redraws the label from its own total value
rather than trusting the number rendered here. #}
<button type="button"
data-mail--list-toolbar-target="wholeView"
data-action="click->mail--list-toolbar#selectWholeView"
data-label="{{ 'toolbar.select.whole_view'|trans }}"
class="{{ total > per_page ? 'block' : 'hidden' }} w-full text-left px-3 py-1.5
border-t border-line mt-1 pt-2
text-ink-soft whitespace-nowrap
hover:bg-hover
transition-colors cursor-pointer
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent">
{{ 'toolbar.select.whole_view'|trans({ '%count%': total }) }}
</button>
</div>
</div>
</div>
{# ════════════════════════════════════════════════════════════════════════
Left action slot: bulk actions, visible when ≥1 row is selected. There
used to be a Refresh button holding this spot in the unselected state —
removed, because the topbar's sync button is the one that refreshes and
two spinners answering one question always had one of them lying.
════════════════════════════════════════════════════════════════════════ #}
{# Bulk actions — hidden until ≥1 selected #}
<div
data-mail--list-toolbar-target="actions"
class="hidden items-center gap-2"
aria-hidden="true"
>
{{ m.tbtn('click->mail--list-toolbar#archiveSelected', 'fa-solid fa-box-archive', 'toolbar.actions.archive'|trans) }}
{# Delete, or Delete forever where the selection is already thrown away.
Delete forever is the user's choice (Settings → Mail → Disposal), off
until they make it, and is the one button here that cannot be undone.
With it on, Spam and the bin offer it in place of Delete, as the rows
there do: in Spam the trash would only be a second place to delete the
same mail from, and in the bin it trashes mail that already carries
Trash, which changes nothing on the server while the answer still
takes the rows out of the list, so mail looked deleted and was back on
the next load.
With it off, Spam keeps Delete, and the bin has no Delete at all, for
the second reason: the only way out of it is Move to inbox. What asks
first before a delete forever is mail--list-toolbar#purgeSelected. #}
{% if list_scope|default('') in ['spam', 'trash'] and app.user and app.user.deleteNowButtons %}
{{ m.tbtn('click->mail--list-toolbar#purgeSelected', 'fa-solid fa-fire', 'toolbar.actions.purge'|trans, 'hover:text-danger') }}
{% elseif 'trash' != list_scope|default('') %}
{{ m.tbtn('click->mail--list-toolbar#deleteSelected', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans, 'hover:text-danger') }}
{% endif %}
{# Beside archive and delete because it is the third way a selection
LEAVES this list, and those three are what a selection is nearly
always made for. #}
{% include '_partials/_move_menu.html.twig' with {
labels: sidebarCounts.flattenedUserLabels,
} only %}
{# ── More ──────────────────────────────────────────────────────────
Everything else a selection can have done to it. Nine buttons in a
row were wider than the list whenever it shared its card — with a
docked calendar, or with the reading pane beside it — and the row
was cut off at the pane's edge. Three stay out; six go in here.
Snooze and Label are menus themselves, and a menu inside a menu is
two things to dismiss. So their rows close this one and open theirs
in its place: the two panels live in this wrapper, under the same
button, each behind a trigger nobody sees. That trigger is also what
`b` and `l` press, so the keys and the rows go through one door. #}
<div class="relative" data-controller="ui--dropdown" data-toolbar-more>
<button
type="button"
data-action="click->ui--dropdown#toggle"
aria-haspopup="menu"
aria-label="{{ 'toolbar.actions.more'|trans }}"
title="{{ 'toolbar.actions.more'|trans }}"
class="flex items-center justify-center w-8 h-8 rounded-full
text-ink-muted hover:text-ink hover:bg-hover
transition-colors duration-100 cursor-pointer"
>
<i class="fa-solid fa-ellipsis-vertical text-[15px]" aria-hidden="true"></i>
</button>
<div data-ui--dropdown-target="menu" hidden role="menu"
aria-label="{{ 'toolbar.actions.more'|trans }}"
class="absolute left-0 top-full mt-1 z-30 w-max min-w-48 popover py-1">
{# Star stars; it does not toggle — a selection is a mix of both,
and the server's bulk star is the one a drop on Starred
already uses. It is here for `s` as much as for the pointer:
a key presses the control that exists. #}
{{ m.mrow('click->mail--list-toolbar#starSelected', 'fa-solid fa-star', 'toolbar.actions.star'|trans) }}
{{ m.mrow('click->mail--list-toolbar#markReadSelected', 'fa-solid fa-envelope-open', 'toolbar.actions.mark_read'|trans) }}
{{ m.mrow('click->mail--list-toolbar#markUnreadSelected', 'fa-solid fa-envelope', 'toolbar.actions.mark_unread'|trans) }}
{{ m.mrow('click->mail--list-toolbar#openFlyout', 'fa-solid fa-clock', 'toolbar.actions.snooze'|trans, '', 'snooze') }}
{{ m.mrow('click->mail--list-toolbar#openFlyout', 'fa-solid fa-tag', 'toolbar.actions.label'|trans, '', 'label') }}
{# Not where the selection is already thrown away. In the bin the move
would pull mail back out of it and into Spam; in Spam it is nothing. #}
{% if list_scope|default('') not in ['spam', 'trash'] %}
<div class="my-1 border-t border-line"></div>
{{ m.mrow('click->mail--list-toolbar#spamSelected', 'fa-solid fa-circle-exclamation', 'toolbar.actions.spam'|trans, 'hover:text-danger') }}
{% endif %}
</div>
{# The two panels the rows above hand over to. Laid exactly over
the button and deaf to the pointer, so the button under it still
takes its own clicks; the panels switch the pointer back on for
themselves. Over the button, not beside it, because that is
where a panel has to hang from — the label panel measures its
trigger to place itself, and a trigger with no box measures as
the corner of the window. #}
<div class="absolute inset-0 pointer-events-none *:pointer-events-none [&_[data-ui--dropdown-target=menu]]:pointer-events-auto">
{# A menu, not a direct action: this used to hardcode "tomorrow
8am" with a comment asking for a picker. #}
<div class="absolute inset-0" data-controller="ui--dropdown">
<button type="button" tabindex="-1" aria-hidden="true"
data-toolbar-flyout="snooze"
data-action="click->ui--dropdown#toggle"
class="block w-full h-full invisible"
aria-label="{{ 'toolbar.actions.snooze'|trans }}"></button>
{% include '_partials/_snooze_menu.html.twig' with {
controller: 'mail--list-toolbar',
method: 'snoozeSelected',
snoozed: false,
align: 'left',
} only %}
</div>
{% include '_partials/_label_menu.html.twig' with {
labels: sidebarCounts.flattenedUserLabels,
targetType: 'thread',
trigger: false,
align: 'left',
} %}
</div>
</div>
<span
data-mail--list-toolbar-target="selectionCount"
class="ml-1 text-xs text-ink-muted tabular-nums whitespace-nowrap"
></span>
</div>
{# ── "You are looking at unread only" ──────────────────────────────────
Said out loud, and with a way back out — the same rule the label view's
account narrowing follows, and for the same reason: a filtered list and
a genuinely quiet one look identical, so a filter that does not announce
itself reads as mail having gone missing.
Here rather than in each view's header because six lists can be narrowed
this way — the inbox, Starred, Archive, Spam, Snoozed and any label,
under an account or across all of them — and they have six templates
between them. The toolbar is the one part every one of them already
shares.
The way out is this route again without `unread`, rebuilt from the
request rather than named: the views reached like this carry route
parameters (a label id) AND query parameters (?account=, ?tab=, ?sort=),
and dropping either on the way back would answer "show all" with a
different list than the one being filtered. Only `unread` and `page` are
removed — page, because page four of the unread threads is not a page
that need exist once the filter is gone. #}
{% if unread_only|default(false) %}
{% set _back = path(
app.request.attributes.get('_route'),
app.request.attributes.get('_route_params')|default({})|merge(
app.request.query.all|filter((v, k) => k not in ['unread', 'page'])
)
) %}
<span class="flex items-center gap-1.5 text-xs shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full
bg-accent-soft text-accent font-medium">
<i class="fa-solid fa-envelope text-[9px]" aria-hidden="true"></i>
{{ 'toolbar.filter.unread_only'|trans }}
</span>
<a href="{{ _back }}" class="text-ink-muted hover:text-ink hover:underline">
{{ 'toolbar.filter.show_all'|trans }}
</a>
</span>
{% endif %}
{# Spacer #}
<div class="flex-1"></div>
{# The list of keyboard shortcuts, for somebody who has them. `?` opens the
same dialog, and a key nobody has told you about is not a way in — this
is the visible one, on the bar the keys act on. Absent with the
shortcuts switched off, where it would advertise keys that do nothing;
the list is still in Settings → General for deciding to switch them on.
Before the sort menu and the pager so those two keep the far end, where
they have always been. #}
{% if app.user and app.user.keyboardShortcuts %}
<button type="button"
data-controller="ui--modal"
data-ui--modal-src-value="{{ path('app_shortcuts_help') }}"
data-ui--modal-title-value="{{ 'shortcuts.title'|trans }}"
data-action="click->ui--modal#open"
data-shortcuts-button
aria-label="{{ 'shortcuts.title'|trans }}"
title="{{ 'shortcuts.title'|trans }} (?)"
class="hidden md:flex items-center justify-center shrink-0
w-8 h-8 mr-1 rounded-full
text-ink-faint hover:text-ink hover:bg-hover
transition-colors duration-100 cursor-pointer">
<i class="fa-regular fa-keyboard text-[15px]" aria-hidden="true"></i>
</button>
{% endif %}
{# ── Sort order ────────────────────────────────────────────────────────
Sits before the pagination because switching it resets the pagination,
and a control that changes the one beside it belongs upstream of it.
Two sources, one control. `sort` is the search page's SearchSortOrder,
whose second position is relevance; `list_sort` is a folder's
ListSortOrder, whose second position is oldest-first. Relevance has no
meaning without a query, so the two enums stay separate and the menu
renders whichever it is handed. #}
{% if sort is defined and sort is not null and total > 0 %}
{% include '_partials/_sort_menu.html.twig' with {
current: sort,
options: enum('App\\Domain\\Enum\\Mail\\SearchSortOrder').cases,
route: 'app_mail_search',
} only %}
{% elseif list_sort is defined and list_sort is not null and total > 0 %}
{% include '_partials/_sort_menu.html.twig' with {
current: list_sort,
options: enum('App\\Domain\\Enum\\Mail\\ListSortOrder').cases,
route: app.request.attributes.get('_route'),
} only %}
{% endif %}
{# ── Pagination ─────────────────────────────────────────────────────────
The one part of this toolbar a background refresh replaces. The range
and the prev/next state are derived from a total the server owns, so
they go stale after a bulk action the way the category tabs do — and
unlike the buttons to the left, nothing here is the target of the click
that triggered the refresh. Everything else in this toolbar is left
exactly where it is: see mail--mail-pane#_swapRegions for why.
The wrapper renders whether or not there is a page to describe, because
a region that vanishes at zero cannot be filled again. `contents`, so it
adds no box to the toolbar's flex row. #}
<div data-list-region="pagination" class="contents">
{% if total > 0 %}
<div class="flex items-center gap-1 text-xs text-ink-muted shrink-0 pr-1">
{# `data-page-range`: what app.css takes out of a narrow toolbar
while rows are ticked — see "A selection in a narrow list". #}
<span class="tabular-nums mr-1" data-page-range>
{{ page_start }}–{{ page_end }} {{ 'toolbar.pagination.of'|trans }} {{ total }}
</span>
{% if has_prev %}
{% set prev_params = base_params|merge({page: page - 1}) %}
{{ m.pgnav(path(app.request.attributes.get('_route'), prev_params), 'fa-solid fa-chevron-left', 'toolbar.pagination.prev'|trans, false) }}
{% else %}
{{ m.pgnav('', 'fa-solid fa-chevron-left', 'toolbar.pagination.prev'|trans, true) }}
{% endif %}
{% if has_next %}
{% set next_params = base_params|merge({page: page + 1}) %}
{{ m.pgnav(path(app.request.attributes.get('_route'), next_params), 'fa-solid fa-chevron-right', 'toolbar.pagination.next'|trans, false) }}
{% else %}
{{ m.pgnav('', 'fa-solid fa-chevron-right', 'toolbar.pagination.next'|trans, true) }}
{% endif %}
</div>
{% endif %}
</div>
</div>{# ── end of the controls row ──────────────────────────────────── #}
{# The offer to select past the page. Hidden until every row on screen is
ticked AND the view holds more than fits — before that it would be a
control that does nothing. Filled in by the controller, which is the only
thing that knows both numbers.
`-mx-3` so the tinted strip runs the full width of the bar while the
toolbar keeps its own horizontal padding, and `mt-1` to clear the row
above rather than sit against it. #}
<div data-mail--list-toolbar-target="viewBanner"
class="hidden -mx-3 mt-1 flex items-center justify-center gap-2
px-3 py-1.5 text-xs bg-accent-soft text-ink border-t border-line">
<span data-mail--list-toolbar-target="viewBannerText"></span>
<button type="button"
data-mail--list-toolbar-target="viewBannerAction"
data-action="click->mail--list-toolbar#selectAllInView"
class="font-semibold text-accent underline underline-offset-2 hover:opacity-70 cursor-pointer">
</button>
</div>
</div>