crossmate

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

AnnouncementCenter.swift (10533B)


      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     /// Reassurance shown on the Game List when a share was accepted but its
    117     /// puzzle had not finished syncing before the join wait timed out. The game
    118     /// surfaces on its own once sync settles, so this clears itself rather than
    119     /// asking the user to act.
    120     static func puzzleStillSyncing() -> Announcement {
    121         Announcement(
    122             id: "share-join-pending-sync",
    123             scope: .global,
    124             severity: .info,
    125             title: "Puzzle Unavailable",
    126             body: "This puzzle is still syncing and will appear in your list shortly.",
    127             dismissal: .transient(after: 6)
    128         )
    129     }
    130 }
    131 
    132 /// The persisted, open-relevant facts about a game — the input to
    133 /// `OpenPuzzleBanner.announcements(for:)`. Grouped into a value so the
    134 /// reconciler can be unit-tested without standing up a `GameMutator`.
    135 struct OpenPuzzleState {
    136     let gameID: UUID
    137     let isAccessRevoked: Bool
    138 }
    139 
    140 /// A banner that may be (re)posted when a puzzle is opened, reconciled from
    141 /// *persisted* game state rather than a live sync transition. Each such
    142 /// banner is otherwise posted only on the event that first produces it, into
    143 /// an in-memory `AnnouncementCenter` that does not survive a process restart
    144 /// — so a puzzle opened in a later process would show nothing. Extend by
    145 /// adding a case and its switch arm.
    146 enum OpenPuzzleBanner: CaseIterable {
    147     case accessRevoked
    148 
    149     /// The announcement this banner contributes for `state`, or `nil` when
    150     /// the state does not warrant it.
    151     func announcement(for state: OpenPuzzleState) -> Announcement? {
    152         switch self {
    153         case .accessRevoked:
    154             state.isAccessRevoked ? .accessRevoked(gameID: state.gameID) : nil
    155         }
    156     }
    157 
    158     /// Every banner to (re)post for a puzzle opened in `state`. The caller
    159     /// posts each one; `AnnouncementCenter.post` is idempotent by id, so
    160     /// re-posting a banner that is already showing is a no-op.
    161     static func announcements(for state: OpenPuzzleState) -> [Announcement] {
    162         allCases.compactMap { $0.announcement(for: state) }
    163     }
    164 }
    165 
    166 /// Single source of truth for transient banner-style status messages. Holds
    167 /// at most one announcement per scope; reposting with the same id replaces
    168 /// the prior copy. Surfaces (PuzzleHeader, GameListView) read via the
    169 /// scope-specific accessors and observe via `@Observable`.
    170 @MainActor
    171 @Observable
    172 final class AnnouncementCenter {
    173     /// All active announcements, keyed by id. Kept private so callers go
    174     /// through `current(forGame:)` / `currentGlobal()` and inherit the
    175     /// scope-precedence rule (game > global) consistently.
    176     private var byId: [String: Announcement] = [:]
    177     /// Auto-dismiss tasks for `.transient` announcements; cancelled when
    178     /// the announcement is replaced or dismissed early so we don't fire a
    179     /// stale dismissal against a fresh announcement that happens to share
    180     /// the id.
    181     private var dismissalTasks: [String: Task<Void, Never>] = [:]
    182     /// Sleep primitive used by transient auto-dismiss timers. Injected so
    183     /// tests can drive expiry deterministically instead of racing wall-clock
    184     /// `Task.sleep` on a contended simulator.
    185     private let sleep: @Sendable (Duration) async throws -> Void
    186 
    187     init(
    188         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
    189     ) {
    190         self.sleep = sleep
    191     }
    192 
    193     func post(_ announcement: Announcement) {
    194         if let existing = dismissalTasks.removeValue(forKey: announcement.id) {
    195             existing.cancel()
    196         }
    197         byId[announcement.id] = announcement
    198         if case let .transient(after) = announcement.dismissal {
    199             let id = announcement.id
    200             let sleep = self.sleep
    201             dismissalTasks[id] = Task { @MainActor [weak self] in
    202                 try? await sleep(.seconds(after))
    203                 guard !Task.isCancelled else { return }
    204                 self?.autoDismiss(id: id)
    205             }
    206         }
    207     }
    208 
    209     func dismiss(id: String) {
    210         byId.removeValue(forKey: id)
    211         if let task = dismissalTasks.removeValue(forKey: id) {
    212             task.cancel()
    213         }
    214     }
    215 
    216     /// Topmost announcement to display in the puzzle header for `gameID`.
    217     /// Prefers game-scoped over global so a puzzle-relevant message isn't
    218     /// hidden behind an app-wide one; ties broken by severity, then by
    219     /// createdAt (newest wins). Tips are excluded entirely: they're a Game
    220     /// List-only affordance and must never surface over the puzzle grid.
    221     func current(forGame gameID: UUID) -> Announcement? {
    222         let gameScoped = byId.values.filter { $0.scope == .game(gameID) }
    223         if let pick = pick(from: gameScoped) { return pick }
    224         let global = currentGlobal()
    225         return global?.severity == .tip ? nil : global
    226     }
    227 
    228     /// Topmost global announcement (used by surfaces that have no specific
    229     /// game context, like the game list).
    230     func currentGlobal() -> Announcement? {
    231         pick(from: byId.values.filter { $0.scope == .global })
    232     }
    233 
    234     /// Whether any currently-showing announcement for `gameID` flags input
    235     /// as blocked. Drives the greyed-out keyboard + hardware-key gating.
    236     func isInputBlocked(forGame gameID: UUID) -> Bool {
    237         current(forGame: gameID)?.blocksInput == true
    238     }
    239 
    240     private func pick(from candidates: some Collection<Announcement>) -> Announcement? {
    241         candidates.max { lhs, rhs in
    242             if lhs.severity != rhs.severity { return lhs.severity < rhs.severity }
    243             return lhs.createdAt < rhs.createdAt
    244         }
    245     }
    246 
    247     private func autoDismiss(id: String) {
    248         // Re-check before removing: a later `post(_:)` may have superseded
    249         // this announcement with a non-transient one under the same id.
    250         guard let active = byId[id],
    251               case .transient = active.dismissal
    252         else {
    253             dismissalTasks.removeValue(forKey: id)
    254             return
    255         }
    256         byId.removeValue(forKey: id)
    257         dismissalTasks.removeValue(forKey: id)
    258     }
    259 }