commit dec707454fa1c8badb7e280d2781c475f383f838
parent 51dce3833634990a781dce26e0f00fbfacaf425a
Author: Michael Camilleri <[email protected]>
Date: Tue, 16 Jun 2026 20:43:11 +0900
Wait on the joining screen until a shared puzzle is ready
When a tapped share link was accepted but its puzzle had not finished
syncing, the joining screen was dismissed the moment zone discovery
returned, dropping the user back at the Game List with the game present
but unopened. Discovery downloads the Game record and its puzzleSource
asset inline, so the puzzle is usually ready at once — but a slow sync
can fall through instead of waiting.
This commit holds the joining screen through the sync. The accept flow
now polls the joined game's own zone until its puzzle is playable, up to
a 30-second timeout, and only then navigates — so the user waits on the
'Joining puzzle…' screen they were already shown rather than being
returned to the list. A join that does time out still surfaces in the
Game List once sync settles.
Because that screen is a full-screen overlay above the navigation stack,
with the back button covered, a waiting user had no way out. A Cancel
button now fades in after four seconds — late enough that a fast join
never flashes it — and tapping it cancels the in-flight accept and
dismisses the screen. The accept re-checks for cancellation before it
navigates, so a Cancel that lands just as the puzzle becomes ready does
not pull the user into the game anyway.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 71 insertions(+), 12 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -446,6 +446,9 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var navigationPath = NavigationPath()
@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>?
var body: some View {
NavigationStack(path: $navigationPath) {
@@ -469,10 +472,13 @@ struct RootView: View {
}
.environment(services.preferences)
.overlay {
- if let pendingJoin {
- JoiningPuzzleView(shape: pendingJoin.shape)
- .transition(.opacity)
- .zIndex(1)
+ if let join = pendingJoin {
+ JoiningPuzzleView(shape: join.shape, onCancel: {
+ joinTask?.cancel()
+ withAnimation { pendingJoin = nil }
+ })
+ .transition(.opacity)
+ .zIndex(1)
}
}
.task {
@@ -492,7 +498,7 @@ struct RootView: View {
ShareLinkBroker.shared.onOpenShareLink = { url in
guard let route = ShareLinkRoute(shortLink: url) else { return }
withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
- Task {
+ joinTask = Task {
do {
try await services.cloudService.acceptShare(url: route.iCloudShareURL)
} catch {
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -91,13 +91,15 @@ final class CloudService {
_ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
}
// Navigate once the game's puzzle has actually synced and is
- // playable. Discovery downloads the Game record (and its
- // `puzzleSource` asset) inline, so the common case is ready here;
- // `joinedSharedGameIDs()` excludes a bare placeholder, so a slow
- // join simply lands in the list rather than opening an empty grid.
- let joinedGameID = sharedGameID.flatMap {
- store.joinedSharedGameIDs().contains($0) ? $0 : nil
- }
+ // playable. The caller holds the join placeholder up for the whole
+ // of this call, so waiting here keeps the user on the joining screen
+ // through a slow sync rather than dropping them back at the Game
+ // List with an unopened game.
+ let joinedGameID = await waitForPlayablePuzzle(gameID: sharedGameID)
+ // The user tapped Cancel on the joining screen — don't pull them
+ // into the game. The joined zone still surfaces in the Game List on
+ // its own once sync settles.
+ guard !Task.isCancelled else { return }
if let joinedGameID {
try await shareController.confirmSeatAfterJoin(gameID: joinedGameID)
}
@@ -115,6 +117,38 @@ final class CloudService {
}
}
+ /// How long `acceptShare` holds the joining screen waiting for the puzzle to
+ /// become playable before returning the user to the Game List. Mirrors
+ /// `RootView`'s invite-ping join wait.
+ private static let joinSyncTimeout: TimeInterval = 30
+ private static let joinSyncPollInterval: Duration = .seconds(1)
+
+ /// Polls the just-joined game's own zone until its puzzle is playable, so
+ /// the joining screen holds through a slow sync rather than dropping the
+ /// user back at the Game List. Discovery downloads the Game record and its
+ /// `puzzleSource` asset inline, so this returns on the first check in the
+ /// common case. Returns nil on timeout, or when the join `Task` is
+ /// cancelled (the user tapped Cancel) — the caller then doesn't navigate.
+ private func waitForPlayablePuzzle(gameID: UUID?) async -> UUID? {
+ guard let gameID else { return nil }
+ if store.joinedSharedGameIDs().contains(gameID) { return gameID }
+ let deadline = Date().addingTimeInterval(Self.joinSyncTimeout)
+ while Date() < deadline {
+ do {
+ try await Task.sleep(for: Self.joinSyncPollInterval)
+ } catch {
+ return nil // cancelled
+ }
+ _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID)
+ if store.joinedSharedGameIDs().contains(gameID) { return gameID }
+ }
+ syncMonitor.note(
+ "acceptShare: puzzle not playable within \(Int(Self.joinSyncTimeout))s " +
+ "for \(gameID.uuidString)"
+ )
+ return nil
+ }
+
func resetAllData() async throws {
await syncEngine.resetSyncState()
diff --git a/Crossmate/Views/Puzzle/JoiningPuzzleView.swift b/Crossmate/Views/Puzzle/JoiningPuzzleView.swift
@@ -7,6 +7,10 @@ import SwiftUI
/// spinner underneath. With no silhouette it falls back to the spinner alone.
struct JoiningPuzzleView: View {
let shape: GridSilhouette.Grid?
+ /// Cancels the in-flight join and dismisses this screen. The view is a
+ /// full-screen overlay over the navigation stack, so without this the user
+ /// would have no way out while a slow (or stalled) join is in progress.
+ var onCancel: () -> Void
/// Seconds the view has been on screen. The join has no observable stages
/// to drive the message (only coarse start/complete signals exist), so the
@@ -14,6 +18,10 @@ struct JoiningPuzzleView: View {
/// still happening rather than stalled.
@State private var elapsed = 0
+ /// Seconds before the Cancel button appears, so a fast join — the common
+ /// case — completes without ever flashing it.
+ private static let cancelRevealDelay = 4
+
private var message: String {
switch elapsed {
case ..<5: return "Joining puzzle…"
@@ -49,7 +57,18 @@ struct JoiningPuzzleView: View {
.animation(.default, value: message)
}
}
+
+ if elapsed >= Self.cancelRevealDelay {
+ VStack {
+ Spacer()
+ Button("Cancel", role: .cancel, action: onCancel)
+ .buttonStyle(.bordered)
+ .padding(.bottom, 40)
+ }
+ .transition(.opacity)
+ }
}
+ .animation(.default, value: elapsed >= Self.cancelRevealDelay)
.accessibilityElement(children: .combine)
.accessibilityLabel("Joining puzzle")
.task {