commit 8a546ce89521d917cd4abb2ab4701dbfd891cb5a
parent e25402049e53ea079fa26499d65496239a7d7f04
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 22:48:24 +0900
Seed the share sheet's invite limit from the share
This commit makes the share sheet learn that a game is full when it
opens instead of when an invite fails. The invite-limit flag was
session-local state, so a puzzle whose seat was taken in an earlier
session (or from another device) opened with enabled friend buttons
that only greyed out after a tap bounced off the server's capacity
check.
Alongside the existing-link load, the sheet now asks the share's
participant list whether the invitee seat is taken — a pending invite
counts, since the seat is committed once offered — and seeds its
invite-limit state from the answer. A full game opens with the friend
buttons disabled and the header copy explaining the player limit. The
seed also flows into the friend picker, and the tap-time server check
in addFriendParticipant remains the authoritative gate.
The check is best-effort: an unshared game or a transient CloudKit
failure reports "not full" and the sheet behaves as before. The Share
menu items themselves stay enabled because no local signal can
distinguish a committed-but-pending invite from a seat freed by a
departed crossmate without fetching the share.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 47 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -291,6 +291,26 @@ final class ShareController {
}
}
+ /// Whether the puzzle's invitee seat is already taken, per the share's
+ /// actual participant list (a pending invite counts — the seat is
+ /// committed once offered). Seeds the share sheet so a full game opens
+ /// with invites already disabled instead of surfacing the limit as a
+ /// tap-time error. Best-effort: an unshared game or a transient fetch
+ /// failure reports `false`, and the capacity check in
+ /// `addFriendParticipant` remains the authoritative gate.
+ func isAtInviteCapacity(for gameID: UUID) async -> Bool {
+ 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 false }
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ guard let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil
+ else { return false }
+ return Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle
+ }
+
private func prepareShareRecord(
for gameID: UUID,
publicPermission: CKShare.ParticipantPermission,
diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift
@@ -19,9 +19,14 @@ struct FriendPickerView: View {
@State private var invitingAuthorID: String?
@State private var invitedAuthorIDs: Set<String> = []
- @State private var isInviteLimitReached = false
+ @State private var isInviteLimitReached: Bool
@State private var errorMessage: String?
+ init(gameID: UUID, isInviteLimitReached: Bool = false) {
+ self.gameID = gameID
+ _isInviteLimitReached = State(initialValue: isInviteLimitReached)
+ }
+
var body: some View {
List {
Section {
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -46,9 +46,7 @@ struct GameShareSheet: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
- Text(ShareController.isPublicLinkSharingEnabled
- ? "Any iCloud user with the link will be able to join your game and collaborate."
- : "Invite one Crossmate to join your game and collaborate.")
+ Text(headerSubtitle)
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
@@ -121,7 +119,10 @@ struct GameShareSheet: View {
}
NavigationLink {
- FriendPickerView(gameID: gameID)
+ FriendPickerView(
+ gameID: gameID,
+ isInviteLimitReached: isInviteLimitReached
+ )
} label: {
VStack(spacing: 6) {
Image(systemName: "ellipsis")
@@ -170,11 +171,27 @@ 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.
+ async let seatTaken = shareController.isAtInviteCapacity(for: gameID)
await loadExistingLink()
+ if await seatTaken {
+ isInviteLimitReached = true
+ }
}
}
}
+ private var headerSubtitle: String {
+ if ShareController.isPublicLinkSharingEnabled {
+ "Any iCloud user with the link will be able to join your game and collaborate."
+ } else if isInviteLimitReached {
+ "This game already has its Crossmate. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
+ } else {
+ "Invite one Crossmate to join your game and collaborate."
+ }
+ }
+
@ViewBuilder
private func friendInviteButton(for friend: FriendEntity) -> some View {
let authorID = friend.authorID ?? ""