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:
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) {