crossmate

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

commit 4fcc40cc30b3e6f30eef6db55fb24752c2fe5a6c
parent a516df0db69f0b9c01d5107c25f4be6d8e073405
Author: Michael Camilleri <[email protected]>
Date:   Mon,  4 May 2026 08:13:44 +0900

Support author tinting on share

When a puzzle is shared, this commit updates the currently entered
squares with an author tint.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 64++++++++++++++++++++++++++++++++++------------------------------
MCrossmate/Persistence/GameMutator.swift | 6++++--
MCrossmate/Persistence/GameStore.swift | 8++++++++
MCrossmate/Services/AppServices.swift | 3+++
MCrossmate/Sync/ShareController.swift | 6++++++
5 files changed, 55 insertions(+), 32 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -307,42 +307,15 @@ private struct PuzzleDisplayView: View { .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .task(id: sharedID) { - guard sharedID != nil else { return } + guard let session, sharedID != nil, preferences.isICloudSyncEnabled else { return } + await activateSharing(for: session) await pollOpenSharedPuzzle() } .task(id: gameID) { NotificationState.setActivePuzzleID(gameID) do { let (game, mutator) = try store.loadGame(id: gameID) - let newSession = PlayerSession(game: game, mutator: mutator) - if mutator.isShared && preferences.isICloudSyncEnabled { - Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } - roster = services.makePlayerRoster(for: gameID, preferences: preferences) - // Fan out the local user's name to every shared/joined - // game's zone before any presence write — otherwise the - // partner sees "Player" until we happen to rename - // ourselves and trigger NameBroadcaster's observer. - // Idempotent if the name has already been broadcast. - await services.nameBroadcaster?.broadcastName() - if let authorID = services.identity.currentID { - let presence = services.presencePublisher - await presence.begin( - gameID: gameID, - authorID: authorID, - currentName: preferences.name - ) - newSession.onSelectionChanged = { selection in - Task { await presence.publish(selection) } - } - let initial = PlayerSelection( - row: newSession.selectedRow, - col: newSession.selectedCol, - direction: newSession.direction - ) - await presence.publish(initial) - } - } - session = newSession + session = PlayerSession(game: game, mutator: mutator) } catch { loadError = String(describing: error) } @@ -359,6 +332,37 @@ private struct PuzzleDisplayView: View { } } + /// Initialises shared-game state (roster, presence, name broadcast) for + /// the open session. Called when the puzzle first appears as shared, and + /// again if a previously-solo game becomes shared mid-session. + private func activateSharing(for session: PlayerSession) async { + Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } + if roster == nil { + roster = services.makePlayerRoster(for: gameID, preferences: preferences) + } + // Fan out the local user's name to every shared/joined game's zone + // before any presence write — otherwise the partner sees "Player" + // until we happen to rename ourselves and trigger NameBroadcaster's + // observer. Idempotent if the name has already been broadcast. + await services.nameBroadcaster?.broadcastName() + guard let authorID = services.identity.currentID else { return } + let presence = services.presencePublisher + await presence.begin( + gameID: gameID, + authorID: authorID, + currentName: preferences.name + ) + session.onSelectionChanged = { selection in + Task { await presence.publish(selection) } + } + let initial = PlayerSelection( + row: session.selectedRow, + col: session.selectedCol, + direction: session.direction + ) + await presence.publish(initial) + } + private func pollOpenSharedPuzzle() async { await services.syncOpenSharedPuzzle() while !Task.isCancelled { diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -21,8 +21,10 @@ final class GameMutator { /// `true` when the current user owns the CloudKit zone for this game. let isOwned: Bool /// `true` when the game is shared — either the owner has an active share - /// or the current user joined via one. - let isShared: Bool + /// or the current user joined via one. Mutable so the store can flip it + /// when a share is created mid-session, which lets `PuzzleDisplayView` + /// react and build a roster without requiring the user to re-open. + var isShared: Bool /// Set to `true` when the owner has revoked the current user's access to /// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -832,6 +832,14 @@ final class GameStore { currentMutator?.isAccessRevoked = true } + /// Flips the active game's mutator to shared after `ShareController` + /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster, + /// starts publishing presence) without requiring the user to re-open. + func markShared(gameID: UUID) { + guard currentEntity?.id == gameID else { return } + currentMutator?.isShared = true + } + private func makeMutator(game: Game, entity: GameEntity) -> GameMutator { guard let gameID = entity.id else { fatalError("GameEntity missing id — data model invariant violated") diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -49,6 +49,9 @@ final class AppServices { syncEngine: syncEngine, syncMonitor: self.syncMonitor ) + self.shareController.onShareSaved = { [weak store] gameID in + store?.markShared(gameID: gameID) + } let moveBuffer = MoveBuffer( debounceInterval: .milliseconds(1500), persistence: persistence, diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -14,6 +14,11 @@ final class ShareController { private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor? + /// Fired after `persistShareName` has saved the local entity's + /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator + /// `isShared` flag) can flip without waiting for the user to re-open. + var onShareSaved: (@MainActor (UUID) -> Void)? + enum ShareError: Error { case gameNotFound case invalidShareRecord @@ -178,6 +183,7 @@ final class ShareController { guard entity.ckShareRecordName != recordName else { return } entity.ckShareRecordName = recordName try ctx.save() + onShareSaved?(gameID) } /// Removes the current user's participation from a shared game and deletes