| 1 | import Foundation |
| 2 | import Intents |
| 3 | import UserNotifications |
| 4 | |
| 5 | // Re-presents a message banner as a system "communication notification" — the |
| 6 | // sender's face as the big icon with the app icon badged on its corner, like |
| 7 | // Messages/WhatsApp — by describing the message to iOS as an INSendMessageIntent |
| 8 | // and letting UNNotificationContent.updating(from:) restyle the content. Shared |
| 9 | // by the iOS notification extension (APNs path) and the Mac's local banners so |
| 10 | // the two platforms can't drift. Purely cosmetic + best-effort: any failure |
| 11 | // (no communication entitlement on this build, malformed image) returns nil and |
| 12 | // the caller ships the plain banner it already built. |
| 13 | public enum CommunicationNotification { |
| 14 | /// The decorated content, or nil to keep the caller's plain banner. |
| 15 | /// - senderUID keys the avatar lookup (SharedAvatarCache) and gives the |
| 16 | /// system a stable per-person identity; without one the sender still gets |
| 17 | /// named, just with a monogram circle. |
| 18 | /// - groupName (a group chat's title) renders as the subtitle line; pass |
| 19 | /// nil for DMs, where the sender name alone is the title. |
| 20 | /// - messageText is the plain body — no "Sender: " prefix; the intent |
| 21 | /// carries the sender, so a prefixed body would read twice. |
| 22 | public static func decorate(_ content: UNNotificationContent, |
| 23 | senderName: String, senderUID: String?, |
| 24 | origin: String, conversationID: String, |
| 25 | groupName: String?, messageText: String) -> UNNotificationContent? { |
| 26 | // The sender's cached photo, else the same initials-on-colour monogram |
| 27 | // the app draws for them — never the system's gray silhouette. |
| 28 | var image: INImage? |
| 29 | if let uid = senderUID, let data = SharedAvatarCache.imageData(origin: origin, uid: uid) { |
| 30 | image = INImage(imageData: data) |
| 31 | } else if let data = MonogramImage.pngData(name: senderName) { |
| 32 | image = INImage(imageData: data) |
| 33 | } |
| 34 | // The handle/customIdentifier scope the person to their instance — |
| 35 | // the same uid on two instances is two different people. |
| 36 | let handle = INPersonHandle(value: "\(origin)|\(senderUID ?? senderName)", type: .unknown) |
| 37 | let sender = INPerson(personHandle: handle, nameComponents: nil, |
| 38 | displayName: senderName, image: image, |
| 39 | contactIdentifier: nil, |
| 40 | customIdentifier: handle.value) |
| 41 | let intent = INSendMessageIntent(recipients: nil, |
| 42 | outgoingMessageType: .outgoingMessageText, |
| 43 | content: messageText, |
| 44 | speakableGroupName: groupName.map { INSpeakableString(spokenPhrase: $0) }, |
| 45 | conversationIdentifier: conversationID, |
| 46 | serviceName: nil, |
| 47 | sender: sender, |
| 48 | attachments: nil) |
| 49 | // The donation is what feeds share-sheet/Siri people suggestions; |
| 50 | // the visual restyle below works without it, so ignore failures. |
| 51 | let interaction = INInteraction(intent: intent, response: nil) |
| 52 | interaction.direction = .incoming |
| 53 | interaction.donate(completion: nil) |
| 54 | |
| 55 | guard let mutable = content.mutableCopy() as? UNMutableNotificationContent else { return nil } |
| 56 | // updating(from:) takes title/subtitle from the intent's sender/group; |
| 57 | // the body stays ours, so strip any sender prefix down to the text. |
| 58 | mutable.body = messageText |
| 59 | return try? mutable.updating(from: intent) |
| 60 | } |
| 61 | } |
| 62 | |