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:
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])
+ }
+}