Steps to reproduce
- Alice starts a thread in a room and posts a message.
- Bob (you) reads it and replies in the thread.
- Alice — or anyone other than Bob — then sends an event into that thread that does not trigger
an unread count: a reaction, an edit, a redacted message, an m.room.member change, a beacon, or
any event with no renderer.
- Look at the thread's unread state on Bob's client (
doesTimelineHaveUnreadMessages, e.g. via the
Threads Activity Centre or the room list unread dot).
Outcome
What did you expect?
The thread reads as read. Bob replied after the only incoming message, so he has demonstrably seen
it, and nothing unread-triggering has arrived since.
What happened instead?
The thread reads as unread, and stays that way until Bob gets a threaded read receipt covering
Alice's message.
Analysis
doesTimelineHaveUnreadMessages (Unread.ts:67)
does:
const latestImportantEventId = findLatestImportantEvent(room.client, timeline)?.getId();
if (latestImportantEventId) {
return !room.hasUserReadEvent(myUserId, latestImportantEventId);
}
Two things combine:
-
findLatestImportantEvent filters through eventTriggersUnreadCount, which returns false for
our own events (Unread.ts:24-27).
So the "latest important event" is the newest message from someone else — Alice's message in
the repro, never Bob's reply.
-
room.hasUserReadEvent() resolves in matrix-js-sdk RoomReceipts.hasUserReadEvent(). With no
receipt covering Alice's message, the only thing that can save us is this shortcut:
// TODO: what if they sent the second-last event in the thread?
if (this.userSentLatestEventInThread(threadId, userId)) {
return true;
}
and userSentLatestEventInThread only checks the very last timeline entry:
return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId);
(src/models/room-receipts.ts:161 and :185 on develop — note the pre-existing TODO on the
line above the shortcut; this exact case is already flagged there.)
So as soon as any event lands after Bob's reply, the "I sent the last message, it's all read"
shortcut stops applying, while the event Bob is being judged against is still Alice's older message.
The thread flips back to unread.
The reply itself doesn't need to be the trigger — any non-counting event after our latest reply
reproduces it, which makes reactions on threads a fairly easy way to hit this.
Possible fix
Rather than "did the user send the literal last event", the question should be "is the user's latest
event at or after the latest incoming event". That could be fixed in either layer:
- in
doesTimelineHaveUnreadMessages / findLatestImportantEvent in element-web, or
- in the js-sdk shortcut, which is where the existing TODO lives.
Context
Raised in review of #32851 (Threads Activity
Centre cross-room threads panel), where the TAC currently carries a local guard
(hasUnreadAfterMyLatestReply) to suppress the false positive. Filing this so the guard can be
removed once the underlying behaviour is fixed.
Environment
- Application version:
develop (analysis against develop at time of drafting)
- Homeserver: any — reproduces with local timeline inspection, independent of server notification
counts.
Steps to reproduce
an unread count: a reaction, an edit, a redacted message, an
m.room.memberchange, a beacon, orany event with no renderer.
doesTimelineHaveUnreadMessages, e.g. via theThreads Activity Centre or the room list unread dot).
Outcome
What did you expect?
The thread reads as read. Bob replied after the only incoming message, so he has demonstrably seen
it, and nothing unread-triggering has arrived since.
What happened instead?
The thread reads as unread, and stays that way until Bob gets a threaded read receipt covering
Alice's message.
Analysis
doesTimelineHaveUnreadMessages(Unread.ts:67)does:
Two things combine:
findLatestImportantEventfilters througheventTriggersUnreadCount, which returnsfalseforour own events (
Unread.ts:24-27).So the "latest important event" is the newest message from someone else — Alice's message in
the repro, never Bob's reply.
room.hasUserReadEvent()resolves in matrix-js-sdkRoomReceipts.hasUserReadEvent(). With noreceipt covering Alice's message, the only thing that can save us is this shortcut:
and
userSentLatestEventInThreadonly checks the very last timeline entry:(
src/models/room-receipts.ts:161and:185ondevelop— note the pre-existing TODO on theline above the shortcut; this exact case is already flagged there.)
So as soon as any event lands after Bob's reply, the "I sent the last message, it's all read"
shortcut stops applying, while the event Bob is being judged against is still Alice's older message.
The thread flips back to unread.
The reply itself doesn't need to be the trigger — any non-counting event after our latest reply
reproduces it, which makes reactions on threads a fairly easy way to hit this.
Possible fix
Rather than "did the user send the literal last event", the question should be "is the user's latest
event at or after the latest incoming event". That could be fixed in either layer:
doesTimelineHaveUnreadMessages/findLatestImportantEventin element-web, orContext
Raised in review of #32851 (Threads Activity
Centre cross-room threads panel), where the TAC currently carries a local guard
(
hasUnreadAfterMyLatestReply) to suppress the false positive. Filing this so the guard can beremoved once the underlying behaviour is fixed.
Environment
develop(analysis againstdevelopat time of drafting)counts.