crossmate

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

commit 23dcb635efd09a516f60aecebcb9856a42ec58f2
parent dd09dc9da82715d063ddcde1376df52a2b27d7b0
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 00:13:12 +0900

Show already-invited friends when re-opening the share screen

When the user re-opened a game's share screen, friends who had already
been invited showed no checkmark — the row looked un-invited. This made
it easy to re-tap the same friend and fire a redundant second invite,
sending them a duplicate 'Invited' ping, without any sign the first
invite had landed.

The cause was that both invite surfaces — the friend row in the Share
Puzzle sheet and the full Invite a Crossmate picker — seeded their
invited-author set only from invites made during that view's own
lifetime. Dismissing and re-presenting reset the set to empty, so a
friend already on the CKShare rendered as though never asked.

This commit adds ShareController.invitedAuthorIDs(for:), which unions
the share's current invitee participants with the author IDs added this
session — the latter covering the eventual-consistency window where a
freshly added participant has not yet appeared in a share fetch. Both
surfaces now seed their state from it on appear, so a re-opened screen
reflects the true invitee list and the checkmark persists across
dismissals.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Sync/ShareController.swift | 23+++++++++++++++++++++++
MCrossmate/Views/Friends/FriendPickerView.swift | 12+++++++++++-
MCrossmate/Views/GameList/GameShareItem.swift | 11+++++++++--
3 files changed, 43 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -365,6 +365,29 @@ final class ShareController { return Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle } + /// The author IDs already holding an invitee seat on the game's share. + /// Seeds the invite UI so a re-opened share sheet shows everyone you've + /// already added with a checkmark instead of an un-invited glyph, which + /// otherwise tempts a redundant second invite. Unions the share's current + /// invitee participants with the author IDs added this session, since an + /// eventually-consistent share fetch can omit a participant added moments + /// earlier. Best-effort: an unshared game or a transient fetch failure + /// falls back to the session set alone. + func invitedAuthorIDs(for gameID: UUID) async -> Set<String> { + var invited = sessionInvitedAuthorIDs[gameID] ?? [] + let ctx = persistence.viewContext + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try? ctx.fetch(request).first, + entity.databaseScope == 0 else { return invited } + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + if let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil { + invited.formUnion(Self.inviteeAuthorIDs(in: share)) + } + return invited + } + /// The game's grid silhouette for share-link previews, read from the /// cached block layout so it costs nothing at link-creation time. Returns /// `nil` when the cache hasn't been populated, in which case the link simply diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -6,6 +6,7 @@ import SwiftUI /// `FriendController`). struct FriendPickerView: View { let gameID: UUID + let shareController: ShareController @Environment(\.inviteFriend) private var inviteFriend @Environment(\.dismiss) private var dismiss @@ -22,8 +23,9 @@ struct FriendPickerView: View { @State private var isInviteLimitReached: Bool @State private var errorMessage: String? - init(gameID: UUID, isInviteLimitReached: Bool = false) { + init(gameID: UUID, shareController: ShareController, isInviteLimitReached: Bool = false) { self.gameID = gameID + self.shareController = shareController _isInviteLimitReached = State(initialValue: isInviteLimitReached) } @@ -59,6 +61,14 @@ struct FriendPickerView: View { } .navigationTitle("Invite a Crossmate") .navigationBarTitleDisplayMode(.inline) + .task { + // Reflect friends already on the share so re-opening the picker + // shows their checkmark instead of an un-invited glyph. + let invited = await shareController.invitedAuthorIDs(for: gameID) + if !invited.isEmpty { + withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) } + } + } } @ViewBuilder diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -127,6 +127,7 @@ struct GameShareSheet: View { NavigationLink { FriendPickerView( gameID: gameID, + shareController: shareController, isInviteLimitReached: isInviteLimitReached ) } label: { @@ -195,13 +196,19 @@ struct GameShareSheet: View { } } .task { - // Both loads hit CloudKit; run them together so a full game - // shows its disabled invite buttons as fast as the link check. + // All three loads hit CloudKit; run them together so a full + // game shows its disabled invite buttons — and already-invited + // friends show their checkmark — as fast as the link check. async let seatTaken = shareController.isAtInviteCapacity(for: gameID) + async let alreadyInvited = shareController.invitedAuthorIDs(for: gameID) await loadExistingLink() if await seatTaken { isInviteLimitReached = true } + let invited = await alreadyInvited + if !invited.isEmpty { + withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) } + } } } }