crossmate

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

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:
MCrossmate/Sync/ShareController.swift | 20++++++++++++++++++++
MCrossmate/Views/FriendPickerView.swift | 7++++++-
MCrossmate/Views/GameShareItem.swift | 25+++++++++++++++++++++----
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 ?? ""