crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/CrossmateApp.swift | 1+
MCrossmate/Services/AppServices.swift | 21+++++++++++++++++++++
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