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)