From c3bafe09cc1839935a6fa43a4934a2eaad2f9dfa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:45:26 -0400 Subject: [PATCH 1/6] fix(unread): suppress phantom badges when user sent the latest message --- src/app/utils/room.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e15630c79..2fa63f83c 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -312,6 +312,18 @@ 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() && latestEvent.getSender() === userId) { + return { roomId: room.roomId, highlight: 0, total: 0 }; + } + } + let total = room.getUnreadNotificationCount(NotificationCountType.Total); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); From 3a9b5aea34e9511a7e3f954fcb2530ff890773c0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 16:40:24 -0400 Subject: [PATCH 2/6] fix(unreads): remove synthetic dot badge when server reports zero unread The no-readUpToId path in getUnreadInfo returned { total: 1 } (a phantom dot) whenever the local SDK had no read receipt cached AND there was activity from others in the live timeline - even when the server's notification count was 0. A server count of 0 means the server has the user's receipt and knows the room is fully read; the local cache is just stale (common after iOS cold-start, sync gap, or rooms outside the active sliding-sync subscription window). Trusting the synthetic count over the server's authoritative 0 caused phantom unread dots on both DMs and Rooms that the user had already read. Fix: remove the hasActivity + synthetic total:1 fallback entirely. Rooms with no local receipt but non-zero server counts already fall through to the final return statement (with DM force-highlight applied if needed), so real counts are still displayed correctly. Rooms where both server and local cache agree there is nothing unread now return 0, eliminating the phantom dots. --- src/app/utils/room.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 2fa63f83c..5dc853457 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -389,31 +389,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 From 0a2eb4fb0cfc7b41989ac6348bde339efa553c84 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 18:52:59 -0400 Subject: [PATCH 3/6] chore: add changeset for fix/phantom-unreads --- .changeset/fix-phantom-unreads.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-phantom-unreads.md 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. From 07b45bc16671bd5d43a2cf4ece0c3c6508e101d8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 19:56:38 -0400 Subject: [PATCH 4/6] fix(phantom-unreads): only suppress unread badge for message-like events Restrict the phantom-unread suppression to events in NOTIFICATION_EVENT_TYPES (m.room.message, m.room.encrypted, m.sticker, etc.) so that reactions, membership changes, and other non-message events no longer incorrectly zero the badge. --- src/app/utils/room.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 5dc853457..7469485ec 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -319,7 +319,12 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn if (userId && !room.getEventReadUpTo(userId)) { const liveEvents = room.getLiveTimeline().getEvents(); const latestEvent = liveEvents[liveEvents.length - 1]; - if (latestEvent && !latestEvent.isSending() && latestEvent.getSender() === userId) { + if ( + latestEvent && + !latestEvent.isSending() && + latestEvent.getSender() === userId && + isNotificationEvent(latestEvent) + ) { return { roomId: room.roomId, highlight: 0, total: 0 }; } } From d67a2549191f2057774ba38efa7c886949fc44ed Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 22:49:52 -0400 Subject: [PATCH 5/6] fix(phantom-unreads): restrict badge suppression to message event types Guard the phantom-unread suppression path with SUPPRESSABLE_SENT_EVENT_TYPES (m.room.message, m.room.encrypted, m.sticker) so that state events such as m.room.create and reactions do not incorrectly clear notification badges. Also pass room and userId to isNotificationEvent for consistent filtering. Addresses Copilot review comment on #883. --- src/app/utils/room.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 7469485ec..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)) { @@ -322,8 +331,9 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn if ( latestEvent && !latestEvent.isSending() && + SUPPRESSABLE_SENT_EVENT_TYPES.has(latestEvent.getType()) && latestEvent.getSender() === userId && - isNotificationEvent(latestEvent) + isNotificationEvent(latestEvent, room, userId) ) { return { roomId: room.roomId, highlight: 0, total: 0 }; } From c7272f5562fef2729de3521d26e218d94ec7073d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 21 May 2026 14:18:40 -0400 Subject: [PATCH 6/6] fix(unreads): prevent reaction-based phantom unreads and DM thread highlight Two bugs in getUnreadInfo: 1. Fallback path (SDK=0 but roomHaveUnread) was counting reactions to the user's own message as unread events. isNotificationEvent returns true for reactions to own messages, but the SDK never counts them toward notification totals. The fix gates fallbackTotal on pushProcessor.actionsForEvent(event).notify so only events the push processor would actually notify about are counted. This prevents phantom 1/0 atoms that mark-as-read couldn't clear (because the receipt was already at the reaction and no SDK event would re-fire). 2. DM force-highlight guard was using total > 0, where total includes thread reply counts. A DM with only thread-only unreads (roomTotal=0) would incorrectly have its entire total promoted to highlight. Fix: guard on roomLevelTotal (getRoomUnreadNotificationCount) which excludes thread counts. --- src/app/utils/room.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 1b1fe11f7..820b2d722 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -390,9 +390,14 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn if (!event) break; if (event.getId() === readUpToId) break; if (isNotificationEvent(event, room, userId) && event.getSender() !== userId) { - fallbackTotal += 1; const pushActions = pushProcessor.actionsForEvent(event); - if (pushActions?.tweaks?.highlight) fallbackHighlight += 1; + // Only count events that would actually generate a push notification. + // This excludes reactions (which use dont_notify by default push rules) + // and prevents the fallback from creating phantom unreads the SDK ignores. + if (pushActions?.notify) { + fallbackTotal += 1; + if (pushActions.tweaks?.highlight) fallbackHighlight += 1; + } } } if (fallbackTotal > 0) { @@ -408,7 +413,10 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn // 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 // member_count condition failures, or sliding sync with limited required_state). - if (shouldForceDMHighlight && total > 0 && highlight === 0) { + // Guard on room-level (non-thread) total: thread-only unreads in DMs should not + // be force-highlighted — the thread's own push rules handle highlight there. + const roomLevelTotal = room.getRoomUnreadNotificationCount(NotificationCountType.Total); + if (shouldForceDMHighlight && roomLevelTotal > 0 && highlight === 0) { return { roomId: room.roomId, highlight: total, // Treat all unread messages as highlights for DMs