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 }