crossmate

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

PuzzleNotificationText.swift (7777B)


      1 import Foundation
      2 
      3 /// Composes the visible text of a Crossmate notification from its parts. This
      4 /// is the single source of truth for alert wording: the sender builds the body
      5 /// it ships through these builders, and the notification service extension
      6 /// calls the same ones (via `PushPayload.composedBody`) to rebuild the body
      7 /// with the recipient's private nickname in place of the sender's name. Pure
      8 /// string logic only, so it lives in `Shared` and compiles into both the app
      9 /// and the extension.
     10 enum PuzzleNotificationText {
     11     static func title(_ title: String, publisher: String?, date: Date?) -> String {
     12         let subtitle = subtitle(publisher: publisher, date: date)
     13         guard let subtitle else { return title }
     14         return "\(title) – \(subtitle)"
     15     }
     16 
     17     /// Body for a nudge push: "Alice nudged you to play the puzzle 'X'". A
     18     /// deliberate rouse from the in-game players menu, so it always names an
     19     /// action even when nothing in the grid changed.
     20     static func nudgeBody(playerName: String, puzzleTitle: String) -> String {
     21         "\(resolvedName(playerName)) nudged you to play \(puzzleSuffix(puzzleTitle))"
     22     }
     23 
     24     /// Body for a join push: "Alice joined the puzzle 'X'". Sent to everyone
     25     /// already in the room when a new player accepts the invitation, so it
     26     /// always names the action even though joining changes nothing in the grid.
     27     static func joinBody(playerName: String, puzzleTitle: String) -> String {
     28         "\(resolvedName(playerName)) joined \(puzzleSuffix(puzzleTitle))"
     29     }
     30 
     31     /// Body for a completion push — "Alice solved …" or, when `resigned`,
     32     /// "Alice resigned …" (the resign sentence ends in a full stop to match the
     33     /// app's existing wording).
     34     static func completionBody(
     35         playerName: String,
     36         puzzleTitle: String,
     37         resigned: Bool
     38     ) -> String {
     39         let name = resolvedName(playerName)
     40         let suffix = puzzleSuffix(puzzleTitle)
     41         return resigned
     42             ? "\(name) resigned \(suffix)."
     43             : "\(name) solved \(suffix)"
     44     }
     45 
     46     /// Body for a session-end push, addressed to a single recipient,
     47     /// describing what the peer did since that recipient last looked (entries
     48     /// in the peer's journal newer than the recipient's last-known
     49     /// `Player.readAt`): net letter `fills` / `clears`, and the number of
     50     /// `checks` / `reveals` *gestures* run. When every count is zero the
     51     /// recipient still gets the push as a presence signal ("stopped solving")
     52     /// — the session end is worth surfacing even with nothing unseen — but the
     53     /// payload's zero counts keep it from bumping the badge.
     54     static func pauseBody(
     55         playerName: String,
     56         puzzleTitle: String,
     57         fills: Int,
     58         clears: Int,
     59         checks: Int,
     60         reveals: Int
     61     ) -> String {
     62         let name = resolvedName(playerName)
     63         let suffix = puzzleSuffix(puzzleTitle)
     64         guard let actions = pauseActions(fills: fills, clears: clears, checks: checks, reveals: reveals) else {
     65             return "\(name) stopped solving \(suffix)."
     66         }
     67         return "\(name) \(actions) in \(suffix)"
     68     }
     69 
     70     /// The action phrase of a pause — "filled 3 letters and ran 1 check" —
     71     /// without the name or puzzle suffix, or `nil` when nothing changed. Shared
     72     /// by `pauseBody` and the multi-contributor `coalescedBody` so a single
     73     /// sender and a coalesced one read in exactly the same voice.
     74     private static func pauseActions(
     75         fills: Int,
     76         clears: Int,
     77         checks: Int,
     78         reveals: Int
     79     ) -> String? {
     80         func letters(_ n: Int) -> String { "\(n) \(n == 1 ? "letter" : "letters")" }
     81 
     82         var clauses: [String] = []
     83         if fills > 0 { clauses.append("filled \(letters(fills))") }
     84         if clears > 0 { clauses.append("cleared \(letters(clears))") }
     85         // Checks and reveals are help gestures; fold them into one "ran …"
     86         // clause so the sentence doesn't repeat the verb.
     87         var help: [String] = []
     88         if checks > 0 { help.append("\(checks) \(checks == 1 ? "check" : "checks")") }
     89         if reveals > 0 { help.append("\(reveals) \(reveals == 1 ? "reveal" : "reveals")") }
     90         if !help.isEmpty { clauses.append("ran \(joinList(help))") }
     91 
     92         return clauses.isEmpty ? nil : joinList(clauses)
     93     }
     94 
     95     /// Body for a coalesced game tile that has folded together one or more
     96     /// session-end (`pause`) updates (see `CoalescedSummary`). Each contributor
     97     /// is described in the same wording a single pause uses; several are joined
     98     /// with semicolons and the puzzle is named once at the end ("Alice filled 5
     99     /// letters and cleared 1 letter; Bob filled 3 letters in the puzzle 'X'").
    100     /// Returns `nil` when there is nothing to summarise.
    101     static func coalescedBody(
    102         puzzleTitle: String,
    103         contributors: [CoalescedSummary.Contributor]
    104     ) -> String? {
    105         switch contributors.count {
    106         case 0:
    107             return nil
    108         case 1:
    109             let only = contributors[0]
    110             return pauseBody(
    111                 playerName: only.name,
    112                 puzzleTitle: puzzleTitle,
    113                 fills: only.fills,
    114                 clears: only.clears,
    115                 checks: only.checks,
    116                 reveals: only.reveals
    117             )
    118         default:
    119             // One player per clause in the single-pause voice, separated by
    120             // semicolons (each clause already uses commas/"and" internally),
    121             // with the puzzle named once at the end.
    122             let phrases = contributors.map { contributor -> String in
    123                 let name = resolvedName(contributor.name)
    124                 guard let actions = pauseActions(
    125                     fills: contributor.fills,
    126                     clears: contributor.clears,
    127                     checks: contributor.checks,
    128                     reveals: contributor.reveals
    129                 ) else {
    130                     // A contributor always has unseen changes (a pause is only
    131                     // sent when it does), so this is unreachable in practice;
    132                     // degrade to a neutral phrase rather than drop them.
    133                     return "\(name) made changes"
    134                 }
    135                 return "\(name) \(actions)"
    136             }
    137             return "\(phrases.joined(separator: "; ")) in \(puzzleSuffix(puzzleTitle))"
    138         }
    139     }
    140 
    141     /// The empty name falls back to a neutral label, so a peer who hasn't set a
    142     /// name still reads as a person rather than a blank.
    143     private static func resolvedName(_ name: String) -> String {
    144         name.isEmpty ? "A player" : name
    145     }
    146 
    147     /// "the puzzle" alone, or "the puzzle 'Title'" when a title is known.
    148     private static func puzzleSuffix(_ title: String) -> String {
    149         title.isEmpty ? "the puzzle" : "the puzzle '\(title)'"
    150     }
    151 
    152     /// Joins clauses into prose: "a", "a and b", or "a, b and c".
    153     private static func joinList(_ parts: [String]) -> String {
    154         switch parts.count {
    155         case 0: return ""
    156         case 1: return parts[0]
    157         case 2: return "\(parts[0]) and \(parts[1])"
    158         default: return "\(parts.dropLast().joined(separator: ", ")) and \(parts[parts.count - 1])"
    159         }
    160     }
    161 
    162     private static func subtitle(publisher: String?, date: Date?) -> String? {
    163         let formattedDate = date?.formatted(date: .long, time: .omitted)
    164         if let publisher, !publisher.isEmpty, let formattedDate {
    165             return "\(publisher) · \(formattedDate)"
    166         }
    167         if let formattedDate {
    168             return formattedDate
    169         }
    170         if let publisher, !publisher.isEmpty {
    171             return publisher
    172         }
    173         return nil
    174     }
    175 }