Skip to content

fix(unread): fix badge counts stuck at "1" for unvisited rooms in sliding sync#538

Draft
Just-Insane wants to merge 3 commits intoSableClient:devfrom
Just-Insane:fix/unread-counts
Draft

fix(unread): fix badge counts stuck at "1" for unvisited rooms in sliding sync#538
Just-Insane wants to merge 3 commits intoSableClient:devfrom
Just-Insane:fix/unread-counts

Conversation

@Just-Insane
Copy link
Contributor

Description

With sliding sync, unvisited rooms (no read receipt) show a badge of 1 instead of the true unread count. After visiting a room, the badge corrects itself. After a cache clear or force-close, all badges are wrong on the initial sync pass.

Root Cause

Room list subscriptions use timeline_limit=1, so unvisited rooms only have a single event in memory. room.fixupNotifications(userId) re-derives the notification count from whatever events are in the live timeline — for these rooms that's exactly 1 event, so it sets _notificationCounts.total = 1 and overwrites the server-supplied notification_count.

Flow for a room with 15 true unreads:

  1. Server delivers notification_count: 15 → stored in SDK
  2. fixupNotifications() → recalculates from 1-event timeline → total = 1
  3. roomHaveUnread() returns false (no read receipt) → stale-clamp fires but hasUserReadEvent is also false → total stays at 1
  4. !readUpToId fallback sees hasActivity=true, total=1≠0 → returns { total: 1 } ← bug

After the user opens a room, the active subscription delivers the full timeline and a read receipt is sent. At that point fixup gets the full event set and works correctly.

Fix

Guard room.fixupNotifications(userId) with room.getEventReadUpTo(userId) so fixup is only applied for rooms the user has visited (where a read receipt or fully-read marker is present). Unvisited rooms use the raw server-supplied notification_count directly.

This also resolves the related symptom where unread counts are wrong after a cache clear or force-close: all rooms start without receipts, so fixup was corrupting every badge on the initial sync pass.

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings

AI disclosure:

  • Fully AI generated (explain what all the generated code does in moderate detail).

Adds room.getEventReadUpTo(userId) guard around the room.fixupNotifications(userId) call in getUnreadInfo in src/app/utils/room.ts. When the guard fails (no read receipt present), the function skips the fixup and uses the server-provided notification count directly.

…e unread counts

With sliding sync, room list subscriptions use timeline_limit=1 so unvisited rooms
(no read receipt) only have 1 event in memory. fixupNotifications() re-derives the
notification count from whatever events are in the live timeline; for these rooms it
computes 1 and overwrites the server-supplied notification_count, causing every
unvisited-room badge to show '1' instead of the true count.

After the user opens a room the active subscription delivers the full timeline and a
read receipt is sent, at which point fixup (applied only for visited rooms) works
correctly. This explains why badges 'fix themselves' after visiting a room.

Fix: add room.getEventReadUpTo(userId) guard so fixupNotifications is only called
for rooms that have been visited this session (have a read receipt or fully-read
marker). Unvisited rooms use the raw server-supplied notification_count directly.

This also resolves the related symptom where unread counts are wrong after a cache
clear or force-close: all rooms start without receipts, so fixup was corrupting every
badge on the initial sync pass.
@Just-Insane Just-Insane marked this pull request as ready for review March 25, 2026 14:50
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners March 25, 2026 14:50
@dozro
Copy link
Contributor

dozro commented Mar 25, 2026

hey it seems like you're missing a changeset. Can you please add a changeset, as described here https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md#documenting-a-change

Thank you :3

The previous guard (`getEventReadUpTo(userId)` truthy) only blocked
truly unvisited rooms. For visited rooms with a truncated timeline,
fixup still ran against 1-event timelines and overwrote the server count
with 1.

The correct condition is: only run fixupNotifications when the receipt
event itself is present in the live timeline -- i.e., the timeline is
deep enough for the re-derivation to be accurate. With sliding sync list
subscriptions (timeline_limit=1) the receipt for a room with 15 unreads
is rarely the very last event, so the receipt won't be in the 1-event
timeline and fixup is skipped, preserving the server-supplied count.
@Just-Insane
Copy link
Contributor Author

Just-Insane commented Mar 25, 2026

Updated this slightly so that it works better for sliding sync

@Just-Insane Just-Insane marked this pull request as draft March 25, 2026 17:00
@Just-Insane
Copy link
Contributor Author

I can't get this reliably working with the current sliding sync implementation. Will leave this open (draft) and come back to it at some point maybe.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants