// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Foundation import Intents import MatrixRustSDK import SwiftUI import UserNotifications import Version nonisolated struct NotificationContentBuilder { let messageEventStringBuilder: RoomMessageEventStringBuilder let notificationSoundName: UNNotificationSoundName let userSession: NSEUserSessionProtocol /// Process the given notification item proxy /// - Parameters: /// - notificationItem: The notification item /// - mediaProvider: Media provider to process also media. May be passed nil to ignore media operations. /// - Returns: A notification content object if the notification should be displayed. Otherwise nil. func process(notificationContent: inout UNMutableNotificationContent, notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol) async { notificationContent.receiverID = notificationItem.receiverID notificationContent.roomID = notificationItem.roomID notificationContent.threadRootEventID = notificationItem.threadRootEventID switch notificationItem.event { case .timeline(let event): notificationContent.eventID = event.eventId() case .invite, .none: notificationContent.eventID = nil } // So that the UI groups notification that are received for the same room/thread but also for the same user let threadIdentifier = if userSession.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { // If a threaded message we group notifications also by thread root id "\(notificationItem.receiverID)\(notificationItem.roomID)\(threadRootEventID)" } else { // otherwise only by room and receiver id "\(notificationItem.receiverID)\(notificationItem.roomID)" } // Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped notificationContent.threadIdentifier = threadIdentifier.replacingOccurrences(of: "@", with: "") MXLog.info("isNoisy: \(notificationItem.isNoisy)") notificationContent.sound = notificationItem.isNoisy ? .init(named: notificationSoundName) : nil switch notificationItem.event { case .none: processEmpty(¬ificationContent) case .invite: await processInvited(notificationContent: ¬ificationContent, notificationItem: notificationItem, mediaProvider: mediaProvider) case .timeline(let event): guard case let .messageLike(messageContent) = try? event.content() else { processEmpty(¬ificationContent) return } await processMessageLike(notificationContent: ¬ificationContent, notificationItem: notificationItem, mediaProvider: mediaProvider) switch messageContent { case .roomMessage(let messageType, _): await processRoomMessage(notificationContent: ¬ificationContent, notificationItem: notificationItem, messageType: messageType, mediaProvider: mediaProvider) case .poll(let question): notificationContent.body = L10n.commonPollSummary(question) case .callInvite: notificationContent.body = L10n.commonUnsupportedCall case .rtcNotification: notificationContent.body = L10n.notificationIncomingCall default: processEmpty(¬ificationContent) } } } // MARK: - Private private func processEmpty(_ notificationContent: inout UNMutableNotificationContent) { notificationContent.title = InfoPlistReader(bundle: .app).bundleDisplayName notificationContent.body = L10n.notification notificationContent.categoryIdentifier = NotificationConstants.Category.message } private func processInvited(notificationContent: inout UNMutableNotificationContent, notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol) async { notificationContent.categoryIdentifier = NotificationConstants.Category.invite notificationContent.body = if notificationItem.isDM { L10n.notificationInviteBody } else if notificationItem.isRoomSpace { L10n.notificationSpaceInviteBody } else { L10n.notificationRoomInviteBody } let name = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName await addCommunicationContext(notificationContent: ¬ificationContent, senderID: notificationItem.senderID, senderAvatarDisplayName: name, senderDisplayName: name, icon: icon(for: notificationItem), forcePlaceholder: userSession.inviteAvatarsVisibility == .off, mediaProvider: mediaProvider) } private func processMessageLike(notificationContent: inout UNMutableNotificationContent, notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol) async { notificationContent.title = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName if notificationContent.title != notificationItem.roomDisplayName { notificationContent.subtitle = notificationItem.roomDisplayName } notificationContent.categoryIdentifier = NotificationConstants.Category.message let senderAvatarDisplayName = if let displayName = notificationItem.senderDisplayName { displayName } else { notificationItem.senderID } let senderDisplayName = notificationItem.hasMention ? L10n.notificationSenderMentionReply(senderAvatarDisplayName) : senderAvatarDisplayName await addCommunicationContext(notificationContent: ¬ificationContent, senderID: notificationItem.senderID, senderAvatarDisplayName: senderAvatarDisplayName, senderDisplayName: senderDisplayName, icon: icon(for: notificationItem), mediaProvider: mediaProvider) } private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon { if notificationItem.isDM { if userSession.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { .init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: .init(avatarDisplayName: notificationItem.senderDisplayName ?? notificationItem.senderID, displayName: L10n.commonThread, id: "\(notificationItem.roomID)\(threadRootEventID)")) } else { .init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil) } } else { if userSession.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { .init(mediaSource: notificationItem.roomAvatarMediaSource, groupInfo: .init(avatarDisplayName: notificationItem.roomDisplayName, displayName: L10n.notificationThreadInRoom(notificationItem.roomDisplayName), id: "\(notificationItem.roomID)\(threadRootEventID)")) } else { .init(mediaSource: notificationItem.roomAvatarMediaSource, groupInfo: .init(avatarDisplayName: notificationItem.roomDisplayName, displayName: notificationItem.roomDisplayName, id: notificationItem.roomID)) } } } private func processRoomMessage(notificationContent: inout UNMutableNotificationContent, notificationItem: NotificationItemProxyProtocol, messageType: MessageType, mediaProvider: MediaProviderProtocol) async { let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName notificationContent.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName, isOutgoing: false).characters) let timelineMediaVisibility = await userSession.mediaPreviewVisibility guard timelineMediaVisibility == .on || (timelineMediaVisibility == .private && notificationItem.isRoomPrivate) else { return } switch messageType { case .image(content: let content): await addMediaAttachment(notificationContent: ¬ificationContent, using: mediaProvider, mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) case .audio(content: let content): await addMediaAttachment(notificationContent: ¬ificationContent, using: mediaProvider, mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) case .video(content: let content): await addMediaAttachment(notificationContent: ¬ificationContent, using: mediaProvider, mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) default: break } } private func addMediaAttachment(notificationContent: inout UNMutableNotificationContent, using mediaProvider: MediaProviderProtocol, mediaSource: MediaSourceProxy) async { switch await mediaProvider.loadFileFromSource(mediaSource) { case .success(let file): do { guard let url = file.url else { MXLog.error("Couldn't add media attachment: URL is nil") return } let identifier = ProcessInfo.processInfo.globallyUniqueString let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url, with: "\(identifier).\(url.pathExtension)") let attachment = try UNNotificationAttachment(identifier: identifier, url: newURL, options: nil) notificationContent.attachments.append(attachment) } catch { MXLog.error("Couldn't add media attachment:: \(error)") return } case .failure(let error): MXLog.error("Couldn't load the file for media attachment: \(error)") } } private func addCommunicationContext(notificationContent: inout UNMutableNotificationContent, senderID: String, senderAvatarDisplayName: String, senderDisplayName: String, icon: NotificationIcon, forcePlaceholder: Bool = false, mediaProvider: MediaProviderProtocol) async { var fetchedImage: INImage? let image: INImage if !forcePlaceholder, let mediaSource = icon.mediaSource { switch await mediaProvider.loadThumbnailForSource(source: mediaSource, size: .init(width: 100, height: 100)) { case .success(let data): fetchedImage = INImage(imageData: data) case .failure(let error): MXLog.error("Couldn't add sender icon: \(error)") } } if let fetchedImage { image = fetchedImage } else if let data = await Self.getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName, id: icon.groupInfo?.id ?? senderID) { image = INImage(imageData: data) } else { image = INImage(named: "") } let senderHandle = INPersonHandle(value: senderID, type: .unknown) let sender = INPerson(personHandle: senderHandle, nameComponents: nil, displayName: senderDisplayName, image: !icon.shouldDisplayAsGroup ? image : nil, contactIdentifier: nil, customIdentifier: nil) // These are required to show the group name as subtitle var speakableGroupName: INSpeakableString? var recipients: [INPerson]? if let groupInfo = icon.groupInfo { let meHandle = INPersonHandle(value: notificationContent.receiverID, type: .unknown) let me = INPerson(personHandle: meHandle, nameComponents: nil, displayName: nil, image: nil, contactIdentifier: nil, customIdentifier: nil, isMe: true) speakableGroupName = INSpeakableString(spokenPhrase: groupInfo.displayName) recipients = [sender, me] } let intent = INSendMessageIntent(recipients: recipients, outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: speakableGroupName, conversationIdentifier: notificationContent.roomID, serviceName: nil, sender: sender, attachments: nil) if speakableGroupName != nil { intent.setImage(image, forParameterNamed: \.speakableGroupName) } // Use the intent to initialize the interaction. let interaction = INInteraction(intent: intent, response: nil) // Interaction direction is incoming because the user is // receiving this message. interaction.direction = .incoming // Donate the interaction before updating notification content. if !ProcessInfo.isRunningTests { try? await interaction.donate() } // Update notification content before displaying the // communication notification. if let updatedContent = try? notificationContent.updating(from: intent) { // swiftlint:disable:next force_cast let content = updatedContent.mutableCopy() as! UNMutableNotificationContent notificationContent = content } } @MainActor static func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? { // The version value is used in case the design of the placeholder is updated to force a replacement let prefix = "notification_placeholderV9" let fileName = "\(prefix)_\(name)_\(id).png" if let data = try? Data(contentsOf: URL.temporaryDirectory.appendingPathComponent(fileName)) { MXLog.info("Found existing notification icon placeholder") return data } MXLog.info("Generating notification icon placeholder") let data = Avatars.generatePlaceholderAvatarImageData(name: name, id: id, size: .init(width: 50, height: 50)) if let data { do { // cache image data try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: fileName) } catch { MXLog.error("Could not store placeholder image") return data } } return data } } private nonisolated struct NotificationIcon { struct GroupInfo { let avatarDisplayName: String let displayName: String let id: String } let mediaSource: MediaSourceProxy? /// Required as the key to set images for groups let groupInfo: GroupInfo? var shouldDisplayAsGroup: Bool { groupInfo != nil } }