Files
Paul LütznerandClaude Opus 5.5 3b78d277d7 A send that fails for a passing reason is retried; one that finally fails is marked on the draft (#242)
Every sender turned every failure into `false`, so a one-minute Gmail
rate limit or a short outage lost the send like a refused recipient.

The three senders now classify through MailDeliveryInterface::deliver():
deferred (rate limits, 5xx, a connection that never opened, SMTP 4xx),
refused (other 4xx, SMTP 5xx, too large) or outcome unknown (a lost
answer, 502/504). send() stays a bool for invitations, alerts and read
receipts.

SendMessageHandler retries a deferral for at most two minutes, keeping
the provider's Retry-After. A lost answer is never resent blind: Gmail
and Graph look for the Message-ID in Sent first (now committed before
the first attempt), SMTP reports it as possibly sent. A final failure,
from the handler or from SendGaveUpSubscriber when the ladder runs out,
marks the draft, words the toast by its reason, and shows a red warning
triangle at the start of the draft's row and on Drafts in the sidebar.

Migration: four nullable columns and a partial index on message.

Closes #125

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

869 lines
37 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
import { jsonCsrfHeaders } from "../../csrf.js";
import { WRITE_EVENT } from "../../mail_writes.js";
// `is-active` carries no colour of its own — it is the hook the sidebar's
// Gmail-style pill shape hangs off in app.css.
//
// The server renders these same two sets on every row it draws (OPEN and
// CLOSED in App\Twig\SidebarRowStateExtension), so on those rows the pass in
// connect() changes nothing. Keep the lists identical: a row the server drew
// one way and this pass redraws the other is a row that fades on every load,
// from whatever it was painted as. SidebarRowStateTest compares the two files.
const ACTIVE_CLASSES = ["is-active", "bg-accent-soft", "text-accent", "font-medium"];
// A parent of the open row. Its own class rather than the active set: the
// trail should read as "you are in here", not as a second selection.
const ANCESTOR_CLASS = "is-active-ancestor";
const INACTIVE_CLASSES = ["text-ink-muted", "hover:bg-hover"];
const SYNC_EVENTS = ["core--mercure:mailbox-synced", "core--mercure:account-synced"];
/**
* The server saying this user's mail changed — see MailChangeNotifier.
*
* Its own event and its own, shorter floor (CHANGED_FLOOR_MS). A sync event
* says a mailbox was looked at, usually finds nothing, and comes one per
* mailbox per account; this one is only published when something was actually
* recorded, once per request or handled message, and it is what tells a tab
* that the mail sent ten seconds ago has now left Drafts. Fifteen seconds of
* patience is right for the first and is the bug for the second.
*/
const CHANGED_EVENT = "core--mercure:mail-changed";
/** The key the counts payload carries its change sequence under. */
const SEQUENCE_KEY = "state:sequence";
/** The frame the mail list lives in — see templates/_layout/_mailbox.html.twig. */
const LIST_FRAME_ID = "inbox-list-frame";
/**
* The inert marker a folder row ships instead of naming the list frame itself.
*
* THE SIDEBAR IS ON FOUR PAGES AND ONLY ONE OF THEM HAS THE LIST FRAME —
* _layout/_mailbox.html.twig has it, admin/, calendar/ and settings/index.html
* .twig include the same partial and do not. A row that named the frame in its
* own markup therefore named it on all four, and v0.2.34 shipped exactly that.
* See templates/_partials/_sidebar.html.twig#list_frame for the measurement;
* the short version is that Turbo's hover PREFETCH sends `Turbo-Frame:
* inbox-list-frame` without checking that the frame exists, the server answers
* with the chrome-less fragment, and the click then renders that fragment as
* the whole document because it finds the prefetched response waiting in a
* URL-keyed cache. A bare message list on a page with no sidebar.
*
* No template can decide this. Two of the three that render these rows are not
* even told which page they are on: mail/_account_folder_rows.html.twig is
* fetched into its own frame by its own controller action, and
* label/_lists.stream.html.twig is a stream that re-renders the label rows into
* whatever page is open when someone renames a label. The live DOM is the only
* thing that knows, and this controller is standing in it.
*/
const LIST_FRAME_MARKER = "[data-list-frame]";
/**
* A write to this user's mail finished — from anywhere.
*
* Kept apart from SYNC_EVENTS rather than added to them, because the two want
* opposite treatment. A sync is the server volunteering that something changed
* somewhere, arrives in bursts, and is rate-limited below on purpose. This is a
* person having just pressed a button and watching the number they changed, so
* it skips the limit — see refreshCounts({ immediate }).
*
* This used to be `mail--list-toolbar:written`, listened for on the reasoning
* that the toolbar was already announcing itself and nothing was listening on
* behalf of the badges. True, and too narrow: the toolbar was the ONLY writer
* making the announcement, so the badges kept up with bulk actions and with
* nothing else — including the ordinary act of opening a mail and reading it.
* The announcement is now everyone's, and lives in assets/mail_writes.js.
*/
const WRITTEN_EVENT = WRITE_EVENT;
/**
* Where the nav was scrolled to.
*
* Session storage, because it belongs to this run of the app and restoring
* yesterday's scroll would be a surprise. Which sections and label trees are
* collapsed used to live beside it in localStorage and does not any more: that
* is a preference rather than a position, so it is stored per user on the
* server and rendered into `open` before the first paint. See rememberCollapse.
*/
const SCROLL_KEY = "sidebar_scroll";
/**
* One counts request, however many sidebars are listening.
*
* The sidebar partial is included twice — once for the mobile drawer, once for
* the desktop column — so there are always two instances of this controller on
* a mail page, both subscribed to the same sync events on `document`. Every
* sync therefore fetched the counts twice, and a sync run publishes one event
* per mailbox per account, so an idle inbox was measured at thirty-two counts
* requests in ten seconds.
*
* Both problems are the same problem — the fetch belongs to the page, not to
* the instance — so it lives here, at module scope, and both instances patch
* their badges from the one answer.
*/
let inFlight = null;
let lastFetchAt = 0;
/**
* The post-write request every listener of one write shares.
*
* Separate from `inFlight` because the two answer different questions.
* `inFlight` is "a request is on the wire"; this is "a request has been asked
* for on behalf of this write and has not gone out yet". Only the second can
* dedupe callers who all heard the same announcement, and that is what there
* always are: the sidebar partial is on the page twice, so every write is
* announced to both instances in one tick.
*
* The previous attempt at this deduped on `inFlight` and could not work. The
* second caller waited for the first one's request, and the thing it was
* waiting for cleared `inFlight` in its own `finally` on the way out — so the
* re-check that followed the await was looking for a request that, by the only
* route that reaches that line, is guaranteed to be gone. Every write made two
* requests for the same numbers, which is what opening a mail was measured
* doing.
*
* Released the moment the request is DISPATCHED rather than when it settles.
* That is the whole distinction between coalescing and staleness: everyone who
* announced a write before it went out is served by it and is correct to be,
* while a write announced afterwards finds this null and gets a request of its
* own — which is the entire point of the `fresh` flag.
*/
let freshPending = null;
/** The floor between two counts requests. */
const MIN_COUNTS_MS = 15000;
/** The floor after a `mail.changed` nudge. See CHANGED_EVENT. */
const CHANGED_FLOOR_MS = 2000;
/**
* How long a nudge waits before it is compared with what has been counted.
* Longer than the announcement window in mail_writes.js plus a request leaving,
* so this tab's own post-write request is on the wire by then.
*/
const CHANGED_SETTLE_MS = 400;
/**
* The change sequence the newest counts answer was read at, or null before
* the first one.
*
* A `mail.changed` nudge carries the sequence of the change it is about. One
* that is not newer than this is a change the badges already show — nearly
* always this tab's own, which re-read its counts on the response to the
* request that made it, a moment before the hub delivered the nudge for the
* same write. Without the comparison every write would be counted twice.
*/
let countsSequence = null;
/**
* @param {string} url
* @param {boolean} fresh A caller that has just written and cannot be served
* an answer the server composed before that write landed.
*/
async function fetchCounts(url, fresh = false) {
// Already asking: the second caller waits for the first one's answer rather
// than starting a second request for the same numbers.
//
// Except after a write. That in-flight request was sent before the write
// committed, so its numbers are the old ones — sharing it would patch the
// badges back to exactly the values the user just changed. Such a caller
// waits for it to settle (so the two do not race to write the same badges)
// and then asks again for itself.
if (true === fresh) {
// Somebody else already asked for a post-write request on behalf of
// this same write. One is enough, and it is exactly as fresh as the one
// this call was about to make.
if (freshPending !== null) {
return freshPending;
}
freshPending = (async () => {
// Yields even when there is nothing on the wire, and that is
// load-bearing rather than incidental: an async function body runs
// SYNCHRONOUSLY up to its first await, so without one the line
// below would clear `freshPending` before the assignment this
// promise is being given to has happened — and the stale promise
// left behind would then be handed to every later write, which
// would never ask for anything again. `await undefined` costs a
// microtask and buys the guarantee. It cost two red tests to
// learn, both of them a badge that went down and never came back.
//
// When there IS a request on the wire, waiting it out also stops
// the two racing. Its numbers are the pre-write ones — it was sent
// before this write committed — so letting them land in either
// order would decide the badges by which response was quicker.
await inFlight?.catch(() => null);
// Released here, not in a `finally`: the request below is about to
// go out, so it answers for every write announced up to this
// instant and none announced after it. Releasing on settle instead
// would fold a write that arrived mid-flight into an answer the
// server composed before it existed.
freshPending = null;
return request(url);
})();
return freshPending;
}
// Already asking, and this caller has not written anything: it waits for
// that answer rather than starting a second request for the same numbers.
if (inFlight !== null) {
return inFlight;
}
return request(url);
}
/** The fetch itself, and the bookkeeping that says one is on the wire. */
function request(url) {
lastFetchAt = Date.now();
inFlight = fetch(url, { headers: { Accept: "application/json" } })
.then((response) => (response.ok ? response.json() : null))
.then((counts) => {
const sequence = counts?.[SEQUENCE_KEY];
// Never backwards: two answers can land out of order, and the
// older one must not make a change already counted look new.
if ("number" === typeof sequence && (null === countsSequence || sequence > countsSequence)) {
countsSequence = sequence;
}
return counts;
})
.catch(() => null)
.finally(() => {
inFlight = null;
});
return inFlight;
}
/**
* Show or hide the "something arrived here" marks from the same payload.
*
* Document-wide rather than over this controller's targets, and that is the
* point: the marks are not all in the sidebar. The inbox's category tabs carry
* them too, they live inside the list frame — outside every sidebar element —
* and they are the primary surface for this marker. Scoping to targets would
* have left the tabs waiting for the next list refresh to learn that mail had
* arrived, which is up to MIN_REFRESH_MS later than the sidebar beside them.
*
* Two shapes under one attribute. The sidebar's mark is a bare 6px dot and is
* only toggled — a count written into it would render as a smear. The tabs'
* mark is a Gmail-style pill that says "3 new", and it announces itself by
* carrying its templates: translated server-side with the %count% placeholder
* left in, so this patcher fills numbers into sentences it cannot read. The
* label template is patched alongside the text for the same reason _nameBadge
* exists — a count and the name it is read out with are one statement. Keys
* are namespaced "new:" server-side so neither shape can be fed the unread
* badges' numbers.
*
* Idempotent, which matters because the sidebar partial is on the page twice
* (drawer and column) and both instances run this over the same elements.
*/
function patchNewDots(counts) {
document.querySelectorAll("[data-new-dot]").forEach((dot) => {
const count = counts[dot.dataset.countKey];
if (count === undefined) {
return;
}
dot.classList.toggle("hidden", count === 0);
// Nothing is written at zero: the pill is hidden then, and "0 new" is
// not a sentence it ever says — the next non-zero pass rewrites both
// strings anyway.
if (dot.dataset.countTemplate !== undefined && count > 0) {
dot.textContent = dot.dataset.countTemplate.replace("%count%", String(count));
dot.setAttribute("aria-label", dot.dataset.labelTemplate.replace("%count%", String(count)));
}
});
}
/**
* The warning on Drafts while any draft's send has finally failed (#125):
* shown above zero, hidden at it. A number in the payload rather than a flag,
* because every value there is one, under its own "unsent:" key so no other
* marker can be fed it. Document-wide for the reason patchNewDots is.
*/
function patchUnsentMarks(counts) {
document.querySelectorAll("[data-unsent-mark]").forEach((mark) => {
const count = counts[mark.dataset.countKey];
if (count === undefined) {
return;
}
mark.classList.toggle("hidden", count === 0);
});
}
/**
* The sender hints on the category tabs, from the same payload.
*
* The "senders:" family holds the one value in the payload that is a STRING —
* sender names, joined and localised server-side; it hides when it has
* nothing to say, so a hintless tab centres its label — the tab's own
* min-height is what keeps the strip from changing size around it. Outside
* every sidebar element, hence document-wide, same as patchNewDots.
*/
function patchTabMarks(counts) {
document.querySelectorAll("[data-tab-senders]").forEach((hint) => {
const senders = counts[hint.dataset.countKey];
if (senders === undefined) {
return;
}
hint.textContent = senders;
hint.classList.toggle("hidden", senders === "");
});
}
/**
* The category-tab icon tint, from the same payload.
*
* The "category:" family is an unread count. Here it decides a colour: tinted
* means there is unread mail behind that tab, untinted means there is not —
* patchTabUnread() below prints the same number. See the tabIconTones comment
* in mail/inbox.html.twig for why the tint exists at all and why only the
* unread-only view draws it.
*
* The tone class travels on the element rather than living here, because which
* of the five a tab wears is the template's business — this only knows whether
* to put it on. Split on whitespace: the tone is a pair (a light value and its
* dark: variant) and classList takes them one at a time.
*
* Only the unread-only view renders these attributes at all, so on every other
* list this selector matches nothing and the function costs one query. Same
* document-wide reach and the same idempotence as the two patchers above.
*/
function patchTabIcons(counts) {
document.querySelectorAll("[data-tab-icon]").forEach((icon) => {
const count = counts[icon.dataset.countKey];
if (count === undefined) {
return;
}
const tone = (icon.dataset.toneClass || "").split(" ").filter(Boolean);
if (tone.length === 0) {
return;
}
tone.forEach((cls) => icon.classList.toggle(cls, count > 0));
});
}
/**
* The unread number on each category tab, from the same payload as the tint.
*
* The "category:" family is the number of conversations holding unread mail in
* that tab — the figure the sidebar's Inbox badge adds up, split by tab. The
* element is rendered whatever the number is and hidden at zero, so this only
* writes text, the accessible name and the visibility.
*
* Not patchNewDots(), and not given data-new-dot: that family is the "3 new"
* pill, whose template would write "N new" over an unread count. The label
* template travels on the element for the same reason it does there — which
* sentence a number belongs to is the template's business, rendered in the
* viewer's language.
*
* Document-wide for the same reason as the patchers above, and idempotent: a
* pass with nothing changed rewrites the same text.
*/
function patchTabUnread(counts) {
document.querySelectorAll("[data-tab-unread]").forEach((badge) => {
const count = counts[badge.dataset.countKey];
if (count === undefined) {
return;
}
badge.textContent = String(count);
badge.classList.toggle("hidden", count === 0);
if (badge.dataset.labelTemplate !== undefined) {
badge.setAttribute("aria-label", badge.dataset.labelTemplate.replace("%count%", String(count)));
}
});
}
export default class extends Controller {
static targets = ["link", "badge", "scroller", "collapseSummary"];
static values = { countsUrl: String, collapseUrl: String, sequence: Number, i18n: Object };
connect() {
// Before the first paint of this element, so nothing is seen in the
// wrong place: the sidebar re-renders on every visit now, and a scroll
// jump would be exactly the flicker turbo-permanent avoided.
//
// There is no _restoreTrees() beside it any more, and its absence is
// the feature. Which sections are collapsed arrives already applied, in
// the HTML, so there is nothing to put back after the fact.
this._restoreScroll();
// Before anything a mouse could reach, because a HOVER is what starts
// the prefetch this guards.
this._bindListFrame();
this._updateActive();
// `turbo:load` fires on the first page load as well as on every Drive
// visit, and _bindListFrame is here for the first of those: this
// sidebar is earlier in the document than the list frame is, so
// Stimulus can connect it while the frame is still being parsed. The
// pass above would then find no frame, leave the markers inert, and
// never be asked again — the rows would fetch whole pages for the rest
// of the session, quietly, with nothing on screen to see. That is the
// exact failure mode the list_frame macro was written to avoid.
this._onTurboLoad = () => {
this._bindListFrame();
this._updateActive();
};
document.addEventListener("turbo:load", this._onTurboLoad);
// A folder click is a FRAME navigation — see the list_frame macro in
// _sidebar.html.twig — and a frame navigation fires no turbo:load,
// because no page was loaded. The sidebar is not re-rendered by one
// either, so without this the highlighted row would simply stay on the
// folder you left: the click worked, the list changed, and the nav went
// on pointing at the wrong place until the next full visit.
//
// turbo:frame-load bubbles to the document, and Turbo has already
// written the new URL into history by the time it fires (it calls
// changeHistory() before rendering the frame), so _matches() below is
// reading the location it is about to be asked about.
this._onFrameLoad = (event) => {
if (LIST_FRAME_ID !== event.target.id) {
return;
}
this._updateActive();
};
document.addEventListener("turbo:frame-load", this._onFrameLoad);
// Counts go stale WITHOUT a navigation, which is the case this covers:
// mail arrives, or a conversation is read in the pane beside the nav,
// and the badge beside Inbox is wrong until something fetches it. A
// sync event is that something.
//
// The comment here used to say the desktop sidebar is
// data-turbo-permanent and that this existed to unfreeze it. It is not
// — there is no such attribute on it, and `_sidebar.html.twig` says at
// length why not: carried across visits, the nav kept deleted labels
// linking to 404s and never showed a rename. It re-renders per visit,
// so a navigation already brings fresh numbers and this is only ever
// about the time between navigations.
this._onSynced = () => this.refreshCounts();
SYNC_EVENTS.forEach((name) =>
document.addEventListener(name, this._onSynced),
);
// The badges this element was rendered with were counted at this
// sequence. Without it a freshly loaded page knows nothing, and the
// first nudge to reach it — the hub replays the last one to a tab that
// reconnects — would re-read numbers the server had just sent.
if (this.hasSequenceValue && (null === countsSequence || this.sequenceValue > countsSequence)) {
countsSequence = this.sequenceValue;
}
this._onWritten = () => this.refreshCounts({ immediate: true });
document.addEventListener(WRITTEN_EVENT, this._onWritten);
this._onChanged = async (event) => {
const sequence = event.detail?.sequence;
// Not yet. For this tab's own write the hub is quicker than the
// tab: the nudge was measured arriving before the counts request
// the write itself sets off had even left, so comparing sequences
// straight away always found the nudge "newer" and every write was
// counted twice. Give that request time to go out, then wait for
// whatever is on the wire, and only then ask whether the change is
// one the badges already show.
await new Promise((resolve) => setTimeout(resolve, CHANGED_SETTLE_MS));
await inFlight?.catch(() => null);
if (false === this.element.isConnected) {
return;
}
// Already counted — see countsSequence. A nudge with no sequence
// is from a server that sends none, and is taken at its word.
if ("number" === typeof sequence && null !== countsSequence && sequence <= countsSequence) {
return;
}
this.refreshCounts({ floor: CHANGED_FLOOR_MS });
};
document.addEventListener(CHANGED_EVENT, this._onChanged);
this._trailing = null;
}
/**
* An account's folder list arrives later, in its own frame, so the rows in
* it miss the pass that ran on connect — they would sit unhighlighted
* until the next navigation, which is exactly the click that just expanded
* them.
*/
linkTargetConnected() {
// Rows that arrive after connect need the same pass, and they are
// precisely the ones no template could have got right: an account's
// folders fetched into their own frame, and the label rows a
// label/_lists.stream.html.twig replaces wholesale after a rename.
this._bindListFrame();
this._updateActive();
}
/**
* Let a folder row open the list in place — but only on a page that has a
* list to open it in.
*
* The rows ship `data-list-frame`, which means nothing to Turbo, and this
* turns it into the `data-turbo-frame` that does. The guard is the whole
* point and it is deliberately a lookup in the live document rather than a
* flag passed down from the layout: a flag is something the next person
* adding a row to this sidebar has to know to thread through, and the
* regression this replaces happened because that knowledge lived in one
* person's head while the markup was shared by four pages.
*
* Off a mail page nothing is promoted, the row stays an ordinary link, and
* the click gets a whole page — which is what it should have got all along.
*
* Scoped to `this.element`, so the desktop sidebar and the mobile drawer
* each do their own and neither reaches into the other's rows.
*
* Cheap enough to be unconditional: a dozen rows, an attribute each, and
* only on connect or when new rows land. Setting an attribute to the value
* it already holds is a no-op in the DOM, so the repeat passes cost nothing.
*
* NOTE the one row that starts out naming a frame rather than naming
* nothing: mail/_account_folder_rows.html.twig ships `_top`, because those
* rows sit INSIDE the account's own frame and a link naming nothing there
* targets that frame instead of the page. Overwriting `_top` is correct and
* intended — see the comment on that template.
*/
_bindListFrame() {
if (null === document.getElementById(LIST_FRAME_ID)) {
return;
}
this.element
.querySelectorAll(LIST_FRAME_MARKER)
.forEach((link) => link.setAttribute("data-turbo-frame", LIST_FRAME_ID));
}
disconnect() {
document.removeEventListener("turbo:load", this._onTurboLoad);
document.removeEventListener("turbo:frame-load", this._onFrameLoad);
SYNC_EVENTS.forEach((name) =>
document.removeEventListener(name, this._onSynced),
);
document.removeEventListener(WRITTEN_EVENT, this._onWritten);
document.removeEventListener(CHANGED_EVENT, this._onChanged);
// A refresh owed to a sidebar that has gone is owed to nobody.
clearTimeout(this._trailing);
}
/**
* A section heading or a label tree was opened or closed.
*
* One handler for both, because they are one <details> idiom with one key
* space — `section:labels`, `section:accounts`, `label:<full name>`. COLLAPSED
* is what is sent, so a label created later shows up expanded rather than
* needing a backfill of everyone's preferences: the same rule the admin
* panels follow.
*
* The sidebar is on the page twice (drawer and desktop column) and both
* copies carry the same keys, so toggling one leaves the other showing the
* opposite until it next re-renders. Mirrored here rather than left to the
* next navigation — opening the drawer to find the section you just
* collapsed still expanded reads as the preference not having saved.
*/
rememberCollapse(event) {
const details = event.target;
const key = details.dataset.collapseKey;
if (!key) {
return;
}
this._announce(details);
this._mirror(key, details.open);
this._persistCollapse(key, false === details.open);
}
/**
* Keep the summary's aria-expanded in step with the disclosure.
*
* <details>/<summary> already exposes its state natively, so this is belt
* and braces — but the attribute is rendered server-side with the stored
* state, and an aria-expanded frozen at its initial value is worse than one
* that was never there.
*/
_announce(details) {
details
.querySelector(":scope > summary[aria-expanded]")
?.setAttribute("aria-expanded", details.open ? "true" : "false");
}
/** The other copy of this sidebar, if it is on the page. */
_mirror(key, open) {
document
.querySelectorAll(`details[data-collapse-key="${CSS.escape(key)}"]`)
.forEach((other) => {
if (other.open === open) {
return;
}
// Assigning fires `toggle` on the other one too, which lands
// back here — the guard above makes that second pass a no-op
// rather than a loop, since by then the two already agree.
other.open = open;
});
}
/**
* Fire and forget, with keepalive, exactly as the account expander does:
* the click that collapses a section is often the click that navigates, and
* a preference that fails to save is not worth interrupting anyone over —
* the section is shut either way, it just will not be next time.
*/
_persistCollapse(key, collapsed) {
if (false === this.hasCollapseUrlValue) {
return;
}
fetch(this.collapseUrlValue, {
method: "POST",
headers: jsonCsrfHeaders(),
body: JSON.stringify({ key, collapsed }),
keepalive: true,
}).catch(() => {});
}
rememberScroll() {
sessionStorage.setItem(SCROLL_KEY, String(this.scrollerTarget.scrollTop));
}
async refreshCounts({ immediate = false, floor = MIN_COUNTS_MS } = {}) {
if (!this.hasCountsUrlValue) {
return;
}
// Nobody is looking at the badges. The next sync after the tab comes
// back redraws them, and a full page load renders them server-side.
if (document.hidden && !immediate) {
return;
}
// A burst of sync events is one refresh — but the LAST one still lands.
//
// This used to return here and drop the event, on the reasoning that "a
// missed counts update is corrected by the next one, and there is
// always a next one". That holds for a mailbox syncing on a timer and
// is false for the case a person actually watches: the demo's Receive
// button publishes exactly one event, and if it fell inside the window
// the badge simply never moved. Nothing came afterwards to correct it.
//
// Trailing edge instead. The window still collapses a burst of five
// accounts into one request, and the final state is always fetched.
//
// The timer is PER INSTANCE, and that is the other half of the fix.
// There are two sidebars on the page — the desktop rail and the drawer
// — so there are two controllers, each holding its own badges. A single
// shared slot meant the second one to schedule cancelled the first, so
// only one sidebar was ever brought up to date and the other kept its
// number indefinitely. The rate limit stays shared, which is what it is
// for: both wake at the end of the same window and `inFlight`
// deduplicates them into one round trip.
if (!immediate && inFlight === null && Date.now() - lastFetchAt < floor) {
clearTimeout(this._trailing);
this._trailing = setTimeout(
() => this.refreshCounts({ immediate: true }),
floor - (Date.now() - lastFetchAt),
);
return;
}
clearTimeout(this._trailing);
// Offline, or the sync raced a navigation — fetchCounts answers null and
// the next sync retries.
const counts = await fetchCounts(this.countsUrlValue, immediate);
if (counts === null) {
return;
}
this.badgeTargets.forEach((badge) => {
const count = counts[badge.dataset.countKey];
if (count === undefined) {
return;
}
badge.textContent = count;
badge.classList.toggle("hidden", count === 0);
// The number and the name it is read out with are one statement.
// Patching only the text left a screen reader saying "3 unread"
// over a badge that had moved on to 7 — and on Trash and Drafts it
// would have said "unread" over a total, which is the very thing
// the two badge shapes exist to keep apart.
this._nameBadge(badge, count);
});
patchNewDots(counts);
patchTabMarks(counts);
patchUnsentMarks(counts);
patchTabIcons(counts);
patchTabUnread(counts);
this._updateTitle(counts);
}
/**
* Re-label one badge for the count it now shows.
*
* A badge with no `data-badge-kind`, or a page that shipped no strings, is
* left exactly as the server rendered it: a wrong name is worse than the
* one that is already correct.
*/
_nameBadge(badge, count) {
const template = this.hasI18nValue ? this.i18nValue[badge.dataset.badgeKind] : null;
if (!template) {
return;
}
badge.setAttribute("aria-label", template.replace("%count%", String(count)));
}
/**
* Keep the "(n)" in the tab in step with the badges.
*
* The title is server-rendered once and the badges are patched on every
* sync, so the tab sat at (4) while the sidebar had moved on to 3. It now
* comes from this same payload — the page names which key its title counts
* (see app.html.twig) and nothing here needs to know what a mailbox is.
*
* The existing prefix is stripped rather than a stored base being kept,
* because the rest of the title is localised and page-specific and there is
* no reason to have a second copy of it that can go stale.
*/
_updateTitle(counts) {
const key = document
.querySelector('meta[name="title-count-key"]')
?.content;
if (!key) {
return;
}
const count = counts[key];
if (count === undefined) {
return;
}
const base = document.title.replace(/^\(\d+\)\s*/, "");
document.title = count > 0 ? `(${count}) ${base}` : base;
}
_updateActive() {
const activeRows = [];
this.linkTargets.forEach((link) => {
// A system row is its own .nav-item; a custom label wraps the
// anchor in one so the disclosure arrow and the edit button stay
// outside the link. Tint the row either way, or a label would get
// the colour on its text only while the pill stayed empty.
const row = link.closest(".nav-item") ?? link;
row.classList.remove(...ACTIVE_CLASSES, ANCESTOR_CLASS);
row.classList.add(...INACTIVE_CLASSES);
if (false === this._matches(link)) {
return;
}
row.classList.add(...ACTIVE_CLASSES);
row.classList.remove(...INACTIVE_CLASSES);
activeRows.push(row);
});
// Marked after the loop, not inside it: an ancestor is itself a link
// target, and the loop would have just finished calling it inactive.
activeRows.forEach((row) => this._markAncestors(row));
}
/**
* Whether a nav link points at what is currently open.
*
* Compared as a URL rather than as the raw attribute: an account's label
* rows carry `?account=` and the location's pathname does not, so a string
* compare matched nothing and those rows never highlighted at all. The
* account has to agree too — the same label under another account is a
* different view, and both rows lighting up says the wrong thing.
*
* Restated in PHP as SidebarRowStateExtension::isOpen(), which decides the
* rows the server renders. A change here that is not made there is a row
* the two disagree about, and it fades on every load.
*/
_matches(link) {
const target = new URL(link.href, window.location.origin);
const path = window.location.pathname;
// Prefix, so a parent label counts as open while a child of it is:
// full names are the path, "Work" is a prefix of "Work/Clients".
if (path !== target.pathname && false === path.startsWith(target.pathname + "/")) {
return false;
}
const wanted = target.searchParams.get("account");
return null === wanted
|| wanted === new URLSearchParams(window.location.search).get("account");
}
/**
* Where the nav was left scrolled to. Applied on connect, before paint.
*/
_restoreScroll() {
if (false === this.hasScrollerTarget) {
return;
}
const top = Number(sessionStorage.getItem(SCROLL_KEY) ?? 0);
if (top > 0) {
this.scrollerTarget.scrollTop = top;
}
}
/**
* The trail down to an open row.
*
* Every nested label lives inside its parent's <details>, so the ancestry
* is already in the DOM and needs no ids to walk — which matters for the
* account lists, where the href is an id and carries no hint of the tree.
*
* `summary.nav-item` is what makes this stop at the label trees rather than
* running on up into the LABELS and ACCOUNTS section headings, which are
* <details> too now. Those are furniture, not a row you can be "inside", and
* tinting them would read as a second selection.
*/
_markAncestors(row) {
let details = row.closest("details")?.parentElement?.closest("details");
while (details) {
details.querySelector(":scope > summary.nav-item")?.classList.add(ANCESTOR_CLASS);
details = details.parentElement?.closest("details");
}
}
}