diff --git a/Mail/Views/Thread/Message/BodyImageProcessor.swift b/Mail/Views/Thread/Message/BodyImageProcessor.swift new file mode 100644 index 000000000..428d2dc7f --- /dev/null +++ b/Mail/Views/Thread/Message/BodyImageProcessor.swift @@ -0,0 +1,130 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import MailCore +import OSLog +import UIKit + +struct BodyImageProcessor { + private let bodyImageMutator = BodyImageMutator() + + /// Download and encode all images for the current chunk in parallel. + public func fetchBase64Images( + _ attachments: ArraySlice, + mailboxManager: MailboxManager + ) async -> [ImageBase64AndMime?] { + // Force a fixed max concurrency to be a nice citizen to the network. + let base64Images: [ImageBase64AndMime?] = await attachments + .concurrentMap(customConcurrency: Constants.concurrentNetworkCalls) { attachment in + do { + let attachmentData = try await mailboxManager.attachmentData(attachment) + + // Skip compression on non static images types or already heic sources + guard attachment.mimeType.contains("jpg") + || attachment.mimeType.contains("jpeg") + || attachment.mimeType.contains("png") else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachment.mimeType) + } + + // Skip compression with lockdown mode enables as images can glitch + let isLockdownModeEnabled = (UserDefaults.standard.object(forKey: "LDMGlobalEnabled") as? Bool) ?? false + guard !isLockdownModeEnabled else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachment.mimeType) + } + + let compressedImage = compressedBase64ImageAndMime( + attachmentData: attachmentData, + attachmentMime: attachment.mimeType + ) + return compressedImage + } catch { + Logger.general.error("Error \(error) : Failed to fetch data for attachment: \(attachment)") + return nil + } + } + + assert(base64Images.count == attachments.count, "Arrays count should match") + return base64Images + } + + /// Try to compress the attachment with the best matched algorithm. Trade CPU cycles to reduce render time and memory usage. + private func compressedBase64ImageAndMime(attachmentData: Data, attachmentMime: String) -> ImageBase64AndMime { + guard #available(iOS 17.0, *) else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachmentMime) + } + + // On iOS17 Safari and iOS has support for heic. Quality is unchanged. Size is halved. + let image = UIImage(data: attachmentData) + guard let imageCompressed = image?.heicData(), imageCompressed.count < attachmentData.count else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachmentMime) + } + + let base64String = imageCompressed.base64EncodedString() + return ImageBase64AndMime(base64String, "image/heic") + } + + /// Inject base64 images in a body + public func injectImagesInBody( + body: String?, + attachments: ArraySlice, + base64Images: [ImageBase64AndMime?] + ) async -> String? { + guard let body, !body.isEmpty else { + return nil + } + + var workingBody = body + for (index, attachment) in attachments.enumerated() { + guard !Task.isCancelled else { + break + } + + guard let contentId = attachment.contentId, + let base64Image = base64Images[safe: index] as? ImageBase64AndMime else { + continue + } + + bodyImageMutator.replaceContentIdForBase64Image( + in: &workingBody, + contentId: contentId, + mimeType: base64Image.mimeType, + contentBase64Encoded: base64Image.imageEncoded + ) + } + return workingBody + } +} + +struct BodyImageMutator { + func replaceContentIdForBase64Image( + in body: inout String, + contentId: String, + mimeType: String, + contentBase64Encoded: String + ) { + body = body.replacingOccurrences( + of: "cid:\(contentId)", + with: "data:\(mimeType);base64,\(contentBase64Encoded)" + ) + } +} diff --git a/Mail/Views/Thread/Message/InlineAttachmentWorker.swift b/Mail/Views/Thread/Message/InlineAttachmentWorker.swift deleted file mode 100644 index ce5cec4e1..000000000 --- a/Mail/Views/Thread/Message/InlineAttachmentWorker.swift +++ /dev/null @@ -1,307 +0,0 @@ -/* - Infomaniak Mail - iOS App - Copyright (C) 2022 Infomaniak Network SA - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - */ - -import Algorithms -import CocoaLumberjackSwift -import Foundation -import InfomaniakConcurrency -import InfomaniakCore -import MailCore -import SwiftUI - -/// Something to process the Attachments outside of the mainActor -/// -/// Call `start()` to begin processing, call `stop` to make sure internal Task is cancelled. -final class InlineAttachmentWorker: ObservableObject { - private let bodyImageProcessor = BodyImageProcessor() - - /// The presentableBody with the current pre-processing (partial or done) - @Published var presentableBody: PresentableBody - - /// Set to true when done processing - @Published var isMessagePreprocessed: Bool - - var mailboxManager: MailboxManager? - - private let messageUid: String - - /// Tracking the preprocessing Task tree - private var processing: Task? - - public init(messageUid: String) { - self.messageUid = messageUid - isMessagePreprocessed = false - presentableBody = PresentableBody() - } - - deinit { - stop() - } - - func stop() { - processing?.cancel() - processing = nil - } - - func start(mailboxManager: MailboxManager) { - // Content was processed or is processing - guard !isMessagePreprocessed else { - return - } - - self.mailboxManager = mailboxManager - processing = Task { [weak self] in - guard let message = mailboxManager.transactionExecutor.fetchObject( - ofType: Message.self, - forPrimaryKey: self?.messageUid - )?.freeze() else { - return - } - - await self?.prepareBody(frozenMessage: message) - - guard !Task.isCancelled else { - return - } - - await self?.insertInlineAttachments(frozenMessage: message) - - guard !Task.isCancelled else { - return - } - - await self?.processingCompleted() - } - } - - private func prepareBody(frozenMessage: Message) async { - guard !Task.isCancelled else { - return - } - guard let updatedPresentableBody = await MessageBodyUtils.prepareWithPrintOption(message: frozenMessage) else { return } - - // Mutate DOM if task is active - guard !Task.isCancelled else { - return - } - await setPresentableBody(updatedPresentableBody) - } - - private func insertInlineAttachments(frozenMessage: Message) async { - guard !Task.isCancelled else { - return - } - - // Since mutation of the DOM is costly, I batch the processing of images, then mutate the DOM. - let attachmentsArray = frozenMessage.attachments.filter { $0.contentId != nil }.toArray() - - guard !attachmentsArray.isEmpty else { - return - } - - // Chunking, and processing each chunk. Opportunity to yield between each batch. - let chunks = attachmentsArray.chunks(ofCount: Constants.inlineAttachmentBatchSize) - for attachments in chunks { - guard !Task.isCancelled else { - return - } - - // Run each batch in a `Task` to get an `autoreleasepool` behaviour - let batchTask = Task { - await processInlineAttachments(attachments) - } - await batchTask.finish() - await Task.yield() - } - } - - private func processInlineAttachments(_ attachments: ArraySlice) async { - guard !Task.isCancelled else { - return - } - - guard let mailboxManager else { - Logger.general.error("processInlineAttachments will fail without a mailboxManager") - return - } - - let base64Images = await bodyImageProcessor.fetchBase64Images(attachments, mailboxManager: mailboxManager) - - guard !Task.isCancelled else { - return - } - - // Read the DOM once - let bodyParameters = await readPresentableBody() - let detachedBody = bodyParameters.detachedBody - - // process compact and base body in parallel - async let mailBody = bodyImageProcessor.injectImagesInBody(body: bodyParameters.bodyString, - attachments: attachments, - base64Images: base64Images) - - async let compactBody = bodyImageProcessor.injectImagesInBody(body: bodyParameters.compactBody, - attachments: attachments, - base64Images: base64Images) - - let bodyValue = await mailBody - let compactBodyCopy = await compactBody - detachedBody?.value = bodyValue - - let updatedPresentableBody = PresentableBody( - body: detachedBody, - compactBody: compactBodyCopy, - quotes: presentableBody.quotes - ) - - // Mutate DOM if task is still active - guard !Task.isCancelled else { - return - } - - await setPresentableBody(updatedPresentableBody) - } - - @MainActor private func setPresentableBody(_ body: PresentableBody) { - presentableBody = body - } - - @MainActor func processingCompleted() { - isMessagePreprocessed = true - } - - typealias BodyParts = (bodyString: String?, compactBody: String?, detachedBody: Body?) - @MainActor private func readPresentableBody() -> BodyParts { - let mailBody = presentableBody.body?.value - let compactBody = presentableBody.compactBody - let detachedBody = presentableBody.body?.detached() - - return (mailBody, compactBody, detachedBody) - } -} - -/// Something to package a base64 encoded image and its mime type -typealias ImageBase64AndMime = (imageEncoded: String, mimeType: String) - -/// Download compress and format images into a mail body -struct BodyImageProcessor { - private let bodyImageMutator = BodyImageMutator() - - /// Download and encode all images for the current chunk in parallel. - public func fetchBase64Images(_ attachments: ArraySlice, - mailboxManager: MailboxManager) async -> [ImageBase64AndMime?] { - // Force a fixed max concurrency to be a nice citizen to the network. - let base64Images: [ImageBase64AndMime?] = await attachments - .concurrentMap(customConcurrency: Constants.concurrentNetworkCalls) { attachment in - do { - let attachmentData = try await mailboxManager.attachmentData(attachment) - - // Skip compression on non static images types or already heic sources - guard attachment.mimeType.contains("jpg") - || attachment.mimeType.contains("jpeg") - || attachment.mimeType.contains("png") else { - let base64String = attachmentData.base64EncodedString() - return ImageBase64AndMime(base64String, attachment.mimeType) - } - - // Skip compression with lockdown mode enables as images can glitch - let isLockdownModeEnabled = (UserDefaults.standard.object(forKey: "LDMGlobalEnabled") as? Bool) ?? false - guard !isLockdownModeEnabled else { - let base64String = attachmentData.base64EncodedString() - return ImageBase64AndMime(base64String, attachment.mimeType) - } - - let compressedImage = compressedBase64ImageAndMime( - attachmentData: attachmentData, - attachmentMime: attachment.mimeType - ) - return compressedImage - - } catch { - Logger.general.error("Error \(error) : Failed to fetch data for attachment: \(attachment)") - return nil - } - } - - assert(base64Images.count == attachments.count, "Arrays count should match") - return base64Images - } - - /// Try to compress the attachment with the best matched algorithm. Trade CPU cycles to reduce render time and memory usage. - private func compressedBase64ImageAndMime(attachmentData: Data, attachmentMime: String) -> ImageBase64AndMime { - guard #available(iOS 17.0, *) else { - let base64String = attachmentData.base64EncodedString() - return ImageBase64AndMime(base64String, attachmentMime) - } - - // On iOS17 Safari _and_ iOS has support for heic. Quality is unchanged. Size is halved. - let image = UIImage(data: attachmentData) - guard let imageCompressed = image?.heicData(), - imageCompressed.count < attachmentData.count else { - let base64String = attachmentData.base64EncodedString() - return ImageBase64AndMime(base64String, attachmentMime) - } - - let base64String = imageCompressed.base64EncodedString() - return ImageBase64AndMime(base64String, "image/heic") - } - - /// Inject base64 images in a body - public func injectImagesInBody(body: String?, - attachments: ArraySlice, - base64Images: [ImageBase64AndMime?]) async -> String? { - guard let body, !body.isEmpty else { - return nil - } - - var workingBody = body - for (index, attachment) in attachments.enumerated() { - guard !Task.isCancelled else { - break - } - - guard let contentId = attachment.contentId, - let base64Image = base64Images[safe: index] as? ImageBase64AndMime else { - continue - } - - bodyImageMutator.replaceContentIdForBase64Image( - in: &workingBody, - contentId: contentId, - mimeType: base64Image.mimeType, - contentBase64Encoded: base64Image.imageEncoded - ) - } - return workingBody - } -} - -/// Something to insert base64 image into a mail body. Easily testable. -struct BodyImageMutator { - func replaceContentIdForBase64Image( - in body: inout String, - contentId: String, - mimeType: String, - contentBase64Encoded: String - ) { - body = body.replacingOccurrences( - of: "cid:\(contentId)", - with: "data:\(mimeType);base64,\(contentBase64Encoded)" - ) - } -} diff --git a/Mail/Views/Thread/Message/MessageBodyView.swift b/Mail/Views/Thread/Message/MessageBody/MessageBodyContentView.swift similarity index 72% rename from Mail/Views/Thread/Message/MessageBodyView.swift rename to Mail/Views/Thread/Message/MessageBody/MessageBodyContentView.swift index 17480c370..ed3f71bca 100644 --- a/Mail/Views/Thread/Message/MessageBodyView.swift +++ b/Mail/Views/Thread/Message/MessageBody/MessageBodyContentView.swift @@ -20,62 +20,47 @@ import InfomaniakCoreUI import InfomaniakDI import MailCore import MailCoreUI -import MailResources import RealmSwift import SwiftSoup import SwiftUI -struct MessageBodyView: View { +struct MessageBodyContentView: View { @LazyInjectService private var matomo: MatomoUtils @LazyInjectService private var snackbarPresenter: SnackBarPresentable @StateObject private var model = WebViewModel() - let presentableBody: PresentableBody - let isMessagePreprocessed: Bool - var blockRemoteContent: Bool - let messageUid: String - @Binding var displayContentBlockedActionView: Bool + let presentableBody: PresentableBody? + let blockRemoteContent: Bool + let messageUid: String + private let printNotificationPublisher = NotificationCenter.default.publisher(for: Notification.Name.printNotification) var body: some View { ZStack { VStack { - if presentableBody.body != nil { + if let presentableBody, presentableBody.body != nil { WebView(webView: model.webView, messageUid: messageUid) { - loadBody(blockRemoteContent: blockRemoteContent) + loadBody(blockRemoteContent: blockRemoteContent, presentableBody: presentableBody) } .frame(height: model.webViewHeight) .onAppear { - loadBody(blockRemoteContent: blockRemoteContent) - } - .onChange(of: presentableBody) { _ in - loadBody(blockRemoteContent: blockRemoteContent) + loadBody(blockRemoteContent: blockRemoteContent, presentableBody: presentableBody) } - .onChange(of: isMessagePreprocessed) { _ in - loadBody(blockRemoteContent: blockRemoteContent) + .onChange(of: presentableBody) { newValue in + loadBody(blockRemoteContent: blockRemoteContent, presentableBody: newValue) } .onChange(of: model.showBlockQuote) { _ in - loadBody(blockRemoteContent: blockRemoteContent) + loadBody(blockRemoteContent: blockRemoteContent, presentableBody: presentableBody) } .onChange(of: blockRemoteContent) { newValue in - loadBody(blockRemoteContent: newValue) + loadBody(blockRemoteContent: newValue, presentableBody: presentableBody) } if !presentableBody.quotes.isEmpty { - Button( - model.showBlockQuote - ? MailResourcesStrings.Localizable.messageHideQuotedText - : MailResourcesStrings.Localizable.messageShowQuotedText - ) { - model.showBlockQuote.toggle() - } - .buttonStyle(.ikBorderless(isInlined: true)) - .controlSize(.small) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, value: .medium) + ShowBlockquoteButton(showBlockquote: $model.showBlockQuote) } } } @@ -110,7 +95,9 @@ struct MessageBodyView: View { } } - private func loadBody(blockRemoteContent: Bool) { + private func loadBody(blockRemoteContent: Bool, presentableBody: PresentableBody?) { + guard let presentableBody else { return } + Task { let loadResult = try await model.loadBody( presentableBody: presentableBody, @@ -124,11 +111,10 @@ struct MessageBodyView: View { } #Preview { - MessageBodyView( + MessageBodyContentView( + displayContentBlockedActionView: .constant(false), presentableBody: PreviewHelper.samplePresentableBody, - isMessagePreprocessed: true, blockRemoteContent: false, - messageUid: "message_uid", - displayContentBlockedActionView: .constant(false) + messageUid: "message_uid" ) } diff --git a/Mail/Views/Thread/Message/MessageBody/MessageBodyView.swift b/Mail/Views/Thread/Message/MessageBody/MessageBodyView.swift new file mode 100644 index 000000000..eae8c83a8 --- /dev/null +++ b/Mail/Views/Thread/Message/MessageBody/MessageBodyView.swift @@ -0,0 +1,68 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import MailCoreUI +import MailResources +import SwiftUI + +struct MessageBodyView: View { + @EnvironmentObject private var messagesWorker: MessagesWorker + + @State private var isShowingLoadingError = false + + @Binding var displayContentBlockedActionView: Bool + + let isRemoteContentBlocked: Bool + let messageUid: String + + var body: some View { + ZStack { + if isShowingLoadingError { + Text(MailResourcesStrings.Localizable.errorLoadingMessage) + .textStyle(.bodySmallItalicSecondary) + .padding(value: .medium) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + MessageBodyContentView( + displayContentBlockedActionView: $displayContentBlockedActionView, + presentableBody: messagesWorker.presentableBodies[messageUid], + blockRemoteContent: isRemoteContentBlocked, + messageUid: messageUid + ) + } + } + .task { + await tryOrDisplayError { + do { + try await messagesWorker.fetchAndProcessIfNeeded(messageUid: messageUid) + } catch is MessagesWorker.WorkerError { + isShowingLoadingError = true + } + } + } + } +} + +#Preview { + MessageBodyView( + displayContentBlockedActionView: .constant(false), + isRemoteContentBlocked: false, + messageUid: PreviewHelper.sampleMessage.uid + ) +} diff --git a/Mail/Views/Thread/Message/MessageBody/ShowBlockquoteButton.swift b/Mail/Views/Thread/Message/MessageBody/ShowBlockquoteButton.swift new file mode 100644 index 000000000..e57f41754 --- /dev/null +++ b/Mail/Views/Thread/Message/MessageBody/ShowBlockquoteButton.swift @@ -0,0 +1,47 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCoreUI +import MailResources +import SwiftUI + +struct ShowBlockquoteButton: View { + @Binding var showBlockquote: Bool + + private var label: String { + if showBlockquote { + return MailResourcesStrings.Localizable.messageHideQuotedText + } else { + return MailResourcesStrings.Localizable.messageShowQuotedText + } + } + + var body: some View { + Button(label) { + showBlockquote.toggle() + } + .buttonStyle(.ikBorderless(isInlined: true)) + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(value: .medium) + } +} + +#Preview { + ShowBlockquoteButton(showBlockquote: .constant(true)) +} diff --git a/Mail/Views/Thread/Message/MessageView.swift b/Mail/Views/Thread/Message/MessageView.swift index 3b5f14699..cdd4c11a1 100644 --- a/Mail/Views/Thread/Message/MessageView.swift +++ b/Mail/Views/Thread/Message/MessageView.swift @@ -34,15 +34,9 @@ extension EnvironmentValues { /// Something that can display an email struct MessageView: View { - @LazyInjectService private var snackbarPresenter: SnackBarPresentable - @Environment(\.isMessageInteractive) private var isMessageInteractive - @EnvironmentObject private var mailboxManager: MailboxManager - - @State private var isShowingErrorLoading = false @State private var displayContentBlockedActionView = false - @StateObject private var inlineAttachmentWorker: InlineAttachmentWorker @Binding var threadForcedExpansion: [String: MessageExpansionType] @@ -57,12 +51,6 @@ struct MessageView: View { threadForcedExpansion[message.uid] == .expanded } - init(message: Message, threadForcedExpansion: Binding<[String: MessageExpansionType]>) { - self.message = message - _threadForcedExpansion = threadForcedExpansion - _inlineAttachmentWorker = StateObject(wrappedValue: InlineAttachmentWorker(messageUid: message.uid)) - } - var body: some View { VStack(spacing: 0) { MessageHeaderView( @@ -83,41 +71,14 @@ struct MessageView: View { ) } - if isShowingErrorLoading { - Text(MailResourcesStrings.Localizable.errorLoadingMessage) - .textStyle(.bodySmallItalicSecondary) - .padding(.horizontal, value: .medium) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - MessageBodyView( - presentableBody: inlineAttachmentWorker.presentableBody, - isMessagePreprocessed: inlineAttachmentWorker.isMessagePreprocessed, - blockRemoteContent: isRemoteContentBlocked, - messageUid: message.uid, - displayContentBlockedActionView: $displayContentBlockedActionView - ) - } + MessageBodyView( + displayContentBlockedActionView: $displayContentBlockedActionView, + isRemoteContentBlocked: isRemoteContentBlocked, + messageUid: message.uid + ) } } } - .onAppear { - prepareBodyIfNeeded() - } - .task { - await fetchMessageAndEventCalendar() - } - .task(id: isMessageExpanded) { - await fetchMessageAndEventCalendar() - } - .onDisappear { - inlineAttachmentWorker.stop() - } - .onChange(of: message.fullyDownloaded) { _ in - prepareBodyIfNeeded() - } - .onChange(of: isMessageExpanded) { _ in - prepareBodyIfNeeded() - } .accessibilityAction(named: MailResourcesStrings.Localizable.expandMessage) { guard isMessageInteractive else { return } withAnimation { @@ -125,65 +86,20 @@ struct MessageView: View { } } } - - private func fetchMessageAndEventCalendar() async { - guard isMessageExpanded else { return } - - async let fetchMessageResult: Void = fetchMessage() - - async let fetchEventCalendar: Void = fetchEventCalendar() - - await fetchMessageResult - await fetchEventCalendar - } - - private func fetchMessage() async { - guard message.shouldComplete else { return } - - await tryOrDisplayError { - do { - try await mailboxManager.message(message: message) - } catch let error as MailApiError where error == .apiMessageNotFound { - snackbarPresenter.show(message: error.errorDescription ?? "") - try await mailboxManager.refreshFolder(from: [message], additionalFolder: nil) - } catch let error as AFErrorWithContext where error.afError.isExplicitlyCancelledError { - isShowingErrorLoading = false - } catch { - isShowingErrorLoading = true - } - } - } - - private func fetchEventCalendar() async { - try? await mailboxManager.calendarEvent(from: message.uid) - } -} - -/// MessageView code related to pre-processing -extension MessageView { - func prepareBodyIfNeeded() { - guard message.fullyDownloaded, isMessageExpanded else { - return - } - - inlineAttachmentWorker.start(mailboxManager: mailboxManager) - } } @available(iOS 17.0, *) #Preview("Message collapsed", traits: .sizeThatFitsLayout) { MessageView( - message: PreviewHelper.sampleMessage, - threadForcedExpansion: .constant([PreviewHelper.sampleMessage.uid: .collapsed]) + threadForcedExpansion: .constant([PreviewHelper.sampleMessage.uid: .collapsed]), + message: PreviewHelper.sampleMessage ) - .environmentObject(PreviewHelper.sampleMailboxManager) } @available(iOS 17.0, *) #Preview("Message expanded", traits: .sizeThatFitsLayout) { MessageView( - message: PreviewHelper.sampleMessage, - threadForcedExpansion: .constant([PreviewHelper.sampleMessage.uid: .expanded]) + threadForcedExpansion: .constant([PreviewHelper.sampleMessage.uid: .expanded]), + message: PreviewHelper.sampleMessage ) - .environmentObject(PreviewHelper.sampleMailboxManager) } diff --git a/Mail/Views/Thread/MessageListView.swift b/Mail/Views/Thread/MessageListView.swift index a2fa99f91..483334577 100644 --- a/Mail/Views/Thread/MessageListView.swift +++ b/Mail/Views/Thread/MessageListView.swift @@ -28,6 +28,7 @@ enum MessageExpansionType: Equatable { } struct MessageListView: View { + @StateObject private var messagesWorker = MessagesWorker() @State private var messageExpansion = [String: MessageExpansionType]() let messages: [Message] @@ -43,10 +44,7 @@ struct MessageListView: View { } } else if messageExpansion[message.uid] != .superCollapsed { VStack(spacing: 0) { - MessageView( - message: message, - threadForcedExpansion: $messageExpansion - ) + MessageView(threadForcedExpansion: $messageExpansion, message: message) if divider(for: message) { IKDivider(type: .full) } @@ -58,8 +56,7 @@ struct MessageListView: View { .onAppear { computeExpansion(from: messages) - guard messages.count > 1, - let firstExpandedUid = firstExpanded()?.uid else { + guard messages.count > 1, let firstExpandedUid = firstExpanded()?.uid else { return } @@ -70,6 +67,7 @@ struct MessageListView: View { } } } + .environmentObject(messagesWorker) .id(messages.id) } } diff --git a/Mail/Views/Thread/MessagesWorker.swift b/Mail/Views/Thread/MessagesWorker.swift new file mode 100644 index 000000000..9e501274f --- /dev/null +++ b/Mail/Views/Thread/MessagesWorker.swift @@ -0,0 +1,199 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakDI +import MailCore + +typealias ImageBase64AndMime = (imageEncoded: String, mimeType: String) + +extension MessagesWorker { + enum WorkerError: Error { + case cantFetchMessage + } +} + +@MainActor +final class MessagesWorker: ObservableObject { + @LazyInjectService private var accountManager: AccountManager + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + + @Published var presentableBodies = [String: PresentableBody]() + + private var replacedAllAttachments = [String: Bool]() + private let bodyImageProcessor = BodyImageProcessor() + + func fetchAndProcessIfNeeded(messageUid: String) async throws { + guard let mailboxManager = accountManager.currentMailboxManager else { + return + } + + try await fetchMessageAndCalendar(of: messageUid, with: mailboxManager) + guard !Task.isCancelled else { return } + await prepareBodyAndAttachments(of: messageUid, with: mailboxManager) + } +} + +// MARK: - Fetch Message and Calendar Event + +extension MessagesWorker { + private func fetchMessageAndCalendar(of messageUid: String, with mailboxManager: MailboxManager) async throws { + guard let message = getFrozenMessage(uid: messageUid, with: mailboxManager) else { + return + } + + async let fetchMessageResult: Void = fetchMessage(of: message, with: mailboxManager) + async let fetchEventCalendar: Void = fetchEventCalendar(of: message, with: mailboxManager) + + try await fetchMessageResult + await fetchEventCalendar + } + + private func fetchMessage(of message: Message, with mailboxManager: MailboxManager) async throws { + guard message.shouldComplete else { + return + } + + do { + try await mailboxManager.message(message: message) + } catch let error as MailApiError where error == .apiMessageNotFound { + snackbarPresenter.show(message: error.errorDescription ?? "") + try? await mailboxManager.refreshFolder(from: [message], additionalFolder: nil) + } catch { + throw WorkerError.cantFetchMessage + } + } + + private func fetchEventCalendar(of message: Message, with mailboxManager: MailboxManager) async { + try? await mailboxManager.calendarEvent(from: message.uid) + } +} + +// MARK: - Prepare body + +extension MessagesWorker { + private func prepareBodyAndAttachments(of messageUid: String, with mailboxManager: MailboxManager) async { + guard let message = getFrozenMessage(uid: messageUid, with: mailboxManager) else { + return + } + + await prepareBody(of: message) + guard !Task.isCancelled else { return } + await insertInlineAttachments(for: message, with: mailboxManager) + } + + private func prepareBody(of message: Message) async { + guard !Task.isCancelled else { return } + + guard !hasPresentableBody(messageUid: message.uid), + let updatedPresentableBody = await MessageBodyUtils.prepareWithPrintOption(message: message) else { + return + } + + setPresentableBody(updatedPresentableBody, for: message) + } +} + +// MARK: - Inline attachments + +extension MessagesWorker { + private func insertInlineAttachments(for frozenMessage: Message, with mailboxManager: MailboxManager) async { + guard !Task.isCancelled else { return } + + guard !hasPresentableBodyWithAllAttachments(messageUid: frozenMessage.uid) else { + return + } + + let attachmentsArray = frozenMessage.attachments.filter { $0.contentId != nil }.toArray() + guard !attachmentsArray.isEmpty else { + return + } + + let chunks = attachmentsArray.chunks(ofCount: Constants.inlineAttachmentBatchSize) + for attachments in chunks { + guard !Task.isCancelled else { return } + let batchTask = Task { + await processInlineAttachments(attachments, for: frozenMessage, with: mailboxManager) + } + await batchTask.finish() + } + + setReplacedAllAttachments(for: frozenMessage) + } + + private func processInlineAttachments( + _ attachments: ArraySlice, + for frozenMessage: Message, + with mailboxManager: MailboxManager + ) async { + guard !Task.isCancelled else { return } + + guard let presentableBody = presentableBodies[frozenMessage.uid] else { return } + + let base64Images = await bodyImageProcessor.fetchBase64Images(attachments, mailboxManager: mailboxManager) + + async let mailBody = bodyImageProcessor.injectImagesInBody( + body: presentableBody.body?.value, + attachments: attachments, + base64Images: base64Images + ) + async let compactBody = bodyImageProcessor.injectImagesInBody( + body: presentableBody.compactBody, + attachments: attachments, + base64Images: base64Images + ) + + let bodyValue = await mailBody + let compactBodyCopy = await compactBody + + let body = presentableBody.body?.detached() + body?.value = bodyValue + + let updatedPresentableBody = PresentableBody( + body: body, + compactBody: compactBodyCopy, + quotes: presentableBody.quotes + ) + + setPresentableBody(updatedPresentableBody, for: frozenMessage) + } +} + +// MARK: - Utils + +extension MessagesWorker { + private func getFrozenMessage(uid: String, with mailboxManager: MailboxManager) -> Message? { + return mailboxManager.transactionExecutor.fetchObject(ofType: Message.self, forPrimaryKey: uid)?.freeze() + } + + private func setPresentableBody(_ presentableBody: PresentableBody, for message: Message) { + presentableBodies[message.uid] = presentableBody + } + + private func hasPresentableBody(messageUid: String) -> Bool { + return presentableBodies[messageUid] != nil + } + + private func setReplacedAllAttachments(for message: Message) { + replacedAllAttachments[message.uid] = true + } + + private func hasPresentableBodyWithAllAttachments(messageUid: String) -> Bool { + return replacedAllAttachments[messageUid, default: false] + } +} diff --git a/Mail/Views/Thread/ThreadView.swift b/Mail/Views/Thread/ThreadView.swift index 9f9a217ee..9ebb29e5b 100644 --- a/Mail/Views/Thread/ThreadView.swift +++ b/Mail/Views/Thread/ThreadView.swift @@ -67,12 +67,6 @@ struct ThreadView: View { .task { await markThreadAsReadIfNeeded(thread: thread) } - .onChange(of: thread) { newValue in - guard newValue.uid != thread.uid else { return } - Task { - await markThreadAsReadIfNeeded(thread: newValue) - } - } .navigationTitle(displayNavigationTitle ? thread.formattedSubject : "") .navigationBarThreadViewStyle(appearance: displayNavigationTitle ? BarAppearanceConstants .threadViewNavigationBarScrolledAppearance : BarAppearanceConstants.threadViewNavigationBarAppearance) @@ -82,6 +76,7 @@ struct ThreadView: View { frozenFolder: thread.folder?.freezeIfNeeded(), frozenMessages: thread.messages.freezeIfNeeded().toArray() ) + .id(thread.id) .matomoView(view: [MatomoUtils.View.threadView.displayName, "Main"]) } diff --git a/MailCore/Models/Body.swift b/MailCore/Models/Body.swift index 5fdeb44cc..2eda1e0bd 100644 --- a/MailCore/Models/Body.swift +++ b/MailCore/Models/Body.swift @@ -106,10 +106,6 @@ final class ProxyBody: Codable { compactBody = nil quotes = [] } - - public init(presentableBody: PresentableBody) { - self.init(body: presentableBody.body, compactBody: presentableBody.compactBody, quotes: presentableBody.quotes) - } } // MARK: - SubBody diff --git a/MailNotificationContentExtension/NotificationViewController.swift b/MailNotificationContentExtension/NotificationViewController.swift index ffb8fb966..aada4dcc8 100644 --- a/MailNotificationContentExtension/NotificationViewController.swift +++ b/MailNotificationContentExtension/NotificationViewController.swift @@ -89,12 +89,9 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi } let messageView = ScrollView { - MessageView( - message: message, - threadForcedExpansion: .constant([messageUid: .expanded]) - ) - .environment(\.isMessageInteractive, false) - .environmentObject(mailboxManager) + MessageView(threadForcedExpansion: .constant([messageUid: .expanded]), message: message) + .environment(\.isMessageInteractive, false) + .environmentObject(mailboxManager) } let hostingViewController = UIHostingController(rootView: messageView)