Files
pl_mail/assets/controllers/mail/thread_read_controller.js
Paul LütznerandClaude Opus 5 0e996073a5 fix(mail): the unread badges follow mail that is read, not only mail acted on
Reported as the counter updating "nicht zuverlässig" when mail is read.
Unreliably 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 afterwards.

It was never unreliable. The badges had exactly one immediate trigger,
`mail--list-toolbar:written`, and only the BULK toolbar ever dispatched it. So
they kept up with the one path almost nobody uses and with none of the paths
people actually read mail through: 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 three POST to the same status
endpoints, redraw their row from the returned Turbo Stream, and told nobody
else. Measured on the inbox after reading one mail: a full minute, no sync
event, no counts request, the badge still one too high — while /mail/sidebar/
counts had the right number the whole time. Only a reload put it right.

The announcement now belongs to everybody, in assets/mail_writes.js. A write
path that forgets to call it has the same bug, but it has it in a form somebody
can grep for.

Deliberately NOT folded into the toolbar's existing `:writing`/`:written` pair,
which is untouched. That pair is a different statement — "hold the list frame
refresh, I am part-way through a run of posts" — answered by mail--mail-pane
with a fetch of the whole frame. Firing it from mail--thread-read would re-read
the entire list on every mail opened, which is the commonest action in the app.
This event asks only for the counts, which is the thing that was stale.

thread-read announces only when the read moved a number. It marks read on every
open, including mail already read, and that is by far the commoner case once an
inbox is under control — those posts change nothing, so announcing them would
put a counts request behind every click on a mail. The sidebar's rate limiting
exists because this endpoint was once asked thirty-two times in ten seconds;
this keeps that honest, and live-counts.spec.ts pins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:08:40 +02:00

58 lines
2.0 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
import { jsonCsrfHeaders } from "../../csrf.js";
import { announceWrite } from "../../mail_writes.js";
export default class extends Controller {
static values = {
id: Number,
delay: { type: Number, default: 2000 },
markUrl: String,
/**
* Whether this conversation was still unread when the page rendered.
*
* Defaults TRUE, so a caller that does not say assumes the read moved
* a number and announces it. A missing announcement is the bug this
* whole change is about; a spare one costs a small JSON fetch.
*/
wasUnread: { type: Boolean, default: true },
};
#timer = null;
connect() {
this.#timer = setTimeout(() => this.#markRead(), this.delayValue);
}
disconnect() {
// User navigated away before the delay elapsed — cancel.
clearTimeout(this.#timer);
}
async #markRead() {
const response = await fetch(this.markUrlValue, {
method: "POST",
headers: jsonCsrfHeaders(),
body: JSON.stringify({ read: true }),
});
if (!response.ok) return;
const html = await response.text();
Turbo.renderStreamMessage(html);
// The stream above re-renders the list ROW. The sidebar badge this
// mail was just counted in is somewhere else entirely and hears
// nothing from a stream, so it is told. This is the single biggest
// source of the stale counter: opening a mail is how mail gets read.
//
// Only when the read actually moved a number. This controller marks
// read on every open, including mail that was already read, and that
// is by far the commoner case once an inbox is under control — those
// posts change nothing, so announcing them would put a counts request
// behind every click on a mail for no number that could differ.
if (true === this.wasUnreadValue) {
announceWrite();
}
}
}