diff --git a/.changeset/fix-phantom-unreads.md b/.changeset/fix-phantom-unreads.md new file mode 100644 index 000000000..a461295f9 --- /dev/null +++ b/.changeset/fix-phantom-unreads.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix phantom unread dot badges when server reports zero unreads or when you sent the latest message. diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e15630c79..1b1fe11f7 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -221,6 +221,15 @@ const NOTIFICATION_EVENT_TYPES = new Set([ 'm.sticker', 'm.reaction', ]); + +// Event types that represent actual user-sent messages. +// Used to guard phantom-unread suppression so state events (e.g. m.room.create, +// m.room.member) and reactions do not incorrectly clear notification badges. +const SUPPRESSABLE_SENT_EVENT_TYPES = new Set([ + 'm.room.message', + 'm.room.encrypted', + 'm.sticker', +]); export const isNotificationEvent = (mEvent: MatrixEvent, room?: Room, userId?: string) => { const eType = mEvent.getType(); if (!NOTIFICATION_EVENT_TYPES.has(eType)) { @@ -312,6 +321,24 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } + // If the user's own message is the most recent event in the live timeline they + // implicitly read everything before it when they composed that reply. Return zero + // to suppress phantom unread badges that arise from stale SDK counters in sliding + // sync when no explicit read receipt is present. + if (userId && !room.getEventReadUpTo(userId)) { + const liveEvents = room.getLiveTimeline().getEvents(); + const latestEvent = liveEvents[liveEvents.length - 1]; + if ( + latestEvent && + !latestEvent.isSending() && + SUPPRESSABLE_SENT_EVENT_TYPES.has(latestEvent.getType()) && + latestEvent.getSender() === userId && + isNotificationEvent(latestEvent, room, userId) + ) { + return { roomId: room.roomId, highlight: 0, total: 0 }; + } + } + let total = room.getUnreadNotificationCount(NotificationCountType.Total); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); @@ -377,31 +404,6 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } - // Sliding sync limitation: unvisited rooms don't have read receipt data, but may have - // timeline activity. Check for notification events from others in the timeline to show a - // badge even when SDK counts are 0 (or unreliable without receipts). - if (userId) { - const readUpToId = room.getEventReadUpTo(userId); - - // If we have no read receipt, SDK counts may be unreliable. Always check timeline. - if (!readUpToId) { - const liveEvents = room.getLiveTimeline().getEvents(); - - const hasActivity = liveEvents.some( - (event) => event.getSender() !== userId && isNotificationEvent(event, room, userId) - ); - - if (hasActivity) { - // If SDK already has counts, use those. Otherwise show dot badge (count=1). - if (total === 0 && highlight === 0) { - return { roomId: room.roomId, highlight: 0, total: 1 }; - } - // SDK has counts but no receipt - trust the counts and show them - return { roomId: room.roomId, highlight, total }; - } - } - } - // For DMs with Default or AllMessages notification type: if there are unread messages, // ensure we show a notification badge (treat as highlight for badge color purposes). // This handles cases where push rules don't properly match (e.g., classic sync with