commit 165d16b51ee110f8c634c1d372c43d58154908f1 parent 42bda51e5e7ff94433e73f5ea70e923c624af903 Author: Michael Camilleri <[email protected]> Date: Thu, 18 Jun 2026 05:35:13 +0900 Replace session-begin pushes with manual nudges This commit removes the automatic 'Alice is solving X' push and gates the session-end summary on real grid changes, so peers are no longer pinged for a glance at a puzzle or a stray check. Players who want to rouse the others now do so deliberately: a 'Nudge Players' action in the in-game players menu broadcasts a 'nudge' push to every other player who is not already present, throttled to one nudge per game per minute. The session-end push is now sent to a recipient only when this session changed letters they have not seen — net fills, clears or reveals. A cursor-only or check-only visit reaches no one, and the session-begin grace that previously decided whether a pause was owed is gone, its significance role subsumed by the letter-change gate. The 'play' event is kept decodable for a mixed-version rollout but is never sent, and a nudge carries no grid summary, so it never marks a game unread. The notification settings are renamed to match: the four toggles are now Nudges, Pauses, Invitations and Completions, with Nudges muting the new 'nudge' kind in place of the retired 'play'. The push worker treats a nudge as ephemeral, like 'play' presence, since a 'come play' alert delivered hours later is stale. Co-Authored-By: Claude Opus 4.8 <[email protected]> Diffstat:
19 files changed, 291 insertions(+), 374 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -701,6 +701,8 @@ private struct PuzzleDisplayView: View { } }, onDelete: { try store.deleteGame(id: gameID) }, + onNudge: { await services.sessions.nudge(gameID: gameID) }, + canNudge: { services.sessions.canNudge(gameID: gameID) }, loadReplay: { let short = gameID.uuidString.prefix(8) // Finished-game timelines are immutable (edit-lockout), @@ -918,13 +920,9 @@ private struct PuzzleDisplayView: View { let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater let id = gameID - // Navigating away is a leave: clear the active-puzzle ID, commit - // the catch-up baseline, and drop a begin push still inside its - // grace (idempotent with the .background path). `owesEndPush` is - // false when the visit never outlasted the begin grace — the - // session was never announced to peers, so there's no play - // session to pause on the way out. - let owesEndPush = services.sessions.notePuzzleClosed(gameID: id) + // Navigating away is a leave: clear the active-puzzle ID and commit + // the catch-up baseline (idempotent with the .background path). + services.sessions.notePuzzleClosed(gameID: id) services.engagement.scheduleEngagementEnd(gameID: id) // Navigating away is a leave: stamp the away-change baseline so the // next open diffs against now. @@ -937,12 +935,10 @@ private struct PuzzleDisplayView: View { // Player-record changes on its own schedule. await selectionPublisher.clear() await services.publishReadCursor(for: id, mode: .currentTime) - // Backgrounding may have already drained the tracker; the - // skip-if-zero guard inside publishSessionEndPush keeps the - // close-after-background case from firing a second push. - if owesEndPush { - await services.sessions.publishSessionEndPush(gameID: id) - } + // The pause self-gates on content (no letter changes reaches + // no one) and supersedes any pending grace-window timer, so the + // close-after-background case never fires a second push. + await services.sessions.publishSessionEndPush(gameID: id) } } } diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -18,10 +18,10 @@ final class PlayerPreferences { static let colorID = "playerColorID" static let name = "playerName" static let isICloudSyncEnabled = "isICloudSyncEnabled" - static let notifiesSessionBegin = "notifiesSessionBegin" - static let notifiesSessionEnd = "notifiesSessionEnd" - static let notifiesPuzzleFinished = "notifiesPuzzleFinished" - static let notifiesInvites = "notifiesInvites" + static let notifiesNudges = "notifiesNudges" + static let notifiesPauses = "notifiesPauses" + static let notifiesCompletions = "notifiesCompletions" + static let notifiesInvitations = "notifiesInvitations" } private let local: UserDefaults @@ -55,23 +55,23 @@ final class PlayerPreferences { /// Notification preferences are per-device, like the system's own /// notification settings, so they are stored locally only. The first three /// gate worker pushes (the device registers the kinds it has muted and the - /// worker drops them before APNs); `notifiesInvites` gates the local + /// worker drops them before APNs); `notifiesInvitations` gates the local /// notification posted when an invite Ping arrives — the invite itself /// still appears in the Invited section either way. - var notifiesSessionBegin: Bool { - didSet { local.set(notifiesSessionBegin, forKey: Keys.notifiesSessionBegin) } + var notifiesNudges: Bool { + didSet { local.set(notifiesNudges, forKey: Keys.notifiesNudges) } } - var notifiesSessionEnd: Bool { - didSet { local.set(notifiesSessionEnd, forKey: Keys.notifiesSessionEnd) } + var notifiesPauses: Bool { + didSet { local.set(notifiesPauses, forKey: Keys.notifiesPauses) } } - var notifiesPuzzleFinished: Bool { - didSet { local.set(notifiesPuzzleFinished, forKey: Keys.notifiesPuzzleFinished) } + var notifiesCompletions: Bool { + didSet { local.set(notifiesCompletions, forKey: Keys.notifiesCompletions) } } - var notifiesInvites: Bool { - didSet { local.set(notifiesInvites, forKey: Keys.notifiesInvites) } + var notifiesInvitations: Bool { + didSet { local.set(notifiesInvitations, forKey: Keys.notifiesInvitations) } } init( @@ -88,10 +88,10 @@ final class PlayerPreferences { self.name = storedName ?? "Player" self.hasName = storedName.map(Self.isUsableName) ?? false self.isICloudSyncEnabled = local.object(forKey: Keys.isICloudSyncEnabled) as? Bool ?? true - self.notifiesSessionBegin = local.object(forKey: Keys.notifiesSessionBegin) as? Bool ?? true - self.notifiesSessionEnd = local.object(forKey: Keys.notifiesSessionEnd) as? Bool ?? true - self.notifiesPuzzleFinished = local.object(forKey: Keys.notifiesPuzzleFinished) as? Bool ?? true - self.notifiesInvites = local.object(forKey: Keys.notifiesInvites) as? Bool ?? true + self.notifiesNudges = local.object(forKey: Keys.notifiesNudges) as? Bool ?? true + self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true + self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true + self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true cloud.synchronize() NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -49,24 +49,24 @@ final class AccountPushCoordinator { } /// Maps the notification toggles to the worker `kind` denylist. The - /// "puzzle finished" toggle covers both completion outcomes. + /// "Completions" toggle covers both completion outcomes. nonisolated static func mutedPushKinds( - sessionBegin: Bool, - sessionEnd: Bool, - puzzleFinished: Bool + nudges: Bool, + pauses: Bool, + completions: Bool ) -> Set<String> { var muted: Set<String> = [] - if !sessionBegin { muted.insert("play") } - if !sessionEnd { muted.insert("pause") } - if !puzzleFinished { muted.formUnion(["win", "resign"]) } + if !nudges { muted.insert("nudge") } + if !pauses { muted.insert("pause") } + if !completions { muted.formUnion(["win", "resign"]) } return muted } private func currentMutedPushKinds() -> Set<String> { Self.mutedPushKinds( - sessionBegin: preferences.notifiesSessionBegin, - sessionEnd: preferences.notifiesSessionEnd, - puzzleFinished: preferences.notifiesPuzzleFinished + nudges: preferences.notifiesNudges, + pauses: preferences.notifiesPauses, + completions: preferences.notifiesCompletions ) } @@ -80,9 +80,9 @@ final class AccountPushCoordinator { while !Task.isCancelled { await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in withObservationTracking { - _ = self.preferences.notifiesSessionBegin - _ = self.preferences.notifiesSessionEnd - _ = self.preferences.notifiesPuzzleFinished + _ = self.preferences.notifiesNudges + _ = self.preferences.notifiesPauses + _ = self.preferences.notifiesCompletions } onChange: { cont.resume() } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1339,12 +1339,10 @@ final class AppServices { try await syncEngine.fetchBackgroundSessionsDirect(scope: scope) } if let result { - // Session-start notifications now ride on sender-side APNs - // (see `publishSessionBeginPush`); the receiver-side - // `presentBegins` path is no longer wired up. The catch-up - // banner that summarises peer adds/clears still consumes the - // SessionMonitor buckets via `consumeOnOpen` — see - // `handlePuzzleOpened`. + // The receiver-side `presentBegins` path is no longer wired + // up. The catch-up banner that summarises peer adds/clears + // still consumes the SessionMonitor buckets via `consumeOnOpen` + // — see `handlePuzzleOpened`. if result.isEmpty { syncMonitor.note("remote-notification background session scan: no active sessions") } diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -591,7 +591,7 @@ final class InviteCoordinator { } // Invite banners are user-toggleable; the invite row itself still // lands in the Invited section through `applyInvitePings`. - if ping.kind == .invite, !preferences.notifiesInvites { + if ping.kind == .invite, !preferences.notifiesInvitations { syncMonitor.note("ping(invite): banner disabled in settings for \(ping.gameID.uuidString)") continue } diff --git a/Crossmate/Services/PuzzleSession.swift b/Crossmate/Services/PuzzleSession.swift @@ -1,12 +1,11 @@ import Foundation import UIKit -/// Per-open-game session state machine. Owns every timer and the announced -/// state for one game — the begin-grace timer, the end-grace timer with its -/// background-execution assertion, the catch-up-banner settle timer, and the -/// "play announced, pause not yet sent" flag — so the begin/end/grace -/// interleavings live (and are testable) in one place instead of being spread -/// across per-game dictionaries and view lifecycle handlers. +/// Per-open-game session state machine. Owns every timer for one game — the +/// end-grace timer with its background-execution assertion and the +/// catch-up-banner settle timer — so the leave/resume/grace interleavings live +/// (and are testable) in one place instead of being spread across per-game +/// dictionaries and view lifecycle handlers. /// /// `SessionCoordinator` creates one per open game, injects the side effects /// (the actual pushes and the banner), and prunes the session once it is @@ -16,9 +15,6 @@ import UIKit final class PuzzleSession { /// Side effects, injected by `SessionCoordinator` and stubbed in tests. struct Effects { - /// Sender-side "is solving" push — - /// `SessionCoordinator.publishSessionBeginPush(gameID:)`. - var publishBegin: @MainActor () async -> Void /// Sender-side pause push, carrying the wall-clock captured when the /// grace timer was scheduled — /// `SessionCoordinator.publishSessionEndPush(gameID:pauseStart:)`. @@ -42,15 +38,6 @@ final class PuzzleSession { /// wall-clock timing; production uses `Task.sleep`. private let sleep: @Sendable (Duration) async throws -> Void - /// When the still-open session was announced to peers (a "play" push went - /// out) and no "pause" has been sent since; `nil` re-arms the begin push. - /// The begin push consults this so "is solving" fires once per session: a - /// brief background bounce that never produced a stop must not - /// re-announce. Keyed off the *sent stop* — `noteEndAnnounced` is called - /// when a pause is actually published — not off a pending timer, so it - /// stays accurate even when a stop carries no unseen content. - private(set) var announcedAt: Date? - private var pendingBeginTask: Task<Void, Never>? private var pendingEndTask: Task<Void, Never>? private var pendingSummaryBannerTask: Task<Void, Never>? /// Background-execution assertion keeping the end-grace timer alive after @@ -59,13 +46,11 @@ final class PuzzleSession { /// pause early rather than letting suspension drop it. private var endAssertion: UIBackgroundTaskIdentifier = .invalid - /// True when nothing keeps this session alive: no timer pending, no - /// assertion held, and no announced play session awaiting its pause. - /// `SessionCoordinator` prunes idle sessions from its per-game map. + /// True when nothing keeps this session alive: no timer pending and no + /// assertion held. `SessionCoordinator` prunes idle sessions from its + /// per-game map. var isIdle: Bool { - announcedAt == nil - && pendingBeginTask == nil - && pendingEndTask == nil + pendingEndTask == nil && pendingSummaryBannerTask == nil && endAssertion == .invalid } @@ -80,55 +65,6 @@ final class PuzzleSession { self.sleep = sleep } - // MARK: Announced state - - /// True when a begin push should fire: this session isn't already - /// announced (see `announcedAt`). - var shouldAnnounceBegin: Bool { announcedAt == nil } - - /// Record that a "play" push has been sent, stamping when the session was - /// announced so a later pause can report its start. - func noteBeginAnnounced(at time: Date = Date()) { - announcedAt = time - } - - /// Record that a "pause" push has been sent, re-arming the next begin push. - func noteEndAnnounced() { - announcedAt = nil - } - - // MARK: Begin grace - - /// Defer the session-begin push by `seconds`, replacing any pending - /// timer. The opening device owns the notification timing, but a brief - /// visit shouldn't reach peers at all, so the "Alice is solving X" push - /// waits out the grace; backing out before it elapses (app backgrounded, - /// or the puzzle view disappearing) calls `cancelPendingBeginPush` to - /// drop it silently. Unlike the pause timer this holds no background - /// assertion: the user is foregrounded and looking at the puzzle for the - /// whole window, and any departure cancels. - func scheduleBeginPush(after seconds: TimeInterval) { - pendingBeginTask?.cancel() - let sleep = self.sleep - pendingBeginTask = Task { [weak self] in - try? await sleep(.seconds(seconds)) - guard !Task.isCancelled, let self else { return } - self.pendingBeginTask = nil - await self.effects.publishBegin() - } - } - - /// Drop a begin push still waiting out its grace window. Returns whether - /// one was pending — i.e. the session was never announced to peers — so - /// the caller can skip the matching pause push (there's no play to pause). - @discardableResult - func cancelPendingBeginPush() -> Bool { - guard let task = pendingBeginTask else { return false } - pendingBeginTask = nil - task.cancel() - return true - } - // MARK: End grace /// Defer the session-end push by `seconds`, replacing any previously diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -4,26 +4,24 @@ import Foundation import UIKit /// Owns the play-session lifecycle: the sender-side session pushes -/// (play / pause / win / resign / replay) and the receiver-side catch-up -/// banner, driven by three lifecycle events — `notePuzzleActive`, -/// `notePuzzleBackgrounded`, `notePuzzleClosed` — that `CrossmateApp`'s -/// scene-phase and `onDisappear` handlers forward. The per-game timer and -/// announced state lives in one `PuzzleSession` state machine per open game, -/// so the begin/end/grace interleavings are decided (and testable) in one +/// (pause / win / resign / replay), the manual `nudge` push, and the +/// receiver-side catch-up banner, driven by three lifecycle events — +/// `notePuzzleActive`, `notePuzzleBackgrounded`, `notePuzzleClosed` — that +/// `CrossmateApp`'s scene-phase and `onDisappear` handlers forward. The +/// per-game timer lives in one `PuzzleSession` state machine per open game, so +/// the leave/resume/grace interleavings are decided (and testable) in one /// place. `AppServices` composes one instance. @MainActor final class SessionCoordinator { - /// Grace window before opening a puzzle is announced to peers as a play - /// session. A user who pops in and out — opens the wrong puzzle, peeks at - /// a shared grid, then leaves — within this window should fan out nothing. - /// The begin push is deferred by this much and cancelled if the user backs - /// out (app backgrounded or navigated away) before it elapses. - static let sessionBeginGrace: TimeInterval = 10 /// 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 — + /// call) should not fan out a pause ping to peers on every flicker — /// only a sustained absence does. static let sessionEndGrace: TimeInterval = 30 + /// Minimum gap between nudges for a given game, enforced per device. A + /// nudge is a deliberate manual ping from the players menu, so the + /// cooldown only guards against the button being spammed. + static let nudgeCooldown: TimeInterval = 60 /// Settle delay before the catch-up banner is computed on open. Lets the /// `.appeared` grid freshen land peer moves first, so the diff reflects the /// settled grid rather than a half-synced snapshot; cancelled if the user @@ -46,6 +44,11 @@ final class SessionCoordinator { /// `PuzzleSession`. private var sessions: [UUID: PuzzleSession] = [:] + /// When this device last sent a nudge for each game, used to enforce + /// `nudgeCooldown`. Device-local and ephemeral — a relaunch clears it, + /// which at worst allows one extra nudge. + private var lastNudge: [UUID: Date] = [:] + init( persistence: PersistenceController, store: GameStore, @@ -77,9 +80,6 @@ final class SessionCoordinator { let session = PuzzleSession( gameID: gameID, effects: PuzzleSession.Effects( - publishBegin: { [weak self] in - await self?.publishSessionBeginPush(gameID: gameID) - }, publishEnd: { [weak self] pauseStart in await self?.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart) }, @@ -118,54 +118,36 @@ final class SessionCoordinator { /// The puzzle is on screen and the scene is active (open or resume). /// Stamps the active-puzzle ID for notification suppression. If a pause /// was queued in the grace window and the user returned before it fired, - /// drops it and skips the matching play push — peers should see one - /// continuous session, not a pause/play pair, for a brief absence (phone - /// sleep, a call, an app-switcher peek that escalated to background). - /// Otherwise defers the play push by the begin grace so a quick - /// pop-in/pop-out reaches no one. Either way, schedules the catch-up - /// banner after a short settle; the matching baseline commit happens on - /// leave. + /// drops it — peers should see one continuous session, not a stray pause, + /// for a brief absence (phone sleep, a call, an app-switcher peek that + /// escalated to background). Either way, schedules the catch-up banner + /// after a short settle; the matching baseline commit happens on leave. func notePuzzleActive(gameID: UUID) { NotificationState.setActivePuzzleID(gameID) - let session = session(for: gameID) - let resumed = session.cancelPendingEndPush() + session(for: gameID).cancelPendingEndPush() handlePuzzleOpened(gameID: gameID) - if !resumed { - session.scheduleBeginPush(after: Self.sessionBeginGrace) - } } /// The app backgrounded while the puzzle is open. Clears the /// active-puzzle ID and commits the catch-up baseline (the user has seen - /// what's on screen). A session still inside its begin grace was never - /// announced, so there's nothing to pause — drop the deferred play push - /// and skip the matching pause rather than firing an unpaired one; - /// otherwise defer the pause by the end grace under a background - /// assertion. + /// what's on screen), then defers the pause by the end grace under a + /// background assertion. The pause self-gates on content when it fires, so + /// a brief visit that changed no letters reaches no one regardless. func notePuzzleBackgrounded(gameID: UUID) { NotificationState.clearActivePuzzleID(if: gameID) handlePuzzleLeft(gameID: gameID) - let session = session(for: gameID) - if session.cancelPendingBeginPush() { - pruneIfIdle(gameID) - return - } - session.scheduleEndPush(after: Self.sessionEndGrace) + session(for: gameID).scheduleEndPush(after: Self.sessionEndGrace) } - /// The user navigated away from the puzzle (`onDisappear`). Same commit - /// as backgrounding, plus the begin-grace check: returns whether a - /// session-end push is owed — `false` when the visit never outlasted the - /// begin grace, so there is no play session to pause. The caller - /// sequences `publishSessionEndPush` after the moves flush: the pause - /// counts read the journal, so the buffered cell edits must land first. - @discardableResult - func notePuzzleClosed(gameID: UUID) -> Bool { + /// The user navigated away from the puzzle (`onDisappear`). Same commit as + /// backgrounding. The caller sequences `publishSessionEndPush` after the + /// moves flush: the pause counts read the journal, so the buffered cell + /// edits must land first. The pause self-gates on content, so a visit that + /// changed no letters reaches no one. + func notePuzzleClosed(gameID: UUID) { NotificationState.clearActivePuzzleID(if: gameID) handlePuzzleLeft(gameID: gameID) - let neverAnnounced = sessions[gameID]?.cancelPendingBeginPush() ?? false pruneIfIdle(gameID) - return !neverAnnounced } /// Completion fan-out, delivered through the push worker. Win sets @@ -177,76 +159,82 @@ final class SessionCoordinator { await publishReplayPush(gameID: gameID) } - /// Sender-side session-begin push. Peers get "Alice is solving X" once the - /// open has outlasted `sessionBeginGrace` (see `scheduleSessionBeginPush`). - /// Kind is `play` because the user-facing event is "started playing", not - /// the durable share-acceptance fact "joined". - private func publishSessionBeginPush(gameID: UUID) async { - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - syncMonitor.note("push(play): skipped (no authorID)") + // MARK: Nudge + + /// Whether a nudge for `gameID` is allowed right now — i.e. the cooldown + /// since the last one this device sent has elapsed. The players menu reads + /// this (rebuilt each time it opens) to disable the button. It does not + /// consult the roster or push capability; an empty fan-out is a silent + /// no-op inside `nudge`. + func canNudge(gameID: UUID, asOf now: Date = Date()) -> Bool { + guard let last = lastNudge[gameID] else { return true } + return now.timeIntervalSince(last) >= Self.nudgeCooldown + } + + /// Sends a manual nudge for `gameID` to every other player who isn't + /// currently present in the puzzle, rousing them through an APNs alert. A + /// deliberate action from the players menu, so unlike the session pushes it + /// carries no grid summary — just "Alice nudged you to play X". Gated by + /// `nudgeCooldown` (the button is also disabled via `canNudge`, but the + /// guard here closes the double-tap race), and skipped on a finished or + /// access-revoked game where there's nothing to rouse anyone into. + func nudge(gameID: UUID) async { + guard canNudge(gameID: gameID) else { + syncMonitor.note("push(nudge): skipped (cooldown)") return } - // Already announced this session and no stop has been sent since, so - // we're inside the same continuous session (e.g. a brief background - // bounce). Re-announcing "is solving" here is the notification spam - // we're suppressing — the next genuine stop clears this. - guard sessions[gameID]?.shouldAnnounceBegin ?? true else { - syncMonitor.note("push(play): skipped (session already announced)") + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + syncMonitor.note("push(nudge): skipped (no authorID)") return } guard let pushClient else { - syncMonitor.note("push(play): skipped (no pushClient)") + syncMonitor.note("push(nudge): skipped (no pushClient)") return } let plan = pushPlan(for: gameID, excluding: localAuthorID) guard !plan.recipients.isEmpty else { - syncMonitor.note("push(play): skipped (no recipients)") + syncMonitor.note("push(nudge): skipped (no recipients)") return } - // Opening a finished puzzle (review, share view) isn't a play - // session — peers don't need a "solving the puzzle" alert. Same for - // a shared game we no longer have access to: the owner already - // unshared or deleted it, and the worker would just bounce the push. guard plan.completedAt == nil else { - syncMonitor.note("push(play): skipped (game completed)") + syncMonitor.note("push(nudge): skipped (game completed)") return } guard !plan.isAccessRevoked else { - syncMonitor.note("push(play): skipped (access revoked)") + syncMonitor.note("push(nudge): skipped (access revoked)") return } - // A recipient whose presence lease is live is in the game watching us - // appear through the roster — pushing would banner their *other* - // devices for a session they're already in. + // 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(play): skipped \(plan.recipients.count - recipients.count) present recipient(s)" + "push(nudge): skipped \(plan.recipients.count - recipients.count) present recipient(s)" ) } let addressees = recipients.compactMap { recipient in recipient.pushAddress.map { - PushClient.Addressee(address: $0, payload: PushPayload(event: .play)) + PushClient.Addressee(address: $0, payload: PushPayload(event: .nudge)) } } guard !addressees.isEmpty else { - syncMonitor.note("push(play): skipped (no addressable recipients)") + syncMonitor.note("push(nudge): skipped (no addressable recipients)") return } + // Stamp the cooldown the moment we commit to sending, so a rapid + // second tap is rejected even before this publish returns. + lastNudge[gameID] = Date() await pushClient.publish( - kind: "play", + kind: "nudge", gameID: gameID, addressees: addressees, title: "Crossmate", puzzleTitle: plan.title, - body: PuzzleNotificationText.playBody( + body: PuzzleNotificationText.nudgeBody( playerName: preferences.name, puzzleTitle: plan.title ) ) - // Session is now announced to peers; a "play" won't fire again until a - // "pause" is actually sent (see `publishSessionEndPush`). - session(for: gameID).noteBeginAnnounced() } /// Sender-side session-end push. For each recipient, tallies this @@ -271,7 +259,7 @@ final class SessionCoordinator { } // During the grace window this device wrote nothing to Player // (any local activity would have reset the timer via - // `cancelPendingSessionEndPush`). A Player `updatedAt` newer than + // `cancelPendingEndPush`). A Player `updatedAt` newer than // pauseStart therefore came from another device of this author — // that device is still active, so let its eventual pause cover // the session. @@ -289,8 +277,8 @@ final class SessionCoordinator { syncMonitor.note("push(pause): skipped (no recipients)") return } - // Symmetric with `publishSessionBeginPush`: a finished or revoked - // game has no live play session, so a pause summary is meaningless. + // A finished or revoked game has no live play session, so a pause + // summary is meaningless. guard plan.completedAt == nil else { syncMonitor.note("push(pause): skipped (game completed)") return @@ -299,10 +287,10 @@ final class SessionCoordinator { syncMonitor.note("push(pause): skipped (access revoked)") return } - // Same gate as the begin push: 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. + // 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( @@ -311,9 +299,7 @@ final class SessionCoordinator { } guard !recipients.isEmpty else { // Everyone watched live, so no push goes out — but the session - // still closed: re-arm the next "play" announce and release the - // per-game state machine, exactly as a sent pause would. - sessions[gameID]?.noteEndAnnounced() + // still closed: release the per-game state machine. pruneIfIdle(gameID) return } @@ -329,10 +315,9 @@ final class SessionCoordinator { var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID) ?? PushPayload.Diagnostics() diagnostics.senderNow = Date() - diagnostics.sessionStart = sessions[gameID]?.announcedAt - // Caught-up recipients are *not* dropped: a session end is a presence - // signal worth delivering even with nothing unseen (see - // `SessionPushPlanner.sessionEndAddressees`). + // Each recipient is addressed only when this session changed letters + // they haven't seen; cursor-only and check-only recipients are dropped + // (see `SessionPushPlanner.sessionEndAddressees`). let addressees = SessionPushPlanner.sessionEndAddressees( recipients: recipients, journalEntries: journalEntries, @@ -342,7 +327,11 @@ final class SessionCoordinator { diagnostics: diagnostics ) guard !addressees.isEmpty else { - syncMonitor.note("push(pause): skipped (no addressable recipients)") + // No recipient had unseen letter changes (cursor-only or + // check-only session), or none could be addressed. Nothing to + // report — the session still closed, so release the state machine. + syncMonitor.note("push(pause): skipped (no letter changes to report)") + pruneIfIdle(gameID) return } // Top-level broadcast body is the worker's fallback if an addressee @@ -364,18 +353,19 @@ final class SessionCoordinator { puzzleTitle: plan.title, body: fallbackBody ) - // Peers have now been told the session ended, so a fresh "play" is - // allowed again (see `publishSessionBeginPush`). - sessions[gameID]?.noteEndAnnounced() // Advance each addressed recipient's notified-through watermark to the // latest move this pause reported. A later pause windows its counts to // the later of this and the recipient's readAt, so a bounce that adds - // no new move re-tallies to zero ("stopped solving") instead of - // repeating the same summary. Recipients we couldn't address (no push - // capability) keep their old watermark and catch up when reachable. + // no new move re-tallies to zero and reaches no one instead of + // repeating the same summary. Only recipients we actually pushed to + // advance: a recipient dropped for no letter changes (or no push + // capability) keeps their old watermark and catches up when there's + // genuinely new content. The addressee list carries only push + // addresses, so map those back to author IDs. if let notifiedThrough = journalEntries.map(\.timestamp).max() { + let addressedAddresses = Set(addressees.map(\.address)) let addressed = recipients - .filter { $0.pushAddress != nil } + .filter { $0.pushAddress.map(addressedAddresses.contains) ?? false } .map(\.authorID) store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough) } diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -54,21 +54,24 @@ enum SessionPushPlanner { recipients.filter { !PeerPresence.isPresent(readAt: $0.readAt, asOf: now) } } - /// Builds the per-recipient addressees for a session-end push. Every - /// addressable recipient is included — caught-up recipients too, with zero - /// counts — because a session end is a presence signal worth delivering - /// even when nothing changed. The per-recipient `PushPayload` counts let - /// the extension decide the badge; the body renders "stopped solving" at - /// zero. Recipients with no published push capability are dropped. + /// 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 + /// that only moved the cursor or ran checks reaches no one: the summary is + /// about grid letters, not presence (the begin push that used to carry the + /// "is solving" presence signal is gone). Recipients with no published push + /// capability are dropped, as are caught-up recipients and check-only + /// recipients. /// /// `journalEntries` is the sender's own local journal for the game (this /// device's recorded moves, in seq order). Counts are derived from it — /// not the merged grid — so the summary can name *gestures*: net letter /// `fills` / `clears` plus the number of `check` / `reveal` gestures, each - /// windowed to entries newer than the recipient's `readThrough`. The journal is - /// this device's only, so a session run on another of the author's devices - /// is described by *that* device's own pause; "eventual consistency is OK" - /// covers the gap. + /// windowed to entries newer than the recipient's `readThrough`. A check + /// gesture rides the body when it accompanies a letter change but never on + /// its own. The journal is this device's only, so a session run on another + /// of the author's devices is described by *that* device's own pause; + /// "eventual consistency is OK" covers the gap. static func sessionEndAddressees( recipients: [PushRecipient], journalEntries: [JournalValue], @@ -104,6 +107,10 @@ enum SessionPushPlanner { since: cutoff ?? .distantPast, selfAuthorID: selfAuthorID ) + // Only letter changes the recipient hasn't seen warrant a push. + // Checks alter marks, not letters, so a check-only session — or a + // pure cursor session that tallies to nothing — addresses no one. + guard counts.fills + counts.clears + counts.reveals > 0 else { return nil } let body = PuzzleNotificationText.pauseBody( playerName: playerName, puzzleTitle: puzzleTitle, diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -7,6 +7,12 @@ struct PuzzleToolbarModifier: ViewModifier { let isSolved: Bool let canResign: Bool let canDelete: Bool + /// Sends a broadcast nudge to the other players; `nil` hides the button + /// (solo/test sessions). + var onNudge: (() async -> Void)? = nil + /// Whether a nudge is allowed right now (cooldown elapsed). Read when the + /// menu is built. + var canNudge: () -> Bool = { false } @Binding var isRenaming: Bool @Binding var renameDraft: String @Binding var isConfirmingResign: Bool @@ -175,6 +181,26 @@ struct PuzzleToolbarModifier: ViewModifier { .disabled(true) } } + nudgeSection + } + + /// Broadcast "Nudge Players" action. Shown only when nudging is wired up + /// (a shared session) and there is at least one other player to rouse; + /// disabled while the puzzle is solved or the per-game cooldown is still + /// running. The Menu rebuilds each time it opens, so `canNudge()` is read + /// fresh and the disabled state tracks the cooldown without observation. + @ViewBuilder + private var nudgeSection: some View { + if let onNudge, roster.entries.contains(where: { !$0.isLocal }) { + Section { + Button { + Task { await onNudge() } + } label: { + Label("Nudge Players", systemImage: "hand.wave") + } + .disabled(isSolved || !canNudge()) + } + } } private var playerPreferencesSection: some View { diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -29,6 +29,13 @@ struct PuzzleView: View { var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil var onResign: (() throws -> Void)? = nil var onDelete: (() throws -> Void)? = nil + /// Sends a broadcast nudge to the other players. `nil` for solo/test + /// sessions, which hides the menu button. + var onNudge: (() async -> Void)? = nil + /// Whether a nudge is allowed right now (cooldown elapsed). Read when the + /// players menu is built. Defaults to `false` so a session without nudging + /// wired up never offers an enabled button. + var canNudge: () -> Bool = { false } /// Loads the finished game's merged journal for the finish-banner replay /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. var loadReplay: () async -> JournalReplayResult = { .unavailable } @@ -129,6 +136,8 @@ struct PuzzleView: View { isSolved: isSolved, canResign: onResign != nil, canDelete: onDelete != nil, + onNudge: onNudge, + canNudge: canNudge, isRenaming: $isRenaming, renameDraft: $renameDraft, isConfirmingResign: $isConfirmingResign, diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -67,14 +67,14 @@ struct SettingsView: View { } Section { - Toggle("Started Solving", isOn: $preferences.notifiesSessionBegin) - Toggle("Stopped Solving", isOn: $preferences.notifiesSessionEnd) - Toggle("Puzzle Invited", isOn: $preferences.notifiesInvites) - Toggle("Puzzle Finished", isOn: $preferences.notifiesPuzzleFinished) + Toggle("Nudges", isOn: $preferences.notifiesNudges) + Toggle("Pauses", isOn: $preferences.notifiesPauses) + Toggle("Invitations", isOn: $preferences.notifiesInvitations) + Toggle("Completions", isOn: $preferences.notifiesCompletions) } header: { Text("Notifications") } footer: { - Text("Receive notifications when friends start solving, stop solving, invite you to or finish a shared puzzle. These settings apply only to this device.") + Text("Receive notifications when friends nudge you, pause playing after making changes, invite you to play or finish a puzzle. These settings apply only to this device.") } if debugMode { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -17,9 +17,9 @@ import UserNotifications /// - a `pause` with unseen cells, or a `win` / `resign` — mark `gameID` /// unread. Per-game horizon semantics make repeats idempotent (a pause /// followed by a win for the same game is one badge unit, not two). -/// - a `pause` with zero counts (a presence-only "stopped solving") or a -/// `play` — presence only; the grid has nothing unseen for this recipient, -/// so stamp the current count without growing it. +/// - a `nudge` (a manual "come play" ping), a legacy `play`, or a `pause` +/// with zero counts — presence only; the grid has nothing unseen for this +/// recipient, so stamp the current count without growing it. /// When the payload is absent (an older sender, or the worker not yet /// forwarding it) we fall back to the coarse top-level `kind`. final class NotificationService: UNNotificationServiceExtension { diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -125,6 +125,10 @@ struct PushPayload: Codable, Sendable, Equatable { case win case resign case replay + /// A manual "nudge" one player sends from the in-game players menu to + /// rouse the others into the puzzle. Presence only — it carries no + /// grid change — so it never marks the game unread. + case nudge /// An event introduced by a newer build. Treated as carrying no /// unseen content for badge purposes. case unknown @@ -139,7 +143,7 @@ struct PushPayload: Codable, Sendable, Equatable { case .pause(let fills, let clears, let checks, let reveals): return fills + clears + checks + reveals > 0 case .win, .resign: return true - case .play, .replay, .unknown: return false + case .play, .replay, .nudge, .unknown: return false } } } @@ -186,6 +190,11 @@ extension PushPayload { puzzleTitle: puzzleTitle, resigned: true ) + case .nudge: + return PuzzleNotificationText.nudgeBody( + playerName: playerName, + puzzleTitle: puzzleTitle + ) case .replay, .unknown: return nil } @@ -214,7 +223,7 @@ extension PushPayload.Event: Codable { } private enum Discriminator: String { - case play, pause, win, resign, replay + case play, pause, win, resign, replay, nudge } init(from decoder: Decoder) throws { @@ -235,6 +244,8 @@ extension PushPayload.Event: Codable { self = .resign case .replay: self = .replay + case .nudge: + self = .nudge case nil: // A discriminator this build doesn't know — a newer sender. self = .unknown @@ -258,6 +269,8 @@ extension PushPayload.Event: Codable { try container.encode(Discriminator.resign.rawValue, forKey: .type) case .replay: try container.encode(Discriminator.replay.rawValue, forKey: .type) + case .nudge: + try container.encode(Discriminator.nudge.rawValue, forKey: .type) case .unknown: // Not produced as an outgoing event by this build; encode a stable // marker so an `.unknown` round-trips back to `.unknown`. diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -19,6 +19,13 @@ enum PuzzleNotificationText { "\(resolvedName(playerName)) is solving \(puzzleSuffix(puzzleTitle))" } + /// Body for a nudge push: "Alice nudged you to play the puzzle 'X'". A + /// deliberate rouse from the in-game players menu, so it always names an + /// action even when nothing in the grid changed. + static func nudgeBody(playerName: String, puzzleTitle: String) -> String { + "\(resolvedName(playerName)) nudged you to play \(puzzleSuffix(puzzleTitle))" + } + /// Body for a completion push — "Alice solved …" or, when `resigned`, /// "Alice resigned …" (the resign sentence ends in a full stop to match the /// app's existing wording). diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -19,7 +19,8 @@ struct PushPayloadTests { .pause(fills: 0, clears: 0, checks: 0, reveals: 2), .win, .resign, - .replay + .replay, + .nudge ] for event in cases { let decoded = try roundTrip(PushPayload(event: event)) @@ -42,6 +43,7 @@ struct PushPayloadTests { .composedBody(playerName: "Mum") } #expect(body(.play) == "Mum is solving the puzzle 'Saturday'") + #expect(body(.nudge) == "Mum nudged you to play the puzzle 'Saturday'") #expect(body(.win) == "Mum solved the puzzle 'Saturday'") #expect(body(.resign) == "Mum resigned the puzzle 'Saturday'.") #expect(body(.pause(fills: 3, clears: 0, checks: 0, reveals: 0)) @@ -161,6 +163,8 @@ struct PushPayloadTests { #expect(!PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0)).marksUnread) #expect(!PushPayload(event: .play).marksUnread) + // A nudge is a manual presence ping — it never marks a game unread. + #expect(!PushPayload(event: .nudge).marksUnread) #expect(!PushPayload(event: .replay).marksUnread) #expect(!PushPayload(event: .unknown).marksUnread) } diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -41,6 +41,15 @@ struct PuzzleNotificationTextTests { == "A player is solving the puzzle") } + @Test("nudgeBody names the nudger and puzzle") + func nudgeBodyNamesNudger() { + #expect(PuzzleNotificationText.nudgeBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle") + == "Alice nudged you to play the puzzle 'Saturday Puzzle'") + // Empty name and title fall back to neutral wording. + #expect(PuzzleNotificationText.nudgeBody(playerName: "", puzzleTitle: "") + == "A player nudged you to play the puzzle") + } + @Test("completionBody distinguishes a solve from a resignation") func completionBodySolveVsResign() { #expect(PuzzleNotificationText.completionBody( @@ -176,9 +185,9 @@ struct NotificationMutedKindsTests { @Test("All toggles on mutes nothing") func allOnMutesNothing() { let muted = AccountPushCoordinator.mutedPushKinds( - sessionBegin: true, - sessionEnd: true, - puzzleFinished: true + nudges: true, + pauses: true, + completions: true ) #expect(muted.isEmpty) @@ -187,31 +196,31 @@ struct NotificationMutedKindsTests { @Test("Each toggle maps to its push kinds") func togglesMapToKinds() { #expect(AccountPushCoordinator.mutedPushKinds( - sessionBegin: false, - sessionEnd: true, - puzzleFinished: true - ) == ["play"]) + nudges: false, + pauses: true, + completions: true + ) == ["nudge"]) #expect(AccountPushCoordinator.mutedPushKinds( - sessionBegin: true, - sessionEnd: false, - puzzleFinished: true + nudges: true, + pauses: false, + completions: true ) == ["pause"]) #expect(AccountPushCoordinator.mutedPushKinds( - sessionBegin: true, - sessionEnd: true, - puzzleFinished: false + nudges: true, + pauses: true, + completions: false ) == ["win", "resign"]) } @Test("Muted kinds never include background or invite kinds") func neverMutesBackgroundKinds() { let muted = AccountPushCoordinator.mutedPushKinds( - sessionBegin: false, - sessionEnd: false, - puzzleFinished: false + nudges: false, + pauses: false, + completions: false ) - #expect(muted == ["play", "pause", "win", "resign"]) + #expect(muted == ["nudge", "pause", "win", "resign"]) #expect(!muted.contains("replay")) #expect(!muted.contains("accountJoined")) #expect(!muted.contains("accountSeen")) diff --git a/Tests/Unit/PuzzleSessionTests.swift b/Tests/Unit/PuzzleSessionTests.swift @@ -12,7 +12,6 @@ struct PuzzleSessionTests { /// handler by hand. @MainActor final class EffectLog { - private(set) var begins = 0 private(set) var ends: [Date] = [] private(set) var banners = 0 private(set) var notes: [String] = [] @@ -22,7 +21,6 @@ struct PuzzleSessionTests { var effects: PuzzleSession.Effects { PuzzleSession.Effects( - publishBegin: { self.begins += 1 }, publishEnd: { self.ends.append($0) }, postSummaryBanner: { self.banners += 1 }, note: { self.notes.append($0) }, @@ -47,57 +45,6 @@ struct PuzzleSessionTests { #expect(condition()) } - @Test("Begin push fires once the grace elapses") - func beginPushFiresAfterGrace() async throws { - let log = EffectLog() - let sleeps = ManualDebounceSleep() - let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) - - session.scheduleBeginPush(after: 10) - #expect(!session.isIdle) - try await sleeps.waitForSleeperCount(1) - #expect(log.begins == 0) // still inside the grace - - sleeps.releaseAll() - try await waitUntil { log.begins == 1 } - // The stubbed publish never announced, so nothing holds the session. - #expect(session.isIdle) - } - - @Test("Backing out inside the begin grace suppresses the play push") - func cancelInsideBeginGrace() async throws { - let log = EffectLog() - let sleeps = ManualDebounceSleep() - let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) - - session.scheduleBeginPush(after: 10) - try await sleeps.waitForSleeperCount(1) - #expect(session.cancelPendingBeginPush()) // true: never announced - #expect(!session.cancelPendingBeginPush()) // nothing left to drop - - sleeps.releaseAll() - try await Task.sleep(for: .milliseconds(120)) - #expect(log.begins == 0) - #expect(session.isIdle) - } - - @Test("Rescheduling the begin push replaces the pending timer") - func rescheduleReplacesPendingBegin() async throws { - let log = EffectLog() - let sleeps = ManualDebounceSleep() - let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) - - session.scheduleBeginPush(after: 10) - try await sleeps.waitForSleeperCount(1) - session.scheduleBeginPush(after: 10) - try await sleeps.waitForSleeperCount(2) - - sleeps.releaseAll() - try await waitUntil { log.begins == 1 } - try await Task.sleep(for: .milliseconds(120)) - #expect(log.begins == 1) // the replaced timer never fires - } - @Test("End push fires with the pause start captured at scheduling time") func endPushFiresWithScheduledPauseStart() async throws { let log = EffectLog() @@ -184,24 +131,6 @@ struct PuzzleSessionTests { #expect(session.isIdle) } - @Test("Announced state gates the begin push and is re-armed by a sent stop") - func announcedStateLifecycle() { - let log = EffectLog() - let session = PuzzleSession(gameID: UUID(), effects: log.effects) - let start = Date(timeIntervalSince1970: 5_000) - - #expect(session.shouldAnnounceBegin) // fresh session announces - #expect(session.announcedAt == nil) - session.noteBeginAnnounced(at: start) - #expect(!session.shouldAnnounceBegin) // re-entry within session: suppressed - #expect(session.announcedAt == start) - #expect(!session.isIdle) // an announced session awaits its pause - session.noteEndAnnounced() - #expect(session.shouldAnnounceBegin) // a stop was sent: re-armed - #expect(session.announcedAt == nil) - #expect(session.isIdle) - } - @Test("Catch-up banner fires after the settle delay") func bannerFiresAfterSettle() async throws { let log = EffectLog() diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -46,8 +46,8 @@ struct SessionPushPlannerTests { ) } - @Test("A caught-up recipient is still addressed, with a presence-only payload") - func caughtUpRecipientIncluded() { + @Test("A caught-up recipient is dropped — a session end is no longer a presence ping") + func caughtUpRecipientDropped() { let edit = Date(timeIntervalSince1970: 1_000) let entries = [ entry("X", at: edit, seq: 1, col: 0), @@ -63,11 +63,9 @@ struct SessionPushPlannerTests { puzzleTitle: "Tuesday" ) - #expect(addressees.count == 1) - #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) - #expect(addressees[0].payload?.marksUnread == false) - #expect(addressees[0].body == "Alice stopped solving the puzzle 'Tuesday'.") + // Nothing the recipient hasn't seen, so no push goes out at all — the + // begin push that used to carry presence is gone. + #expect(addressees.isEmpty) } @Test("A behind recipient gets net fills/clears and a badge-marking payload") @@ -102,8 +100,8 @@ struct SessionPushPlannerTests { func notifiedThroughWindowsOutPriorMoves() { // The bounce case: the recipient stayed backgrounded (readThrough stuck far // in the past), but we already paused to them once covering `edit`. The - // second pause must not re-report that fill — it tallies to zero and - // reads as a presence-only "stopped solving", not a duplicate summary. + // second pause must not re-report that fill — it tallies to zero, so the + // recipient is dropped entirely rather than getting a duplicate summary. let edit = Date(timeIntervalSince1970: 1_000) let entries = [entry("X", at: edit, seq: 1, col: 0)] let staleReadAt = edit.addingTimeInterval(-3_600) @@ -128,10 +126,7 @@ struct SessionPushPlannerTests { playerName: "Alice", puzzleTitle: "Tuesday" ) - #expect(secondPause.count == 1) - #expect(secondPause[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) - #expect(secondPause[0].body == "Alice stopped solving the puzzle 'Tuesday'.") + #expect(secondPause.isEmpty) } @Test("A present-but-backgrounded recipient still gets a summary (lease ≠ watermark)") @@ -161,12 +156,13 @@ struct SessionPushPlannerTests { #expect(addressees[0].body == "Alice filled 1 letter in the puzzle 'Tuesday'") } - @Test("A whole-grid check reports one check gesture, not a wall of letter churn") - func checkGestureCounted() { + @Test("A check-only session reaches no one — checks aren't letter changes") + func checkOnlySessionDropped() { let edit = Date(timeIntervalSince1970: 1_000) let batch = UUID() // Two letters typed before the cutoff, then one check gesture marks both. - // The mark change re-stamps each cell but leaves the letters intact. + // The mark change re-stamps each cell but leaves the letters intact, so + // there is no unseen letter change to report. let entries = [ entry("A", at: edit.addingTimeInterval(-120), seq: 1, col: 0), entry("B", at: edit.addingTimeInterval(-120), seq: 2, col: 1), @@ -183,17 +179,16 @@ struct SessionPushPlannerTests { puzzleTitle: "Tuesday" ) - #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0))) - #expect(addressees[0].body == "Alice ran 1 check in the puzzle 'Tuesday'") + #expect(addressees.isEmpty) } - @Test("Checking a peer's letter is a check gesture, not a fill by the checker") + @Test("Checking a peer's letter is never a fill, so a peer-check session reaches no one") func checkingPeerLetterIsNotAFill() { // Regression: a peer filled these cells; in *this* author's journal they // appear only as check entries that carry the peer's letter but preserve // the peer's `cellAuthorID`. The whole-grid check of an already-filled - // puzzle must read as one check gesture, not a wall of phantom fills. + // puzzle must not read as a wall of phantom fills — with no genuine + // letter change, no push goes out. let edit = Date(timeIntervalSince1970: 1_000) let batch = UUID() let entries = [ @@ -210,9 +205,7 @@ struct SessionPushPlannerTests { puzzleTitle: "Tuesday" ) - #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0))) - #expect(addressees[0].body == "Alice ran 1 check in the puzzle 'Tuesday'") + #expect(addressees.isEmpty) } @Test("Filling then checking one's own cell still counts as a fill") @@ -299,7 +292,7 @@ struct SessionPushPlannerTests { == "Alice filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'") } - @Test("A cell typed then deleted within the window nets to nothing") + @Test("A cell typed then deleted within the window nets to nothing, so no push goes out") func netPerCellIgnoresTypeThenDelete() { let edit = Date(timeIntervalSince1970: 1_000) let entries = [ @@ -316,9 +309,7 @@ struct SessionPushPlannerTests { puzzleTitle: "Tuesday" ) - #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) - #expect(addressees[0].payload?.marksUnread == false) + #expect(addressees.isEmpty) } @Test("Diagnostics ride each recipient's payload, stamped with that recipient's readThrough") @@ -389,8 +380,8 @@ struct SessionPushPlannerTests { #expect(absent.map(\.pushAddress) == ["addr-departed", "addr-never"]) } - @Test("Caught-up and behind recipients are both addressed in one fan-out") - func mixedRecipientsAllIncluded() { + @Test("Only the behind recipient is addressed; the caught-up one is dropped") + func behindAddressedCaughtUpDropped() { let edit = Date(timeIntervalSince1970: 1_000) let entries = [entry("X", at: edit, seq: 1, col: 0)] let caughtUp = recipient("addr-caught-up", readThrough: edit.addingTimeInterval(60)) @@ -405,8 +396,8 @@ struct SessionPushPlannerTests { ) let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) }) - #expect(addressees.count == 2) - #expect(byAddress["addr-caught-up"]?.payload?.marksUnread == false) + #expect(addressees.count == 1) + #expect(byAddress["addr-caught-up"] == nil) #expect(byAddress["addr-behind"]?.payload?.marksUnread == true) } } diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -559,15 +559,17 @@ export class PushRegistry { // app builds, which the extension handles by falling back to `kind`. if (message.payload) apnsPayload.payload = message.payload; - // "play" presence is ephemeral: deliver now or discard. `accountSeen` is - // also background-only, but it withdraws already-read notifications from - // sibling devices; give APNs a short store-and-forward window so a - // briefly-unreachable device can still converge. Other alert kinds - // (win/resign/pause) are one-time meaningful events and keep the longer - // window so a recipient who is offline at send time still gets the banner. + // "play" presence and a "nudge" rouse are ephemeral: deliver now or + // discard, since "come play" delivered hours later is stale noise. + // `accountSeen` is also background-only, but it withdraws already-read + // notifications from sibling devices; give APNs a short store-and-forward + // window so a briefly-unreachable device can still converge. Other alert + // kinds (win/resign/pause) are one-time meaningful events and keep the + // longer window so a recipient who is offline at send time still gets the + // banner. const expirationSeconds = message.kind === "accountSeen" ? 15 * 60 : - message.background || message.kind === "play" ? 0 : + message.background || message.kind === "play" || message.kind === "nudge" ? 0 : 4 * 60 * 60; const expiration = expirationSeconds === 0 ? "0"