From 8192da5a12cb0b46ca793533a6389ea6b6c01133 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 16:31:10 -0400 Subject: [PATCH] fix(notifications): clear thread receipts on mark-read; cap avatar-decoration refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two federated-room bugs surfaced by the desktop build: 1. markAsRead only sent one unthreaded receipt at the main-timeline tail. With threadSupport enabled, thread replies leave the main timeline, so a reply newer than that tail was never covered — its per-thread notification count (which the room dot sums) lingered, so the unread dot never cleared even after reading. It also early-returned when the main timeline was already read. Now also send a threaded receipt at each unread thread's latest reply. 2. useAvatarDecoration never cached non-404 failures, so every avatar mount re-requested io.lotus.avatar_decoration for federated users whose homeserver 403s/502s the field — a refetch storm that spammed the console and hammered our homeserver's federation. Now cache definitive rejections (400/403/404) and give up after ~2 transient (429/5xx) attempts per session. Gates: tsc/eslint/prettier clean, build OK, 665 tests. Co-Authored-By: Claude Opus 4.8 --- LOTUS_TESTING.md | 5 ++++ src/app/hooks/useAvatarDecoration.ts | 26 ++++++++++++------ src/app/utils/notifications.ts | 41 ++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index 9ad4122e7..c32b806d8 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -675,6 +675,11 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, ## Outstanding verification backlog +**Unread dot on federated rooms + avatar-decoration console storm (2026-07):** + +- Open a room from another homeserver that has thread activity; read it → the room's unread **dot clears** (previously an unread _thread reply_ kept the dot because `markAsRead` only sent an unthreaded receipt at the main-timeline tail). Also confirm opening a thread + reading it clears its part of the badge. +- With DevTools console open on those rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone. + **Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame. _Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._ diff --git a/src/app/hooks/useAvatarDecoration.ts b/src/app/hooks/useAvatarDecoration.ts index c0c43cd50..86f0f975d 100644 --- a/src/app/hooks/useAvatarDecoration.ts +++ b/src/app/hooks/useAvatarDecoration.ts @@ -9,6 +9,9 @@ const PROFILE_FIELD = 'io.lotus.avatar_decoration'; const cache = new Map(); // Callbacks waiting for a userId's result const pending = new Map void>>(); +// Transient-failure attempt counts (userId → n) so a flaky federated lookup +// can retry a couple of times, then gives up for the session. +const failures = new Map(); function fetchDecoration( authedRequest: (method: Method, path: string) => Promise>, @@ -33,16 +36,23 @@ function fetchDecoration( return val; }) .catch((err: unknown) => { - // A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no - // decoration". A transient failure (429 rate-limit, 5xx, network) must - // NOT be cached: doing so permanently hides the user's decoration for the - // whole session. This matters most for the member list and timeline, which - // mount many avatars at once and can trip homeserver rate limits — a - // single 429 in that burst would otherwise wipe the decoration until a - // full reload. Leaving the cache unset lets the next mount retry. const status = err instanceof MatrixError ? err.httpStatus : undefined; - if (status === 404) { + // Definitive rejections — the field is unset (404) or the server won't + // serve it (400/403). This is the common case for FEDERATED users whose + // homeserver doesn't support extended profiles / rejects the field. Cache + // "no decoration" so we never refetch: otherwise every avatar mount + // re-requests and floods our homeserver with failing federated profile + // lookups (the 403/502 console storm + real HS load). + if (status === 404 || status === 403 || status === 400) { cache.set(userId, null); + } else { + // Transient (429 rate-limit / 5xx / network). Allow a couple of retries + // — a single 429 in a member-list burst shouldn't permanently hide a + // decoration — then give up for the session so a persistently-failing + // federated link (e.g. a 502'ing remote server) can't loop forever. + const attempts = (failures.get(userId) ?? 0) + 1; + failures.set(userId, attempts); + if (attempts >= 2) cache.set(userId, null); } return null; }) diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index cbf6d7158..2dbaca6bb 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,4 +1,4 @@ -import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; +import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk'; import { getSettings } from '../state/settings'; export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { @@ -6,6 +6,9 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip const room = mx.getRoom(roomId); if (!room) return; + const receiptType = + privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read; + const timeline = room.getLiveTimeline().getEvents(); const readEventId = room.getEventReadUpTo(mx.getUserId()!); @@ -17,17 +20,31 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip } return null; }; - if (timeline.length === 0) return; - const latestEvent = getLatestValidEvent(); - if (latestEvent === null) return; - // Unthreaded receipt: with client threadSupport enabled the SDK would - // otherwise scope this to the main timeline (thread_id: "main"), leaving - // per-thread notification counts permanently unread. Unthreaded preserves - // the pre-threads wire behavior — one receipt clears everything. - await mx.sendReadReceipt( - latestEvent, - privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read, - true, + const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null; + if (latestEvent) { + // Unthreaded receipt: with client threadSupport enabled the SDK would + // otherwise scope this to the main timeline (thread_id: "main"). Unthreaded + // clears the main timeline + every event up to this one, in any thread. + await mx.sendReadReceipt(latestEvent, receiptType, true); + } + + // ...but a thread reply NEWER than the main-timeline tail is not covered by + // the receipt above (threadSupport moves thread replies out of the main + // timeline), so its per-thread notification count — and the room's unread dot, + // which sums thread counts — would linger even after "reading" the room. Send + // a threaded receipt at the latest reply of every thread that still has unread + // counts. Also runs when the main timeline is already read (latestEvent null). + const threads = room.getThreads(); + await Promise.all( + threads.map((thread) => { + const unread = + room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0; + if (unread <= 0) return undefined; + const lastReply = thread.lastReply() ?? thread.rootEvent; + if (!lastReply || lastReply.isSending()) return undefined; + // Threaded receipt (unthreaded = false → the SDK scopes it to this thread). + return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined); + }), ); }