crossmate

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

commit 79801038b2b43fd684747bf9d1818c34bad71ba3
parent 16080a6c31032063b0b7560773b9ac69cb2d42c4
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 06:37:36 +0900

Make the puzzle's share link and direct invites mutually exclusive

The Share Puzzle sheet offered a public share link and direct friend
invites side by side, but CloudKit refuses to mix them on one share:
once publicPermission is .readWrite, addParticipant throws an internal
consistency exception. That exception is an Objective-C NSException
Swift cannot catch, so creating a link and then tapping a friend crashed
the app rather than surfacing an error.

Now the two routes are mutually exclusive. isLinkMode keys off a live
shareURL and isDirectInviteMode off a friend already invited with no
link present; the link's presence wins, since any participants on a
linked share are public joiners rather than directly added friends.
Choosing one route removes the other from the sheet and leaves a line
explaining why — creating a link replaces the friends list with 'Direct
Invites Unavailable', and inviting a friend replaces the link controls
with 'Link Sharing Unavailable'. The prohibited share state is then
unreachable from the UI, so the backend invite path only ever adds a
participant to a share whose publicPermission is still .none.

Direct invites also stay disabled while isLoadingExistingLink is true,
closing the window on reopening a linked puzzle where a friend could be
tapped before the link check resolves.

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

Diffstat:
MCrossmate/Views/GameList/GameShareItem.swift | 39+++++++++++++++++++++++++++++++++++----
1 file changed, 35 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -41,6 +41,20 @@ struct GameShareSheet: View { Array(friends.prefix(4)) } + /// A live public link is the definitive signal the owner took the link + /// route, so it wins over any participants the share carries — those are + /// public joiners, not directly invited friends. CloudKit forbids mixing + /// public access with directly added participants on one share (adding a + /// participant to a `.readWrite` share throws), so the two routes are + /// presented as mutually exclusive: choosing one removes the other. + private var isLinkMode: Bool { shareURL != nil } + + /// The direct-invite route is active once a friend has been invited and no + /// public link exists. Gated on the absence of a link so a public joiner + /// accepting (which also lands a participant) never flips the sheet into + /// this mode while the link is still on offer. + private var isDirectInviteMode: Bool { shareURL == nil && !invitedAuthorIDs.isEmpty } + /// Width of the trailing scroll fade. The scroll content gets a matching /// trailing margin so the last item can scroll clear of the fade instead of /// stopping flush beneath it. @@ -82,7 +96,13 @@ struct GameShareSheet: View { .listRowBackground(Color.clear) Section { - if !isInviteLimitReached { + if isDirectInviteMode { + Label("Link Sharing Unavailable", systemImage: "link.badge.plus") + .foregroundStyle(.secondary) + Text("You've invited a friend directly. Everyone joins this puzzle the same way, so a share link isn't available.") + .font(.footnote) + .foregroundStyle(.secondary) + } else if !isInviteLimitReached { if let shareURL { Button { UIPasteboard.general.string = shareURL.absoluteString @@ -124,7 +144,18 @@ struct GameShareSheet: View { } Section { - if visibleFriends.isEmpty { + if isLinkMode { + VStack(spacing: 6) { + Label("Direct Invites Unavailable", systemImage: "person.crop.circle.badge.xmark") + .foregroundStyle(.secondary) + Text("You've created a share link. Anyone with the link can join, so direct invites aren't available for this puzzle.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) + .padding(.vertical, 4) + } else if visibleFriends.isEmpty { Text("No Prior Crossmates") .font(.body.weight(.medium)) .foregroundStyle(.secondary) @@ -154,7 +185,7 @@ struct GameShareSheet: View { .frame(width: 96, height: 88) } .buttonStyle(.plain) - .disabled(isInviteLimitReached) + .disabled(isInviteLimitReached || isLoadingExistingLink) } .frame(maxWidth: .infinity) .padding(.vertical, 2) @@ -255,7 +286,7 @@ struct GameShareSheet: View { .frame(width: 108, height: 88) } .buttonStyle(.plain) - .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || (isInviteLimitReached && !wasInvited)) + .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || isLoadingExistingLink || (isInviteLimitReached && !wasInvited)) } /// Maps the row's invite state to the avatar's animation phase. Sending