commit bb617626d8923ec896dcc0d9acac0d0c30b9f1d1
parent 8c8eb3161eb6e539c71651ac7e216ecdb89d77f6
Author: Michael Camilleri <[email protected]>
Date: Tue, 16 Jun 2026 22:59:30 +0900
Show the joining screen from Game List accepts
Accepting a ping-based invite from the Game List left the user on the
list with only the row spinner while CloudKit fetched metadata, accepted
the share, discovered the zone and waited for the puzzle to become
playable.
This commit routes Game List accepts through RootView's existing joining
placeholder. The invite row passes the share URL, Ping record name and
decoded grid silhouette upward, letting RootView show the grey puzzle
shape immediately while the normal invite acceptance continues in the
background. Completion still follows the shared acceptance notification,
so navigation and cancellation stay aligned with the link-tap flow.
The invite coordinator now returns the existing CloudService acceptance
outcome so this path can reuse the same 'Almost There' reassurance when
CloudKit has accepted the share but the puzzle has not finished
synchronising yet.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 54 insertions(+), 9 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -53,7 +53,10 @@ struct CrossmateApp: App {
try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
})
.environment(\.acceptInvite, { shareURL, pingRecordName in
- try await services.invites.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
+ _ = try await services.invites.acceptInvite(
+ shareURL: shareURL,
+ pingRecordName: pingRecordName
+ )
})
.environment(\.declineInvite, { gameID in
try await services.invites.declineInvite(gameID: gameID)
@@ -448,7 +451,7 @@ struct RootView: View {
@State private var pendingJoin: PendingJoinPlaceholder?
/// The in-flight share-accept driven by a tapped link, retained so the
/// joining screen's Cancel can stop it (its poll unwinds on cancellation).
- @State private var joinTask: Task<Void, Never>?
+ @State private var joinTask: Task<Void, Error>?
var body: some View {
NavigationStack(path: $navigationPath) {
@@ -458,6 +461,13 @@ struct RootView: View {
onRefresh: { await services.refreshLibrary() },
onAppear: { await services.gameListAppeared() },
onDisappear: { services.gameListDisappeared() },
+ onAcceptInvite: { shareURL, pingRecordName, shape in
+ try await acceptInviteFromGameList(
+ shareURL: shareURL,
+ pingRecordName: pingRecordName,
+ shape: shape
+ )
+ },
navigationPath: $navigationPath
)
.navigationDestination(for: UUID.self) { gameID in
@@ -498,7 +508,7 @@ struct RootView: View {
ShareLinkBroker.shared.onOpenShareLink = { url in
guard let route = ShareLinkRoute(shortLink: url) else { return }
withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
- joinTask = Task {
+ joinTask = Task<Void, Error> {
do {
let outcome = try await services.cloudService.acceptShare(url: route.iCloudShareURL)
// The share was accepted but its puzzle hasn't synced in
@@ -509,7 +519,6 @@ struct RootView: View {
services.announcements.post(.puzzleStillSyncing())
}
} catch {
- withAnimation { pendingJoin = nil }
// A Cancel tap returns without throwing, so reaching
// here is a genuine failure to join. The common one is a
// dead link — the inviter deleted or left the game, so
@@ -517,6 +526,7 @@ struct RootView: View {
// `.unknownItem`/`.zoneNotFound`. Surface it on the Game
// List rather than bouncing the user back in silence.
guard !Task.isCancelled else { return }
+ withAnimation { pendingJoin = nil }
let code = (error as? CKError)?.code
let gone = code == .unknownItem || code == .zoneNotFound
services.eventLog.note(
@@ -573,6 +583,33 @@ struct RootView: View {
}
}
}
+
+ private func acceptInviteFromGameList(
+ shareURL: String,
+ pingRecordName: String,
+ shape: GridSilhouette.Grid?
+ ) async throws {
+ joinTask?.cancel()
+ withAnimation { pendingJoin = PendingJoinPlaceholder(shape: shape) }
+ let task = Task<Void, Error> {
+ let outcome = try await services.invites.acceptInvite(
+ shareURL: shareURL,
+ pingRecordName: pingRecordName
+ )
+ guard !Task.isCancelled else { return }
+ if case .pendingSync = outcome {
+ services.announcements.post(.puzzleStillSyncing())
+ }
+ }
+ joinTask = task
+ do {
+ try await task.value
+ } catch {
+ if task.isCancelled { return }
+ withAnimation { pendingJoin = nil }
+ throw error
+ }
+ }
}
private extension UIApplication {
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -275,12 +275,14 @@ final class InviteCoordinator {
/// (the game now represents it). If CloudKit says the share URL no longer
/// exists, the durable invite row is stale, so it is removed as well.
/// Surfaced via `\.acceptInvite`.
- func acceptInvite(shareURL: String, pingRecordName: String) async throws {
+ @discardableResult
+ func acceptInvite(shareURL: String, pingRecordName: String) async throws -> CloudService.AcceptOutcome {
guard let url = URL(string: shareURL) else {
throw FriendController.FriendError.missingShareURLInPayload
}
+ let outcome: CloudService.AcceptOutcome
do {
- try await cloudService.acceptShare(url: url)
+ outcome = try await cloudService.acceptShare(url: url)
} catch let error as CKError where error.code == .unknownItem {
// Stale share: the row needs to go away too, but the next
// `applyInvitePings` will GC it if this cleanup itself fails.
@@ -295,6 +297,7 @@ final class InviteCoordinator {
throw InviteAcceptanceError.unavailable
}
try await deleteInviteAndPing(pingRecordName: pingRecordName)
+ return outcome
}
/// Accepts the pending invite for `gameID`, if one is still recorded
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -7,6 +7,7 @@ struct GameListView: View {
let onRefresh: () async -> Void
let onAppear: () async -> Void
let onDisappear: () -> Void
+ let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)?
@Binding var navigationPath: NavigationPath
@Environment(\.managedObjectContext) private var viewContext
@@ -578,15 +579,19 @@ struct GameListView: View {
}
private func accept(_ invite: InviteEntity) async {
- guard let acceptInvite,
- let url = invite.shareURL,
+ guard let url = invite.shareURL,
let ping = invite.pingRecordName
else { return }
+ let shape = invite.gridSilhouette.flatMap(GridSilhouette.decode)
acceptingInviteID = invite.objectID
announcements.dismiss(id: Self.inviteErrorID)
defer { acceptingInviteID = nil }
do {
- try await acceptInvite(url, ping)
+ if let onAcceptInvite {
+ try await onAcceptInvite(url, ping, shape)
+ } else if let acceptInvite {
+ try await acceptInvite(url, ping)
+ }
} catch {
announcements.post(Announcement(
id: Self.inviteErrorID,