crossmate

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

commit f747c91fc21655078a03d9046302309c06b2ec3e
parent 62d0d4c9e96386e502269dc77c3c29bb568b3a31
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 12:37:38 +0900

Send play/pause notifications more judiciously

Diffstat:
MCrossmate/CrossmateApp.swift | 31++++++++++++++++++++++++-------
MCrossmate/Services/AppServices.swift | 38++++++++++++++++++++++++++++++++++++++
2 files changed, 62 insertions(+), 7 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -637,18 +637,35 @@ private struct PuzzleDisplayView: View { private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { let id = gameID - if phase == .active { + switch phase { + case .active: NotificationState.setActivePuzzleID(gameID) - // Hand any pending end-of-session tallies off to the - // in-puzzle banner instead of letting the local notification - // fire a few minutes from now. + // If a pause was queued in the grace window and the user + // returned before it fired, drop it and skip the matching play + // push — peers should see one continuous session, not a + // pause/play pair, for a brief absence (phone sleep, a call, + // app-switcher peek that escalated to background). + let resumed = services.cancelPendingSessionEndPush(gameID: id) Task { await services.handlePuzzleOpened(gameID: id) - await services.publishSessionStartPush(gameID: id) + if !resumed { + await services.publishSessionStartPush(gameID: id) + } } - } else { + case .background: NotificationState.clearActivePuzzleID(if: gameID) - Task { await services.publishSessionEndPush(gameID: id) } + services.scheduleSessionEndPush( + gameID: id, + after: AppServices.sessionEndGrace + ) + case .inactive: + // Transient (lock animation, app switcher, Control Center, + // banners) — the user is still on the puzzle. Toggling the + // active-puzzle ID or firing a pause push here would thrash + // both on every interruption. + break + @unknown default: + break } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -59,6 +59,12 @@ final class AppServices { let identity: AuthorIdentity let pushClient: PushClient? let localSessionTracker = LocalSessionTracker() + /// Grace window before a backgrounded session is treated as ended. A + /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a + /// call) should not fan out pause/play pings to peers on every flicker — + /// only a sustained absence does. + static let sessionEndGrace: TimeInterval = 120 + private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:] let shareController: ShareController let friendController: FriendController let cursorStore: GameCursorStore @@ -637,7 +643,39 @@ final class AppServices { /// background-publish) skips the push entirely. Kind is `pause` — the /// user is stepping away from a puzzle they may return to, not /// permanently leaving the game. + /// Defer the session-end push by `seconds`. Cancels any previously + /// scheduled pause for the same game. If the user resumes within the + /// grace window, call `cancelPendingSessionEndPush` to drop the timer + /// and skip the matching session-start push so peers don't get a + /// pause/play pair for a brief absence. + func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) { + pendingSessionEndTasks[gameID]?.cancel() + pendingSessionEndTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + guard let self else { return } + self.pendingSessionEndTasks[gameID] = nil + await self.publishSessionEndPush(gameID: gameID) + } + } + + /// Cancel any pending scheduled session-end push for `gameID`. Returns + /// `true` if a pending task was dropped, i.e. the caller is inside the + /// grace window and should suppress the matching session-start push. + @discardableResult + func cancelPendingSessionEndPush(gameID: UUID) -> Bool { + guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { + return false + } + task.cancel() + return true + } + func publishSessionEndPush(gameID: UUID) async { + // A direct call (e.g. from `.onDisappear`) supersedes any pending + // grace-window timer for this game — drop it so we don't fire a + // second pause once the timer elapses. + pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel() let counts = localSessionTracker.consume(gameID: gameID) guard counts.added > 0 || counts.cleared > 0 else { syncMonitor.note("push(pause): skipped (no edits)")