crossmate

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

PuzzleNotificationText.swift (8025B)


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