crossmate

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

commit a76af58b9f0a0510ff7cdeb348d9b9b165387fc5
parent 01c28e63d95e239e252fe775e8ce014ebe0f1c72
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 11:38:43 +0900

Add per-kind notification preferences

This commit adds a Settings → Notifications section with four per-device
toggles. Session begin/end and puzzle finished register a mutedKinds
denylist with the push worker, which drops muted kinds before APNs; the
worker only string-matches the list, so unknown kinds always deliver.
Invitations gate the local invite-ping banner in InviteCoordinator
without affecting the Invited section. A toggle flip re-registers
through PushClient's reconcile signature, debounced 250 ms.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Models/PlayerPreferences.swift | 30++++++++++++++++++++++++++++++
MCrossmate/Services/AccountPushCoordinator.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/InviteCoordinator.swift | 6++++++
MCrossmate/Services/PushClient.swift | 43+++++++++++++++++++++++++++++++++++--------
MCrossmate/Views/SettingsView.swift | 11+++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 47+++++++++++++++++++++++++++++++++++++++++++++++
MWorkers/push-worker.js | 28++++++++++++++++++++--------
7 files changed, 211 insertions(+), 16 deletions(-)

diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -18,6 +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" } private let local: UserDefaults @@ -43,6 +47,28 @@ final class PlayerPreferences { set { colorID = newValue.id } } + /// 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 + /// 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 notifiesSessionEnd: Bool { + didSet { local.set(notifiesSessionEnd, forKey: Keys.notifiesSessionEnd) } + } + + var notifiesPuzzleFinished: Bool { + didSet { local.set(notifiesPuzzleFinished, forKey: Keys.notifiesPuzzleFinished) } + } + + var notifiesInvites: Bool { + didSet { local.set(notifiesInvites, forKey: Keys.notifiesInvites) } + } + init( local: UserDefaults = .standard, cloud: NSUbiquitousKeyValueStore = .default @@ -56,6 +82,10 @@ final class PlayerPreferences { ?? local.string(forKey: Keys.name) ?? "Player" 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 cloud.synchronize() NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -1,4 +1,5 @@ import Foundation +import Observation /// Owns the account-scoped push credentials and worker registration that used /// to live in `AppServices`: minting, caching, and rotating the account push @@ -24,6 +25,9 @@ final class AccountPushCoordinator { private let syncMonitor: SyncMonitor private let pushClient: PushClient? + private var preferenceObservationTask: Task<Void, Never>? + private var preferenceDebounceTask: Task<Void, Never>? + init( identity: AuthorIdentity, preferences: PlayerPreferences, @@ -38,6 +42,64 @@ final class AccountPushCoordinator { self.syncEngine = syncEngine self.syncMonitor = syncMonitor self.pushClient = pushClient + // Seed the worker denylist before the first registration (no-op until + // an APNs token arrives), then keep it mirrored as settings change. + pushClient?.setMutedKinds(currentMutedPushKinds()) + startObservingNotificationPreferences() + } + + /// Maps the notification toggles to the worker `kind` denylist. The + /// "puzzle finished" toggle covers both completion outcomes. + nonisolated static func mutedPushKinds( + sessionBegin: Bool, + sessionEnd: Bool, + puzzleFinished: Bool + ) -> Set<String> { + var muted: Set<String> = [] + if !sessionBegin { muted.insert("play") } + if !sessionEnd { muted.insert("pause") } + if !puzzleFinished { muted.formUnion(["win", "resign"]) } + return muted + } + + private func currentMutedPushKinds() -> Set<String> { + Self.mutedPushKinds( + sessionBegin: preferences.notifiesSessionBegin, + sessionEnd: preferences.notifiesSessionEnd, + puzzleFinished: preferences.notifiesPuzzleFinished + ) + } + + /// Re-registers with the worker when a notification toggle changes, so a + /// muted kind takes effect without waiting for the next launch. Debounced + /// like `PlayerNamePublisher` so a burst of toggle flips lands as one + /// registration; `PushClient` dedups unchanged sets anyway. + private func startObservingNotificationPreferences() { + preferenceObservationTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in + withObservationTracking { + _ = self.preferences.notifiesSessionBegin + _ = self.preferences.notifiesSessionEnd + _ = self.preferences.notifiesPuzzleFinished + } onChange: { + cont.resume() + } + } + guard !Task.isCancelled else { break } + self.preferenceDebounceTask?.cancel() + self.preferenceDebounceTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.pushClient?.setMutedKinds(self.currentMutedPushKinds()) + } + } + } } /// Adopts an account push address learned from a sibling device's diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -553,6 +553,12 @@ final class InviteCoordinator { await consumeIfDirected() continue } + // Invite banners are user-toggleable; the invite row itself still + // lands in the Invited section through `applyInvitePings`. + if ping.kind == .invite, !preferences.notifiesInvites { + syncMonitor.note("ping(invite): banner disabled in settings for \(ping.gameID.uuidString)") + continue + } let content = UNMutableNotificationContent() content.title = "Crossmate" diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -25,15 +25,20 @@ final class PushClient { /// one per shared game the account participates in. The worker maps each /// to this device's APNs token so a push to the address reaches here. private var addresses: Set<String> = [] + /// Push kinds this device has muted in notification settings. Registered + /// with the worker as a denylist so muted pushes are dropped before APNs. + private var mutedKinds: Set<String> = [] private var lastRegistered: Registration? - /// The `(token, addresses)` pair last successfully reconciled with the - /// worker. Both must match for a reconcile to be a no-op, so a token - /// rotation re-binds every address and an address-set change registers - /// the delta. + /// The `(token, addresses, mutedKinds)` triple last successfully + /// reconciled with the worker. All must match for a reconcile to be a + /// no-op, so a token rotation re-binds every address, an address-set + /// change registers the delta, and a notification-preference change + /// re-registers every address with the new denylist. private struct Registration: Equatable { let token: String let addresses: Set<String> + let mutedKinds: Set<String> } /// `nil` when the worker isn't configured (e.g. a fresh checkout without a @@ -102,15 +107,32 @@ final class PushClient { Task { await reconcile() } } + /// Sets the push kinds this device's notification settings have muted. + /// The caller (AccountPushCoordinator) mirrors this from preferences on + /// launch and on every settings change. + func setMutedKinds(_ next: Set<String>) { + if next == mutedKinds { return } + mutedKinds = next + Task { await reconcile() } + } + private func reconcile() async { guard let apnsToken else { return } let desired = addresses - let signature = Registration(token: apnsToken, addresses: desired) + let signature = Registration( + token: apnsToken, + addresses: desired, + mutedKinds: mutedKinds + ) if lastRegistered == signature { return } let toRemove = (lastRegistered?.addresses ?? []).subtracting(desired) do { if !desired.isEmpty { - try await register(token: apnsToken, addresses: Array(desired)) + try await register( + token: apnsToken, + addresses: Array(desired), + mutedKinds: signature.mutedKinds.sorted() + ) } if !toRemove.isEmpty { await unregister(addresses: Array(toRemove)) @@ -227,14 +249,19 @@ final class PushClient { return formatter }() - private func register(token: String, addresses: [String]) async throws { + private func register( + token: String, + addresses: [String], + mutedKinds: [String] + ) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("register")) request.httpMethod = "POST" let body: [String: Any] = [ "deviceID": deviceID, "token": token, "environment": environment.rawValue, - "addresses": addresses + "addresses": addresses, + "mutedKinds": mutedKinds ] let data = try JSONSerialization.data(withJSONObject: body) request.httpBody = data diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -56,6 +56,17 @@ 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) + } 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.") + } + if debugMode { Section("Debugging") { Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -151,3 +151,50 @@ struct PuzzleNotificationTextTests { #expect(InviteCoordinator.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") } } + +@Suite("Notification preference muting") +struct NotificationMutedKindsTests { + @Test("All toggles on mutes nothing") + func allOnMutesNothing() { + let muted = AccountPushCoordinator.mutedPushKinds( + sessionBegin: true, + sessionEnd: true, + puzzleFinished: true + ) + + #expect(muted.isEmpty) + } + + @Test("Each toggle maps to its push kinds") + func togglesMapToKinds() { + #expect(AccountPushCoordinator.mutedPushKinds( + sessionBegin: false, + sessionEnd: true, + puzzleFinished: true + ) == ["play"]) + #expect(AccountPushCoordinator.mutedPushKinds( + sessionBegin: true, + sessionEnd: false, + puzzleFinished: true + ) == ["pause"]) + #expect(AccountPushCoordinator.mutedPushKinds( + sessionBegin: true, + sessionEnd: true, + puzzleFinished: false + ) == ["win", "resign"]) + } + + @Test("Muted kinds never include background or invite kinds") + func neverMutesBackgroundKinds() { + let muted = AccountPushCoordinator.mutedPushKinds( + sessionBegin: false, + sessionEnd: false, + puzzleFinished: false + ) + + #expect(muted == ["play", "pause", "win", "resign"]) + #expect(!muted.contains("replay")) + #expect(!muted.contains("accountJoined")) + #expect(!muted.contains("accountSeen")) + } +} diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -325,7 +325,7 @@ export class PushRegistry { async handleRegister(bodyText, auth) { const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); - const { deviceID, token, environment, addresses } = body; + const { deviceID, token, environment, addresses, mutedKinds } = body; if (!deviceID || !token || !Array.isArray(addresses)) { return badRequest("deviceID, token, addresses required"); } @@ -335,16 +335,21 @@ export class PushRegistry { if (environment !== "sandbox" && environment !== "production") { return badRequest("environment must be 'sandbox' or 'production'"); } + // Notification preferences ride along as a denylist of `kind` strings the + // device does not want delivered. The worker only string-matches them at + // publish time — it never interprets them — so a missing field (older + // clients) and a kind invented after registration both mean "deliver". + const muted = Array.isArray(mutedKinds) + ? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0) + : []; // Bind this device's APNs token to each per-(account, game) address it // knows. The address is the lookup key; identity never reaches the worker. const updatedAt = Date.now(); for (const address of addresses) { if (typeof address !== "string" || address.length === 0) continue; - await this.state.storage.put(`addr:${address}:${deviceID}`, { - token, - environment, - updatedAt - }); + const registration = { token, environment, updatedAt }; + if (muted.length > 0) registration.mutedKinds = muted; + await this.state.storage.put(`addr:${address}:${deviceID}`, registration); } return new Response(null, { status: 204 }); } @@ -392,13 +397,20 @@ export class PushRegistry { const targets = await this.resolveTargets(addressees, senderDeviceID); if (targets.length === 0) { - return Response.json({ delivered: 0, removed: 0, failed: 0 }); + return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 }); } let delivered = 0; let removed = 0; + let muted = 0; let failed = 0; for (const target of targets) { + // Honor the target device's registered notification preferences: a + // muted kind is dropped here, before APNs, so the device never sees it. + if (Array.isArray(target.mutedKinds) && target.mutedKinds.includes(kind)) { + muted += 1; + continue; + } const result = await this.sendOne(target, { kind, gameID, @@ -418,7 +430,7 @@ export class PushRegistry { failed += 1; } } - return Response.json({ delivered, removed, failed }); + return Response.json({ delivered, removed, muted, failed }); } async resolveTargets(addressees, senderDeviceID) {