Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-phantom-unreads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix phantom unread dot badges when server reports zero unreads or when you sent the latest message.
52 changes: 27 additions & 25 deletions src/app/utils/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'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)) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
Loading