// // NotificationService.swift // NotificationService // // Created by Matias Carulli on 5/7/26. // import UserNotifications import Intents class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) guard let content = bestAttemptContent, let imageUrlString = request.content.userInfo["imageUrl"] as? String, let imageUrl = URL(string: imageUrlString) else { contentHandler(request.content) return } let userInfo = request.content.userInfo // Download the avatar (the sending network's photo), then render the // notification. A data task lets us hand the bytes straight to INImage. URLSession.shared.dataTask(with: imageUrl) { data, _, error in guard let data = data, !data.isEmpty, error == nil else { contentHandler(content) return } // iOS 15+: render as a communication notification — large circular // avatar with the app icon badged in the corner. if #available(iOS 15.0, *) { self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler) } else { // iOS 14 fallback: attach the image as a plain thumbnail. let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png") try? data.write(to: tmpFile) if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) { content.attachments = [attachment] } contentHandler(content) } }.resume() } @available(iOS 15.0, *) private func deliverAsCommunicationNotification( content: UNMutableNotificationContent, imageData: Data, userInfo: [AnyHashable: Any], contentHandler: @escaping (UNNotificationContent) -> Void ) { // The conversation title shown in bold. Defaults to the notification's own // title so existing copy (e.g. "Group | Alice") is preserved. let senderName = (userInfo["senderName"] as? String) ?? content.title let isGroup = (userInfo["isGroup"] as? Bool) ?? false // Group related notifications into one conversation so iOS stacks them. let conversationId = (userInfo["conversationId"] as? String) ?? (userInfo["threadId"] as? String) ?? content.threadIdentifier let avatar = INImage(imageData: imageData) let handle = INPersonHandle(value: conversationId, type: .unknown) let sender = INPerson( personHandle: handle, nameComponents: nil, displayName: senderName, image: avatar, contactIdentifier: nil, customIdentifier: conversationId ) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil, conversationIdentifier: conversationId, serviceName: nil, sender: sender, attachments: nil ) // For a group conversation the avatar comes from the group image; for a // 1:1 it comes from the sender's INPerson image (set above). if isGroup { intent.setImage(avatar, forParameterNamed: \.speakableGroupName) } let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate(completion: nil) do { let updated = try content.updating(from: intent) contentHandler(updated) } catch { // If the intent can't be applied, fall back to the unmodified content // so the notification still shows. contentHandler(content) } } override func serviceExtensionTimeWillExpire() { if let contentHandler = contentHandler, let content = bestAttemptContent { contentHandler(content) } } }