crossmate

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

commit 8d36bbed1260f5dc2b8d115ae53e366852ca890a
parent e21888bfc42715d549a51c0e8f5e90304b7b8159
Author: Michael Camilleri <[email protected]>
Date:   Wed, 29 Jul 2026 23:13:51 +0900

Prioritise recently used friends in the Share Menu

The Share Menu previously ordered its compact friend grid by when each
friendship was created, so repeated invites never brought a frequent
collaborator forward. This commit records successfully sent or durably
queued direct invites in a device-local MRU store and uses that order
for the compact grid.

The full friend picker now sorts by each friend's resolved nickname or
name, keeping 'See All Crossmates' as a predictable alphabetical
directory.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
ACrossmate/Models/FriendInviteRecencyStore.swift | 37+++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppActions.swift | 17++++++++++++++++-
MCrossmate/Services/AppServices.swift | 3+++
MCrossmate/Views/Friends/FriendPickerView.swift | 15++++++++++++++-
MCrossmate/Views/GameList/GameShareItem.swift | 19++++++++++++++++++-
ATests/Unit/FriendInviteRecencyStoreTests.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 151 insertions(+), 3 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; 609364CB79E0C7517298B404 /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; + 61A0F71985C898D046556A1D /* FriendInviteRecencyStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51583D7304A586BF38F0D8E3 /* FriendInviteRecencyStoreTests.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 648EA338E7718079EA354D7D /* EngagementMessageAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */; }; 659F7E48357E164144A19789 /* CloudContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E68FD021EF8ED50BC706EE /* CloudContainer.swift */; }; @@ -222,6 +223,7 @@ D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */; }; D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */; }; + D3E5B61C695CE3DDA7016E47 /* FriendInviteRecencyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D705650CD98E8BB20AC4AD /* FriendInviteRecencyStore.swift */; }; D4EDC0D426688B295DA77C08 /* ShareLinkRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */; }; D5022BFB2F8F2E5904EDF5C8 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F34401948BC53DA9C93D64B /* GameCardView.swift */; }; D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; }; @@ -375,6 +377,7 @@ 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControls.swift; sourceTree = "<group>"; }; 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 51583D7304A586BF38F0D8E3 /* FriendInviteRecencyStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendInviteRecencyStoreTests.swift; sourceTree = "<group>"; }; 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortener.swift; sourceTree = "<group>"; }; 54A448620E71515AAA7BA3DD /* PersistenceControllerHealTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerHealTests.swift; sourceTree = "<group>"; }; @@ -512,6 +515,7 @@ D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouette.swift; sourceTree = "<group>"; }; D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedgerTests.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; + D4D705650CD98E8BB20AC4AD /* FriendInviteRecencyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendInviteRecencyStore.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleFileReaderTests.swift; sourceTree = "<group>"; }; DAD36DEDB3D997AB47146EA9 /* AppActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActions.swift; sourceTree = "<group>"; }; @@ -640,6 +644,7 @@ 5E6BB07A54A3C3AF6F210F49 /* CloudJoinFailureTests.swift */, 382F9AAB0B98B4A78E255F15 /* DecorationRenderingTests.swift */, 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */, + 51583D7304A586BF38F0D8E3 /* FriendInviteRecencyStoreTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */, @@ -715,6 +720,7 @@ 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */, B09D52DB46731E92C3E9297C /* EngagementStore.swift */, 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */, + D4D705650CD98E8BB20AC4AD /* FriendInviteRecencyStore.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */, @@ -1115,6 +1121,7 @@ 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, 931431F8052FC58768C9BC26 /* FriendControllerNicknameReplayTests.swift in Sources */, + 61A0F71985C898D046556A1D /* FriendInviteRecencyStoreTests.swift in Sources */, 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */, 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */, 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */, @@ -1244,6 +1251,7 @@ C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, 7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */, 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */, + D3E5B61C695CE3DDA7016E47 /* FriendInviteRecencyStore.swift in Sources */, 884BC090D4E2D416AA52D6FD /* FriendPickerView.swift in Sources */, 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */, B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */, diff --git a/Crossmate/Models/FriendInviteRecencyStore.swift b/Crossmate/Models/FriendInviteRecencyStore.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Device-local most-recently-used ordering for friends selected for a direct +/// puzzle invite. This is a presentation preference, so it deliberately stays +/// out of the synced `FriendEntity` model. +@MainActor +final class FriendInviteRecencyStore { + private let defaults: UserDefaults + private let defaultsKey = "recentlyInvitedFriendAuthorIDs" + private let maximumCount = 200 + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// A friend selected most recently has rank zero. + var ranksByAuthorID: [String: Int] { + var ranks: [String: Int] = [:] + for authorID in orderedAuthorIDs where ranks[authorID] == nil { + ranks[authorID] = ranks.count + } + return ranks + } + + /// Moves a successfully queued direct invite to the front of the ordering. + func recordInvite(to authorID: String) { + guard !authorID.isEmpty else { return } + var authorIDs = orderedAuthorIDs + authorIDs.removeAll { $0 == authorID } + authorIDs.insert(authorID, at: 0) + defaults.set(Array(authorIDs.prefix(maximumCount)), forKey: defaultsKey) + } + + private var orderedAuthorIDs: [String] { + defaults.stringArray(forKey: defaultsKey) ?? [] + } +} diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift @@ -25,7 +25,22 @@ final class AppActions { } func inviteFriend(gameID: UUID, friendAuthorID: String) async throws { - try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID) + do { + try await services.invites.inviteFriend( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + services.friendInviteRecency.recordInvite(to: friendAuthorID) + } catch let error as SyncEngine.PingOutboxError { + if error == .deliveryPending { + services.friendInviteRecency.recordInvite(to: friendAuthorID) + } + throw error + } + } + + var recentFriendInviteRanks: [String: Int] { + services.friendInviteRecency.ranksByAuthorID } func inviteDelivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -313,6 +313,8 @@ final class AppServices { let friendController: FriendController let gameArchiver: GameArchiver let cursorStore: GameCursorStore + /// Device-local most-recently-used ordering for direct friend invites. + let friendInviteRecency: FriendInviteRecencyStore /// Device-local record of when each game was last viewed; drives the /// "changed while you were away" cell borders. Never synced. let gameViewedStore: GameViewedStore @@ -567,6 +569,7 @@ final class AppServices { let cursorStore = GameCursorStore() self.cursorStore = cursorStore + self.friendInviteRecency = FriendInviteRecencyStore() let gameViewedStore = GameViewedStore() self.gameViewedStore = gameViewedStore self.tips = TipStore() diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -36,6 +36,19 @@ struct FriendPickerView: View { _isInviteLimitReached = State(initialValue: isInviteLimitReached) } + private var alphabetizedFriends: [FriendEntity] { + friends + .map { (friend: $0, name: $0.resolvedDisplayName) } + .sorted { lhs, rhs in + let comparison = lhs.name.localizedStandardCompare(rhs.name) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return (lhs.friend.authorID ?? "") < (rhs.friend.authorID ?? "") + } + .map(\.friend) + } + var body: some View { List { if let failedInviteAuthorID, let inviteFailure { @@ -57,7 +70,7 @@ struct FriendPickerView: View { .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) } else { - ForEach(friends, id: \.authorID) { friend in + ForEach(alphabetizedFriends, id: \.authorID) { friend in friendRow(for: friend) } } diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -53,7 +53,24 @@ struct GameShareSheet: View { } private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> { - Array(friends.prefix(Self.visibleFriendLimit)) + let recentRanks = appActions?.recentFriendInviteRanks ?? [:] + return Array( + friends.sorted { lhs, rhs in + let lhsRank = lhs.authorID.flatMap { recentRanks[$0] } + let rhsRank = rhs.authorID.flatMap { recentRanks[$0] } + switch (lhsRank, rhsRank) { + case let (lhsRank?, rhsRank?): + return lhsRank < rhsRank + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return (lhs.createdAt ?? .distantPast) > (rhs.createdAt ?? .distantPast) + } + } + .prefix(Self.visibleFriendLimit) + ) } private var hasMoreFriends: Bool { friends.count > Self.visibleFriendLimit } diff --git a/Tests/Unit/FriendInviteRecencyStoreTests.swift b/Tests/Unit/FriendInviteRecencyStoreTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import Crossmate + +@MainActor +@Suite("Friend invite recency") +struct FriendInviteRecencyStoreTests { + private func makeStore() throws -> FriendInviteRecencyStore { + let suiteName = "friend-invite-recency-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return FriendInviteRecencyStore(defaults: defaults) + } + + @Test("Invites are ranked from most to least recent") + func ranksInvitesByRecentUse() throws { + let store = try makeStore() + + store.recordInvite(to: "_alice") + store.recordInvite(to: "_bob") + store.recordInvite(to: "_carol") + + #expect(store.ranksByAuthorID == [ + "_carol": 0, + "_bob": 1, + "_alice": 2, + ]) + } + + @Test("Inviting the same friend again moves them to the front") + func reinviteMovesFriendToFront() throws { + let store = try makeStore() + store.recordInvite(to: "_alice") + store.recordInvite(to: "_bob") + + store.recordInvite(to: "_alice") + + #expect(store.ranksByAuthorID == [ + "_alice": 0, + "_bob": 1, + ]) + } + + @Test("Invite ordering survives a new store instance") + func persistsInviteOrdering() throws { + let suiteName = "friend-invite-recency-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + FriendInviteRecencyStore(defaults: defaults).recordInvite(to: "_alice") + + let reloaded = FriendInviteRecencyStore(defaults: defaults) + + #expect(reloaded.ranksByAuthorID == ["_alice": 0]) + } +}