crossmate

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

commit c51ae7b20f45f18a53684907c970f8fcdb4f997b
parent a5ee89929135e0d20291a90cda3a3d283b9c42db
Author: Michael Camilleri <[email protected]>
Date:   Wed, 24 Jun 2026 07:17:02 +0900

Coalesce session-end pushes into one silent per-game tile

A peer who left and rejoined a shared game repeatedly through the day
fired a separate banner for each session-end pause, so the recipient
could accumulate ten or more near-identical alerts for a single game
over a few hours.

This commit collapses those pushes into one Notification Centre tile per
game and delivers them silently. Every alert push for a game now carries
a stable `apns-collapse-id` of `game-<id>`, which the push worker
forwards into the APNs header, so a later push replaces the game's
existing tile in place rather than stacking a new one. The notification
service extension folds each arriving pause into a per-sender
CoalescedSummary stashed in the tile's userInfo and rewrites the body
from it, so the surviving tile reads as a running, multi-sender summary
— for example 'Alice filled 5 letters and cleared 1 letter; Bob filled 3
letters' — rather than only the most recent sender's text.

Every pause is delivered with interruptionLevel .passive, so it updates the
tile and the app-icon badge without a sound or a banner, leaving the badge as
the only proactive signal for solving activity; win and resign keep their usual
alert. To let the extension name a contributor without parsing rendered text,
PushPayload now carries the sender's playerName. The extension still prefers
the recipient's private nickname for that sender, uses the carried playerName
when no nickname is set and falls back to a short author id only when neither
is available.

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

Diffstat:
MCrossmate/Services/PushClient.swift | 15+++++++++++++++
MCrossmate/Services/SessionCoordinator.swift | 4++++
MCrossmate/Services/SessionPushPlanner.swift | 4++++
MNotificationService/NotificationService.swift | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MShared/PushPayload.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MShared/PuzzleNotificationText.swift | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MTests/Unit/PushPayloadTests.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 43+++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/SessionPushPlannerTests.swift | 12++++++------
MWorkers/push-worker.js | 26++++++++++++++++++--------
10 files changed, 413 insertions(+), 20 deletions(-)

diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -195,6 +195,14 @@ final class PushClient { /// `excludeAddress` (the sender's own derived game address) keeps the /// sender's other devices from being notified. Broadcast is only meaningful /// for a game-credentialed push. + /// Stable `apns-collapse-id` for a game's notification tile. All alert + /// pushes for one game share it, so APNs keeps a single replacing tile in + /// Notification Center (the receiver's NSE then folds successive `pause` + /// summaries into it). 41 chars — well under APNs' 64-byte limit. + static func gameCollapseID(_ gameID: UUID) -> String { + "game-\(gameID.uuidString)" + } + func publish( kind: String, gameID: UUID, @@ -206,6 +214,7 @@ final class PushClient { broadcast: Bool = false, excludeAddress: String? = nil, broadcastPayload: PushPayload? = nil, + collapseID: String? = nil, extra: [String: Any] = [:], body: String ) async { @@ -255,6 +264,12 @@ final class PushClient { if let credential { payload["credID"] = credential.credID.uuidString } + // Forwarded verbatim into the APNs `apns-collapse-id` header by the + // worker (alert pushes only). Opaque to the worker — the coalescing + // policy it encodes lives here, not in the worker. + if let collapseID { + payload["collapseID"] = collapseID + } // Broadcast: the worker resolves targets from the credential's whole // address set, so the empty `addressees` above is ignored. Carry the // uniform payload at top level (the per-addressee slot is unused) and diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -236,6 +236,7 @@ final class SessionCoordinator { broadcast: true, excludeAddress: store.localPushAddress(gameID: gameID, authorID: localAuthorID), broadcastPayload: PushPayload(event: .nudge), + collapseID: PushClient.gameCollapseID(gameID), body: PuzzleNotificationText.nudgeBody( playerName: preferences.name, puzzleTitle: plan.title @@ -278,6 +279,7 @@ final class SessionCoordinator { broadcast: true, excludeAddress: excludeAddress, broadcastPayload: PushPayload(event: .join), + collapseID: PushClient.gameCollapseID(gameID), body: PuzzleNotificationText.joinBody( playerName: preferences.name, puzzleTitle: plan.title @@ -389,6 +391,7 @@ final class SessionCoordinator { addressees: addressees, title: "Crossmate", puzzleTitle: plan.title, + collapseID: PushClient.gameCollapseID(gameID), body: fallbackBody ) // Advance each addressed recipient's notified-through watermark to the @@ -452,6 +455,7 @@ final class SessionCoordinator { addressees: addressees, title: "Crossmate", puzzleTitle: plan.title, + collapseID: PushClient.gameCollapseID(gameID), body: body ) } diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -115,6 +115,10 @@ enum SessionPushPlanner { checks: counts.checks, reveals: counts.reveals ), + // Carried so the receiver's NSE can name this sender in a + // coalesced multi-sender summary when it holds no private + // nickname for them. + playerName: playerName, diagnostics: perRecipient ) ) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -145,7 +145,117 @@ final class NotificationService: UNNotificationServiceExtension { source: "notification-service-extension-badge" ) - contentHandler(bestAttemptContent) + // Coalesce successive session-end summaries for one game into the + // single Notification Center tile they share (same apns-collapse-id). + // Only a `pause` carrying structured counts can be folded; everything + // else (and an older payload-less sender) is delivered as prepared. + let pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)? + if case let .pause(fills, clears, checks, reveals) = payload?.event { + pauseCounts = (fills, clears, checks, reveals) + } else { + pauseCounts = nil + } + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + self.finalize( + content: bestAttemptContent, + gameID: gameID, + fromAuthorID: fromAuthorID, + puzzleTitle: payload?.puzzleTitle, + playerName: payload?.playerName, + pauseCounts: pauseCounts, + delivered: delivered, + center: center, + contentHandler: contentHandler + ) + } + } + + /// Applies game-tile coalescing once the already-delivered notifications + /// are known, then hands the (possibly rewritten) content back to iOS. + /// + /// A `pause` is low-stakes presence chatter, so it is always delivered + /// `.passive` — it updates the tile and the app badge without a sound or a + /// banner-wake. When a tile for this game is already showing, this is a + /// follow-up session-end push: its counts are folded into the running + /// per-sender tally the tile carries in `userInfo` and the body is + /// rewritten to the combined summary. The first push for a game finds no + /// tile and merely seeds the tally. A non-pause push (or an older + /// payload-less sender) carries no counts and is delivered unchanged; its + /// shared collapse id still replaces any tile in place. + private func finalize( + content: UNMutableNotificationContent, + gameID: UUID?, + fromAuthorID: String?, + puzzleTitle: String?, + playerName: String?, + pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?, + delivered: [UNNotification], + center: UNUserNotificationCenter, + contentHandler: @escaping (UNNotificationContent) -> Void + ) { + guard let gameID, let pauseCounts else { + contentHandler(content) + return + } + + let existing = delivered.filter { + ($0.request.content.userInfo["gameID"] as? String) == gameID.uuidString + } + // Seed from whichever existing tile already carries a tally (the most + // recent wins), else start fresh so this push still records itself. + var summary = existing + .compactMap { + CoalescedSummary.decode(from: $0.request.content.userInfo["coalescedSummary"] as? String) + } + .last ?? CoalescedSummary() + // Prefer the receiver's private nickname, fall back to the sender's own + // name from the payload, then to a short author id — so a contributor + // is always named without parsing the rendered body. + let authorID = fromAuthorID.flatMap { $0.isEmpty ? nil : $0 } + let name = authorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname } + ?? playerName.flatMap { $0.isEmpty ? nil : $0 } + ?? authorID.map { String($0.prefix(8)) } + ?? "" + summary.add( + authorID: authorID ?? "?", + name: name, + fills: pauseCounts.fills, + clears: pauseCounts.clears, + checks: pauseCounts.checks, + reveals: pauseCounts.reveals + ) + var userInfo = content.userInfo + if let encoded = summary.encodedString() { + userInfo["coalescedSummary"] = encoded + } + content.userInfo = userInfo + + // Always deliver a pause quietly — no sound, no banner-wake — leaving + // the badge as the only visible signal. Idempotent with the + // present-sibling suppression path above. + content.interruptionLevel = .passive + let coalescing = !existing.isEmpty + if coalescing { + if let body = PuzzleNotificationText.coalescedBody( + puzzleTitle: puzzleTitle ?? "", + contributors: summary.contributors + ) { + content.body = body + } + // Drop the superseded tiles so only this freshest one remains; the + // shared collapse id already replaces a same-id tile, but this also + // clears any pre-collapse-id leftovers the system won't merge. + let identifiers = existing.map { $0.request.identifier } + if !identifiers.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + VisibleNotificationReceiptLog.record( + body: "coalesced=\(coalescing) contributors=\(summary.contributors.count) passive=true", + source: "notification-service-extension-coalesce" + ) + contentHandler(content) } override func serviceExtensionTimeWillExpire() { diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -26,6 +26,14 @@ struct PushPayload: Codable, Sendable, Equatable { /// `nil` from older senders (and on bodyless pushes like replay), in which /// case the NSE leaves the original body untouched. var puzzleTitle: String? + /// The sender's own chosen name, carried structurally so the notification + /// service extension can name this sender in a *coalesced* multi-sender + /// summary (see `CoalescedSummary`). The single-push nickname rewrite uses + /// the receiver's private nickname instead and never reads this; it exists + /// only as the fall-back display name when the receiver has set no nickname + /// for the sender. `nil` from older senders, which the NSE handles by + /// falling back to a short author id. + var playerName: String? /// Optional, opaque-to-the-worker diagnostic context attached by the /// sender. Carries the inputs that produced a pause body's counts so a /// recipient can record them (via the NSE) and reconstruct *why* the @@ -38,11 +46,13 @@ struct PushPayload: Codable, Sendable, Equatable { version: Int = PushPayload.currentVersion, event: Event, puzzleTitle: String? = nil, + playerName: String? = nil, diagnostics: Diagnostics? = nil ) { self.version = version self.event = event self.puzzleTitle = puzzleTitle + self.playerName = playerName self.diagnostics = diagnostics } @@ -222,6 +232,80 @@ extension PushPayload { } } +/// Running, per-sender tally the Notification Service Extension carries in a +/// coalesced game tile's `userInfo`. When several session-end (`pause`) pushes +/// for one game arrive in a row, they collapse to a single Notification Center +/// tile (same `apns-collapse-id`); each replacement would otherwise overwrite +/// the previous body. Stashing this accumulator in the delivered tile's +/// `userInfo` — as base64 JSON, exactly like `PushPayload` — lets the next +/// push read back what the tile already showed and *add* to it, since the +/// extension's separate per-push process invocations share no other state. +struct CoalescedSummary: Codable, Sendable, Equatable { + struct Contributor: Codable, Sendable, Equatable { + var authorID: String + var name: String + var fills: Int + var clears: Int + var checks: Int + var reveals: Int + } + + /// First-seen order, so the rendered summary lists players in the order + /// their first update arrived rather than reshuffling on every push. + var contributors: [Contributor] + + init(contributors: [Contributor] = []) { + self.contributors = contributors + } + + /// Folds one pause contribution into the tally: a sender already present + /// has the new counts summed onto theirs; a new sender is appended. A + /// later non-empty `name` refreshes the stored one (a rename, or a + /// nickname the receiver only just learned), while an empty name never + /// overwrites a real one. + mutating func add( + authorID: String, + name: String, + fills: Int, + clears: Int, + checks: Int, + reveals: Int + ) { + if let index = contributors.firstIndex(where: { $0.authorID == authorID }) { + contributors[index].fills += fills + contributors[index].clears += clears + contributors[index].checks += checks + contributors[index].reveals += reveals + if !name.isEmpty { contributors[index].name = name } + } else { + contributors.append(Contributor( + authorID: authorID, + name: name, + fills: fills, + clears: clears, + checks: checks, + reveals: reveals + )) + } + } + + /// Base64-encoded JSON for the tile's `coalescedSummary` userInfo field. + func encodedString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return data.base64EncodedString() + } + + /// Decodes the tile's `coalescedSummary` userInfo field. Returns `nil` + /// when absent or unparseable, leaving the caller to seed a fresh tally. + static func decode(from string: String?) -> CoalescedSummary? { + guard let string, + let data = Data(base64Encoded: string), + let summary = try? JSONDecoder().decode(CoalescedSummary.self, from: data) + else { return nil } + return summary + } +} + extension PushPayload.Event: Codable { private enum CodingKeys: String, CodingKey { case type, fills, clears, checks, reveals diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -59,9 +59,24 @@ enum PuzzleNotificationText { checks: Int, reveals: Int ) -> String { - let resolvedName = resolvedName(playerName) - let puzzleSuffix = puzzleSuffix(puzzleTitle) + let name = resolvedName(playerName) + let suffix = puzzleSuffix(puzzleTitle) + guard let actions = pauseActions(fills: fills, clears: clears, checks: checks, reveals: reveals) else { + return "\(name) stopped solving \(suffix)." + } + return "\(name) \(actions) in \(suffix)" + } + /// The action phrase of a pause — "filled 3 letters and ran 1 check" — + /// without the name or puzzle suffix, or `nil` when nothing changed. Shared + /// by `pauseBody` and the multi-contributor `coalescedBody` so a single + /// sender and a coalesced one read in exactly the same voice. + private static func pauseActions( + fills: Int, + clears: Int, + checks: Int, + reveals: Int + ) -> String? { func letters(_ n: Int) -> String { "\(n) \(n == 1 ? "letter" : "letters")" } var clauses: [String] = [] @@ -74,10 +89,53 @@ enum PuzzleNotificationText { if reveals > 0 { help.append("\(reveals) \(reveals == 1 ? "reveal" : "reveals")") } if !help.isEmpty { clauses.append("ran \(joinList(help))") } - if clauses.isEmpty { - return "\(resolvedName) stopped solving \(puzzleSuffix)." + return clauses.isEmpty ? nil : joinList(clauses) + } + + /// Body for a coalesced game tile that has folded together one or more + /// session-end (`pause`) updates (see `CoalescedSummary`). Each contributor + /// is described in the same wording a single pause uses; several are joined + /// with semicolons and the puzzle is named once at the end ("Alice filled 5 + /// letters and cleared 1 letter; Bob filled 3 letters in the puzzle 'X'"). + /// Returns `nil` when there is nothing to summarise. + static func coalescedBody( + puzzleTitle: String, + contributors: [CoalescedSummary.Contributor] + ) -> String? { + switch contributors.count { + case 0: + return nil + case 1: + let only = contributors[0] + return pauseBody( + playerName: only.name, + puzzleTitle: puzzleTitle, + fills: only.fills, + clears: only.clears, + checks: only.checks, + reveals: only.reveals + ) + default: + // One player per clause in the single-pause voice, separated by + // semicolons (each clause already uses commas/"and" internally), + // with the puzzle named once at the end. + let phrases = contributors.map { contributor -> String in + let name = resolvedName(contributor.name) + guard let actions = pauseActions( + fills: contributor.fills, + clears: contributor.clears, + checks: contributor.checks, + reveals: contributor.reveals + ) else { + // A contributor always has unseen changes (a pause is only + // sent when it does), so this is unreachable in practice; + // degrade to a neutral phrase rather than drop them. + return "\(name) made changes" + } + return "\(name) \(actions)" + } + return "\(phrases.joined(separator: "; ")) in \(puzzleSuffix(puzzleTitle))" } - return "\(resolvedName) \(joinList(clauses)) in \(puzzleSuffix)" } /// The empty name falls back to a neutral label, so a peer who hasn't set a diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -36,6 +36,27 @@ struct PushPayloadTests { #expect(decoded.puzzleTitle == "Saturday Crossword") } + @Test("playerName round-trips through the wire encoding") + func playerNameRoundTrips() throws { + let payload = PushPayload( + event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), + playerName: "Alice" + ) + let decoded = try roundTrip(payload) + #expect(decoded == payload) + #expect(decoded.playerName == "Alice") + } + + @Test("An older payload without playerName decodes it as nil") + func playerNameAbsenceTolerated() throws { + let json = #"{"version":1,"event":{"type":"pause","fills":1,"clears":0,"checks":0,"reveals":0}}"# + let encoded = Data(json.utf8).base64EncodedString() + + let decoded = try #require(PushPayload.decode(from: encoded)) + + #expect(decoded.playerName == nil) + } + @Test("composedBody rebuilds each event's body with the given name") func composedBodySubstitutesName() { func body(_ event: PushPayload.Event) -> String? { @@ -170,3 +191,47 @@ struct PushPayloadTests { #expect(!PushPayload(event: .unknown).marksUnread) } } + +@Suite("Coalesced summary") +struct CoalescedSummaryTests { + @Test("add sums counts for a repeated sender and appends new ones in order") + func addAccumulates() { + var summary = CoalescedSummary() + summary.add(authorID: "a", name: "Alice", fills: 3, clears: 1, checks: 0, reveals: 0) + summary.add(authorID: "b", name: "Bob", fills: 2, clears: 0, checks: 1, reveals: 0) + summary.add(authorID: "a", name: "Alice", fills: 2, clears: 0, checks: 0, reveals: 1) + + #expect(summary.contributors.count == 2) + let alice = summary.contributors[0] + #expect(alice.authorID == "a") + #expect(alice.fills == 5) + #expect(alice.clears == 1) + #expect(alice.reveals == 1) + // First-seen order preserved despite Alice's second update. + #expect(summary.contributors[1].authorID == "b") + } + + @Test("A later non-empty name refreshes the stored one; an empty name does not") + func nameRefresh() { + var summary = CoalescedSummary() + summary.add(authorID: "a", name: "ab12cd34", fills: 1, clears: 0, checks: 0, reveals: 0) + summary.add(authorID: "a", name: "Alice", fills: 1, clears: 0, checks: 0, reveals: 0) + #expect(summary.contributors[0].name == "Alice") + summary.add(authorID: "a", name: "", fills: 1, clears: 0, checks: 0, reveals: 0) + #expect(summary.contributors[0].name == "Alice") + } + + @Test("Round-trips through the userInfo encoding") + func roundTrip() throws { + var summary = CoalescedSummary() + summary.add(authorID: "a", name: "Alice", fills: 3, clears: 1, checks: 0, reveals: 0) + let encoded = try #require(summary.encodedString()) + #expect(CoalescedSummary.decode(from: encoded) == summary) + } + + @Test("Absent or malformed encoding decodes to nil") + func decodeNil() { + #expect(CoalescedSummary.decode(from: nil) == nil) + #expect(CoalescedSummary.decode(from: "not base64 $$$") == nil) + } +} diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -162,6 +162,49 @@ struct PuzzleNotificationTextTests { #expect(body == "A player filled 2 letters in the puzzle") } + @Test("coalescedBody reuses the full pause wording for a single contributor") + func coalescedBodySingle() { + let body = PuzzleNotificationText.coalescedBody( + puzzleTitle: "Saturday Puzzle", + contributors: [ + .init(authorID: "a", name: "Alice", fills: 5, clears: 2, checks: 0, reveals: 0) + ] + ) + + #expect(body == "Alice filled 5 letters and cleared 2 letters in the puzzle 'Saturday Puzzle'") + } + + @Test("coalescedBody describes multiple contributors in full, joined by semicolons") + func coalescedBodyMultiple() { + let body = PuzzleNotificationText.coalescedBody( + puzzleTitle: "X", + contributors: [ + .init(authorID: "a", name: "Alice", fills: 5, clears: 1, checks: 0, reveals: 0), + .init(authorID: "b", name: "Bob", fills: 3, clears: 0, checks: 0, reveals: 0) + ] + ) + + #expect(body == "Alice filled 5 letters and cleared 1 letter; Bob filled 3 letters in the puzzle 'X'") + } + + @Test("coalescedBody reports each contributor's help gestures in full") + func coalescedBodyGestures() { + let body = PuzzleNotificationText.coalescedBody( + puzzleTitle: "X", + contributors: [ + .init(authorID: "a", name: "Alice", fills: 0, clears: 0, checks: 2, reveals: 0), + .init(authorID: "b", name: "Bob", fills: 0, clears: 0, checks: 0, reveals: 1) + ] + ) + + #expect(body == "Alice ran 2 checks; Bob ran 1 reveal in the puzzle 'X'") + } + + @Test("coalescedBody is nil with no contributors") + func coalescedBodyEmpty() { + #expect(PuzzleNotificationText.coalescedBody(puzzleTitle: "X", contributors: []) == nil) + } + @Test("Invite body names the inviter and puzzle") func inviteBody() { let ping = Ping( diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -88,7 +88,7 @@ struct SessionPushPlannerTests { #expect(addressees.count == 1) #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 1, clears: 1, checks: 0, reveals: 0))) + == PushPayload(event: .pause(fills: 1, clears: 1, checks: 0, reveals: 0), playerName: "Alice")) #expect(addressees[0].payload?.marksUnread == true) #expect(addressees[0].body == "Alice filled 1 letter and cleared 1 letter in the puzzle 'Tuesday'") @@ -113,7 +113,7 @@ struct SessionPushPlannerTests { puzzleTitle: "Tuesday" ) #expect(firstPause[0].payload - == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0))) + == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), playerName: "Alice")) // Second pause after a bounce with no new move: readThrough is still stale, // but the watermark already covers `edit`, so nothing is re-reported. @@ -150,7 +150,7 @@ struct SessionPushPlannerTests { #expect(addressees.count == 1) #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0))) + == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), playerName: "Alice")) #expect(addressees[0].body == "Alice filled 1 letter in the puzzle 'Tuesday'") } @@ -228,7 +228,7 @@ struct SessionPushPlannerTests { ) #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 1, clears: 0, checks: 1, reveals: 0))) + == PushPayload(event: .pause(fills: 1, clears: 0, checks: 1, reveals: 0), playerName: "Alice")) #expect(addressees[0].body == "Alice filled 1 letter and ran 1 check in the puzzle 'Tuesday'") } @@ -252,7 +252,7 @@ struct SessionPushPlannerTests { ) #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1))) + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1), playerName: "Alice")) #expect(addressees[0].body == "Alice ran 1 reveal in the puzzle 'Tuesday'") } @@ -285,7 +285,7 @@ struct SessionPushPlannerTests { ) #expect(addressees[0].payload - == PushPayload(event: .pause(fills: 1, clears: 1, checks: 1, reveals: 1))) + == PushPayload(event: .pause(fills: 1, clears: 1, checks: 1, reveals: 1), playerName: "Alice")) #expect(addressees[0].body == "Alice filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'") } diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -427,6 +427,7 @@ export class PushRegistry { background, broadcast, excludeAddress, + collapseID, payload } = body; if (!kind) { @@ -476,6 +477,7 @@ export class PushRegistry { title, body: target.body || alertBody, payload: target.payload, + collapseID: typeof collapseID === "string" ? collapseID : undefined, background: background === true }); if (result === "ok") delivered += 1; @@ -624,16 +626,24 @@ export class PushRegistry { ? "0" : String(Math.floor(Date.now() / 1000) + expirationSeconds); + const headers = { + authorization: `bearer ${jwt}`, + "apns-topic": topic, + "apns-push-type": message.background ? "background" : "alert", + "apns-priority": message.background ? "5" : "10", + "apns-expiration": expiration, + "content-type": "application/json" + }; + // Coalesce alert pushes for one game into a single Notification Center tile + // (the app picks the id; the receiver's NSE folds successive summaries into + // it). Meaningless on a background push, which displays nothing. + if (message.collapseID && !message.background) { + headers["apns-collapse-id"] = message.collapseID; + } + const response = await fetch(`https://${host}/3/device/${target.token}`, { method: "POST", - headers: { - authorization: `bearer ${jwt}`, - "apns-topic": topic, - "apns-push-type": message.background ? "background" : "alert", - "apns-priority": message.background ? "5" : "10", - "apns-expiration": expiration, - "content-type": "application/json" - }, + headers, body: JSON.stringify(apnsPayload) });