crossmate

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

commit a700d308b8af52b5c77c64c2866076214d40144b
parent 691272b99f517663126263bd68e4c0fe8c71bfb7
Author: Michael Camilleri <[email protected]>
Date:   Mon, 29 Jun 2026 10:20:54 +0900

Open invite notifications to the Game List

Invite notifications should let the user review the pending invitation
before deciding whether to join. The previous tap path opened the puzzle
destination, treated the missing local game as a join in progress, and
accepted the pending CKShare from inside PuzzleDisplayView.

This commit routes invite notification taps back to the Game List, where
the existing 'Invited' row remains the place that accepts or declines the
share. Other game notifications still open their puzzle directly, and the
notification broker keeps buffering both game and list opens across cold
launch.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 97+++++++++++++++++++++----------------------------------------------------------
MCrossmate/Services/InviteCoordinator.swift | 24------------------------
MTests/Unit/NotificationNavigationBrokerTests.swift | 66++++++++++++++++++++++++++++++++++++++++++------------------------
3 files changed, 68 insertions(+), 119 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -204,14 +204,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU return } - let fromInvitePing = - (userInfo["pingKind"] as? String) == PingKind.invite.rawValue - Task { @MainActor in - NotificationNavigationBroker.shared.openGame( - gameID, - fromInvitePing: fromInvitePing - ) + if (userInfo["pingKind"] as? String) == PingKind.invite.rawValue { + NotificationNavigationBroker.shared.openGameList() + } else { + NotificationNavigationBroker.shared.openGame(gameID) + } completionHandler() } } @@ -307,19 +305,16 @@ final class NotificationNavigationBroker { var onOpenGame: ((UUID) -> Void)? { didSet { flushPendingGameIDs() } } + var onOpenGameList: (() -> Void)? { + didSet { flushPendingGameListOpen() } + } private var pendingGameIDs: [UUID] = [] - /// Game IDs whose navigation was triggered by tapping an `.invite` ping - /// notification. The CKShare that materialises such a game is accepted on - /// a separate path and can land seconds after the tap, so the destination - /// view consumes this flag to know it should wait rather than hard-error - /// when the game isn't in the local store yet. - private var inviteOriginGameIDs: Set<UUID> = [] + private var pendingGameListOpen = false private init() {} - func openGame(_ gameID: UUID, fromInvitePing: Bool = false) { - if fromInvitePing { inviteOriginGameIDs.insert(gameID) } + func openGame(_ gameID: UUID) { guard let onOpenGame else { pendingGameIDs.append(gameID) return @@ -327,11 +322,12 @@ final class NotificationNavigationBroker { onOpenGame(gameID) } - /// Returns `true` exactly once if `gameID` was opened by tapping an - /// `.invite` ping notification, clearing the flag so a later re-open of - /// the same game (e.g. from the library list) behaves normally. - func consumeInviteOrigin(_ gameID: UUID) -> Bool { - inviteOriginGameIDs.remove(gameID) != nil + func openGameList() { + guard let onOpenGameList else { + pendingGameListOpen = true + return + } + onOpenGameList() } private func flushPendingGameIDs() { @@ -342,6 +338,12 @@ final class NotificationNavigationBroker { onOpenGame(gameID) } } + + private func flushPendingGameListOpen() { + guard let onOpenGameList, pendingGameListOpen else { return } + pendingGameListOpen = false + onOpenGameList() + } } @MainActor @@ -515,6 +517,10 @@ struct RootView: View { navigationPath = NavigationPath() navigationPath.append(gameID) } + NotificationNavigationBroker.shared.onOpenGameList = { + UIApplication.shared.dismissPresentedViewControllers() + navigationPath = NavigationPath() + } // A tapped Crossmate share link (universal link), routed here by the // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity` // never fires once a custom scene delegate is installed. Show the @@ -644,14 +650,6 @@ private extension UIApplication { /// Loads a game when navigated to. private struct PuzzleDisplayView: View { - /// When opened from an `.invite` ping notification, the game's local - /// `GameEntity` does not exist yet: the join path accepts the pending - /// CKShare and fetches its zone. Keep showing the join spinner and retry - /// the local load this long — measured from when the accept completes — - /// before giving up and surfacing the underlying error. - private static let inviteJoinTimeout: TimeInterval = 30 - private static let inviteJoinPollInterval: Duration = .seconds(1) - private var syncedID: UUID? { preferences.isICloudSyncEnabled && session != nil ? gameID : nil } @@ -809,16 +807,6 @@ private struct PuzzleDisplayView: View { noteSessionPhase(scenePhase) Task { await services.badge.dismissDeliveredNotifications(for: gameID) } - // Tapping an `.invite` ping notification navigates here at once, - // before this game's `GameEntity` exists locally. Only for that - // explicitly-flagged case do we treat a missing game as "still - // joining": accept the pending CKShare (see below) and wait for - // the shared zone to land, instead of hard-erroring. - let fromInvitePing = - NotificationNavigationBroker.shared.consumeInviteOrigin(gameID) - var joinDeadline = Date().addingTimeInterval(Self.inviteJoinTimeout) - var didAcceptInvite = false - while !Task.isCancelled { do { if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) { @@ -853,39 +841,6 @@ private struct PuzzleDisplayView: View { ) } break - } catch GameStore.LoadError.gameNotFound - where fromInvitePing - && preferences.isICloudSyncEnabled - && Date() < joinDeadline { - loadingMessage = "Joining shared puzzle..." - - // Tapping the `.invite` notification only navigated here; - // it did not accept the CKShare. Do that ourselves, once — - // without it the game's `GameEntity` never materialises and - // the join can only time out. The accept also fetches the - // shared zone, so on success the next `loadGame` finds the - // game; the deadline is reset because the accept itself can - // outlast the original window. - if !didAcceptInvite { - didAcceptInvite = true - do { - try await services.invites.acceptPendingInvite(gameID: gameID) - joinDeadline = Date() - .addingTimeInterval(Self.inviteJoinTimeout) - } catch { - if !Task.isCancelled { - loadError = error.localizedDescription - } - break - } - continue - } - - do { - try await Task.sleep(for: Self.inviteJoinPollInterval) - } catch { - break // task cancelled - } } catch { loadError = String(describing: error) break diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -319,30 +319,6 @@ final class InviteCoordinator { return outcome } - /// Accepts the pending invite for `gameID`, if one is still recorded - /// locally. The puzzle-display join path calls this when the user reached - /// a not-yet-joined shared game by tapping its `.invite` notification: - /// that tap only navigates, so the CKShare must still be accepted here. - /// The durable `InviteEntity` (written when the `.invite` Ping arrived) - /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when - /// no such invite exists — the same error the stale-share case surfaces. - func acceptPendingInvite(gameID: UUID) async throws { - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) - req.sortDescriptors = [ - NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false) - ] - req.fetchLimit = 1 - guard let invite = (try? ctx.fetch(req))?.first, - let shareURL = invite.shareURL, - let pingRecordName = invite.pingRecordName - else { - throw InviteAcceptanceError.unavailable - } - try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) - } - /// The XD source recorded on the durable invite for `pingRecordName`, if /// the inviting build carried one. Read just before acceptance so the /// accept path can construct a playable game without the shared-zone fetch. diff --git a/Tests/Unit/NotificationNavigationBrokerTests.swift b/Tests/Unit/NotificationNavigationBrokerTests.swift @@ -6,49 +6,67 @@ import Testing @Suite("Notification navigation broker", .serialized) @MainActor struct NotificationNavigationBrokerTests { - @Test("Invite-origin is reported exactly once, then cleared") - func inviteOriginConsumedOnce() { + @Test("Game opens are delivered to the game handler") + func gameOpenDelivered() { let broker = NotificationNavigationBroker.shared + broker.onOpenGame = nil + broker.onOpenGameList = nil let gameID = UUID() + var openedGameID: UUID? + var openedList = false - broker.openGame(gameID, fromInvitePing: true) + broker.onOpenGame = { openedGameID = $0 } + broker.onOpenGameList = { openedList = true } + broker.openGame(gameID) - // First read sees the invite origin... - #expect(broker.consumeInviteOrigin(gameID)) - // ...and clears it, so a later re-open from the library list (which - // does not pass the flag) behaves like a normal navigation. - #expect(!broker.consumeInviteOrigin(gameID)) + #expect(openedGameID == gameID) + #expect(!openedList) } - @Test("A non-invite open carries no invite origin") - func defaultOpenHasNoInviteOrigin() { + @Test("Game opens are buffered until the game handler is installed") + func gameOpenBuffered() { let broker = NotificationNavigationBroker.shared + broker.onOpenGame = nil + broker.onOpenGameList = nil let gameID = UUID() + var openedGameIDs: [UUID] = [] broker.openGame(gameID) + #expect(openedGameIDs.isEmpty) + + broker.onOpenGame = { openedGameIDs.append($0) } - #expect(!broker.consumeInviteOrigin(gameID)) + #expect(openedGameIDs == [gameID]) } - @Test("Consuming an untouched game ID returns false") - func consumeWithoutOpenReturnsFalse() { + @Test("Game List opens are delivered to the list handler") + func gameListOpenDelivered() { let broker = NotificationNavigationBroker.shared + broker.onOpenGame = nil + broker.onOpenGameList = nil + var openedGameID: UUID? + var openedList = false - #expect(!broker.consumeInviteOrigin(UUID())) + broker.onOpenGame = { openedGameID = $0 } + broker.onOpenGameList = { openedList = true } + broker.openGameList() + + #expect(openedGameID == nil) + #expect(openedList) } - @Test("Invite origin is tracked per game ID") - func inviteOriginIsPerGame() { + @Test("Game List opens are buffered until the list handler is installed") + func gameListOpenBuffered() { let broker = NotificationNavigationBroker.shared - let invitedID = UUID() - let otherID = UUID() + broker.onOpenGame = nil + broker.onOpenGameList = nil + var openedListCount = 0 + + broker.openGameList() + #expect(openedListCount == 0) - broker.openGame(invitedID, fromInvitePing: true) - broker.openGame(otherID, fromInvitePing: false) + broker.onOpenGameList = { openedListCount += 1 } - // Consuming the unrelated game neither reports nor disturbs the - // invited game's flag. - #expect(!broker.consumeInviteOrigin(otherID)) - #expect(broker.consumeInviteOrigin(invitedID)) + #expect(openedListCount == 1) } }