import UserNotifications import UIKit import Intents import os let APP_GROUP = "group.app.bsky" private let logger = Logger(subsystem: "xyz.blueskyweb.app.BlueskyNSE", category: "NotificationService") typealias ContentHandler = (UNNotificationContent) -> Void // This extension allows us to do some processing of the received notification // data before displaying the notification to the user. In our use case, there // are a few particular things that we want to do: // // - Determine whether we should play a sound for the notification // - Download and display any images for the notification // - Update the badge count accordingly // // The extension may or may not create a new process to handle a notification. // It is also possible that multiple notifications will be processed by the // same instance of `NotificationService`, though these will happen in // parallel. // // Because multiple instances of `NotificationService` may exist, we should // be careful in accessing preferences that will be mutated _by the // extension itself_. For example, we should not worry about `playChatSound` // changing, since we never mutate that value within the extension itself. // However, since we mutate `badgeCount` frequently, we should ensure that // these updates always run sync with each other and that the have access // to the most recent values. class NotificationService: UNNotificationServiceExtension { private var contentHandler: ContentHandler? private var bestAttempt: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler guard let bestAttempt = NSEUtil.createCopy(request.content), let reason = request.content.userInfo["reason"] as? String else { contentHandler(request.content) return } self.bestAttempt = bestAttempt if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) let finalContent = createCommunicationNotification( from: bestAttempt, userInfo: request.content.userInfo ) contentHandler(finalContent) } else { mutateWithBadge(bestAttempt) contentHandler(bestAttempt) } } override func serviceExtensionTimeWillExpire() { guard let contentHandler = self.contentHandler, let bestAttempt = self.bestAttempt else { return } contentHandler(bestAttempt) } // MARK: Communication Notification func createCommunicationNotification( from content: UNMutableNotificationContent, userInfo: [AnyHashable: Any] ) -> UNNotificationContent { let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" let convoId = userInfo["convoId"] as? String let avatarUrlString = userInfo["senderAvatarUrl"] as? String logger.info("Communication notification: sender=\(senderDisplayName, privacy: .public) convoId=\(convoId ?? "nil", privacy: .public) avatarUrl=\(avatarUrlString ?? "nil", privacy: .public)") var avatarImage: INImage? = nil if let avatarUrlString { avatarImage = downloadAvatarImage(from: avatarUrlString) logger.info("Avatar download result: \(avatarImage != nil ? "success" : "failed", privacy: .public)") } else { logger.info("No avatar URL in payload") } let senderHandle = INPersonHandle(value: nil, type: .unknown) let sender = INPerson( personHandle: senderHandle, nameComponents: nil, displayName: senderDisplayName, image: avatarImage, contactIdentifier: nil, customIdentifier: nil ) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, speakableGroupName: nil, conversationIdentifier: convoId, serviceName: nil, sender: sender, attachments: nil ) let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate(completion: nil) do { return try content.updating(from: intent) } catch { return content } } func downloadAvatarImage(from urlString: String) -> INImage? { var thumbnailUrlString = urlString.replacingOccurrences( of: "/img/avatar/", with: "/img/avatar_thumbnail/" ) // CDN URLs may end with @webp or no extension (which defaults to webp). // INImage doesn't reliably handle WebP, so force JPEG. if let atRange = thumbnailUrlString.range(of: "@", options: .backwards) { thumbnailUrlString = String(thumbnailUrlString[.. UNMutableNotificationContent? { return content.mutableCopy() as? UNMutableNotificationContent } }