A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.
Add readReceiptCoversTail(room, userId): walking the live timeline newest→
oldest, if we reach the user's read-receipt event without crossing any
notification-worthy event, the room is genuinely read and a lingering Total is
suppressed to {0,0} in getUnreadInfo / getUnreadInfos. Safe by construction —
a real unread sits AFTER the receipt and stops the walk at isNotificationEvent
— and guarded against unread threads (markAsRead clears threads unconditionally)
and off-window receipts (can't confirm → don't suppress). Self-correcting: a
new message becomes the tail and the walk stops suppressing.
Also recognize polls (m.poll.start / msc3381) as notification events so a
poll-only unread is never walked past (closes a pre-existing gap in the
tail scans), and factor the unread-thread guard into roomHasUnreadThread.
Reviewed by 3 agents (false-suppression safety, unread-system regression,
SDK behavior): no real unread is hidden for any standard content, no
regression to the atom/PUT-DELETE paths, and the fix produces {0,0} for the
target scenario and stays resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.
Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
Conservative: never suppresses when the read marker is off-window, the tail
is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
the request (the only SDK-durable lever), once per room per session, gated on
the same verification-only predicate so it can never ack a real message.
unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.
Reviewed by 5 agents; the thread-scope guard closes the one bug they found.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In-room verification requests arrive as m.room.message with
msgtype 'm.key.verification.request', so isNotificationEvent() counted them
(type is m.room.message). A stale/old request at the tail of a DM therefore
re-lit the room's unread dot — and could fire a toast/OS notification — on every
fresh sync (app update / CTRL+F5 cache clear); opening the room only cleared it
via the local read-receipt echo, so it returned on the next reload.
Exclude that msgtype from isNotificationEvent so verification control messages
never drive unread/notifications (mirrors the existing member/redaction/edit
exclusions). The rest of the verification flow already uses distinct event types
that aren't notification events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).
Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).
Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>