crossmate

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

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:
MCrossmate/Models/PlayerRoster.swift | 6+++++-
MCrossmate/Persistence/GameStore.swift | 5++++-
MCrossmate/Services/AppServices.swift | 5+++++
MCrossmate/Views/GameList/GameListView.swift | 96++++++++++++++++++-------------------------------------------------------------
MTests/Unit/GameSummaryThumbnailTests.swift | 20++++++++++++++++++++
MTests/Unit/PlayerRosterTests.swift | 13+++++++++++++
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()