crossmate

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

commit 8c6d0f8187fc517e00471d446516db3a00f59afa
parent 2ead503eff4fd82a92775537651ae3025dfbf0c0
Author: Michael Camilleri <[email protected]>
Date:   Sat, 20 Jun 2026 09:31:40 +0900

Remove the dead .play presence notification

The session-begin push that announced 'X is solving the puzzle' was
retired when presence moved onto live engagement state, but its plumbing
lingered: the .play case in PushPayload.Event, the playBody text
builder, and a `kind === "play"` branch in the push worker that chose
the deliver-now-or-discard expiration.

This commit deletes that path end to end. The PushPayload codec drops
.play from its event enum, discriminator, and the encode, decode,
composedBody and marksUnread arms; because decoding is deliberately
tolerant, any `"play"` payload still in flight now resolves to .unknown
— no rebuilt body and no badge, matching the event's former behaviour.
PuzzleNotificationText loses the orphaned playBody, and the push worker
drops the dead expiration clause along with its play comment.

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

Diffstat:
MShared/PushPayload.swift | 20++++++--------------
MShared/PuzzleNotificationText.swift | 5-----
MTests/Unit/PushPayloadTests.swift | 7++-----
MTests/Unit/PuzzleNotificationTextTests.swift | 9---------
MWorkers/push-worker.js | 17++++++++---------
5 files changed, 16 insertions(+), 42 deletions(-)

diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -54,8 +54,10 @@ struct PushPayload: Codable, Sendable, Equatable { /// The sender's wall clock when the pause was computed — surfaces /// clock skew against the recipient's own clock. var senderNow: Date? = nil - /// When the sender believes this play session began (the begin-push - /// timestamp). `nil` if the session was never announced (e.g. solo). + /// When the sender believes the current solving session began. Now that + /// the begin push (which used to stamp this) is gone, no sender + /// populates it — it is always `nil` in practice and kept only so the + /// receipt log keeps a stable slot should a session-start signal return. var sessionStart: Date? = nil /// The recipient's `Player.readAt` *as the sender saw it* — the exact /// cutoff the per-recipient diff used. A stale value here widens the @@ -114,7 +116,6 @@ struct PushPayload: Codable, Sendable, Equatable { } enum Event: Sendable, Equatable { - case play /// A session-end summary, broken down by what the peer did since the /// recipient last looked: net letter `fills` / `clears`, and the count /// of `checks` / `reveals` *gestures* run. Letter counts are @@ -143,7 +144,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, .nudge, .unknown: return false + case .replay, .nudge, .unknown: return false } } } @@ -164,11 +165,6 @@ extension PushPayload { func composedBody(playerName: String) -> String? { guard let puzzleTitle else { return nil } switch event { - case .play: - return PuzzleNotificationText.playBody( - playerName: playerName, - puzzleTitle: puzzleTitle - ) case .pause(let fills, let clears, let checks, let reveals): return PuzzleNotificationText.pauseBody( playerName: playerName, @@ -223,15 +219,13 @@ extension PushPayload.Event: Codable { } private enum Discriminator: String { - case play, pause, win, resign, replay, nudge + case pause, win, resign, replay, nudge } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let raw = try container.decode(String.self, forKey: .type) switch Discriminator(rawValue: raw) { - case .play: - self = .play case .pause: let fills = try container.decodeIfPresent(Int.self, forKey: .fills) ?? 0 let clears = try container.decodeIfPresent(Int.self, forKey: .clears) ?? 0 @@ -255,8 +249,6 @@ extension PushPayload.Event: Codable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .play: - try container.encode(Discriminator.play.rawValue, forKey: .type) case .pause(let fills, let clears, let checks, let reveals): try container.encode(Discriminator.pause.rawValue, forKey: .type) try container.encode(fills, forKey: .fills) diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -14,11 +14,6 @@ enum PuzzleNotificationText { return "\(title) – \(subtitle)" } - /// Body for a session-begin push: "Alice is solving the puzzle 'X'". - static func playBody(playerName: String, puzzleTitle: String) -> String { - "\(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. diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -13,7 +13,6 @@ struct PushPayloadTests { @Test("Events round-trip through the wire encoding") func eventsRoundTrip() throws { let cases: [PushPayload.Event] = [ - .play, .pause(fills: 3, clears: 2, checks: 1, reveals: 0), .pause(fills: 0, clears: 0, checks: 0, reveals: 0), .pause(fills: 0, clears: 0, checks: 0, reveals: 2), @@ -30,7 +29,7 @@ struct PushPayloadTests { @Test("Puzzle title round-trips through the wire encoding") func puzzleTitleRoundTrips() throws { - let payload = PushPayload(event: .play, puzzleTitle: "Saturday Crossword") + let payload = PushPayload(event: .win, puzzleTitle: "Saturday Crossword") let decoded = try roundTrip(payload) #expect(decoded == payload) #expect(decoded.puzzleTitle == "Saturday Crossword") @@ -42,7 +41,6 @@ struct PushPayloadTests { PushPayload(event: event, puzzleTitle: "Saturday") .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'.") @@ -55,7 +53,7 @@ struct PushPayloadTests { @Test("composedBody returns nil when the body can't be rebuilt") func composedBodyNilWhenUncomposable() { // No puzzle title (older sender) — can't faithfully rebuild. - #expect(PushPayload(event: .play).composedBody(playerName: "Mum") == nil) + #expect(PushPayload(event: .win).composedBody(playerName: "Mum") == nil) // Bodyless events carry no visible text to rebuild. #expect(PushPayload(event: .replay, puzzleTitle: "Saturday") .composedBody(playerName: "Mum") == nil) @@ -162,7 +160,6 @@ struct PushPayloadTests { #expect(PushPayload(event: .resign).marksUnread) #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) diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -32,15 +32,6 @@ struct PuzzleNotificationTextTests { #expect(InviteCoordinator.bodyText(for: ping) == "system-only ping should not be presented") } - @Test("playBody names the solver and puzzle") - func playBodyNamesSolver() { - #expect(PuzzleNotificationText.playBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle") - == "Alice is solving the puzzle 'Saturday Puzzle'") - // Empty name and title fall back to neutral wording. - #expect(PuzzleNotificationText.playBody(playerName: "", puzzleTitle: "") - == "A player is solving the puzzle") - } - @Test("nudgeBody names the nudger and puzzle") func nudgeBodyNamesNudger() { #expect(PuzzleNotificationText.nudgeBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle") diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -559,17 +559,16 @@ export class PushRegistry { // app builds, which the extension handles by falling back to `kind`. if (message.payload) apnsPayload.payload = message.payload; - // "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. + // A "nudge" rouse is 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" || message.kind === "nudge" ? 0 : + message.background || message.kind === "nudge" ? 0 : 4 * 60 * 60; const expiration = expirationSeconds === 0 ? "0"