crossmate

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

commit 2ead503eff4fd82a92775537651ae3025dfbf0c0
parent 5931ee62a39b20f57163797d1c50f6da724fedf9
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 23:04:58 +0900

Stop gating push notifications on the stale presence lease

The nudge, pause and completion pushes each dropped any recipient whose
presence lease (Player.readAt) showed them in the puzzle. That lease is
forward-dated ten minutes and refreshed only lazily, so it kept
reporting a co-solver as present for up to ten minutes after they had
actually left — a manual nudge sent in that window was silently
discarded before any push left the device, and the pause, win and resign
summaries were suppressed the same way.

This commit removes the send-side presence gate (absentRecipients) and
instead addresses every participant that has published a push
capability. Presence is now decided per device on receipt, where the
answer is exact rather than guessed from the sender's stale copy of the
lease: the device the user is actually playing on suppresses the banner
itself (the foreground isSuppressed check), while an idle sibling
delivers the alert passively — a .passive interruption level, so no
banner or sound, just a quiet Notification Center entry — and the
present device's next read-cursor sync sweeps that entry away.

For the sweep to also clear completion and pause alerts a sibling has
already shown, the accountSeen handler now distinguishes an active
presence lease (a forward-dated readAt, meaning a sibling is in the game
right now and has watched its events live) from a plain past read
watermark. The former retracts even the unread-marking notifications;
the latter still preserves genuinely-unread alerts. The Notification
Service Extension reads the same suppression horizon to decide when to
downgrade an incoming alert to .passive.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 9++++++++-
MCrossmate/Services/SessionCoordinator.swift | 55+++++++++++++++++--------------------------------------
MCrossmate/Services/SessionPushPlanner.swift | 26+++-----------------------
MNotificationService/NotificationService.swift | 11+++++++++++
MShared/NotificationState.swift | 11+++++++++++
MTests/Unit/SessionPushPlannerTests.swift | 24+-----------------------
6 files changed, 51 insertions(+), 85 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1563,11 +1563,18 @@ final class AppServices { // accurate, on the sibling's `Player.sessionSnapshot` via the // record sync this push's companion DB change triggers. This fast // push is now only the cross-device notification-dismissal signal. + // A forward-dated readAt is an active presence lease: the sibling is + // in this game right now and has seen its events live, so retract + // even the unread-marking notifications (win/resign/pause) from this + // device — the "soon-swept" half of sending to every device. A past + // readAt is a plain read watermark (the sibling left), where we still + // preserve genuinely-unread alerts. + let siblingPresent = readAt > Date() await badge.dismissDeliveredNotifications( for: gameID, seenAt: readAt, publishAccountSeen: false, - preserveUnread: true + preserveUnread: !siblingPresent ) default: break diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -224,15 +224,11 @@ final class SessionCoordinator { syncMonitor.note("push(nudge): skipped (access revoked)") return } - // A present recipient is already in the puzzle, so a nudge would only - // banner their other devices for a session they're in. Drop them. - let recipients = SessionPushPlanner.absentRecipients(plan.recipients) - if recipients.count < plan.recipients.count { - syncMonitor.note( - "push(nudge): skipped \(plan.recipients.count - recipients.count) present recipient(s)" - ) - } - let addressees = recipients.compactMap { recipient in + // Send to every participant: presence is no longer guessed here. A + // recipient who is actually present suppresses the banner on the device + // they're using (foreground `isSuppressed`) and sweeps it from their + // other devices once their present device's read cursor syncs. + let addressees = plan.recipients.compactMap { recipient in recipient.pushAddress.map { PushClient.Addressee(address: $0, payload: PushPayload(event: .nudge)) } @@ -304,22 +300,12 @@ final class SessionCoordinator { syncMonitor.note("push(pause): skipped (access revoked)") return } - // A leased-present recipient watched this session live, so the pause - // would only banner their other devices. Skipped recipients keep their - // `notifiedThrough` — nothing was reported to them, so the next pause - // re-tallies from their watermark. - let recipients = SessionPushPlanner.absentRecipients(plan.recipients) - if recipients.count < plan.recipients.count { - syncMonitor.note( - "push(pause): skipped \(plan.recipients.count - recipients.count) present recipient(s)" - ) - } - guard !recipients.isEmpty else { - // Everyone watched live, so no push goes out — but the session - // still closed: release the per-game state machine. - pruneIfIdle(gameID) - return - } + // Send to every participant: presence is no longer guessed here. A + // present recipient suppresses the banner on the device they're using + // and sweeps it from their others once their read cursor syncs. The + // per-recipient tally below still drops recipients with nothing unseen, + // so a session that changed no letters still reaches no one. + let recipients = plan.recipients // The pause counts are derived from this device's own journal (gesture // history), not the merged grid, so the summary can name fills/clears/ // checks/reveals. The merged-grid measurements still ride the @@ -406,17 +392,11 @@ final class SessionCoordinator { syncMonitor.note("push(\(kindLabel)): skipped (no recipients)") return } - // Same gate as play/pause: a leased-present recipient sees the - // completion land in the grid live, so the push would only banner - // their other devices. - let recipients = SessionPushPlanner.absentRecipients(plan.recipients) - if recipients.count < plan.recipients.count { - syncMonitor.note( - "push(\(kindLabel)): skipped \(plan.recipients.count - recipients.count) present recipient(s)" - ) - } + // Send to every participant: presence is no longer guessed here. A + // present recipient suppresses the banner where they're playing and + // sweeps it from their other devices once their read cursor syncs. let event: PushPayload.Event = resigned ? .resign : .win - let addressees = recipients.compactMap { recipient in + let addressees = plan.recipients.compactMap { recipient in recipient.pushAddress.map { PushClient.Addressee(address: $0, payload: PushPayload(event: event)) } @@ -494,7 +474,7 @@ final class SessionCoordinator { gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) gReq.fetchLimit = 1 guard let game = try? ctx.fetch(gReq).first else { return .empty } - var byAuthor: [String: (readAt: Date?, readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:] + var byAuthor: [String: (readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:] let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") pReq.predicate = NSPredicate(format: "game == %@", game) for p in (try? ctx.fetch(pReq)) ?? [] { @@ -503,12 +483,11 @@ final class SessionCoordinator { !a.isEmpty else { continue } if let authorID, a == authorID { continue } - byAuthor[a] = (p.readAt, p.readThrough, p.notifiedThrough, p.pushAddress) + byAuthor[a] = (p.readThrough, p.notifiedThrough, p.pushAddress) } let recipients = byAuthor.map { PushRecipient( authorID: $0.key, - readAt: $0.value.readAt, readThrough: $0.value.readThrough, notifiedThrough: $0.value.notifiedThrough, pushAddress: $0.value.pushAddress diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -7,17 +7,11 @@ import Foundation /// Everything a sender-side push helper needs to know about a game in /// one Core Data round-trip: the roster authors to notify (each with the -/// last-known `Player.readAt` so the pause path can compute a -/// per-recipient diff), the puzzle's display title, and the gating flags -/// callers consult before emitting. +/// read/notified watermarks the pause path windows its per-recipient diff +/// on), the puzzle's display title, and the gating flags callers consult +/// before emitting. struct PushRecipient: Sendable, Equatable { let authorID: String - /// The recipient's presence lease (`Player.readAt`), forward-dated while - /// one of their devices is in the puzzle. Used only to gate whether to - /// address them at all (`absentRecipients`) — a present recipient is - /// watching live, so a banner on their other devices is noise. Counts - /// never window on it; that's `readThrough`'s job. - let readAt: Date? /// The recipient's read watermark (`Player.readThrough`): the latest /// other-author move time they've actually seen. The session-end tally /// windows on this — never the forward-dated presence lease — so a peer @@ -40,20 +34,6 @@ struct PushRecipient: Sendable, Equatable { } enum SessionPushPlanner { - /// Drops recipients whose presence lease shows them in the game right now - /// (`PeerPresence.isPresent`, including its bounce grace). Their devices - /// render the session live through the roster and engagement channel, so a - /// play/pause banner — which lands on *every* device of their account, - /// including the one in the game — is noise. The cost is the sync window: - /// a recipient whose collapsed lease hasn't reached the sender yet is - /// skipped and catches up via the in-app summary banner instead. - static func absentRecipients( - _ recipients: [PushRecipient], - asOf now: Date = Date() - ) -> [PushRecipient] { - recipients.filter { !PeerPresence.isPresent(readAt: $0.readAt, asOf: now) } - } - /// Builds the per-recipient addressees for a session-end push. A recipient /// is addressed only when this session changed letters *they* haven't seen /// — net `fills`, `clears`, or `reveals` since their watermark. A session diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -115,6 +115,16 @@ final class NotificationService: UNNotificationServiceExtension { if let gameID, marksUnread { BadgeState.markUnread(gameID: gameID) } + // While another device of this account is present in the game, deliver + // passively: no banner, no sound — the alert drops quietly into + // Notification Center, where the present device's read-cursor sync will + // sweep it shortly. The full no-show path (`willPresent` → `[]`) only + // runs on a foreground app; on an idle sibling the NSE is the only code + // that runs, and `.passive` is as quiet as it can make an alert push. + let deliveredPassively = gameID.map { BadgeState.isSuppressed(gameID: $0) } ?? false + if deliveredPassively { + bestAttemptContent.interruptionLevel = .passive + } // Fold in pending invites the app published to the App Group: a moves // push must not re-stamp the badge to the moves-only count and drop a // still-pending invite. The two sets are disjoint, so the union is exact. @@ -127,6 +137,7 @@ final class NotificationService: UNNotificationServiceExtension { "kind=\(kind ?? "nil")", "payload=\(payload == nil ? "absent" : "present")", "marksUnread=\(marksUnread)", + "passive=\(deliveredPassively)", "ledgerUnread=\(BadgeState.unreadGameIDs().count)", "pendingInvites=\(BadgeState.pendingInviteGameIDs().count)", "stampedBadge=\(count)" diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -218,6 +218,17 @@ enum BadgeState { NotificationState.sharedDefaultsForSiblings } + /// True while a sibling device of this account is present in `gameID`: the + /// suppression horizon (extended by `accountSeen`/the local lease via + /// `extendSuppression`/`adoptReadHorizon`) is still in the future. The + /// Notification Service Extension reads this to deliver passively while the + /// user is playing on another device, rather than bannering a session + /// they're watching live. + static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool { + guard let until = loadLedger()[gameID.uuidString]?.suppressedUntil else { return false } + return until > now + } + static func unreadGameIDs() -> Set<UUID> { let ledger = loadLedger() return Set(ledger.compactMap { key, entry in diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -34,12 +34,10 @@ struct SessionPushPlannerTests { private func recipient( _ address: String?, readThrough: Date?, - notifiedThrough: Date? = nil, - readAt: Date? = nil + notifiedThrough: Date? = nil ) -> PushRecipient { PushRecipient( authorID: "peer", - readAt: readAt, readThrough: readThrough, notifiedThrough: notifiedThrough, pushAddress: address @@ -360,26 +358,6 @@ struct SessionPushPlannerTests { #expect(addressees.isEmpty) } - @Test("A leased-present recipient is filtered out of the fan-out") - func presentRecipientFiltered() { - let now = Date(timeIntervalSince1970: 100_000) - // In the game: lease minted into the future. - let present = recipient("addr-present", readThrough: nil, readAt: now.addingTimeInterval(600)) - // Bounced moments ago: collapsed lease still inside the presence grace. - let bounced = recipient("addr-bounced", readThrough: nil, readAt: now.addingTimeInterval(-30)) - // Genuinely gone: lease lapsed beyond the grace. - let departed = recipient("addr-departed", readThrough: nil, readAt: now.addingTimeInterval(-120)) - // Never present (no lease ever synced). - let never = recipient("addr-never", readThrough: nil) - - let absent = SessionPushPlanner.absentRecipients( - [present, bounced, departed, never], - asOf: now - ) - - #expect(absent.map(\.pushAddress) == ["addr-departed", "addr-never"]) - } - @Test("Only the behind recipient is addressed; the caught-up one is dropped") func behindAddressedCaughtUpDropped() { let edit = Date(timeIntervalSince1970: 1_000)