Files
Paul LütznerandClaude Opus 5.5 6eaa33a534 Tell the sidebar whenever mail changes, instead of when a button remembers to
The badges are patched from /mail/sidebar/counts, and what made a tab
ask was a list: the sync's hub events, and announceWrite() called by
each controller that wrote. Whatever was not on the list had a stale
badge. Measured in a browser (#86): a draft autosaved, the server
counted 1, the badge stayed at 0, and in forty seconds the page made no
counts request at all. The composer saves through a Turbo form on a
timer and had no controller to call from; the send that follows is a
worker's, ten seconds later, and a mail read on a phone is another
client's. No browser code can announce those.

Every change to a user's mail already passes one place, StateManager,
because a phone's /changes has to be complete. That is now what tells
the browser too:

  X-Mail-Changed: 1    on the response to any request that recorded a
                       change (MailChangedHeaderSubscriber). mail_writes.js
                       reads it off every fetch and every Turbo request
                       and announces the write
  mail.changed         on the hub, from the drain that already tells the
                       phones (MailChangeNotifier), for a change this tab
                       did not make
  state:sequence       the change sequence the counts were read at, in
                       the counts answer and on the rendered sidebar. A
                       tab ignores a nudge that is not newer

The sequence is what keeps it to one request per change. The hub's
nudge for a tab's own write was measured arriving before that tab's
counts request had left, so the comparison waits 400 ms and for
whatever is on the wire. announceWrite() gathers calls for 120 ms, since
a write is now announced by the header and, where one exists, by its
controller.

Opening a conversation marked it read every time, read or not: seenAt
was replaced, the provider told again and a JMAP change recorded. With
the header that became a counts request behind every click on read mail.
ThreadStatusUpdater::markRead() now writes only the messages that
change, keeps the first seenAt, and records nothing when nothing moved.

One query more on a page that draws a sidebar (the sequence); none on a
list-frame navigation.

Not exercised end to end here: the worker's own send. The test stack's
send transport is in-memory, so the browser test for "a change made
elsewhere" uses a second tab.

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

139 lines
5.9 KiB
JavaScript

/**
* "Something about my mail just changed on the server — re-read your numbers."
*
* The sidebar badges are patched from /mail/sidebar/counts, and the only
* immediate trigger they ever had was `mail--list-toolbar:written`, dispatched
* by the bulk toolbar. That covered the one path a person almost never uses.
* The three paths they do use — opening a conversation (mail--thread-read),
* the envelope button on a row (mail--message-row) and the reading pane's
* overflow menu (mail--message-actions) — all POST to the same status
* endpoints, redraw their row from the returned Turbo Stream, and said nothing
* to anybody else. So the row went un-bold while "Inbox 4" sat beside it,
* and nothing corrected it: a mark-read publishes no Mercure sync, so the
* badges' other trigger never came either. Measured at a full minute with no
* refetch; only a reload or a navigation put the number right.
*
* Reported as the counter updating "nicht zuverlässig" rather than never,
* because navigating between folders re-renders the badges server-side — so
* whether it looked broken depended on whether you happened to click away
* after reading.
*
* Hence one announcement, in one place, that every write path makes. A new
* one that forgets to call this has the same bug, but it has it in a form
* somebody can grep for.
*
* Deliberately NOT the toolbar's existing `:writing`/`:written` pair, which
* stays exactly as it was. That pair is a different statement — "hold the list
* frame refresh, I am part-way through a run of posts" — and it is answered by
* mail--mail-pane#hold/#release with a fetch of the whole list frame. Firing
* it from mail--thread-read would re-read the entire list on every mail
* opened, which is the most common action in the app. This event asks only for
* the counts, which is the thing that was actually stale.
*/
/** The document-level event. Listened for by ui--sidebar. */
export const WRITE_EVENT = "mail:written";
/** The response header the server sets on a request that changed mail. */
const CHANGED_HEADER = "X-Mail-Changed";
/**
* How long announcements are gathered before one is made.
*
* A write is now announced twice on most paths: once by watchForMailChanges()
* below, the moment the response arrives, and once by the controller that made
* the request, after it has drawn the result. The sidebar answers each
* announcement with a counts request of its own unless they arrive together,
* and "one request per write" is a property this file has been broken on
* before. So they are gathered: every announcement inside the window is one
* event. Long enough to cover a controller parsing and rendering its response,
* far too short to see.
*/
const ANNOUNCE_WINDOW_MS = 120;
let announcing = null;
/**
* Announce that a write landed.
* Bubbling and dispatched on `document`, because the listeners are the two
* sidebars (the mobile drawer and the desktop column) and the writers are
* inside the list frame or the reading pane — several DOM subtrees away, and
* in the frame's case a subtree that is replaced wholesale.
*/
export function announceWrite() {
if (null !== announcing) {
return;
}
announcing = setTimeout(() => {
announcing = null;
document.dispatchEvent(new CustomEvent(WRITE_EVENT, { bubbles: true }));
}, ANNOUNCE_WINDOW_MS);
}
/**
* Announce a write for every response the server marks as having changed mail.
*
* THE ANNOUNCEMENT USED TO BE A CONVENTION, AND CONVENTIONS HAVE GAPS. Each
* controller that wrote was meant to call announceWrite() after its fetch. The
* docblock at the top of this file even says what happens to one that forgets
* — "the same bug, in a form somebody can grep for" — and the composer was one:
* saving a draft, discarding it and sending it are all writes, none of them
* announced, and the Drafts badge kept whatever number it had at page load. So
* did every form that posts through Turbo rather than through a controller's
* own fetch(), because there was nowhere in such a form to make the call.
*
* The server knows whether a request changed mail without being told — it has
* to record every change for the phone apps — and says so on the response
* (MailChangedHeaderSubscriber). Reading that here covers every request this
* page makes, by whatever route, including ones not written yet.
*
* TWO HOOKS, BECAUSE THERE ARE TWO WAYS A REQUEST LEAVES THIS PAGE. Controllers
* call fetch(); Turbo makes its own for form submissions, frames and visits,
* through a reference to fetch it took when it loaded and which wrapping
* `window.fetch` afterwards cannot reach. So fetch is wrapped for the first and
* Turbo's own event is listened to for the second. A response both hooks see
* is announced once — and announceWrite() gathers the rest.
*
* The explicit announceWrite() calls stay where they are. A write that changes
* what the sidebar shows without touching a message would not be marked, and
* they cost nothing beside this.
*/
export function watchForMailChanges() {
if (true === window.__plMailWatchesChanges) {
return;
}
window.__plMailWatchesChanges = true;
const seen = new WeakSet();
const inspect = (response) => {
if (!(response instanceof Response) || seen.has(response)) {
return;
}
seen.add(response);
// Null for a cross-origin response that does not expose the header,
// which is every one of them: only this server sets it.
if ("1" === response.headers.get(CHANGED_HEADER)) {
announceWrite();
}
};
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const response = await nativeFetch(...args);
inspect(response);
return response;
};
document.addEventListener("turbo:before-fetch-response", (event) => {
inspect(event.detail?.fetchResponse?.response);
});
}