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:
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