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