Skip to content

Commit fe4adbb

Browse files
committed
Fix unread marker and room dot not clearing reliably
The 'New' unread marker in the timeline never auto-dismissed because the dismiss timer in .task fired before the diff pipeline had computed firstUnreadMessageId. Move the timer to an onChange handler so it starts exactly when the marker position is first set. The sidebar unread dot reappeared after markAsRead because applyRoomInfo unconditionally overwrote the optimistically-cleared counts with stale SDK values. Add an isOptimisticallyCleared flag on RoomSummary that guards against stale overwrites until the SDK confirms zero unreads. Fixes: #73 Assisted-By: Claude
1 parent b2bbf81 commit fe4adbb

4 files changed

Lines changed: 55 additions & 25 deletions

File tree

Packages/RelayInterface/Sources/RelayInterface/Models/RoomSummary.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ public final class RoomSummary: Identifiable {
105105
/// cleared when the room is marked as read.
106106
public var hasKeywordHighlight: Bool = false
107107

108+
/// Whether unread counts were optimistically cleared by ``markAsRead``.
109+
///
110+
/// When `true`, the room list manager should not overwrite the cleared counts
111+
/// with stale SDK values. The flag is reset when the SDK itself reports zero
112+
/// unread messages, confirming the server has processed the read receipt.
113+
public var isOptimisticallyCleared: Bool = false
114+
108115
/// The user's current membership state in this room.
109116
public var membership: RoomMembership
110117

Relay/Views/Timeline/TimelineView.swift

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -205,27 +205,31 @@ struct TimelineView: View { // swiftlint:disable:this type_body_length
205205
// Fetch room members for mention autocomplete
206206
compose.members = await matrixService.roomMembers(roomId: roomId)
207207

208-
// Auto-dismiss the "New" marker after 5 seconds, then clear it
209-
if viewModel.firstUnreadMessageId != nil {
210-
showUnreadMarker = true
211-
unreadMarkerDismissTask?.cancel()
212-
unreadMarkerDismissTask = Task {
213-
try? await Task.sleep(for: .seconds(5))
214-
guard !Task.isCancelled else { return }
215-
withAnimation(.easeOut(duration: 0.4)) {
216-
showUnreadMarker = false
217-
}
218-
try? await Task.sleep(for: .milliseconds(500))
219-
guard !Task.isCancelled else { return }
220-
viewModel.firstUnreadMessageId = nil
221-
}
222-
}
223208
}
224209
.onDisappear {
225210
if !readOnly, sendTypingNotifications {
226211
Task { await matrixService.sendTypingNotice(roomId: roomId, isTyping: false) }
227212
}
228213
memberRefreshTask?.cancel()
214+
unreadMarkerDismissTask?.cancel()
215+
}
216+
.onChange(of: viewModel.firstUnreadMessageId) { oldValue, newValue in
217+
// When the unread marker position is first computed (nil -> value),
218+
// start a 5-second auto-dismiss timer. This avoids the race where
219+
// the old .task check fired before the diff pipeline had set the ID.
220+
guard oldValue == nil, newValue != nil else { return }
221+
showUnreadMarker = true
222+
unreadMarkerDismissTask?.cancel()
223+
unreadMarkerDismissTask = Task {
224+
try? await Task.sleep(for: .seconds(5))
225+
guard !Task.isCancelled else { return }
226+
withAnimation(.easeOut(duration: 0.4)) {
227+
showUnreadMarker = false
228+
}
229+
try? await Task.sleep(for: .milliseconds(500))
230+
guard !Task.isCancelled else { return }
231+
viewModel.firstUnreadMessageId = nil
232+
}
229233
}
230234
.onChange(of: membershipEventCount) {
231235
guard !readOnly else { return }

RelayKit/Services/MatrixService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,10 +662,14 @@ public final class MatrixService: MatrixServiceProtocol {
662662

663663
// Optimistically clear unread indicators so the room list updates immediately
664664
// rather than waiting for the server round-trip through the sync loop.
665+
// The isOptimisticallyCleared flag prevents the room info listener from
666+
// overwriting these zeros with stale SDK values before the server processes
667+
// the read receipt.
665668
if let summary = rooms.first(where: { $0.id == roomId }) {
666669
summary.unreadMessages = 0
667670
summary.unreadMentions = 0
668671
summary.hasKeywordHighlight = false
672+
summary.isOptimisticallyCleared = true
669673
}
670674

671675
let receiptType: ReceiptType = sendPublicReceipt ? .read : .readPrivate

RelayKit/Services/RoomListManager.swift

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,32 @@ private final class RoomEntry: Identifiable {
437437
summary.avatarURL = nil
438438
}
439439

440-
summary.unreadMessages = UInt(info.numUnreadMessages)
441-
// Use the SDK's mention count as a floor; our client-side detection
442-
// may have already incremented unreadMentions higher than what the
443-
// SDK reports (e.g. for keyword matches). When the SDK value drops
444-
// to zero (room marked as read), reset our count too.
440+
// When the room was optimistically marked as read, the SDK may still
441+
// report stale non-zero unread counts until the server processes the
442+
// read receipt. Skip overwriting the cleared values in that case.
443+
// Once the SDK itself reports zero, clear the optimistic flag.
444+
let sdkUnread = UInt(info.numUnreadMessages)
445445
let sdkMentions = UInt(info.numUnreadMentions)
446-
if sdkMentions == 0 && info.numUnreadMessages == 0 {
447-
summary.unreadMentions = 0
448-
summary.hasKeywordHighlight = false
449-
} else if sdkMentions > summary.unreadMentions {
450-
summary.unreadMentions = sdkMentions
446+
if summary.isOptimisticallyCleared {
447+
if sdkUnread == 0 && sdkMentions == 0 {
448+
// Server confirmed — safe to clear the guard.
449+
summary.isOptimisticallyCleared = false
450+
summary.unreadMentions = 0
451+
summary.hasKeywordHighlight = false
452+
}
453+
// Otherwise keep the optimistically-cleared zeros.
454+
} else {
455+
summary.unreadMessages = sdkUnread
456+
// Use the SDK's mention count as a floor; our client-side detection
457+
// may have already incremented unreadMentions higher than what the
458+
// SDK reports (e.g. for keyword matches). When the SDK value drops
459+
// to zero (room marked as read), reset our count too.
460+
if sdkMentions == 0 && sdkUnread == 0 {
461+
summary.unreadMentions = 0
462+
summary.hasKeywordHighlight = false
463+
} else if sdkMentions > summary.unreadMentions {
464+
summary.unreadMentions = sdkMentions
465+
}
451466
}
452467
summary.isDirect = info.isDirect
453468
summary.canonicalAlias = info.canonicalAlias

0 commit comments

Comments
 (0)