commit 6457dbc7d283b0630696eb8ba6457356518ad728
parent 3a16f6f8124d76aad27eebcddde762f572fdef1f
Author: Michael Camilleri <[email protected]>
Date: Thu, 21 May 2026 12:04:36 +0900
Block input on access-revoked puzzles via the announcement banner
This commit activates the blocksInput flag that AnnouncementCenter reserved for
a later stage. Announcement gains an accessRevoked(gameID:) factory: a
game-scoped, .error, .sticky announcement carrying blocksInput: true. The
onGameAccessRevoked callback in AppServices now posts it alongside marking the
game read-only, so a revocation surfaces through the same PuzzleHeader banner
path as session summaries rather than its own overlay.
PuzzleView reads AnnouncementCenter from the environment and derives
isInputBlocked from isInputBlocked(forGame:). When set, the custom KeyboardView
is greyed to 0.4 opacity with hit-testing disabled, and
handleHardwareKeyboardEvent no-ops, so neither the on-screen keys nor a
hardware keyboard can edit a puzzle the user has lost access to. This is
belt-and-suspenders with GameMutator.emitMove, which already drops moves on
isAccessRevoked — the UI now reflects what the mutator was already enforcing.
The bespoke AccessRevokedBanner overlay and its isRevokedBannerDismissed state
are retired in favour of the announcement. One behaviour change follows from
the .sticky choice: the banner is no longer dismissible. The revoked puzzle is
read-only and filtered out of the game list anyway, so the banner stays put
rather than offering a control that only hid it.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
4 files changed, 48 insertions(+), 35 deletions(-)
diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift
@@ -73,6 +73,24 @@ struct Announcement: Identifiable, Equatable, Sendable {
}
}
+extension Announcement {
+ /// The sticky, input-blocking banner shown when a shared puzzle's
+ /// owner revokes the local user's access. Folds the former bespoke
+ /// `AccessRevokedBanner` overlay into the announcement system, so the
+ /// revoked puzzle's keyboard and hardware keys grey out for as long
+ /// as the banner shows.
+ static func accessRevoked(gameID: UUID) -> Announcement {
+ Announcement(
+ id: "access-revoked-\(gameID.uuidString)",
+ scope: .game(gameID),
+ severity: .error,
+ body: "This puzzle is no longer shared with you.",
+ dismissal: .sticky,
+ blocksInput: true
+ )
+ }
+}
+
/// Single source of truth for transient banner-style status messages. Holds
/// at most one announcement per scope; reposting with the same id replaces
/// the prior copy. Surfaces (PuzzleHeader, GameListView) read via the
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -301,9 +301,13 @@ final class AppServices {
await self.identity.refresh(using: self.ckContainer)
}
- await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor] gameID in
+ await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements] gameID in
store.markAccessRevoked(gameID: gameID)
await sessionMonitor.cancel(gameID: gameID)
+ // Surface the revocation as a sticky, input-blocking banner on
+ // the open puzzle, replacing the former AccessRevokedBanner
+ // overlay. Game-scoped, so it only shows for this puzzle.
+ announcements.post(.accessRevoked(gameID: gameID))
}
await syncEngine.setOnGameRemoved { [store, sessionMonitor] gameID in
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -9,6 +9,7 @@ struct PuzzleView: View {
var onDelete: (() throws -> Void)? = nil
@Environment(InputMonitor.self) private var inputMonitor
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(AnnouncementCenter.self) private var announcements
@Environment(\.dismiss) private var dismiss
@State private var isRenaming = false
@State private var renameDraft = ""
@@ -18,7 +19,6 @@ struct PuzzleView: View {
@State private var isConfirmingLeave = false
@State private var leaveError: String?
@State private var destructiveActionError: String?
- @State private var isRevokedBannerDismissed = false
@State private var isShowingShareSheet = false
@State private var hasSolved = false
@State private var padLayout: PadLayout?
@@ -53,6 +53,13 @@ struct PuzzleView: View {
private var isSolved: Bool { hasSolved }
+ /// Whether a sticky, input-blocking announcement (currently only
+ /// access revocation) is showing for this game. Greys out the custom
+ /// keyboard and makes the hardware-key handler a no-op.
+ private var isInputBlocked: Bool {
+ announcements.isInputBlocked(forGame: session.mutator.gameID)
+ }
+
var body: some View {
Group {
switch padLayout {
@@ -64,12 +71,6 @@ struct PuzzleView: View {
phoneLayout
}
}
- .overlay(alignment: .top) {
- if session.mutator.isAccessRevoked && !isRevokedBannerDismissed {
- AccessRevokedBanner { isRevokedBannerDismissed = true }
- .transition(.move(edge: .top).combined(with: .opacity))
- }
- }
.background(Color(.systemBackground))
.background {
HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent)
@@ -296,6 +297,9 @@ struct PuzzleView: View {
} else if showsCustomKeyboard {
ControlsView(height: controlsPanelHeight) {
KeyboardView(session: session, showsNavigationKeys: padLayout != nil)
+ .opacity(isInputBlocked ? 0.4 : 1)
+ .allowsHitTesting(!isInputBlocked)
+ .animation(.easeInOut(duration: 0.3), value: isInputBlocked)
}
.transition(.move(edge: .bottom))
}
@@ -325,7 +329,7 @@ struct PuzzleView: View {
}
private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool {
- guard !isSolved else { return false }
+ guard !isSolved, !isInputBlocked else { return false }
switch event.keyCode {
case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE,
@@ -1441,32 +1445,6 @@ private extension VerticalAlignment {
static let clueCenter = VerticalAlignment(ClueCenterID.self)
}
-private struct AccessRevokedBanner: View {
- let onDismiss: () -> Void
-
- var body: some View {
- HStack(spacing: 8) {
- Image(systemName: "person.slash")
- Text("This puzzle is no longer shared with you.")
- .font(.footnote)
- .frame(maxWidth: .infinity, alignment: .leading)
- Button {
- onDismiss()
- } label: {
- Image(systemName: "xmark")
- .font(.footnote.weight(.semibold))
- }
- .buttonStyle(.plain)
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 10)
- .background(Color(.secondarySystemBackground))
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
- .padding(.horizontal, 16)
- .padding(.top, 8)
- }
-}
-
/// Lays subviews left-to-right, wrapping onto a new line when the next
/// subview would overflow the proposed width. Reports the wrapped
/// height for that width so a surrounding `ViewThatFits` can choose
diff --git a/Tests/Unit/AnnouncementCenterTests.swift b/Tests/Unit/AnnouncementCenterTests.swift
@@ -126,4 +126,17 @@ struct AnnouncementCenterTests {
center.dismiss(id: "k")
#expect(!center.isInputBlocked(forGame: gameID))
}
+
+ @Test("The access-revoked announcement blocks input for its game only")
+ func accessRevokedAnnouncementBlocksInput() {
+ let center = AnnouncementCenter()
+ let gameID = UUID()
+ let announcement = Announcement.accessRevoked(gameID: gameID)
+ #expect(announcement.dismissal == .sticky)
+ #expect(announcement.blocksInput)
+ center.post(announcement)
+ #expect(center.isInputBlocked(forGame: gameID))
+ // Game-scoped: it must not block input on an unrelated puzzle.
+ #expect(!center.isInputBlocked(forGame: UUID()))
+ }
}