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 }