crossmate

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

commit b05cae15dcec44fa44358244ea9d13976ff04099
parent bbd5aff86d49bac858afcffe917052320bfa30ba
Author: Michael Camilleri <[email protected]>
Date:   Mon, 15 Jun 2026 20:35:34 +0900

Recompose notification text to apply a friend's nickname

While a private nickname the user gave a friend showed throughout most
of the app — the Game List, the invite rows — it was not showing up
reliably in notifications related to the friend. The reason was that the
Notification Service Extension substituted the nickname by
string-matching the friend's name inside the sender-composed alert body,
using a snapshot of that name mirrored from Core Data into the App
Group. The snapshot was meant to follow the friend's renames through the
pairwise friend-zone name Decision, but once it drifted from the name
the sender actually stamped into the push the match failed and the
original body showed.

This commit carries the puzzle title on the push as a structured field
and rebuilds the body in the extension from the push's own components —
the event, its counts and the title — substituting the recipient's
nickname for the sender's name. The substring to replace no longer comes
from a stored snapshot, so a later rename can never desync it, and there
is no whole-word matching left to misfire. Sender and extension compose
through the same PuzzleNotificationText builders, now shared so both
sides word a body identically; the sender's text still rides along as a
fallback, so an older sender — whose payload omits the title — leaves
the original body untouched rather than risking a wrong rewrite.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 14++++++++++----
MCrossmate/Models/FriendEntity+DisplayName.swift | 18++++++++----------
ACrossmate/Models/PuzzleNotificationText+GameEntity.swift | 15+++++++++++++++
DCrossmate/Models/PuzzleNotificationText.swift | 81-------------------------------------------------------------------------------
MCrossmate/Services/PushClient.swift | 18+++++++++++++-----
MCrossmate/Services/SessionCoordinator.swift | 31+++++++++++++++----------------
MNotificationService/NotificationService.swift | 40++++++++++++++++------------------------
MShared/NicknameDirectory.swift | 43+++++++++----------------------------------
MShared/PushPayload.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
AShared/PuzzleNotificationText.swift | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/NicknameDirectoryTests.swift | 79+++++--------------------------------------------------------------------------
MTests/Unit/PushPayloadTests.swift | 34++++++++++++++++++++++++++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 19+++++++++++++++++++
13 files changed, 303 insertions(+), 248 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */; }; 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; + 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */; }; 262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; }; 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */; }; 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; }; @@ -45,11 +46,11 @@ 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; }; 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; + 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; - 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; 41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */; }; 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; }; 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; }; @@ -172,6 +173,7 @@ E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; }; E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; + E81F92AAB2968997C3D68809 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */; }; EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; @@ -231,6 +233,7 @@ 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitorTests.swift; sourceTree = "<group>"; }; 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; }; + 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PuzzleNotificationText+GameEntity.swift"; sourceTree = "<group>"; }; 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; }; 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; }; @@ -371,10 +374,10 @@ CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; }; + CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; CF3D29B227D2B0E699423C48 /* Journal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Journal.swift; sourceTree = "<group>"; }; CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; }; D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouette.swift; sourceTree = "<group>"; }; - D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; @@ -525,7 +528,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */, - D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */, + 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */, E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, @@ -612,6 +615,7 @@ 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */, 2D2FD896D75863554E31654C /* NotificationState.swift */, C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */, + CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */, ); path = Shared; sourceTree = "<group>"; @@ -821,6 +825,7 @@ 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */, 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */, F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */, + 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -982,7 +987,8 @@ F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, - 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, + 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */, + E81F92AAB2968997C3D68809 /* PuzzleNotificationText.swift in Sources */, 88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */, E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, diff --git a/Crossmate/Models/FriendEntity+DisplayName.swift b/Crossmate/Models/FriendEntity+DisplayName.swift @@ -48,12 +48,13 @@ extension FriendEntity { } /// Rewrites the App Group nickname directory from Core Data ground truth: - /// one entry per unblocked friend with a nickname, carrying the friend's - /// own current name so the Notification Service Extension can find it in - /// sender-composed alert text. Called after a local rename, after sync - /// applies a `nickname` or `name` Decision (either side of an entry - /// changed), and once at launch as a heal. Must run inside the context's - /// queue (`performAndWait`) when `ctx` is a background context. + /// one `authorID → nickname` entry per unblocked friend with a nickname. + /// The Notification Service Extension pairs these with the live + /// `PushPayload.senderName` to swap the friend's own name for the nickname + /// in sender-composed alert text. Called after a local rename, after sync + /// applies a `nickname` Decision, and once at launch as a heal. Must run + /// inside the context's queue (`performAndWait`) when `ctx` is a background + /// context. static func rebuildNicknameDirectory(in ctx: NSManagedObjectContext) { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate( @@ -66,10 +67,7 @@ extension FriendEntity { .trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty else { continue } - directory[authorID] = NicknameDirectory.Entry( - displayName: friend.givenDisplayName, - nickname: nickname - ) + directory[authorID] = NicknameDirectory.Entry(nickname: nickname) } NicknameDirectory.save(directory) } diff --git a/Crossmate/Models/PuzzleNotificationText+GameEntity.swift b/Crossmate/Models/PuzzleNotificationText+GameEntity.swift @@ -0,0 +1,15 @@ +import Foundation + +extension PuzzleNotificationText { + /// Convenience over `title(_:publisher:date:)` for a `GameEntity`. Lives in + /// the app target because it reaches into Core Data; the core builders are + /// in `Shared` so the notification service extension can use them too. + static func title(for entity: GameEntity?) -> String { + guard let entity else { return "" } + return title( + entity.title ?? "", + publisher: entity.cachedPublisher, + date: entity.cachedPuzzleDate + ) + } +} diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift @@ -1,81 +0,0 @@ -import Foundation - -enum PuzzleNotificationText { - static func title(_ title: String, publisher: String?, date: Date?) -> String { - let subtitle = subtitle(publisher: publisher, date: date) - guard let subtitle else { return title } - return "\(title) – \(subtitle)" - } - - static func title(for entity: GameEntity?) -> String { - guard let entity else { return "" } - return title( - entity.title ?? "", - publisher: entity.cachedPublisher, - date: entity.cachedPuzzleDate - ) - } - - /// Body for a session-end push, addressed to a single recipient, - /// describing what the peer did since that recipient last looked (entries - /// in the peer's journal newer than the recipient's last-known - /// `Player.readAt`): net letter `fills` / `clears`, and the number of - /// `checks` / `reveals` *gestures* run. When every count is zero the - /// recipient still gets the push as a presence signal ("stopped solving") - /// — the session end is worth surfacing even with nothing unseen — but the - /// payload's zero counts keep it from bumping the badge. - static func pauseBody( - playerName: String, - puzzleTitle: String, - fills: Int, - clears: Int, - checks: Int, - reveals: Int - ) -> String { - let resolvedName = playerName.isEmpty ? "A player" : playerName - let puzzleSuffix = puzzleTitle.isEmpty - ? "the puzzle" - : "the puzzle '\(puzzleTitle)'" - - func letters(_ n: Int) -> String { "\(n) \(n == 1 ? "letter" : "letters")" } - - var clauses: [String] = [] - if fills > 0 { clauses.append("filled \(letters(fills))") } - if clears > 0 { clauses.append("cleared \(letters(clears))") } - // Checks and reveals are help gestures; fold them into one "ran …" - // clause so the sentence doesn't repeat the verb. - var help: [String] = [] - if checks > 0 { help.append("\(checks) \(checks == 1 ? "check" : "checks")") } - 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 "\(resolvedName) \(joinList(clauses)) in \(puzzleSuffix)" - } - - /// Joins clauses into prose: "a", "a and b", or "a, b and c". - private static func joinList(_ parts: [String]) -> String { - switch parts.count { - case 0: return "" - case 1: return parts[0] - case 2: return "\(parts[0]) and \(parts[1])" - default: return "\(parts.dropLast().joined(separator: ", ")) and \(parts[parts.count - 1])" - } - } - - private static func subtitle(publisher: String?, date: Date?) -> String? { - let formattedDate = date?.formatted(date: .long, time: .omitted) - if let publisher, !publisher.isEmpty, let formattedDate { - return "\(publisher) · \(formattedDate)" - } - if let formattedDate { - return formattedDate - } - if let publisher, !publisher.isEmpty { - return publisher - } - return nil - } -} diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -176,16 +176,24 @@ final class PushClient { gameID: UUID, addressees: [Addressee], title: String, - body: String, + puzzleTitle: String? = nil, background: Bool = false, - extra: [String: Any] = [:] + extra: [String: Any] = [:], + body: String ) async { guard !addressees.isEmpty else { return } log("push(\(kind)): publishing to \(addressees.count) addressee(s)") + // Stamp the puzzle title onto each addressee's structured payload so the + // receiver's extension can recompose the body from components (swapping + // in its private nickname) rather than editing the sender's text. + // Injected here so the call sites pass it once, not per addressee. let addresseePayloads: [[String: Any]] = addressees.map { addressee in var entry: [String: Any] = ["address": addressee.address] if let body = addressee.body { entry["body"] = body } - if let payload = addressee.payload?.encodedString() { entry["payload"] = payload } + if var payload = addressee.payload { + if let puzzleTitle { payload.puzzleTitle = puzzleTitle } + if let encoded = payload.encodedString() { entry["payload"] = encoded } + } return entry } var payload: [String: Any] = [ @@ -236,9 +244,9 @@ final class PushClient { gameID: gameID, addressees: [Addressee(address: address)], title: "", - body: "", background: true, - extra: extra + extra: extra, + body: "" ) } diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -233,14 +233,16 @@ final class SessionCoordinator { syncMonitor.note("push(play): skipped (no addressable recipients)") return } - let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" await pushClient.publish( kind: "play", gameID: gameID, addressees: addressees, title: "Crossmate", - body: "\(playerName) is solving \(puzzleSuffix)" + puzzleTitle: plan.title, + body: PuzzleNotificationText.playBody( + 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`). @@ -359,6 +361,7 @@ final class SessionCoordinator { gameID: gameID, addressees: addressees, title: "Crossmate", + puzzleTitle: plan.title, body: fallbackBody ) // Peers have now been told the session ended, so a fresh "play" is @@ -415,22 +418,18 @@ final class SessionCoordinator { syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)") return } - let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" - let kind: String - let body: String - if resigned { - kind = "resign" - body = "\(playerName) resigned \(puzzleSuffix)." - } else { - kind = "win" - body = "\(playerName) solved \(puzzleSuffix)" - } + let kind = resigned ? "resign" : "win" + let body = PuzzleNotificationText.completionBody( + playerName: preferences.name, + puzzleTitle: plan.title, + resigned: resigned + ) await pushClient.publish( kind: kind, gameID: gameID, addressees: addressees, title: "Crossmate", + puzzleTitle: plan.title, body: body ) } @@ -459,8 +458,8 @@ final class SessionCoordinator { gameID: gameID, addressees: addressees, title: "", - body: "", - background: true + background: true, + body: "" ) } diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -46,18 +46,19 @@ final class NotificationService: UNNotificationServiceExtension { // The alert body was composed by the *sender* with their own chosen // name ("Alice solved …"). If the user gave that friend a private - // nickname, swap it in: the app mirrors authorID → (displayName, - // nickname) into the App Group, and `fromAuthorID` identifies the - // sender. No match (stale mirror, renamed sender) shows the original - // body — never broken text. + // nickname (mirrored authorID → nickname into the App Group, with + // `fromAuthorID` identifying the sender), rebuild the body from the + // push's structured fields — `PushPayload.composedBody` runs the same + // builders the sender used, but with the nickname substituted for the + // name. Recomposing from components rather than editing the sender's + // text means a friend's later rename can never desync the result, and + // there is no string surgery to misfire. // Record *why* the rewrite did or didn't happen as a diagnostics - // receipt. The substitution is a silent no-op in several distinct cases - // (no sender identity, no nickname for this friend, the friend's name - // not yet synced, or a stored name that doesn't match the embedded one - // — e.g. a stale rename or a whitespace/Unicode mismatch). Without this - // line they're indistinguishable after the fact; with it, the next - // occurrence names the cause and surfaces the stored name to compare - // against the visible body. + // receipt. It is a silent no-op in several distinct cases (no sender + // identity, no nickname for this friend, or a body that can't be + // rebuilt — a bodyless event or an older sender missing the structured + // fields). Without this line they're indistinguishable after the fact; + // with it, the next occurrence names the cause. let fromAuthorID = (userInfo["fromAuthorID"] as? String) .flatMap { $0.isEmpty ? nil : $0 } let rewriteOutcome: String @@ -66,20 +67,11 @@ final class NotificationService: UNNotificationServiceExtension { } else if let fromAuthorID { let fromPrefix = String(fromAuthorID.prefix(8)) if let entry = NicknameDirectory.entry(for: fromAuthorID) { - if let displayName = entry.displayName { - let rewritten = NicknameDirectory.rewritten( - bestAttemptContent.body, - replacing: displayName, - with: entry.nickname - ) - if rewritten == bestAttemptContent.body { - rewriteOutcome = "skipped=no-match from=\(fromPrefix) stored=\"\(displayName)\"" - } else { - bestAttemptContent.body = rewritten - rewriteOutcome = "applied from=\(fromPrefix) stored=\"\(displayName)\"" - } + if let rebuilt = payload?.composedBody(playerName: entry.nickname) { + bestAttemptContent.body = rebuilt + rewriteOutcome = "applied from=\(fromPrefix) nickname=\"\(entry.nickname)\"" } else { - rewriteOutcome = "skipped=nil-stored-name from=\(fromPrefix)" + rewriteOutcome = "skipped=not-composable from=\(fromPrefix)" } } else { rewriteOutcome = "skipped=no-entry from=\(fromPrefix)" diff --git a/Shared/NicknameDirectory.swift b/Shared/NicknameDirectory.swift @@ -8,16 +8,17 @@ import Foundation /// nickname can only be substituted on the receiving device — and when the /// app is suspended, the only code that runs is the Notification Service /// Extension, which cannot reach Core Data. The app therefore mirrors every -/// nickname (with the friend's own current display name, the substring to -/// find) into this file via `FriendEntity.rebuildNicknameDirectory`, and the -/// NSE reads it to rewrite the alert body before display. +/// nickname into this file via `FriendEntity.rebuildNicknameDirectory`, and +/// the NSE reads it to rewrite the alert body before display. The name to +/// replace arrives live on the push (`PushPayload.senderName`), so the only +/// thing the directory needs to persist is the nickname itself. enum NicknameDirectory { + /// The directory holds only the stable `authorID → nickname` mapping. The + /// substring to replace (the friend's own name) is *not* stored here — it + /// rides live on each push in `PushPayload.senderName`, so the rewrite + /// always matches the name the sender actually used and can never go stale + /// against a friend who has since renamed. struct Entry: Codable, Equatable, Sendable { - /// The friend's own, self-chosen display name — the value sender-side - /// composition embeds in notification bodies, and therefore the - /// substring `rewritten` looks for. `nil` when no name has synced yet - /// (nothing to match against). - let displayName: String? /// The local user's private nickname for the friend. let nickname: String } @@ -60,30 +61,4 @@ enum NicknameDirectory { guard !authorID.isEmpty else { return nil } return load()[authorID] } - - /// Replaces whole-word occurrences of `displayName` in sender-composed - /// notification text with `nickname`. Word-boundary lookarounds keep a - /// short name from matching inside a longer word (a friend named "Al" - /// must not rewrite "Also"); names that start or end in non-word - /// characters still match because the lookarounds only reject adjacent - /// *word* characters. Unmatched text comes back unchanged — the worst - /// case is the friend's own name showing, never broken text. - static func rewritten( - _ text: String, - replacing displayName: String, - with nickname: String - ) -> String { - guard !text.isEmpty, !displayName.isEmpty, !nickname.isEmpty, - displayName != nickname - else { return text } - let pattern = "(?<!\\w)" - + NSRegularExpression.escapedPattern(for: displayName) - + "(?!\\w)" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - return regex.stringByReplacingMatches( - in: text, - range: NSRange(text.startIndex..., in: text), - withTemplate: NSRegularExpression.escapedTemplate(for: nickname) - ) - } } diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -19,6 +19,13 @@ struct PushPayload: Codable, Sendable, Equatable { var version: Int var event: Event + /// The puzzle title the sender baked into the alert body. Carried as a + /// structured field so the notification service extension can recompose + /// the body from components — substituting the recipient's private + /// nickname for the sender's name — instead of editing the sender's text. + /// `nil` from older senders (and on bodyless pushes like replay), in which + /// case the NSE leaves the original body untouched. + var puzzleTitle: 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 @@ -30,10 +37,12 @@ struct PushPayload: Codable, Sendable, Equatable { init( version: Int = PushPayload.currentVersion, event: Event, + puzzleTitle: String? = nil, diagnostics: Diagnostics? = nil ) { self.version = version self.event = event + self.puzzleTitle = puzzleTitle self.diagnostics = diagnostics } @@ -140,6 +149,48 @@ struct PushPayload: Codable, Sendable, Equatable { } extension PushPayload { + /// The alert body for this event as `playerName` would read it, rebuilt + /// from the structured fields. The sender composes its own body through the + /// same `PuzzleNotificationText` builders, so passing the local user's name + /// reproduces the shipped text; the notification service extension passes + /// the recipient's private nickname to swap the name without touching the + /// sender's string. Returns `nil` when the body can't be faithfully rebuilt + /// — a bodyless event (replay/unknown) or an older sender that omitted + /// `puzzleTitle` — leaving the original body in place. + 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, + puzzleTitle: puzzleTitle, + fills: fills, + clears: clears, + checks: checks, + reveals: reveals + ) + case .win: + return PuzzleNotificationText.completionBody( + playerName: playerName, + puzzleTitle: puzzleTitle, + resigned: false + ) + case .resign: + return PuzzleNotificationText.completionBody( + playerName: playerName, + puzzleTitle: puzzleTitle, + resigned: true + ) + case .replay, .unknown: + return nil + } + } + /// Base64-encoded JSON for the per-addressee `payload` field on the wire. func encodedString() -> String? { guard let data = try? JSONEncoder().encode(self) else { return nil } diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Composes the visible text of a Crossmate notification from its parts. This +/// is the single source of truth for alert wording: the sender builds the body +/// it ships through these builders, and the notification service extension +/// calls the same ones (via `PushPayload.composedBody`) to rebuild the body +/// with the recipient's private nickname in place of the sender's name. Pure +/// string logic only, so it lives in `Shared` and compiles into both the app +/// and the extension. +enum PuzzleNotificationText { + static func title(_ title: String, publisher: String?, date: Date?) -> String { + let subtitle = subtitle(publisher: publisher, date: date) + guard let subtitle else { return title } + 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 completion push — "Alice solved …" or, when `resigned`, + /// "Alice resigned …" (the resign sentence ends in a full stop to match the + /// app's existing wording). + static func completionBody( + playerName: String, + puzzleTitle: String, + resigned: Bool + ) -> String { + let name = resolvedName(playerName) + let suffix = puzzleSuffix(puzzleTitle) + return resigned + ? "\(name) resigned \(suffix)." + : "\(name) solved \(suffix)" + } + + /// Body for a session-end push, addressed to a single recipient, + /// describing what the peer did since that recipient last looked (entries + /// in the peer's journal newer than the recipient's last-known + /// `Player.readAt`): net letter `fills` / `clears`, and the number of + /// `checks` / `reveals` *gestures* run. When every count is zero the + /// recipient still gets the push as a presence signal ("stopped solving") + /// — the session end is worth surfacing even with nothing unseen — but the + /// payload's zero counts keep it from bumping the badge. + static func pauseBody( + playerName: String, + puzzleTitle: String, + fills: Int, + clears: Int, + checks: Int, + reveals: Int + ) -> String { + let resolvedName = resolvedName(playerName) + let puzzleSuffix = puzzleSuffix(puzzleTitle) + + func letters(_ n: Int) -> String { "\(n) \(n == 1 ? "letter" : "letters")" } + + var clauses: [String] = [] + if fills > 0 { clauses.append("filled \(letters(fills))") } + if clears > 0 { clauses.append("cleared \(letters(clears))") } + // Checks and reveals are help gestures; fold them into one "ran …" + // clause so the sentence doesn't repeat the verb. + var help: [String] = [] + if checks > 0 { help.append("\(checks) \(checks == 1 ? "check" : "checks")") } + 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 "\(resolvedName) \(joinList(clauses)) in \(puzzleSuffix)" + } + + /// The empty name falls back to a neutral label, so a peer who hasn't set a + /// name still reads as a person rather than a blank. + private static func resolvedName(_ name: String) -> String { + name.isEmpty ? "A player" : name + } + + /// "the puzzle" alone, or "the puzzle 'Title'" when a title is known. + private static func puzzleSuffix(_ title: String) -> String { + title.isEmpty ? "the puzzle" : "the puzzle '\(title)'" + } + + /// Joins clauses into prose: "a", "a and b", or "a, b and c". + private static func joinList(_ parts: [String]) -> String { + switch parts.count { + case 0: return "" + case 1: return parts[0] + case 2: return "\(parts[0]) and \(parts[1])" + default: return "\(parts.dropLast().joined(separator: ", ")) and \(parts[parts.count - 1])" + } + } + + private static func subtitle(publisher: String?, date: Date?) -> String? { + let formattedDate = date?.formatted(date: .long, time: .omitted) + if let publisher, !publisher.isEmpty, let formattedDate { + return "\(publisher) · \(formattedDate)" + } + if let formattedDate { + return formattedDate + } + if let publisher, !publisher.isEmpty { + return publisher + } + return nil + } +} diff --git a/Tests/Unit/NicknameDirectoryTests.swift b/Tests/Unit/NicknameDirectoryTests.swift @@ -20,75 +20,6 @@ struct NicknameDirectoryTests { } } - // MARK: - Rewriting - - @Test("rewritten swaps the sender's name at the head of a body") - func rewriteNameFirstBody() { - let body = NicknameDirectory.rewritten( - "Alice solved the puzzle 'Saturday'", - replacing: "Alice", - with: "Mum" - ) - #expect(body == "Mum solved the puzzle 'Saturday'") - } - - @Test("rewritten swaps a mid-sentence occurrence") - func rewriteMidSentence() { - let body = NicknameDirectory.rewritten( - "You and Alice finished the puzzle", - replacing: "Alice", - with: "Mum" - ) - #expect(body == "You and Mum finished the puzzle") - } - - @Test("rewritten does not match inside a longer word") - func rewriteWholeWordsOnly() { - let body = NicknameDirectory.rewritten( - "Al ran 2 checks in the puzzle 'Alphabet Soup'", - replacing: "Al", - with: "Grandpa" - ) - #expect(body == "Grandpa ran 2 checks in the puzzle 'Alphabet Soup'") - } - - @Test("rewritten leaves an unmatched body unchanged") - func rewriteNoMatch() { - let body = NicknameDirectory.rewritten( - "Alicia solved the puzzle", - replacing: "Alice", - with: "Mum" - ) - #expect(body == "Alicia solved the puzzle") - } - - @Test("rewritten treats regex metacharacters in names as literals") - func rewriteEscapesMetacharacters() { - let body = NicknameDirectory.rewritten( - "J. R. (Bob) solved the puzzle", - replacing: "J. R. (Bob)", - with: "Dad" - ) - #expect(body == "Dad solved the puzzle") - let dollars = NicknameDirectory.rewritten( - "Ace solved the puzzle", - replacing: "Ace", - with: "$0 Top$" - ) - #expect(dollars == "$0 Top$ solved the puzzle") - } - - @Test("rewritten is a no-op for empty or identical inputs") - func rewriteDegenerateInputs() { - #expect(NicknameDirectory.rewritten("", replacing: "Alice", with: "Mum") == "") - #expect(NicknameDirectory.rewritten("Alice solved", replacing: "", with: "Mum") - == "Alice solved") - #expect(NicknameDirectory.rewritten("Alice solved", replacing: "Alice", with: "") - == "Alice solved") - #expect(NicknameDirectory.rewritten("Alice solved", replacing: "Alice", with: "Alice") - == "Alice solved") - } - // MARK: - File round trip @Test("save/load/entry round-trips through the backing file") @@ -96,8 +27,8 @@ struct NicknameDirectoryTests { try await withTemporaryDirectoryFile { #expect(NicknameDirectory.load().isEmpty) let directory = [ - "_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum"), - "_bob": NicknameDirectory.Entry(displayName: nil, nickname: "Bobby") + "_alice": NicknameDirectory.Entry(nickname: "Mum"), + "_bob": NicknameDirectory.Entry(nickname: "Bobby") ] NicknameDirectory.save(directory) #expect(NicknameDirectory.load() == directory) @@ -111,7 +42,7 @@ struct NicknameDirectoryTests { func emptySaveRemovesFile() async throws { try await withTemporaryDirectoryFile { NicknameDirectory.save( - ["_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum")] + ["_alice": NicknameDirectory.Entry(nickname: "Mum")] ) #expect(!NicknameDirectory.load().isEmpty) NicknameDirectory.save([:]) @@ -155,8 +86,8 @@ struct NicknameDirectoryTests { let directory = NicknameDirectory.load() #expect(directory == [ - "_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum"), - "_bob": NicknameDirectory.Entry(displayName: nil, nickname: "Bobby") + "_alice": NicknameDirectory.Entry(nickname: "Mum"), + "_bob": NicknameDirectory.Entry(nickname: "Bobby") ]) // Clearing the last nickname empties the directory again. diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -27,6 +27,40 @@ struct PushPayloadTests { } } + @Test("Puzzle title round-trips through the wire encoding") + func puzzleTitleRoundTrips() throws { + let payload = PushPayload(event: .play, puzzleTitle: "Saturday Crossword") + let decoded = try roundTrip(payload) + #expect(decoded == payload) + #expect(decoded.puzzleTitle == "Saturday Crossword") + } + + @Test("composedBody rebuilds each event's body with the given name") + func composedBodySubstitutesName() { + func body(_ event: PushPayload.Event) -> String? { + PushPayload(event: event, puzzleTitle: "Saturday") + .composedBody(playerName: "Mum") + } + #expect(body(.play) == "Mum is solving 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)) + == "Mum filled 3 letters in the puzzle 'Saturday'") + #expect(body(.pause(fills: 0, clears: 0, checks: 0, reveals: 0)) + == "Mum stopped solving the puzzle 'Saturday'.") + } + + @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) + // Bodyless events carry no visible text to rebuild. + #expect(PushPayload(event: .replay, puzzleTitle: "Saturday") + .composedBody(playerName: "Mum") == nil) + #expect(PushPayload(event: .unknown, puzzleTitle: "Saturday") + .composedBody(playerName: "Mum") == nil) + } + @Test("An unrecognised event decodes to .unknown rather than failing") func unknownEventTolerated() throws { // Simulates a payload a newer build might send. diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -32,6 +32,25 @@ 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("completionBody distinguishes a solve from a resignation") + func completionBodySolveVsResign() { + #expect(PuzzleNotificationText.completionBody( + playerName: "Alice", puzzleTitle: "X", resigned: false + ) == "Alice solved the puzzle 'X'") + #expect(PuzzleNotificationText.completionBody( + playerName: "Alice", puzzleTitle: "X", resigned: true + ) == "Alice resigned the puzzle 'X'.") + } + @Test("pauseBody combines fills and clears counts when both are non-zero") func pauseBodyFillsAndClears() { let body = PuzzleNotificationText.pauseBody(