commit f0b816158352c3bf853c4630289719c8d220dbbe
parent a64d7c1356973fe4763e8138bad4ca87fe1b8b45
Author: Michael Camilleri <[email protected]>
Date: Thu, 9 Jul 2026 15:29:30 +0900
Show friends as soon as they accept
Fast invitation acceptance could publish presence and announce the join
without publishing the friend's game-specific name snapshot. Both the
puzzle roster and Game List then discarded the otherwise valid Player
row, leaving the friend absent until another path supplied a name.
This commit treats every Player author as a participant independently of
whether their name has arrived, allowing a private nickname or waiting
label to resolve immediately. Acceptance now publishes the profile-name
snapshot directly instead of relying on a later puzzle open. The Game
List section cache is also reverted so participant changes are derived
from current fetched data again.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
6 files changed, 69 insertions(+), 76 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -43,6 +43,7 @@ final class PlayerRoster {
var ckZoneName: String?
var ckZoneOwnerName: String?
var namesMap: [String: String] = [:]
+ var playerAuthorIDs: [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,
@@ -284,12 +285,14 @@ final class PlayerRoster {
nameReq.predicate = NSPredicate(format: "game == %@", entity)
let nameEntities = (try? ctx.fetch(nameReq)) ?? []
var namesMap: [String: String] = [:]
+ var playerAuthorIDs: [String] = []
var selections: [RawSelection] = []
var presenceUntilByAuthor: [String: Date] = [:]
var timeLogs: [TimeLog] = []
for nr in nameEntities {
timeLogs.append(TimeLog.decode(nr.timeLog))
guard let aid = nr.authorID, !aid.isEmpty else { continue }
+ playerAuthorIDs.append(aid)
if let name = nr.name, !name.isEmpty {
namesMap[aid] = name
}
@@ -335,6 +338,7 @@ final class PlayerRoster {
ckZoneName: entity.ckZoneName,
ckZoneOwnerName: entity.ckZoneOwnerName,
namesMap: namesMap,
+ playerAuthorIDs: playerAuthorIDs,
nicknamesByAuthor: nicknamesByAuthor,
moveAuthorIDs: authorIDs,
rawSelections: selections,
@@ -403,7 +407,7 @@ final class PlayerRoster {
nicknamesByAuthor: fetched.nicknamesByAuthor,
localAuthorID: localAuthorID,
localColor: preferences.color,
- additionalAuthorIDs: shareAuthorIDs
+ additionalAuthorIDs: fetched.playerAuthorIDs + shareAuthorIDs
).map { participant in
Entry(
authorID: participant.authorID,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -206,8 +206,10 @@ struct GameSummary: Identifiable, Equatable {
}
var namesByAuthor: [String: String] = [:]
+ var playerAuthorIDs: [String] = []
for player in playerEntities(for: entity) {
guard let authorID = player.authorID, !authorID.isEmpty else { continue }
+ playerAuthorIDs.append(authorID)
if let name = player.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
namesByAuthor[authorID] = name
@@ -230,7 +232,8 @@ struct GameSummary: Identifiable, Equatable {
localAuthorID: localAuthorID,
localName: localName,
localColor: localColor,
- scoreByAuthorID: scoreByAuthorID
+ scoreByAuthorID: scoreByAuthorID,
+ additionalAuthorIDs: playerAuthorIDs
)
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1188,6 +1188,11 @@ final class AppServices {
let ownAddress = self.identity.currentID.flatMap {
self.accountPush.setDerivedPushAddress(gameID: gameID, authorID: $0)
}
+ // Joining can complete without presenting PuzzleDisplayView (for
+ // example when the app backgrounds during acceptance). Publish the
+ // profile-name snapshot here as part of the join itself rather than
+ // relying on a later puzzle open to fill the Player record.
+ await self.playerNamePublisher?.publishName(for: gameID)
await self.accountPush.publishAccountJoinedPush(gameID: gameID)
// Tell everyone already in the room that we've joined.
await self.sessions.publishJoinPush(gameID: gameID, excludeAddress: ownAddress)
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -12,7 +12,6 @@ struct GameListView: View {
@Binding var pendingInviteNotificationGameID: UUID?
@Binding var navigationPath: NavigationPath
- @Environment(\.managedObjectContext) private var viewContext
@Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -62,7 +61,6 @@ struct GameListView: View {
@State private var showingNamePrompt = false
@State private var nameDraft = ""
@State private var summaryCache = GameSummaryCache()
- @State private var gameListSections = GameListSections()
@State private var completedVisibleCount = completedPageSize
/// Shows the "Never show me tips" opt-out in the banner slot for a few
/// seconds after a tip is dismissed; cleared by the timer or by tapping it.
@@ -73,22 +71,6 @@ struct GameListView: View {
private static let completedPageSize = 7
- private struct GameListSections {
- var invites: [InviteEntity] = []
- var inProgress: [GameSummary] = []
- var revoked: [GameSummary] = []
- var completed: [GameSummary] = []
- var completedTotalCount = 0
- var hasMoreCompleted = false
- }
-
- private struct SectionRefreshToken: Equatable {
- var pendingInviteCount: Int
- var blockedFriendCount: Int
- var name: String
- var colorID: String
- }
-
var body: some View {
VStack(spacing: 0) {
accessibilityHeading
@@ -111,7 +93,7 @@ struct GameListView: View {
.padding(.top, 8)
.transition(.opacity)
}
- content(sections: gameListSections, usesRoomierType: usesRoomierType)
+ content(usesRoomierType: usesRoomierType)
}
.background(Color(.systemGroupedBackground))
.animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal())
@@ -121,9 +103,6 @@ struct GameListView: View {
} action: { height in
containerHeight = height
}
- .onAppear {
- rebuildGameListSections()
- }
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -197,8 +176,7 @@ struct GameListView: View {
.onChange(of: pendingInviteNotificationGameID) { _, _ in
reconcilePendingInviteNotification()
}
- .onChange(of: sectionRefreshToken) { _, _ in
- rebuildGameListSections()
+ .onChange(of: pendingInvites.count) { _, _ in
reconcilePendingInviteNotification()
}
.onChange(of: isVoiceOverEnabled) { _, enabled in
@@ -206,12 +184,6 @@ struct GameListView: View {
focusPuzzleListHeading()
}
}
- .onReceive(NotificationCenter.default.publisher(
- for: .NSManagedObjectContextObjectsDidChange,
- object: viewContext
- )) { _ in
- rebuildGameListSections()
- }
.onDisappear {
onDisappear()
}
@@ -374,15 +346,6 @@ struct GameListView: View {
showingNewGame = true
}
- private var sectionRefreshToken: SectionRefreshToken {
- SectionRefreshToken(
- pendingInviteCount: pendingInvites.count,
- blockedFriendCount: blockedFriends.count,
- name: preferences.name,
- colorID: preferences.colorID
- )
- }
-
private func inviteNewGame(_ gameID: UUID, to target: FriendNewGameTarget) async {
guard let appActions else { return }
announcements.dismiss(id: Self.newGameInviteErrorID)
@@ -404,17 +367,8 @@ struct GameListView: View {
}
}
- private func rebuildGameListSections() {
- let next = makeGameListSections(completedLimit: completedVisibleCount)
- if next.completedTotalCount > gameListSections.completedTotalCount {
- completedVisibleCount += next.completedTotalCount - gameListSections.completedTotalCount
- gameListSections = makeGameListSections(completedLimit: completedVisibleCount)
- } else {
- gameListSections = next
- }
- }
-
- private func makeGameListSections(completedLimit: Int) -> GameListSections {
+ @ViewBuilder
+ private func content(usesRoomierType: Bool) -> some View {
let summaries = games.compactMap {
summaryCache.summary(
for: $0,
@@ -432,7 +386,9 @@ struct GameListView: View {
let completed = summaries
.filter { $0.completedAt != nil && !$0.isAccessRevoked }
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
- let visibleCompletedCount = min(completedLimit, completed.count)
+ let visibleCompletedCount = min(completedVisibleCount, completed.count)
+ let visibleCompleted = Array(completed.prefix(visibleCompletedCount))
+ let hasMore = visibleCompletedCount < completed.count
let blockedIDs = Set(blockedFriends.compactMap { $0.authorID })
let visibleInvites = pendingInvites.filter {
@@ -440,35 +396,23 @@ struct GameListView: View {
return !blockedIDs.contains(inviter)
}
- return GameListSections(
- invites: visibleInvites,
- inProgress: inProgress,
- revoked: revoked,
- completed: Array(completed.prefix(visibleCompletedCount)),
- completedTotalCount: completed.count,
- hasMoreCompleted: visibleCompletedCount < completed.count
- )
- }
-
- @ViewBuilder
- private func content(sections: GameListSections, usesRoomierType: Bool) -> some View {
Group {
if horizontalSizeClass == .regular {
gridLayout(
- invites: sections.invites,
- inProgress: sections.inProgress,
- revoked: sections.revoked,
- completed: sections.completed,
- hasMore: sections.hasMoreCompleted,
+ invites: visibleInvites,
+ inProgress: inProgress,
+ revoked: revoked,
+ completed: visibleCompleted,
+ hasMore: hasMore,
usesRoomierType: usesRoomierType
)
} else {
listLayout(
- invites: sections.invites,
- inProgress: sections.inProgress,
- revoked: sections.revoked,
- completed: sections.completed,
- hasMore: sections.hasMoreCompleted,
+ invites: visibleInvites,
+ inProgress: inProgress,
+ revoked: revoked,
+ completed: visibleCompleted,
+ hasMore: hasMore,
usesRoomierType: usesRoomierType
)
}
@@ -500,6 +444,11 @@ struct GameListView: View {
.background(Color(.systemGroupedBackground))
}
}
+ .onChange(of: completed.count) { oldCount, newCount in
+ if newCount > oldCount {
+ completedVisibleCount += newCount - oldCount
+ }
+ }
}
// MARK: - List layout (compact width / iPhone)
@@ -670,7 +619,6 @@ struct GameListView: View {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
completedVisibleCount += Self.completedPageSize
- rebuildGameListSections()
}
} label: {
Text("Load More")
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -167,6 +167,26 @@ struct GameSummaryThumbnailTests {
#expect(Set(remoteParticipants.map(\.color.id)).count == 2)
}
+ @Test("Shared summary includes a Player row before its name arrives")
+ func sharedSummaryIncludesUnnamedPlayer() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let entity = try makeGame(in: ctx)
+ entity.ckShareRecordName = "cloudkit.zoneshare"
+ addPlayer(authorID: "_Remote", name: "", to: entity, in: ctx)
+ try ctx.save()
+
+ let summary = try #require(GameSummary(
+ entity: entity,
+ localAuthorID: "_Local",
+ localName: "Local Player",
+ localColor: .blue
+ ))
+
+ #expect(summary.allParticipants.map(\.authorID) == ["_Local", "_Remote"])
+ #expect(summary.allParticipants.last?.name == "Waiting for player…")
+ }
+
@Test("Shared strip includes local player and orders by score")
func sharedStripIncludesLocalPlayerAndOrdersByScore() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -220,6 +220,19 @@ struct PlayerRosterTests {
#expect(remote?.name == "Waiting for player…")
}
+ @Test("Player row without name still identifies a remote participant")
+ func unnamedPlayerStillIdentifiesParticipant() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ addPlayerEntity(authorID: "_B", name: "", gameID: gameID, persistence: persistence)
+
+ let roster = makeRoster(gameID: gameID, persistence: persistence)
+ await roster.preload()
+
+ let remote = roster.entries.first { !$0.isLocal }
+ #expect(remote?.authorID == "_B")
+ #expect(remote?.name == "Waiting for player…")
+ }
+
@Test("Current user placeholder is not shown as a remote player")
func currentUserPlaceholderIsHidden() async throws {
let (persistence, gameID) = try makePersistenceWithGame()