commit 2c1772195d543713adbccfa563c6b3a9a57c5fc2
parent 2446484fe1026f01254857662d038b029eed177b
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 15:09:43 +0900
Dismiss notifications when entering relevant game
The active-puzzle guard in presentPings/presentSessions (via
NotificationState.isActive) prevents new notifications from being created while
the user is viewing the matching puzzle, and willPresent acts as a foreground
fallback. Neither path does anything about notifications already delivered into
Notification Center before the user navigated in — so opening game X from a
notification, or from the library after a quiet period, would leave older
banners for X sitting in Notification Center until the user manually swiped
them away.
AppServices.dismissDeliveredNotifications(for:) now enumerates
UNUserNotificationCenter.deliveredNotifications, filters by the crossmateGameID
userInfo key the two presenters already stamp into every request and calls
removeDeliveredNotifications(withIdentifiers:) on the matches.
PuzzleDisplayView's task(id: gameID) fires the dismissal alongside the existing
updateActiveNotificationPuzzleID call, so the act of loading a puzzle both
suppresses future notifications for it and clears any prior ones for the same
game.
The app-icon badge is independently driven by Core Data
(unseenOtherMovesGameCount), and loadGame already advances lastSeenOtherMoveAt
via markOtherMovesSeen which fires onUnseenOtherMovesChanged → refreshAppBadge.
So the badge already decrements on entry; this change does not interfere with
that path and does not need to touch setBadgeCount.
Scope is intentionally per-device. Cross-device dismissal (same iCloud account,
other iPad/iPhone) would require a CloudKit signal into the user's private
database with a silent subscription on the peer devices, and is left for a
follow-up.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 22 insertions(+), 0 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -381,6 +381,7 @@ private struct PuzzleDisplayView: View {
roster = nil
loadError = nil
updateActiveNotificationPuzzleID(for: scenePhase)
+ Task { await services.dismissDeliveredNotifications(for: gameID) }
do {
let (game, mutator) = try store.loadGame(id: gameID)
let newSession = PlayerSession(game: game, mutator: mutator)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1091,6 +1091,27 @@ final class AppServices {
syncMonitor.updateSnapshot(snapshot)
}
+ /// Removes any already-delivered local notifications for `gameID` from
+ /// Notification Center. The active-puzzle guard in `presentPings` /
+ /// `presentSessions` prevents *new* notifications while a game is open,
+ /// but anything delivered before the user navigated in still needs to
+ /// be cleared explicitly so Notification Center isn't littered with
+ /// stale entries for a puzzle they're now actively viewing.
+ func dismissDeliveredNotifications(for gameID: UUID) async {
+ let center = UNUserNotificationCenter.current()
+ let delivered = await center.deliveredNotifications()
+ let identifiers = delivered.compactMap { notification -> String? in
+ let userInfo = notification.request.content.userInfo
+ guard let raw = userInfo["crossmateGameID"] as? String,
+ raw == gameID.uuidString
+ else { return nil }
+ return notification.request.identifier
+ }
+ guard !identifiers.isEmpty else { return }
+ center.removeDeliveredNotifications(withIdentifiers: identifiers)
+ syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
+ }
+
/// Sets the app icon badge to the number of shared games with unseen
/// other-author moves — the same `hasUnseenOtherMoves` signal that drives
/// the per-row dot in the library list. Silently no-ops when the user