crossmate

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

commit e0bee2c7973d378c75d3331d8dabbcee33d05029
parent a89d6a8f2e312bd4968b5d5cf7ad4a0c4caab2c2
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 16:41:25 +0900

Support adding nicknames to friends

The Crossmates sheet's friend menu gains Rename, which sets a private
nickname for that friend — a local label the friend never sees, with a
blank entry reverting to their own name. The nickname lives on
FriendEntity (new nickname/nicknameVersion attributes) and converges
across the user's own devices as a versioned account-zone Decision
(kind 'nickname'), the same channel block rides; applyDecisionRecord
honors it only from the account zone so a friend can't relabel people
in this user's list via the shared pairwise zone, and last-writer-wins
on version settles cross-device races.

The nickname overrides the friend's own name everywhere it surfaces:
resolvedDisplayName (Crossmates sheet, friend picker, share rows), the
in-puzzle roster, the Invited section rows and block alert (new
InviteEntity.resolvedInviterName), and local invite banners. Push
bodies are composed sender-side, so the app mirrors authorID →
(displayName, nickname) into an App Group JSON file
(NicknameDirectory) — rebuilt on rename, when a name or nickname
Decision syncs in, and once at launch — and the Notification Service
Extension rewrites the alert body via fromAuthorID, whole-word only,
falling back to the original text on any mismatch.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 14++++++++++++++
MCrossmate/CrossmateApp.swift | 17+++++++++++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 2++
MCrossmate/Models/FriendEntity+DisplayName.swift | 49++++++++++++++++++++++++++++++++++++++++++++++---
ACrossmate/Models/InviteEntity+DisplayName.swift | 31+++++++++++++++++++++++++++++++
MCrossmate/Models/PlayerRoster.swift | 32++++++++++++++++++++++++++++++--
MCrossmate/Services/AppServices.swift | 9+++++++++
MCrossmate/Services/InviteCoordinator.swift | 13++++++++++---
MCrossmate/Sync/FriendController.swift | 33+++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 4++++
MCrossmate/Sync/RecordSerializer.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 24++++++++++++++++++++----
MCrossmate/Views/FriendsView.swift | 29+++++++++++++++++++++++++++--
MCrossmate/Views/GameListView.swift | 7+++----
MNotificationService/NotificationService.swift | 17+++++++++++++++++
AShared/NicknameDirectory.swift | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/NicknameDirectoryTests.swift | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 744 insertions(+), 18 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 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 */; }; + 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 */; }; @@ -65,6 +66,7 @@ 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */; }; + 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; @@ -78,6 +80,7 @@ 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; + 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; @@ -102,6 +105,7 @@ 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; + A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; A65F99414F8CF6704567BB07 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C18E9B47668E008BE4CF86 /* Archive.swift */; }; @@ -235,6 +239,7 @@ 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; }; 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; }; + 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = "<group>"; }; 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; @@ -299,6 +304,7 @@ 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlannerTests.swift; sourceTree = "<group>"; }; 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; }; + 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectoryTests.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; @@ -351,6 +357,7 @@ D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; + E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InviteEntity+DisplayName.swift"; sourceTree = "<group>"; }; E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSummaryThumbnailTests.swift; sourceTree = "<group>"; }; E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; }; @@ -438,6 +445,7 @@ 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */, 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, + 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */, FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, @@ -477,6 +485,7 @@ 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, + E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */, 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */, DB55FC337CF72C650373210A /* PlayerColor.swift */, 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */, @@ -567,6 +576,7 @@ 9BF7383FE2AB07F12434C013 /* Shared */ = { isa = PBXGroup; children = ( + 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */, 2D2FD896D75863554E31654C /* NotificationState.swift */, C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */, ); @@ -771,6 +781,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */, 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */, 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */, F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */, @@ -807,6 +818,7 @@ C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, + 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */, 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */, @@ -893,6 +905,7 @@ 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */, + 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */, 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */, B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, @@ -907,6 +920,7 @@ 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, + 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */, 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -54,6 +54,23 @@ struct CrossmateApp: App { .environment(\.blockFriend, { friendAuthorID in await services.invites.blockFriend(authorID: friendAuthorID) }) + .environment(\.renameFriend, { friendAuthorID, nickname in + do { + try await services.friendController.setNickname( + friendAuthorID: friendAuthorID, + nickname: nickname + ) + } catch { + services.announcements.post(Announcement( + id: "rename-friend-error-\(friendAuthorID)", + scope: .global, + severity: .error, + title: "Renaming Failed", + body: error.localizedDescription, + dismissal: .manual + )) + } + }) .environment(\.sendResignPings, { gameID in await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) }) diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -101,6 +101,8 @@ <attribute name="friendZoneName" attributeType="String"/> <attribute name="friendZoneOwnerName" attributeType="String"/> <attribute name="isBlocked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="nickname" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="nicknameVersion" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="pairKey" attributeType="String"/> <fetchIndex name="byPairKey"> <fetchIndexElement property="pairKey" type="Binary" order="ascending"/> diff --git a/Crossmate/Models/FriendEntity+DisplayName.swift b/Crossmate/Models/FriendEntity+DisplayName.swift @@ -4,12 +4,28 @@ import Foundation extension FriendEntity { /// The name the invite surfaces should show for this friend. /// + /// A `nickname` the user assigned via Rename wins outright — it's their + /// private label for the friend and never follows the friend's own + /// renames. Otherwise the friend's own name (`givenDisplayName`), then + /// the "Player" placeholder. + var resolvedDisplayName: String { + if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), + !nickname.isEmpty { + return nickname + } + return givenDisplayName ?? "Player" + } + + /// The friend's own, self-chosen name — `resolvedDisplayName` without the + /// local nickname override, and the substring the nickname directory + /// rewrites in sender-composed notification text. + /// /// `displayName` is fed exclusively by the friend's `name` Decision in the /// pairwise friend zone (`RecordSerializer.applyDecisionRecord`), so it is /// the live, rename-following value. Until the first Decision syncs the /// fallback is the freshest per-game `Player` snapshot the friend wrote at - /// game open, then the "Player" placeholder. - var resolvedDisplayName: String { + /// game open; `nil` when neither has arrived. + var givenDisplayName: String? { if let name = displayName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { return name @@ -28,6 +44,33 @@ extension FriendEntity { return name } } - return "Player" + return nil + } + + /// 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. + static func rebuildNicknameDirectory(in ctx: NSManagedObjectContext) { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate( + format: "isBlocked == NO AND nickname != nil AND nickname != %@", "" + ) + var directory: [String: NicknameDirectory.Entry] = [:] + for friend in (try? ctx.fetch(req)) ?? [] { + guard let authorID = friend.authorID, !authorID.isEmpty, + let nickname = friend.nickname? + .trimmingCharacters(in: .whitespacesAndNewlines), + !nickname.isEmpty + else { continue } + directory[authorID] = NicknameDirectory.Entry( + displayName: friend.givenDisplayName, + nickname: nickname + ) + } + NicknameDirectory.save(directory) } } diff --git a/Crossmate/Models/InviteEntity+DisplayName.swift b/Crossmate/Models/InviteEntity+DisplayName.swift @@ -0,0 +1,31 @@ +import CoreData +import Foundation + +extension InviteEntity { + /// The name invite rows and alerts should show for the inviter, or `nil` + /// when nothing presentable is known (callers supply their own + /// placeholder — "A player" in rows, "this player" in the block alert). + /// + /// A nickname the user assigned via Rename wins outright — the same rule + /// as `FriendEntity.resolvedDisplayName`, reached here through + /// `inviterAuthorID` because the row renders from the `InviteEntity`, + /// which only carries the name snapshot the `.invite` Ping shipped. + var resolvedInviterName: String? { + if let authorID = inviterAuthorID, !authorID.isEmpty, + let ctx = managedObjectContext { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", authorID) + req.fetchLimit = 1 + if let nickname = (try? ctx.fetch(req).first)?.nickname? + .trimmingCharacters(in: .whitespacesAndNewlines), + !nickname.isEmpty { + return nickname + } + } + if let name = inviterName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + return nil + } +} diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -43,6 +43,11 @@ final class PlayerRoster { var ckZoneName: String? var ckZoneOwnerName: String? var namesMap: [String: String] = [:] + /// The user's private nicknames (`FriendEntity.nickname`), keyed by + /// authorID. A nickname overrides the peer's own published name + /// everywhere the roster surfaces it (players menu, cursor chips, + /// presence traces). + var nicknamesByAuthor: [String: String] = [:] var moveAuthorIDs: [String] = [] var rawSelections: [RawSelection] = [] var readAtByAuthor: [String: Date] = [:] @@ -226,12 +231,24 @@ final class PlayerRoster { Set(movesEntities.compactMap { $0.authorID }) .subtracting([localAuthorID, CKCurrentUserDefaultName, ""]) ) + let nicknameReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + nicknameReq.predicate = NSPredicate( + format: "isBlocked == NO AND nickname != nil AND nickname != %@", "" + ) + var nicknamesByAuthor: [String: String] = [:] + for friend in (try? ctx.fetch(nicknameReq)) ?? [] { + guard let aid = friend.authorID, !aid.isEmpty, + let nickname = friend.nickname, !nickname.isEmpty + else { continue } + nicknamesByAuthor[aid] = nickname + } return FetchedRoster( databaseScope: entity.databaseScope, ckShareRecordName: entity.ckShareRecordName, ckZoneName: entity.ckZoneName, ckZoneOwnerName: entity.ckZoneOwnerName, namesMap: namesMap, + nicknamesByAuthor: nicknamesByAuthor, moveAuthorIDs: authorIDs, rawSelections: selections, readAtByAuthor: readAtByAuthor @@ -301,7 +318,11 @@ final class PlayerRoster { var remoteEntries: [Entry] = [] var taken: Set<String> = [preferences.color.id] for authorID in otherAuthorIDs.sorted() { - let name = resolveName(authorID: authorID, namesMap: fetched.namesMap) + let name = resolveName( + authorID: authorID, + namesMap: fetched.namesMap, + nicknames: fetched.nicknamesByAuthor + ) let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: taken) taken.insert(color.id) remoteEntries.append(Entry(authorID: authorID, name: name, color: color, isLocal: false)) @@ -456,7 +477,14 @@ final class PlayerRoster { return nil } - private func resolveName(authorID: String, namesMap: [String: String]) -> String { + private func resolveName( + authorID: String, + namesMap: [String: String], + nicknames: [String: String] + ) -> String { + // A nickname the user assigned via Rename wins outright — it's their + // private label for the peer and never follows the peer's renames. + if let nickname = nicknames[authorID] { return nickname } // The game-specific Player record is authoritative for display names. // CKShare metadata can arrive earlier, but it may expose an unrelated // contact/iCloud name and then visibly rename the row a moment later. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -393,6 +393,15 @@ final class AppServices { await badge.refreshAppBadge() await badge.logNotificationStartupSnapshot() + // Heal the App Group nickname directory from Core Data ground truth — + // covers the first run after the feature shipped and any rebuild a + // crash or extension write skipped. Cheap: one fetch over the (small) + // friends table. + let nicknameCtx = persistence.container.newBackgroundContext() + nicknameCtx.performAndWait { + FriendEntity.rebuildNicknameDirectory(in: nicknameCtx) + } + appDelegate.onRemoteNotification = { summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in await self.handleRemoteNotification( diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -562,7 +562,13 @@ final class InviteCoordinator { let content = UNMutableNotificationContent() content.title = "Crossmate" - content.body = Self.bodyText(for: ping) + // Local notifications never pass through the Notification Service + // Extension, so the nickname substitution the NSE does for worker + // pushes happens here instead, off the same App Group directory. + content.body = Self.bodyText( + for: ping, + nickname: NicknameDirectory.entry(for: ping.authorID)?.nickname + ) content.sound = .default content.userInfo = [ "gameID": ping.gameID.uuidString, @@ -615,11 +621,12 @@ final class InviteCoordinator { } } - nonisolated static func bodyText(for ping: Ping) -> String { + nonisolated static func bodyText(for ping: Ping, nickname: String? = nil) -> String { let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" switch ping.kind { case .invite: - let player = ping.playerName.isEmpty ? "A player" : ping.playerName + let player = nickname + ?? (ping.playerName.isEmpty ? "A player" : ping.playerName) return "\(player) invited you to \(puzzleSuffix)" case .friend, .join, .hail: // System-only kinds handled by the friendship-bootstrap / diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -351,6 +351,39 @@ final class FriendController { } } + // MARK: - Rename + + /// Sets (or, with an empty string, clears) the user's private nickname + /// for a friend. The nickname lives on the local `FriendEntity` and is + /// made authoritative across the user's own devices via a versioned + /// `nickname` Decision in the account zone — the same channel `block` + /// rides; it is never written into the friend zone, so the friend never + /// sees it. Each rename bumps the per-friend generation so the newest + /// rename wins any cross-device race (`applyDecisionRecord`). + func setNickname(friendAuthorID: String, nickname: String) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) + req.fetchLimit = 1 + guard let friend = try ctx.fetch(req).first else { + throw FriendError.friendNotFound + } + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let version = friend.nicknameVersion + 1 + friend.nickname = trimmed.isEmpty ? nil : trimmed + friend.nicknameVersion = version + try ctx.save() + FriendEntity.rebuildNicknameDirectory(in: ctx) + // An empty payload propagates the clear: the Decision record stays + // (preserving the version) but applies as "no nickname". + await syncEngine.enqueueDecision( + kind: RecordSerializer.nicknameDecisionKind, + key: friendAuthorID, + payload: trimmed.isEmpty ? nil : trimmed, + version: version + ) + } + // MARK: - CloudKit helpers private func deleteZone( diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -42,6 +42,10 @@ struct BatchEffects { /// the local rename counter so the next rename supersedes the highest /// generation any of this account's devices has published. var selfNameVersions: [Int64] = [] + /// A `name` or `nickname` Decision changed a friend row in this batch — + /// either side of an App Group nickname-directory entry, so the caller + /// rebuilds the directory after the batch saves. + var friendNamesChanged = false /// Diagnostics emitted while applying the batch inside `performAndWait` — /// chiefly Core Data fetch/save failures, which silently drop records (the /// engine's change token has already advanced, so they never redeliver). diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -126,6 +126,13 @@ enum RecordSerializer { return (key, name, decisionVersion(record)) } + /// Kind for the friend-nickname Decision: `decision-nickname-<authorID>`, + /// payload = the nickname this user privately calls that friend (absent or + /// empty = cleared, fall back to the friend's own name), `version` = this + /// user's monotonic rename generation for that friend. Lives only in the + /// account zone — it's the user's own label, never shared with the friend. + static let nicknameDecisionKind = "nickname" + static let accountDecisionKind = "account" static let accountPushAddressDecisionKey = "pushAddress" /// Key for the account-wide push *secret* decision. The secret is the HMAC @@ -981,6 +988,28 @@ enum RecordSerializer { friend.displayName = name friend.displayNameVersion = version return true + case nicknameDecisionKind: + // The user's private nickname for a friend, authoritative across + // their own devices. Honored only from the account zone: friend + // zones are writable by the other participant, who must not be + // able to relabel people in this user's friends list. + guard record.recordID.zoneID == accountZoneID else { return false } + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", key) + req.fetchLimit = 1 + // No resurrection: unlike a name Decision, an account-zone row + // carries no zone provenance to rebuild a usable friendship from, + // and a zoneless row would surface as an uninvitable friend. + guard let friend = try? ctx.fetch(req).first else { return false } + let version = decisionVersion(record) + guard version >= friend.nicknameVersion else { return false } + let nickname = (record["payload"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + // Empty/absent payload is a deliberate clear, not a malformed + // record — the rename alert's blank entry reverts to their name. + friend.nickname = (nickname?.isEmpty == false) ? nickname : nil + friend.nicknameVersion = version + return true default: // Unknown kind from a newer build — ignore rather than guess. return false diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -15,6 +15,9 @@ extension EnvironmentValues { /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, /// leave their shared games, tear down the friend zone. @Entry var blockFriend: ((String) async -> Void)? = nil + /// `(friendAuthorID, nickname)` — sets the user's private nickname for a + /// friend; an empty nickname clears it back to the friend's own name. + @Entry var renameFriend: ((String, String) async -> Void)? = nil /// `(gameID)` — fires the completion APN after a game is resigned from /// the library (the in-puzzle path uses `onResign` directly). @Entry var sendResignPings: ((UUID) async -> Void)? = nil @@ -1517,10 +1520,17 @@ actor SyncEngine { if wrote, let (dKind, dKey) = RecordSerializer.parseDecisionRecordName( record.recordID.recordName - ), - dKind == "left", - let gid = UUID(uuidString: dKey) { - effects.removed.insert(gid) + ) { + if dKind == "left", let gid = UUID(uuidString: dKey) { + effects.removed.insert(gid) + } + // A friend's own rename or this user's nickname landed + // — either side of an App Group nickname-directory + // entry, so it's rebuilt after the batch saves. + if dKind == RecordSerializer.nameDecisionKind + || dKind == RecordSerializer.nicknameDecisionKind { + effects.friendNamesChanged = true + } } case "Journal": // Journals are never applied to Core Data from the sync @@ -1620,6 +1630,12 @@ actor SyncEngine { let maxVersion = effects.selfNameVersions.max() { NameVersionStore.adopt(maxVersion, authorID: localAuthorID) } + if effects.friendNamesChanged { + let mirrorCtx = persistence.container.newBackgroundContext() + mirrorCtx.performAndWait { + FriendEntity.rebuildNicknameDirectory(in: mirrorCtx) + } + } for id in effects.removed { if let cb = onGameRemoved { await cb(id) } } diff --git a/Crossmate/Views/FriendsView.swift b/Crossmate/Views/FriendsView.swift @@ -2,10 +2,12 @@ import SwiftUI /// Sheet listing the user's crossmates (friends), presented from the game /// list. Friends are accumulated automatically the first time you collaborate -/// with someone (see `FriendController`); the only action in v1 is blocking, -/// which tears down the pairwise channel via `\.blockFriend`. +/// with someone (see `FriendController`); the actions are renaming (a private +/// nickname via `\.renameFriend`) and blocking, which tears down the pairwise +/// channel via `\.blockFriend`. struct FriendsView: View { @Environment(\.blockFriend) private var blockFriend + @Environment(\.renameFriend) private var renameFriend @Environment(\.dismiss) private var dismiss @FetchRequest( @@ -16,6 +18,8 @@ struct FriendsView: View { private var friends: FetchedResults<FriendEntity> @State private var blockTarget: FriendEntity? + @State private var renameTarget: FriendEntity? + @State private var renameText = "" var body: some View { NavigationStack { @@ -45,6 +49,21 @@ struct FriendsView: View { .accessibilityLabel("Cancel") } } + .alert("Rename This Player?", isPresented: .init( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } } + )) { + TextField("Name", text: $renameText) + Button("Rename") { + if let authorID = renameTarget?.authorID { + let nickname = renameText + Task { await renameFriend?(authorID, nickname) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Only you will see this name. Leave it blank to use the name they chose.") + } .alert("Block This Player?", isPresented: .init( get: { blockTarget != nil }, set: { if !$0 { blockTarget = nil } } @@ -70,6 +89,12 @@ struct FriendsView: View { Text(friend.resolvedDisplayName) Spacer() Menu { + Button { + renameText = friend.nickname ?? "" + renameTarget = friend + } label: { + Label("Rename", systemImage: "character.cursor.ibeam") + } Button(role: .destructive) { blockTarget = friend } label: { Label("Block", systemImage: "hand.raised") } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -193,8 +193,7 @@ struct GameListView: View { } Button("Cancel", role: .cancel) {} } message: { - let name = (blockTarget?.inviterName?.isEmpty == false) - ? blockTarget!.inviterName! : "this player" + let name = blockTarget?.resolvedInviterName ?? "this player" Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") } } @@ -447,7 +446,7 @@ struct GameListView: View { @ViewBuilder private func inviteCard(for invite: InviteEntity) -> some View { - let inviter = (invite.inviterName?.isEmpty == false) ? invite.inviterName! : "A player" + let inviter = invite.resolvedInviterName ?? "A player" let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { @@ -499,7 +498,7 @@ struct GameListView: View { @ViewBuilder private func inviteRow(for invite: InviteEntity) -> some View { - let inviter = (invite.inviterName?.isEmpty == false) ? invite.inviterName! : "A player" + let inviter = invite.resolvedInviterName ?? "A player" let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" HStack { VStack(alignment: .leading, spacing: 2) { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -44,6 +44,23 @@ final class NotificationService: UNNotificationServiceExtension { let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:)) let payload = PushPayload.decode(from: userInfo["payload"] as? String) + // 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. + if let fromAuthorID = userInfo["fromAuthorID"] as? String, + !bestAttemptContent.body.isEmpty, + let entry = NicknameDirectory.entry(for: fromAuthorID), + let displayName = entry.displayName { + bestAttemptContent.body = NicknameDirectory.rewritten( + bestAttemptContent.body, + replacing: displayName, + with: entry.nickname + ) + } + VisibleNotificationReceiptLog.record( body: bestAttemptContent.body, source: "notification-service-extension" diff --git a/Shared/NicknameDirectory.swift b/Shared/NicknameDirectory.swift @@ -0,0 +1,89 @@ +import Foundation + +/// App Group-shared directory of the user's private friend nicknames, keyed +/// by friend authorID and persisted as a JSON file in the group container. +/// +/// Notification text that names a friend is composed *sender-side* (the +/// sender's device writes "Alice solved …" with its own chosen name), so the +/// 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. +enum NicknameDirectory { + 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 + } + + /// Test-only override for the backing file. Mirrors + /// `NotificationState.testingDefaults`: a `TaskLocal` so per-test + /// temporary URLs flow through actor hops and stay isolated when suites + /// run in parallel; production never sets it. + @TaskLocal static var testingFileURL: URL? + + private static var fileURL: URL? { + if let testingFileURL { return testingFileURL } + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)? + .appendingPathComponent("nickname-directory.json") + } + + static func load() -> [String: Entry] { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let directory = try? JSONDecoder().decode([String: Entry].self, from: data) + else { return [:] } + return directory + } + + /// Replaces the file wholesale — the directory is small (one entry per + /// renamed friend) and always rebuilt from Core Data ground truth, so + /// there is no merge to do. An empty directory removes the file. + static func save(_ directory: [String: Entry]) { + guard let url = fileURL else { return } + guard !directory.isEmpty else { + try? FileManager.default.removeItem(at: url) + return + } + guard let data = try? JSONEncoder().encode(directory) else { return } + try? data.write(to: url, options: .atomic) + } + + static func entry(for authorID: String) -> Entry? { + 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/Tests/Unit/NicknameDirectoryTests.swift b/Tests/Unit/NicknameDirectoryTests.swift @@ -0,0 +1,232 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Nickname directory") +struct NicknameDirectoryTests { + + /// Runs `body` with the directory backed by a per-test temporary file so + /// parallel tests never touch the shared App Group container. + private func withTemporaryDirectoryFile( + _ body: @Sendable () async throws -> Void + ) async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("nickname-directory-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try await NicknameDirectory.$testingFileURL.withValue(url) { + try await body() + } + } + + // 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") + func fileRoundTrip() async throws { + try await withTemporaryDirectoryFile { + #expect(NicknameDirectory.load().isEmpty) + let directory = [ + "_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum"), + "_bob": NicknameDirectory.Entry(displayName: nil, nickname: "Bobby") + ] + NicknameDirectory.save(directory) + #expect(NicknameDirectory.load() == directory) + #expect(NicknameDirectory.entry(for: "_alice")?.nickname == "Mum") + #expect(NicknameDirectory.entry(for: "_carol") == nil) + #expect(NicknameDirectory.entry(for: "") == nil) + } + } + + @Test("saving an empty directory removes the file") + func emptySaveRemovesFile() async throws { + try await withTemporaryDirectoryFile { + NicknameDirectory.save( + ["_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum")] + ) + #expect(!NicknameDirectory.load().isEmpty) + NicknameDirectory.save([:]) + #expect(NicknameDirectory.load().isEmpty) + } + } + + // MARK: - Rebuild from Core Data + + @Test("rebuildNicknameDirectory mirrors nicknamed friends and skips the rest") + func rebuildFromCoreData() async throws { + try await withTemporaryDirectoryFile { + try await MainActor.run { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + func addFriend( + authorID: String, + displayName: String?, + nickname: String?, + isBlocked: Bool = false + ) { + let friend = FriendEntity(context: ctx) + friend.authorID = authorID + friend.pairKey = "pair-\(authorID)" + friend.friendZoneName = "friend-pair-\(authorID)" + friend.friendZoneOwnerName = "_owner" + friend.databaseScope = 0 + friend.createdAt = Date() + friend.displayName = displayName + friend.nickname = nickname + friend.isBlocked = isBlocked + } + addFriend(authorID: "_alice", displayName: "Alice", nickname: "Mum") + addFriend(authorID: "_bob", displayName: nil, nickname: "Bobby") + addFriend(authorID: "_carol", displayName: "Carol", nickname: nil) + addFriend(authorID: "_dave", displayName: "Dave", nickname: "X", isBlocked: true) + try ctx.save() + + FriendEntity.rebuildNicknameDirectory(in: ctx) + + let directory = NicknameDirectory.load() + #expect(directory == [ + "_alice": NicknameDirectory.Entry(displayName: "Alice", nickname: "Mum"), + "_bob": NicknameDirectory.Entry(displayName: nil, nickname: "Bobby") + ]) + + // Clearing the last nickname empties the directory again. + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + for friend in (try? ctx.fetch(req)) ?? [] { + friend.nickname = nil + } + try ctx.save() + FriendEntity.rebuildNicknameDirectory(in: ctx) + #expect(NicknameDirectory.load().isEmpty) + } + } + } + + // MARK: - Invite rows + + @Test("resolvedInviterName prefers the nickname, then the shipped name") + @MainActor func resolvedInviterName() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + func addInvite(inviterAuthorID: String, inviterName: String?) -> InviteEntity { + let invite = InviteEntity(context: ctx) + invite.gameID = UUID() + invite.gameTitle = "Saturday Puzzle" + invite.inviterAuthorID = inviterAuthorID + invite.inviterName = inviterName + invite.pingRecordName = "ping-\(UUID().uuidString)" + invite.shareURL = "https://example.com/share" + invite.createdAt = Date() + return invite + } + + let friend = FriendEntity(context: ctx) + friend.authorID = "_alice" + friend.pairKey = "pair-alice" + friend.friendZoneName = "friend-pair-alice" + friend.friendZoneOwnerName = "_owner" + friend.databaseScope = 0 + friend.createdAt = Date() + friend.nickname = "Mum" + + let nicknamed = addInvite(inviterAuthorID: "_alice", inviterName: "Alice") + let plain = addInvite(inviterAuthorID: "_bob", inviterName: "Bob") + let unnamed = addInvite(inviterAuthorID: "_carol", inviterName: "") + try ctx.save() + + #expect(nicknamed.resolvedInviterName == "Mum") + #expect(plain.resolvedInviterName == "Bob") + #expect(unnamed.resolvedInviterName == nil) + } + + // MARK: - Invite banner substitution + + @Test("Invite body prefers the nickname over the inviter's own name") + func inviteBodyNickname() { + let ping = Ping( + recordName: "ping-test-nickname", + gameID: UUID(), + authorID: "_alice", + deviceID: "device-a", + playerName: "Alice", + puzzleTitle: "Saturday Puzzle", + kind: .invite, + payload: nil, + addressee: "_bob" + ) + #expect(InviteCoordinator.bodyText(for: ping, nickname: "Mum") + == "Mum invited you to the puzzle 'Saturday Puzzle'") + #expect(InviteCoordinator.bodyText(for: ping) + == "Alice invited you to the puzzle 'Saturday Puzzle'") + } +} diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -843,6 +843,137 @@ struct RecordSerializerTests { #expect(blocked.isBlocked == true) } + // MARK: - Nickname decisions + + private func nicknameDecisionRecord( + subject: String, + nickname: String?, + version: Int64, + zone: CKRecordZone.ID = RecordSerializer.accountZoneID + ) -> CKRecord { + RecordSerializer.decisionRecord( + kind: RecordSerializer.nicknameDecisionKind, + key: subject, + payload: nickname, + zone: zone, + version: version + ) + } + + @MainActor + private func makeFriend( + in ctx: NSManagedObjectContext, + authorID: String, + pairedWith localAuthorID: String + ) throws -> FriendEntity { + let pairKey = FriendZone.pairKey(localAuthorID, authorID) + let friend = FriendEntity(context: ctx) + friend.authorID = authorID + friend.pairKey = pairKey + friend.friendZoneName = FriendZone.zoneName(pairKey: pairKey) + friend.friendZoneOwnerName = CKCurrentUserDefaultName + friend.databaseScope = 0 + friend.createdAt = Date() + try ctx.save() + return friend + } + + @Test("applyDecisionRecord(.nickname) sets the nickname on an existing friend") + @MainActor func applyNicknameDecisionUpdatesFriend() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + + let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(wrote) + #expect(friend.nickname == "Bobby") + #expect(friend.nicknameVersion == 1) + #expect(friend.resolvedDisplayName == "Bobby") + } + + @Test("applyDecisionRecord(.nickname) is last-writer-wins on version") + @MainActor func applyNicknameDecisionVersionGate() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + friend.nickname = "Bobby" + friend.nicknameVersion = 3 + try ctx.save() + + let stale = nicknameDecisionRecord(subject: "_bob", nickname: "Old", version: 2) + #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice")) + #expect(friend.nickname == "Bobby") + + let equal = nicknameDecisionRecord(subject: "_bob", nickname: "Rob", version: 3) + #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice")) + #expect(friend.nickname == "Rob") + + let newer = nicknameDecisionRecord(subject: "_bob", nickname: "Robert", version: 4) + #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice")) + #expect(friend.nickname == "Robert") + #expect(friend.nicknameVersion == 4) + } + + @Test("applyDecisionRecord(.nickname) clears the nickname on an empty payload") + @MainActor func applyNicknameDecisionClears() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + friend.displayName = "Brandon" + friend.displayNameVersion = 1 + friend.nickname = "Bobby" + friend.nicknameVersion = 1 + try ctx.save() + + let record = nicknameDecisionRecord(subject: "_bob", nickname: nil, version: 2) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(wrote) + #expect(friend.nickname == nil) + #expect(friend.nicknameVersion == 2) + // Cleared nickname falls back to the friend's own synced name. + #expect(friend.resolvedDisplayName == "Brandon") + } + + @Test("applyDecisionRecord(.nickname) rejects a record outside the account zone") + @MainActor func applyNicknameDecisionRejectsFriendZone() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + + // A "nickname" decision planted by the friend in the shared pairwise + // zone must not relabel anyone in this user's list. + let record = nicknameDecisionRecord( + subject: "_bob", + nickname: "Gotcha", + version: 9, + zone: friendZoneID(local: "_alice", remote: "_bob") + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(!wrote) + #expect(friend.nickname?.isEmpty != false) + } + + @Test("applyDecisionRecord(.nickname) writes no row for an unknown friend") + @MainActor func applyNicknameDecisionSkipsUnknownFriend() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(!wrote) + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + #expect(try ctx.count(for: req) == 0) + } + @Test("parseNameDecision reads subject, name, and version") func parseNameDecisionFields() { let record = nameDecisionRecord(