crossmate

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

commit 25df50d33fc67a4b869bdae65a2f623742a64081
parent acf0e0c1ffc822f1e0e00976380df56f25597edf
Author: Michael Camilleri <[email protected]>
Date:   Fri, 22 May 2026 22:09:15 +0900

Withdraw a game's notifications when a sibling device opens it

Prior to this commit, the session-begin local notification — 'X is solving the
puzzle' — had no cross-device dismissal path. Opening the puzzle on one device
left that notification standing on the account's other devices. The only
cross-device withdrawal ran through directed-ping deletion (onPingDeleted
calling dismissDeliveredNotifications); session-begin notifications are
inferred from Player records, not Ping records, so nothing ever withdrew them.
Player.readAt synced the read horizon between a user's devices, but only fed
the unread-moves badge, never notification dismissal.

setOnIncomingReadCursor — which applies a sibling device's Player.readAt to the
local unread-moves cursor — now also calls dismissDeliveredNotifications for
the game when the incoming readAt is a future-dated active lease. A future
readAt means a sibling device is in that puzzle right now, so any session
notification already delivered for it is moot. A past readAt is only a
closed-session horizon bump and leaves delivered notifications untouched, so a
catch-up re-applying an old Player record cannot sweep a freshly delivered
notification.

dismissDeliveredNotifications makes no CKSyncEngine call — it only touches
UserNotifications — so it is safe to await directly from the delegate callback,
matching the existing onPingDeleted call site on the same stack.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 13+++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -284,10 +284,19 @@ final class AppServices { // A sibling device of the same iCloud account has published its read // horizon; apply it directly because SyncEngine has already accepted - // the Player record under last-writer-wins freshness checks. - await syncEngine.setOnIncomingReadCursor { [store] pairs in + // the Player record under last-writer-wins freshness checks. A + // future-dated readAt is an active-session lease — a sibling is in the + // puzzle right now — so withdraw any session notifications we already + // delivered for that game (e.g. "X is solving"); opening it here is no + // longer something to nudge for. A past readAt is just a closed-session + // horizon bump and leaves delivered notifications untouched. + await syncEngine.setOnIncomingReadCursor { [weak self, store] pairs in + let now = Date() for (gameID, readAt) in pairs { store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) + if readAt > now { + await self?.dismissDeliveredNotifications(for: gameID) + } } }