commit f3bc3a93131bb96e30c7edffc6285ae212c0c290
parent 9d1c128f68851bd3ae671043ad8022e226de25a1
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 23:59:17 +0900
Keep unread notifications through presence leases
This commit stops account presence updates from withdrawing delivered
notifications that still represent unseen moves. A future readAt lease
can arrive while the game remains unread by the real readThroughAt
watermark, which let Crossmate remove Notification Centre entries even
though the badge and game list correctly still showed unseen activity.
The dismissal path now has an explicit preserveUnread mode for
account-seen and synced-cursor updates. In that mode it checks Core
Data's unread-moves predicate and keeps delivered notifications whose
payload marks the game unread, while still allowing presence-only
notifications to be dismissed. Opening the puzzle and ping deletion
keep the existing unconditional dismissal behaviour.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -498,6 +498,17 @@ final class GameStore {
return Set(rows.compactMap(\.id))
}
+ func hasUnreadOtherMoves(gameID: UUID) -> Bool {
+ let request = NSFetchRequest<NSNumber>(entityName: "GameEntity")
+ request.resultType = .countResultType
+ request.fetchLimit = 1
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+ NSPredicate(format: "id == %@", gameID as CVarArg),
+ unreadOtherMovesPredicate
+ ])
+ return ((try? context.count(for: request)) ?? 0) > 0
+ }
+
/// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each
/// game's newest unseen other-author move time (`latestOtherMoveAt`, which
/// the predicate guarantees is non-nil). The app seeds these into the App
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -531,7 +531,8 @@ final class AppServices {
await self?.badge.dismissDeliveredNotifications(
for: gameID,
seenAt: readAt,
- publishAccountSeen: false
+ publishAccountSeen: false,
+ preserveUnread: true
)
} else if NotificationState.activePuzzleID() == gameID {
// A past-dated readAt is a sibling closing its session, which
@@ -1380,7 +1381,8 @@ final class AppServices {
await badge.dismissDeliveredNotifications(
for: gameID,
seenAt: readAt,
- publishAccountSeen: false
+ publishAccountSeen: false,
+ preserveUnread: true
)
default:
break
diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift
@@ -43,20 +43,28 @@ final class BadgeCoordinator {
func dismissDeliveredNotifications(
for gameID: UUID,
seenAt explicitSeenAt: Date? = nil,
- publishAccountSeen: Bool = true
+ publishAccountSeen: Bool = true,
+ preserveUnread: Bool = false
) async {
let center = UNUserNotificationCenter.current()
let delivered = await center.deliveredNotifications()
+ let preserveUnreadNotifications = preserveUnread
+ && store.hasUnreadOtherMoves(gameID: gameID)
let identifiers = delivered.compactMap { notification -> String? in
let userInfo = notification.request.content.userInfo
guard let raw = userInfo["gameID"] as? String,
raw == gameID.uuidString
else { return nil }
+ if preserveUnreadNotifications, notificationMarksUnread(notification) {
+ return nil
+ }
return notification.request.identifier
}
if !identifiers.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: identifiers)
syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
+ } else if preserveUnreadNotifications {
+ syncMonitor.note("notif: kept delivered unread notifications for \(gameID.uuidString)")
}
// While viewing, the horizon is the local lease (forward-dated); the
// ledger adoption splits it — watermark to now, suppression to the
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -362,21 +362,25 @@ struct GameStoreUnreadMovesTests {
store.noteIncomingMovesUpdate(gameIDs: [gameID], currentAuthorID: Self.localAuthorID)
#expect(entity.readThroughAt == nil)
#expect(store.unreadOtherMovesGameCount() == 1)
+ #expect(store.hasUnreadOtherMoves(gameID: gameID))
// A future presence lease must NOT clear the badge — this is the bug:
// a leased-but-backgrounded reader had moves silently swallowed.
#expect(store.setReadCursor(gameID: gameID, readAt: future))
#expect(entity.lastReadOtherMoveAt == future)
#expect(store.unreadOtherMovesGameCount() == 1)
+ #expect(store.hasUnreadOtherMoves(gameID: gameID))
// The watermark, older than the latest move, still leaves it unread.
#expect(store.advanceReadThrough(gameID: gameID, through: earlier))
#expect(store.unreadOtherMovesGameCount() == 1)
+ #expect(store.hasUnreadOtherMoves(gameID: gameID))
// The watermark catching the latest move is what clears the badge.
#expect(store.advanceReadThrough(gameID: gameID, through: latest))
#expect(entity.readThroughAt == latest)
#expect(store.unreadOtherMovesGameCount() == 0)
+ #expect(!store.hasUnreadOtherMoves(gameID: gameID))
}
@Test("Active read leases refresh only when the horizon is below the floor")