mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Four reported items, and the feature that turned out to be the fix for two of
them.
**Selecting past the page.** "Select all" meant the fifty rows on screen, which
is the only thing a checkbox over a paginated list can mean on its own — and not
what anybody wants in a bin holding a hundred and ninety-five. When the page is
fully selected and the view holds more, the toolbar now offers the rest, and the
same gesture covers "every starred mail".
The view arrives as a named scope, not as the URL the user is on. Posting the
URL would make every mail list route an input to a bulk WRITE, so a route added
later for reading becomes a way of deleting without anyone deciding that. The
scope names are the ones the list templates already use for themselves
(`{% block list_scope %}`), so there is nothing new to learn and the two cannot
drift. Unread-only travels with it: a "select all" that ignored the filter would
act on mail the person cannot see.
**One request, not one per conversation.** The toolbar posted per thread, in
parallel — fine for fifty rows, impossible for a folder. That also could not
answer for the LIST: each response redrew its own row and knew nothing else, so
an emptied list still said "1–5 of 5" (Schreibtest 10). The bulk response
carries the rows AND the pane re-reads the list, because the pager and the empty
state are properties of the list that no row-shaped stream can carry.
Two single-thread assumptions had to go with it, and both were correct until
now. `finish()` resynced only `$messages[0]->thread`, so a bulk archive answered
200, reported 71, and changed nothing on screen — the threads kept the Inbox
label their messages no longer had. `markRead()` wrote the whole selection's
unread count onto the first thread, so four conversations were marked read and
three rows went on rendering as unread. Both now work per thread, and the bulk
controller groups by ACCOUNT because ThreadStatusUpdater resolves the
destination folder from the first message's account.
**The 404 was English and in the wrong theme** (Befunde 2). Not an oversight in
the template: a 404 is thrown by the router at priority 32 of kernel.request,
before the firewall at 8 and before UserLocaleSubscriber at 6 — nothing has
authenticated when the exception is handled, so there is no user, no locale and
no theme. The error template must not look one up, and its own docblock explains
why at length. So both hints ride in a cookie written on ordinary responses.
**Sie/du** (Befunde 8). Seven strings addressed the reader formally, and the
report found them in Aliases because that is where the reader happened to look.
Guarded by a test that uses the one rule that can be checked: German capitalises
both the polite second person and the first word of a sentence, so a capitalised
form MID-sentence can only be formal address. A naive search flags a dozen good
sentences, gets an exception list, and the exception list then swallows the next
real one.
**A revert, recorded rather than hidden.** The label menu's escape to the
browser's top layer is gone. The bug is real — the panes' `backdrop-filter`
makes each a stacking context and the containing block for `position: fixed`, so
nothing inside one can rise above the chrome — but the fix broke clicking:
`elementFromPoint` at an option's own coordinates answers null and the click
never lands. It did the same to the snooze menu. Three specs caught it. A menu
that is visible and does nothing is worse than one that is clipped, and I could
not find the cause without seeing the real layout, so the spec is marked fixme
with the whole story in it.
Also: a shared `rowAction()` for the specs. A row's actions are
`visibility: hidden` until hovered, which makes them easy to lose under four
parallel workers and — because a hidden element is not in the accessibility tree
— makes `getByRole` report a missing action for one that is merely unhovered.
That was three of my own specs failing on a different run each time.
4027 unit tests, PHPStan clean, 550 browser tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
3.5 KiB
JavaScript
98 lines
3.5 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
|
|
/**
|
|
* A small click-to-open menu.
|
|
*
|
|
* The compose window already has bespoke open/close logic for the from-address
|
|
* picker, written before there was a second menu to justify sharing anything.
|
|
* This is the generic version for menus that need nothing beyond "show it, and
|
|
* close it again when the user looks away".
|
|
*
|
|
* Outside clicks are caught in the capture phase so the menu closes even when
|
|
* the thing clicked stops propagation on its way up.
|
|
*
|
|
* ## The entrance
|
|
*
|
|
* Declared here rather than on each menu in each template, because this
|
|
* controller is what a menu IS: every popup it will ever open then behaves the
|
|
* same way, and a menu added next month cannot forget to. Putting the attribute
|
|
* in eleven templates instead would mean eleven chances for one of them to feel
|
|
* different from the other ten for no reason anybody could name.
|
|
*
|
|
* No JavaScript plays it and none needs to. The menu is toggled with `hidden`,
|
|
* and a display:none element has no running animation — so the entrance starts
|
|
* by itself every time the menu is shown, which is the one moment it should.
|
|
* There is deliberately no exit: a menu closes because somebody asked it to,
|
|
* and an animation there is time between the ask and the answer.
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["menu"];
|
|
|
|
connect() {
|
|
this._boundOutside = this._handleOutside.bind(this);
|
|
this._boundEscape = this._handleEscape.bind(this);
|
|
|
|
// `slide-down` because that is where nearly all of these come from:
|
|
// anchored under their trigger, so they arrive from above. Nothing
|
|
// waits for it — only opacity and transform move, so the first item is
|
|
// clickable in the first frame.
|
|
//
|
|
// A menu that already names its own entrance keeps it. The handful that
|
|
// open UPWARD instead (the send pill's schedule list, compose's
|
|
// formatting menus — anything positioned `bottom-full`) want `rise`,
|
|
// and a template is the only place that knows which way a given menu
|
|
// is pinned.
|
|
if (this.hasMenuTarget && false === this.menuTarget.hasAttribute("data-enter")) {
|
|
this.menuTarget.setAttribute("data-enter", "slide-down");
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
this._detach();
|
|
}
|
|
|
|
toggle(event) {
|
|
event?.preventDefault();
|
|
|
|
this.menuTarget.hidden ? this.open() : this.close();
|
|
}
|
|
|
|
open() {
|
|
this.menuTarget.hidden = false;
|
|
|
|
// For menus whose contents go stale while closed — the snooze menu
|
|
// recomputes its wake times here, so one left open across midnight
|
|
// cannot offer a "tomorrow" that is already today.
|
|
this.dispatch("opened", { target: this.menuTarget });
|
|
|
|
document.addEventListener("click", this._boundOutside, { capture: true });
|
|
document.addEventListener("keydown", this._boundEscape);
|
|
}
|
|
|
|
close() {
|
|
if (true === this.menuTarget.hidden) {
|
|
return;
|
|
}
|
|
|
|
this.menuTarget.hidden = true;
|
|
this._detach();
|
|
}
|
|
|
|
_handleOutside(event) {
|
|
if (false === this.element.contains(event.target)) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
_handleEscape(event) {
|
|
if ("Escape" === event.key) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
_detach() {
|
|
document.removeEventListener("click", this._boundOutside, { capture: true });
|
|
document.removeEventListener("keydown", this._boundEscape);
|
|
}
|
|
}
|