Files
Paul LütznerandClaude Opus 5.5 464df70473 Move a conversation to a label, and take it out of the list it was in
Issue #25. Filing mail the way Gmail does took two actions here: the
label button adds a label and leaves the mail where it is, and a drop on
a folder needs a pointer and a sidebar in reach. "Move to" is one click,
beside the label button, in the list toolbar and in the reading pane:
the target goes on and the label of the list being looked at comes off.

The client names the view; the server decides what that removes. A body
that lists labels to strip is not an instruction the route has.

  Inbox            target on, archived (Archive on, Inbox off) — or
                   into the folder, where the target is one
  a label          that label swapped for the target
  Trash, Spam      target on, out of the bin, not into the inbox
  anywhere else    target on, Inbox off where it was on
  to Inbox         restore(), then the view's label off
  to Trash, Spam   the existing trash and spam paths, not a label swap

MoveToService composes ThreadStatusUpdater's operations rather than
writing labels itself, inside one transaction, because each of those
carries provider behaviour that took a bug report to get right. Only
the messages that carry the leaving label lose it: a sent reply in an
inbox thread gains the target and is not archived.

The issue asks for "tag(s)", plural. This is Gmail's single target;
several labels are still what the label button is for.

Undo came with it, for archive and labels as the issue asks, and for
the drag onto a folder since the mechanism was there. It remembers each
message's labels before the action, in the session, and puts exactly
those back — not the inverse of the action, which is wrong for a mixed
selection. A whole-view action runs in a worker and has none.

Three things fell out:

  - The toolbar's label menu posted once per ticked row. With a toast
    and an Undo on the answer that was five of each, so it posts the
    selection once now, to /status/bulk/label, which learned `attach`.
  - Inbox, Spam and Trash are asked for by role. Their labels are made
    lazily, so a picker that carried ids had no Trash row for anybody
    who had never deleted anything.
  - Font Awesome keeps its folder-with-arrow in Pro, so the button is
    an inline SVG.

Not verified against a live Gmail or Exchange account: the tests pin
local label state and the IMAP jobs that are queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:56:35 +02:00

75 lines
3.5 KiB
Twig

{#
_toast.html.twig
A pane with a coloured edge, not a coloured slab.
The filled version could not follow a theme: success painted bg-inverse, but
error and info were `text-white` over a fully saturated fill, and --rgb-info
existed only in :root — so on any theme the informational toast was the same
hard blue, and on a light-inked dark theme white-on-danger was barely legible.
Every colour here now comes from a token, so a new theme gets matching toasts
without touching this file.
The edge and the icon carry the type; the surface and the text are the same as
every other panel, which is also why a toast no longer shouts over the UI it is
reporting on.
#}
{% set tone = {
success: { edge: 'border-success', icon: 'fa-circle-check', text: 'text-success' },
error: { edge: 'border-danger', icon: 'fa-circle-exclamation', text: 'text-danger' },
info: { edge: 'border-info', icon: 'fa-circle-info', text: 'text-info' },
}[type ?? 'success'] %}
<div
data-controller="ui--toast"
data-ui--toast-duration-value="{{ extra.hide_after ?? 4000 }}"
{# It travels up into place rather than merely fading: the dock is at the
bottom edge, so "rise" is the direction the toast actually came from, and
a thing that moves the way it appears to have moved is a thing the eye
tracks instead of noticing. #}
data-enter="rise"
role="status"
class="relative pane-flat shadow-float overflow-hidden min-w-[280px] max-w-sm
border-l-4 {{ tone.edge }}"
>
<div class="flex items-center gap-3 px-4 py-3.5 text-sm font-medium text-ink">
<i class="fa-solid {{ tone.icon }} shrink-0 {{ tone.text }}" aria-hidden="true"></i>
<span class="min-w-0" data-toast-message>{{ message }}</span>
{% if extra.undo_url is defined %}
{# Which controller answers the click. A send is called off by
compose--undo-send, which is what this button was built for and
stays the default; a filing action is put back by
mail--status-undo, which also has to re-read the list. Both take
the same two values and the same action name, so the choice is
one identifier rather than a second button. #}
{% set undo = extra.undo_controller ?? 'compose--undo-send' %}
<span data-ui--toast-target="countdown" class="text-ink-faint tabular-nums"></span>
<button
data-controller="{{ undo }}"
data-{{ undo }}-url-value="{{ extra.undo_url }}"
data-{{ undo }}-hide-after-value="{{ extra.hide_after ?? 4000 }}"
data-action="click->{{ undo }}#abort"
class="text-accent underline underline-offset-2 font-semibold
hover:opacity-70 transition-opacity cursor-pointer whitespace-nowrap"
>
{# "Undo" is right for a send already on its way; for a hold set
for Monday it is the wrong verb — there is nothing to undo
yet, only something to call off. Callers that mean the other
thing pass their own key. #}
{{ (extra.undo_label ?? 'toast.undo')|trans }}
</button>
{% endif %}
<button
data-action="click->ui--toast#dismiss"
class="ml-auto shrink-0 text-ink-faint hover:text-ink transition-colors cursor-pointer"
aria-label="{{ 'toast.dismiss'|trans }}"
>
<i class="fa-solid fa-xmark text-sm" aria-hidden="true"></i>
</button>
</div>
</div>