commit 273d4a33bb6d22e17d5cc0954fa19b1d566978dc
parent c4b2efa3eec3297105a08e2d1891c3c4583245b3
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 14:53:12 +0900
Propagate seen horizons to sibling devices
This commit sends an account-level seen push whenever the local app
clears delivered notifications for a puzzle. The clear path now records
a concrete seen horizon in the app-group badge ledger, refreshes the app
icon badge, and publishes that horizon through the existing accountSeen
route so sibling devices can clear their own badge state without waiting
for CloudKit.
The receiving paths now apply those seen horizons without echoing them
back out. Inbound accountSeen pushes, sibling Player.readAt updates and
directed ping deletions clear matching delivered notifications and
refresh the local badge, but they opt out of sending another accountSeen
push. That keeps the account push path as a one-hop notification of
local user action instead of a feedback loop.
When the puzzle is actively visible, the clear path uses the same future
read-lease window as the Player cursor. That keeps an accountSeen push
from arriving after an active-lease update and accidentally collapsing
presence on another device.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
1 file changed, 27 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -488,7 +488,11 @@ final class AppServices {
// won't re-show the same letters when this device opens.
sessionMonitor.refreshMovesSnapshots(for: gameID)
if readAt > now {
- await self?.dismissDeliveredNotifications(for: gameID)
+ await self?.dismissDeliveredNotifications(
+ for: gameID,
+ seenAt: readAt,
+ publishAccountSeen: false
+ )
}
}
}
@@ -568,7 +572,10 @@ final class AppServices {
await syncEngine.setOnPingDeleted { [weak self] gameIDs in
guard let self else { return }
for gameID in gameIDs {
- await self.dismissDeliveredNotifications(for: gameID)
+ await self.dismissDeliveredNotifications(
+ for: gameID,
+ publishAccountSeen: false
+ )
}
}
@@ -2010,7 +2017,11 @@ final class AppServices {
syncMonitor.note("push(accountSeen): sibling saw \(gameID.uuidString.prefix(8))")
store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
sessionMonitor.refreshMovesSnapshots(for: gameID)
- await dismissDeliveredNotifications(for: gameID)
+ await dismissDeliveredNotifications(
+ for: gameID,
+ seenAt: readAt,
+ publishAccountSeen: false
+ )
default:
break
}
@@ -2794,7 +2805,11 @@ final class AppServices {
/// app-icon badge. Without this, pause/win/resign entries added by the
/// Notification Service Extension would otherwise linger past the point
/// where their banners have already been withdrawn.
- func dismissDeliveredNotifications(for gameID: UUID) async {
+ func dismissDeliveredNotifications(
+ for gameID: UUID,
+ seenAt explicitSeenAt: Date? = nil,
+ publishAccountSeen: Bool = true
+ ) async {
let center = UNUserNotificationCenter.current()
let delivered = await center.deliveredNotifications()
let identifiers = delivered.compactMap { notification -> String? in
@@ -2808,8 +2823,15 @@ final class AppServices {
center.removeDeliveredNotifications(withIdentifiers: identifiers)
syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
}
- BadgeState.markSeen(gameID: gameID)
+ let seenAt = explicitSeenAt
+ ?? (NotificationState.isSuppressed(gameID: gameID)
+ ? Date().addingTimeInterval(Self.readLeaseDuration)
+ : Date())
+ BadgeState.markSeen(gameID: gameID, at: seenAt)
await refreshAppBadge()
+ if publishAccountSeen {
+ await publishAccountSeenPush(gameID: gameID, readAt: seenAt)
+ }
}
/// Publishes this account's read horizon for other-author moves by