Skip to content

doesTimelineHaveUnreadMessages: thread stays unread when a non-counting event lands after your own reply #34904

Description

@nathanael-h

Steps to reproduce

  1. Alice starts a thread in a room and posts a message.
  2. Bob (you) reads it and replies in the thread.
  3. 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.
  4. 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:

  1. 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.

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions