crossmate

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

AnnouncementCenter.swift (11432B)


      1 import Foundation
      2 import Observation
      3 
      4 /// One-shot status message surfaced in a banner area — the puzzle header
      5 /// when scoped to a game, the game list when global. Designed to host both
      6 /// info-class summaries (e.g. "Alice added 4 letters")
      7 /// and error-class failures (e.g. "Couldn't accept invite") that previously
      8 /// went through modal alerts.
      9 struct Announcement: Identifiable, Equatable, Sendable {
     10     /// Severity of an announcement, used both for visual treatment and for
     11     /// pick-the-winner logic when two announcements compete for the same
     12     /// surface — higher-severity displaces lower.
     13     enum Severity: Int, Comparable, Sendable {
     14         /// Lowest severity: onboarding tips. Ranked below `info` so a tip never
     15         /// displaces a real status message and is itself displaced by one.
     16         case tip
     17         case info
     18         case warning
     19         case error
     20 
     21         static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rawValue < rhs.rawValue }
     22     }
     23 
     24     /// Dismissal behavior. `.transient` auto-clears after the given delay;
     25     /// `.manual` stays until the user taps it away; `.sticky` requires
     26     /// programmatic dismissal (the producer must call `dismiss(id:)`
     27     /// itself). `.sticky` is the only kind that pairs sensibly with
     28     /// `blocksInput`.
     29     enum Dismissal: Equatable, Sendable {
     30         case transient(after: TimeInterval)
     31         case manual
     32         case sticky
     33     }
     34 
     35     /// Surface scope. Game-scoped announcements take priority over global
     36     /// ones at the puzzle header; global-only ones surface on the game
     37     /// list. A producer that has a relevant `gameID` should prefer
     38     /// `.game(_)` so the announcement only appears where it makes sense.
     39     enum Scope: Hashable, Sendable {
     40         case global
     41         case game(UUID)
     42     }
     43 
     44     /// Stable id; reposting with the same id replaces the prior
     45     /// announcement in place rather than queueing another behind it.
     46     let id: String
     47     let scope: Scope
     48     let severity: Severity
     49     let title: String?
     50     let body: String
     51     let dismissal: Dismissal
     52     /// When true, the puzzle's input layer (custom keyboard + hardware key
     53     /// handler) is greyed out and ignores input for as long as this
     54     /// announcement is showing. Only sensible alongside `.sticky`.
     55     let blocksInput: Bool
     56     let createdAt: Date
     57 
     58     init(
     59         id: String,
     60         scope: Scope,
     61         severity: Severity,
     62         title: String? = nil,
     63         body: String,
     64         dismissal: Dismissal,
     65         blocksInput: Bool = false,
     66         createdAt: Date = Date()
     67     ) {
     68         self.id = id
     69         self.scope = scope
     70         self.severity = severity
     71         self.title = title
     72         self.body = body
     73         self.dismissal = dismissal
     74         self.blocksInput = blocksInput
     75         self.createdAt = createdAt
     76     }
     77 }
     78 
     79 extension Announcement {
     80     /// The sticky, input-blocking banner shown when a shared puzzle's
     81     /// owner revokes the local user's access. Folds the former bespoke
     82     /// `AccessRevokedBanner` overlay into the announcement system, so the
     83     /// revoked puzzle's keyboard and hardware keys grey out for as long
     84     /// as the banner shows.
     85     static func accessRevoked(gameID: UUID) -> Announcement {
     86         Announcement(
     87             id: "access-revoked-\(gameID.uuidString)",
     88             scope: .game(gameID),
     89             severity: .error,
     90             title: "Puzzle Not Shared",
     91             body: "This puzzle is no longer shared with you.",
     92             dismissal: .sticky,
     93             blocksInput: true
     94         )
     95     }
     96 
     97     /// The sticky, input-blocking banner shown when the open puzzle's game is
     98     /// hard-deleted out from under it — a solo puzzle's private zone vanished,
     99     /// or a shared puzzle was left on another device. Game-scoped, so it only
    100     /// surfaces in that puzzle's header, never the game list; the caller posts
    101     /// it only when the removed game is the one on screen. The puzzle stays
    102     /// open and frozen until the user backs out — by then it is already gone
    103     /// from the list.
    104     static func gameRemoved(gameID: UUID) -> Announcement {
    105         Announcement(
    106             id: "game-removed-\(gameID.uuidString)",
    107             scope: .game(gameID),
    108             severity: .error,
    109             title: "Puzzle Removed",
    110             body: "This puzzle was removed.",
    111             dismissal: .sticky,
    112             blocksInput: true
    113         )
    114     }
    115 
    116     /// The sticky, input-blocking banner shown when a game uses sync semantics
    117     /// this app release does not implement. The merged puzzle remains visible,
    118     /// but local edits are disabled so this client cannot publish state that
    119     /// other participants would resolve differently.
    120     static func unsupportedSyncVersion(gameID: UUID) -> Announcement {
    121         Announcement(
    122             id: "unsupported-sync-version-\(gameID.uuidString)",
    123             scope: .game(gameID),
    124             severity: .error,
    125             title: "Crossmate Update Required",
    126             body: "Update Crossmate to make changes to this puzzle.",
    127             dismissal: .sticky,
    128             blocksInput: true
    129         )
    130     }
    131 
    132     /// Reassurance shown on the Game List when a share was accepted but its
    133     /// puzzle had not finished syncing before the join wait timed out. The game
    134     /// surfaces on its own once sync settles, so this clears itself rather than
    135     /// asking the user to act.
    136     static func puzzleStillSyncing() -> Announcement {
    137         Announcement(
    138             id: "share-join-pending-sync",
    139             scope: .global,
    140             severity: .info,
    141             title: "Puzzle Unavailable",
    142             body: "This puzzle is still syncing and will appear in your list shortly.",
    143             dismissal: .transient(after: 6)
    144         )
    145     }
    146 }
    147 
    148 /// The persisted, open-relevant facts about a game — the input to
    149 /// `OpenPuzzleBanner.announcements(for:)`. Grouped into a value so the
    150 /// reconciler can be unit-tested without standing up a `GameMutator`.
    151 struct OpenPuzzleState {
    152     let gameID: UUID
    153     let isAccessRevoked: Bool
    154     let isSyncSupported: Bool
    155 }
    156 
    157 /// A banner that may be (re)posted when a puzzle is opened, reconciled from
    158 /// *persisted* game state rather than a live sync transition. Each such
    159 /// banner is otherwise posted only on the event that first produces it, into
    160 /// an in-memory `AnnouncementCenter` that does not survive a process restart
    161 /// — so a puzzle opened in a later process would show nothing. Extend by
    162 /// adding a case and its switch arm.
    163 enum OpenPuzzleBanner: CaseIterable {
    164     case accessRevoked
    165     case unsupportedSyncVersion
    166 
    167     /// The announcement this banner contributes for `state`, or `nil` when
    168     /// the state does not warrant it.
    169     func announcement(for state: OpenPuzzleState) -> Announcement? {
    170         switch self {
    171         case .accessRevoked:
    172             state.isAccessRevoked ? .accessRevoked(gameID: state.gameID) : nil
    173         case .unsupportedSyncVersion:
    174             state.isSyncSupported ? nil : .unsupportedSyncVersion(gameID: state.gameID)
    175         }
    176     }
    177 
    178     /// Every banner to (re)post for a puzzle opened in `state`. The caller
    179     /// posts each one; `AnnouncementCenter.post` is idempotent by id, so
    180     /// re-posting a banner that is already showing is a no-op.
    181     static func announcements(for state: OpenPuzzleState) -> [Announcement] {
    182         allCases.compactMap { $0.announcement(for: state) }
    183     }
    184 }
    185 
    186 /// Single source of truth for transient banner-style status messages. Holds
    187 /// at most one announcement per scope; reposting with the same id replaces
    188 /// the prior copy. Surfaces (PuzzleHeader, GameListView) read via the
    189 /// scope-specific accessors and observe via `@Observable`.
    190 @MainActor
    191 @Observable
    192 final class AnnouncementCenter {
    193     /// All active announcements, keyed by id. Kept private so callers go
    194     /// through `current(forGame:)` / `currentGlobal()` and inherit the
    195     /// scope-precedence rule (game > global) consistently.
    196     private var byId: [String: Announcement] = [:]
    197     /// Auto-dismiss tasks for `.transient` announcements; cancelled when
    198     /// the announcement is replaced or dismissed early so we don't fire a
    199     /// stale dismissal against a fresh announcement that happens to share
    200     /// the id.
    201     private var dismissalTasks: [String: Task<Void, Never>] = [:]
    202     /// Sleep primitive used by transient auto-dismiss timers. Injected so
    203     /// tests can drive expiry deterministically instead of racing wall-clock
    204     /// `Task.sleep` on a contended simulator.
    205     private let sleep: @Sendable (Duration) async throws -> Void
    206 
    207     init(
    208         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
    209     ) {
    210         self.sleep = sleep
    211     }
    212 
    213     func post(_ announcement: Announcement) {
    214         if let existing = dismissalTasks.removeValue(forKey: announcement.id) {
    215             existing.cancel()
    216         }
    217         byId[announcement.id] = announcement
    218         if case let .transient(after) = announcement.dismissal {
    219             let id = announcement.id
    220             let sleep = self.sleep
    221             dismissalTasks[id] = Task { @MainActor [weak self] in
    222                 try? await sleep(.seconds(after))
    223                 guard !Task.isCancelled else { return }
    224                 self?.autoDismiss(id: id)
    225             }
    226         }
    227     }
    228 
    229     func dismiss(id: String) {
    230         byId.removeValue(forKey: id)
    231         if let task = dismissalTasks.removeValue(forKey: id) {
    232             task.cancel()
    233         }
    234     }
    235 
    236     /// Topmost announcement to display in the puzzle header for `gameID`.
    237     /// Prefers game-scoped over global so a puzzle-relevant message isn't
    238     /// hidden behind an app-wide one; ties broken by severity, then by
    239     /// createdAt (newest wins). Tips are excluded entirely: they're a Game
    240     /// List-only affordance and must never surface over the puzzle grid.
    241     func current(forGame gameID: UUID) -> Announcement? {
    242         let gameScoped = byId.values.filter { $0.scope == .game(gameID) }
    243         if let pick = pick(from: gameScoped) { return pick }
    244         let global = currentGlobal()
    245         return global?.severity == .tip ? nil : global
    246     }
    247 
    248     /// Topmost global announcement (used by surfaces that have no specific
    249     /// game context, like the game list).
    250     func currentGlobal() -> Announcement? {
    251         pick(from: byId.values.filter { $0.scope == .global })
    252     }
    253 
    254     /// Whether any currently-showing announcement for `gameID` flags input
    255     /// as blocked. Drives the greyed-out keyboard + hardware-key gating.
    256     func isInputBlocked(forGame gameID: UUID) -> Bool {
    257         current(forGame: gameID)?.blocksInput == true
    258     }
    259 
    260     private func pick(from candidates: some Collection<Announcement>) -> Announcement? {
    261         candidates.max { lhs, rhs in
    262             if lhs.severity != rhs.severity { return lhs.severity < rhs.severity }
    263             return lhs.createdAt < rhs.createdAt
    264         }
    265     }
    266 
    267     private func autoDismiss(id: String) {
    268         // Re-check before removing: a later `post(_:)` may have superseded
    269         // this announcement with a non-transient one under the same id.
    270         guard let active = byId[id],
    271               case .transient = active.dismissal
    272         else {
    273             dismissalTasks.removeValue(forKey: id)
    274             return
    275         }
    276         byId.removeValue(forKey: id)
    277         dismissalTasks.removeValue(forKey: id)
    278     }
    279 }