diff --git a/Mail/Components/RecipientField.swift b/Mail/Components/RecipientField.swift index f4b489b08..0a0e2540a 100644 --- a/Mail/Components/RecipientField.swift +++ b/Mail/Components/RecipientField.swift @@ -18,6 +18,7 @@ import MailCore import MailResources +import RealmSwift import SwiftUI import WrappingHStack @@ -41,7 +42,7 @@ struct RecipientChip: View { } struct RecipientField: View { - @Binding var recipients: [Recipient] + @Binding var recipients: RealmSwift.List @Binding var autocompletion: [Recipient] @Binding var addRecipientHandler: ((Recipient) -> Void)? @FocusState var focusedField: ComposeViewFieldType? @@ -54,9 +55,7 @@ struct RecipientField: View { if !recipients.isEmpty { WrappingHStack(recipients.indices, spacing: .constant(8), lineSpacing: 8) { i in RecipientChip(recipient: recipients[i]) { - withAnimation { - _ = recipients.remove(at: i) - } + remove(recipientAt: i) } } .alignmentGuide(.newMessageCellAlignment) { d in d[.top] + 21 } @@ -96,17 +95,23 @@ struct RecipientField: View { private func add(recipient: Recipient) { withAnimation { - recipients.append(recipient) + $recipients.append(recipient) } currentText = "" } + + private func remove(recipientAt: Int) { + withAnimation { + $recipients.remove(at: recipientAt) + } + } } struct RecipientField_Previews: PreviewProvider { static var previews: some View { RecipientField(recipients: .constant([ PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 - ]), + ].toRealmList()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), diff --git a/Mail/Helpers/RichTextEditor.swift b/Mail/Helpers/RichTextEditor.swift index 5c2e1d93d..c017aa068 100644 --- a/Mail/Helpers/RichTextEditor.swift +++ b/Mail/Helpers/RichTextEditor.swift @@ -32,12 +32,6 @@ struct RichTextEditor: UIViewRepresentable { @Binding var isShowingPhotoLibrary: Bool var alert: ObservedObject.Wrapper - private var isFirstTime = true - private var delegateCount = 0 - private var isInitialized: Bool { - delegateCount > 2 - } - init(model: Binding, body: Binding, alert: ObservedObject.Wrapper, isShowingCamera: Binding, isShowingFileSelection: Binding, isShowingPhotoLibrary: Binding) { @@ -72,8 +66,6 @@ struct RichTextEditor: UIViewRepresentable { } func editor(_ editor: SQTextEditorView, cursorPositionDidChange position: SQEditorCursorPosition) { - parent.delegateCount += 1 - guard parent.isInitialized else { return } let newCursorPosition = CGFloat(position.bottom) + 20 if parent.model.cursorPosition != newCursorPosition { parent.model.cursorPosition = newCursorPosition @@ -89,10 +81,9 @@ struct RichTextEditor: UIViewRepresentable { func editorContentChanged(_ editor: SQTextEditorView, content: String) { var parentBody = parent.body.trimmingCharacters(in: .whitespacesAndNewlines) parentBody = parentBody.replacingOccurrences(of: "\r", with: "") - if parentBody != content && !parent.isFirstTime { + if parentBody != content { parent.body = content } - parent.isFirstTime = false } } diff --git a/Mail/Utils/DraftUtils.swift b/Mail/Utils/DraftUtils.swift index f66740705..9ce272464 100644 --- a/Mail/Utils/DraftUtils.swift +++ b/Mail/Utils/DraftUtils.swift @@ -20,27 +20,25 @@ import Foundation import MailCore import SwiftUI +@MainActor class DraftUtils { - @MainActor public static func editDraft(from thread: Thread, mailboxManager: MailboxManager, editedMessageDraft: Binding) { + public static func editDraft(from thread: Thread, mailboxManager: MailboxManager, editedMessageDraft: Binding) { guard let message = thread.messages.first else { return } - var sheetPresented = false - // If we already have the draft locally, present it directly if let draft = mailboxManager.draft(messageUid: message.uid)?.detached() { editedMessageDraft.wrappedValue = draft - sheetPresented = true - // Maybe it was an offline draft (If offline draft is created with draft.uuid = thread.uid) - } else if let localDraft = mailboxManager.draft(localUuid: thread.uid)?.detached() { - editedMessageDraft.wrappedValue = localDraft - sheetPresented = true + } else { + DraftUtils.editDraft(from: message, mailboxManager: mailboxManager, editedMessageDraft: editedMessageDraft) } + } - // Update the draft - Task { [sheetPresented] in - let draft = try await mailboxManager.draft(from: message) - if !sheetPresented { - editedMessageDraft.wrappedValue = draft - } + public static func editDraft(from message: Message, mailboxManager: MailboxManager, editedMessageDraft: Binding) { + // If we already have the draft locally, present it directly + if let draft = mailboxManager.draft(messageUid: message.uid)?.detached() { + editedMessageDraft.wrappedValue = draft + // Draft comes from API, we will update it after showing the ComposeMessageView + } else { + editedMessageDraft.wrappedValue = Draft(messageUid: message.uid) } } } diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 14dfedb76..4835a9927 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -53,8 +53,7 @@ struct ComposeMessageView: View { @Environment(\.dismiss) private var dismiss @State private var mailboxManager: MailboxManager - @State private var draft: UnmanagedDraft - @State private var originalBody: String + @StateRealmObject var draft: Draft @State private var editor = RichTextEditorModel() @State private var showCc = false @FocusState private var focusedField: ComposeViewFieldType? @@ -64,50 +63,48 @@ struct ComposeMessageView: View { @State private var isShowingFileSelection = false @State private var isShowingPhotoLibrary = false - @State var sendDraft = false - @State var scrollView: UIScrollView? @StateObject private var alert = NewMessageAlert() - @State private var sendDisabled: Bool - private var shouldDisplayAutocompletion: Bool { return !autocompletion.isEmpty && focusedField != nil } - private init(mailboxManager: MailboxManager, draft: UnmanagedDraft) { + private init(mailboxManager: MailboxManager, draft: Draft) { _mailboxManager = State(initialValue: mailboxManager) - var initialDraft = draft - if initialDraft.identityId.isEmpty, + if draft.identityId == nil || draft.identityId?.isEmpty == true, let signature = mailboxManager.getSignatureResponse() { - initialDraft.setSignature(signature) + draft.setSignature(signature) } - _sendDisabled = State(initialValue: mailboxManager.getSignatureResponse() == nil || draft.to.isEmpty) - initialDraft.delay = UserDefaults.shared.cancelSendDelay.rawValue - _draft = State(initialValue: initialDraft) - _showCc = State(initialValue: !initialDraft.bcc.isEmpty || !initialDraft.cc.isEmpty) - _originalBody = State(initialValue: initialDraft.body) + let realm = mailboxManager.getRealm() + try? realm.write { + draft.action = draft.action == nil && draft.remoteUUID.isEmpty ? .initialSave : .save + draft.delay = UserDefaults.shared.cancelSendDelay.rawValue + + realm.add(draft, update: .modified) + } + + _draft = StateRealmObject(wrappedValue: draft) + _showCc = State(initialValue: !draft.bcc.isEmpty || !draft.cc.isEmpty) } static func newMessage(mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView(mailboxManager: mailboxManager, draft: .empty()) + return ComposeMessageView(mailboxManager: mailboxManager, draft: Draft(localUUID: UUID().uuidString)) } static func replyOrForwardMessage(messageReply: MessageReply, mailboxManager: MailboxManager) -> ComposeMessageView { let message = messageReply.message // If message doesn't exist anymore try to show the frozen one - let realm = mailboxManager.getRealm() - realm.refresh() - let freshMessage = message.fresh(using: realm) ?? message + let freshMessage = message.thaw() ?? message return ComposeMessageView( mailboxManager: mailboxManager, - draft: .replying(to: freshMessage, mode: messageReply.replyMode) + draft: .replying(to: freshMessage, mode: messageReply.replyMode, localDraftUUID: messageReply.localDraftUUID) ) } static func editDraft(draft: Draft, mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView(mailboxManager: mailboxManager, draft: draft.asUnmanaged()) + return ComposeMessageView(mailboxManager: mailboxManager, draft: draft) } static func writingTo(recipient: Recipient, mailboxManager: MailboxManager) -> ComposeMessageView { @@ -115,11 +112,11 @@ struct ComposeMessageView: View { } static func mailTo(urlComponents: URLComponents, mailboxManager: MailboxManager) -> ComposeMessageView { - let draft = UnmanagedDraft.mailTo(subject: urlComponents.getQueryItem(named: "subject"), - body: urlComponents.getQueryItem(named: "body"), - to: [Recipient(email: urlComponents.path, name: "")], - cc: Recipient.createListUsing(from: urlComponents, name: "cc"), - bcc: Recipient.createListUsing(from: urlComponents, name: "bcc")) + let draft = Draft.mailTo(subject: urlComponents.getQueryItem(named: "subject"), + body: urlComponents.getQueryItem(named: "body"), + to: [Recipient(email: urlComponents.path, name: "")], + cc: Recipient.createListUsing(from: urlComponents, name: "cc"), + bcc: Recipient.createListUsing(from: urlComponents, name: "bcc")) return ComposeMessageView(mailboxManager: mailboxManager, draft: draft) } @@ -154,7 +151,8 @@ struct ComposeMessageView: View { .focused($focusedField, equals: .subject) } - if let attachments = draft.attachments?.filter { $0.contentId == nil }, !attachments.isEmpty { + if let attachments = draft.attachments.filter { $0.contentId == nil }.toArray(), + !attachments.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(attachments) { attachment in @@ -179,6 +177,13 @@ struct ComposeMessageView: View { } } } + .overlay { + if draft.messageUid != nil && draft.remoteUUID.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MailResourcesAsset.backgroundColor.swiftUiColor) + } + } .introspectScrollView { scrollView in self.scrollView = scrollView } @@ -198,43 +203,20 @@ struct ComposeMessageView: View { Label(MailResourcesStrings.Localizable.buttonClose, systemImage: "xmark") }, trailing: Button(action: { - sendDraft = true - originalBody = draft.body - Task { - await DraftManager.shared.instantSaveDraftLocally(draft: draft, mailboxManager: mailboxManager, action: .send) - dismiss() - } + sendDraft() }, label: { Image(resource: MailResourcesAsset.send) }) - .disabled(sendDisabled) + .disabled(draft.identityId?.isEmpty == true || draft.to.isEmpty) ) .background(MailResourcesAsset.backgroundColor.swiftUiColor) } - .onChange(of: draft) { _ in - Task { - await DraftManager.shared.saveDraftLocally(draft: draft, mailboxManager: mailboxManager, action: .save) - sendDisabled = draft.to.isEmpty - } - } .onAppear { focusedField = .to } .onDisappear { - // TODO: - Compare message body to original body : guard draft.body != originalBody || !draft.uuid.isEmpty else { return } - Task { - if !sendDraft { - await DraftManager.shared.instantSaveDraftLocally( - draft: draft, - mailboxManager: mailboxManager, - action: .save - ) - } - DraftManager.shared.syncDraft(mailboxManager: mailboxManager) - - sendDraft = false } } .fullScreenCover(isPresented: $isShowingCamera) { @@ -267,6 +249,18 @@ struct ComposeMessageView: View { EmptyView() } } + .task { + guard draft.messageUid != nil && draft.remoteUUID.isEmpty else { return } + + do { + if let fetchedDraft = try await mailboxManager.draft(partialDraft: draft), + let liveFetchedDraft = fetchedDraft.thaw() { + self.draft = liveFetchedDraft + } + } catch { + // Fail silently + } + } .navigationViewStyle(.stack) .defaultAppStorage(.shared) } @@ -287,8 +281,8 @@ struct ComposeMessageView: View { } } - private func binding(for type: ComposeViewFieldType) -> Binding<[Recipient]> { - let binding: Binding<[Recipient]> + private func binding(for type: ComposeViewFieldType) -> Binding> { + let binding: Binding> switch type { case .to: binding = $draft.to @@ -297,11 +291,20 @@ struct ComposeMessageView: View { case .bcc: binding = $draft.bcc default: - binding = .constant([]) + fatalError("Unhandled binding \(type)") } return binding } + private func sendDraft() { + if let liveDraft = draft.thaw() { + try? liveDraft.realm?.write { + liveDraft.action = .send + } + } + dismiss() + } + // MARK: Attachments func addDocumentAttachment(urls: [URL]) async { @@ -434,16 +437,12 @@ struct ComposeMessageView: View { } private func addAttachment(_ attachment: Attachment) { - if draft.attachments == nil { - draft.attachments = [attachment] - } else { - draft.attachments?.append(attachment) - } + draft.attachments.append(attachment) } private func removeAttachment(_ attachment: Attachment) { - if let attachments = draft.attachments, let attachmentToRemove = attachments.firstIndex(of: attachment) { - draft.attachments?.remove(at: attachmentToRemove) + if let attachmentToRemove = draft.attachments.firstIndex(of: attachment) { + draft.attachments.remove(at: attachmentToRemove) } } } diff --git a/Mail/Views/New Message/NewMessageCell.swift b/Mail/Views/New Message/NewMessageCell.swift index 80fc90145..4b0564ca7 100644 --- a/Mail/Views/New Message/NewMessageCell.swift +++ b/Mail/Views/New Message/NewMessageCell.swift @@ -18,6 +18,7 @@ import MailCore import MailResources +import RealmSwift import SwiftUI extension VerticalAlignment { @@ -80,7 +81,7 @@ struct RecipientCellView_Previews: PreviewProvider { static var previews: some View { NewMessageCell(type: .to, showCc: .constant(false)) { - RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1]), + RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), diff --git a/Mail/Views/Thread List/ThreadListViewModel.swift b/Mail/Views/Thread List/ThreadListViewModel.swift index 6302521cf..99d97aca3 100644 --- a/Mail/Views/Thread List/ThreadListViewModel.swift +++ b/Mail/Views/Thread List/ThreadListViewModel.swift @@ -152,7 +152,6 @@ class DateSection: Identifiable { withAnimation { isLoadingPage = false } - await mailboxManager.draftOffline() } func updateThreads(with folder: Folder) async { diff --git a/Mail/Views/Thread/MessageHeaderView.swift b/Mail/Views/Thread/MessageHeaderView.swift index 8045c4b0d..7f2a60dce 100644 --- a/Mail/Views/Thread/MessageHeaderView.swift +++ b/Mail/Views/Thread/MessageHeaderView.swift @@ -52,7 +52,7 @@ struct MessageHeaderView: View { .contentShape(Rectangle()) .onTapGesture { if message.isDraft { - editDraft() + DraftUtils.editDraft(from: message, mailboxManager: mailboxManager, editedMessageDraft: $editedDraft) } else if message.originalParent?.messagesCount ?? 0 > 1 { withAnimation { isHeaderExpanded = false @@ -72,28 +72,10 @@ struct MessageHeaderView: View { ) } - private func editDraft() { - var sheetPresented = false - - // If we already have the draft locally, present it directly - if let draft = mailboxManager.draft(messageUid: message.uid)?.detached() { - editedDraft = draft - sheetPresented = true - } - - // Update the draft - Task { [sheetPresented] in - let draft = try await mailboxManager.draft(from: message) - if !sheetPresented { - editedDraft = draft - } - } - } - private func deleteDraft() { Task { await tryOrDisplayError { - try await mailboxManager.deleteDraft(from: message) + try await mailboxManager.delete(draftMessage: message) } } } diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 4d68c89ff..58f91bd46 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -193,7 +193,7 @@ public class MailApiFetcher: ApiFetcher { return try await perform(request: authenticatedRequest(.resource(resource))).data } - func send(mailbox: Mailbox, draft: UnmanagedDraft) async throws -> CancelResponse { + func send(mailbox: Mailbox, draft: Draft) async throws -> SendResponse { try await perform(request: authenticatedRequest( draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID), method: draft.remoteUUID.isEmpty ? .post : .put, @@ -201,7 +201,7 @@ public class MailApiFetcher: ApiFetcher { )).data } - func save(mailbox: Mailbox, draft: UnmanagedDraft) async throws -> DraftResponse { + func save(mailbox: Mailbox, draft: Draft) async throws -> DraftResponse { try await perform(request: authenticatedRequest( draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID), method: draft.remoteUUID.isEmpty ? .post : .put, @@ -210,13 +210,15 @@ public class MailApiFetcher: ApiFetcher { } @discardableResult - // TODO: change return type when bug will be fixed from API - func deleteDraft(from message: Message) async throws -> Empty? { - guard let resource = message.draftResource else { - throw MailError.resourceError - } - - return try await perform(request: authenticatedRequest(.resource(resource), method: .delete)).data + func deleteDraft(mailbox: Mailbox, draftId: String) async throws -> Empty? { + // TODO: Remove try? when bug will be fixed from API + return try? await perform(request: authenticatedRequest(.draft(uuid: mailbox.uuid, draftUuid: draftId), method: .delete)).data + } + + @discardableResult + func deleteDraft(draftResource: String) async throws -> Empty? { + // TODO: Remove try? when bug will be fixed from API + return try? await perform(request: authenticatedRequest(.resource(draftResource), method: .delete)).data } @discardableResult diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 0b7feb528..52b9eee65 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -19,6 +19,7 @@ import Foundation import InfomaniakCore import MailResources +import RealmSwift import UIKit struct DraftQueueElement { @@ -54,6 +55,7 @@ actor DraftQueue { if let identifier = identifierQueue[uuid], identifier != .invalid { Task { await UIApplication.shared.endBackgroundTask(identifier) + identifierQueue[uuid] = .invalid } } } @@ -67,71 +69,27 @@ public class DraftManager { private init() {} - public func instantSaveDraftLocally(draft: UnmanagedDraft, mailboxManager: MailboxManager, action: SaveDraftOption) async { - await draftQueue.cleanQueueElement(uuid: draft.localUUID) - await mailboxManager.saveLocally(draft: draft, action: action) - } - - public func saveDraftLocally(draft: UnmanagedDraft, mailboxManager: MailboxManager, action: SaveDraftOption) async { - await draftQueue.cleanQueueElement(uuid: draft.localUUID) - - let task = DispatchWorkItem { - Task { - await mailboxManager.saveLocally(draft: draft, action: action) - } - } - await draftQueue.saveTask(task: task, for: draft.localUUID) - - // Debounce the save task - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(DraftManager.saveExpirationSec), execute: task) - } - - private func saveDraft(draft: UnmanagedDraft, - mailboxManager: MailboxManager, - showSnackBar: Bool = false) async { + private func saveDraft(draft: Draft, mailboxManager: MailboxManager) async { await draftQueue.cleanQueueElement(uuid: draft.localUUID) await draftQueue.beginBackgroundTask(withName: "Draft Saver", for: draft.localUUID) - let error = await mailboxManager.save(draft: draft) - await draftQueue.endBackgroundTask(uuid: draft.localUUID) - if let error = error, error.shouldDisplay { - await IKSnackBar.showSnackBar(message: error.localizedDescription) - } else if showSnackBar { - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, - action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in - self?.deleteDraft(localUuid: draft.localUUID, mailboxManager: mailboxManager) - }) - } - } - - private func deleteDraft(localUuid: String, mailboxManager: MailboxManager) { - // Convert draft to thread - let realm = mailboxManager.getRealm() - - guard let draft = mailboxManager.draft(localUuid: localUuid, using: realm)?.freeze(), - let draftFolder = mailboxManager.getFolder(with: .draft, using: realm) else { return } - let thread = Thread(draft: draft) - try? realm.uncheckedSafeWrite { - realm.add(thread, update: .modified) - draftFolder.threads.insert(thread) - } - let frozenThread = thread.freeze() - // Delete - Task { - await tryOrDisplayError { - _ = try await mailboxManager.move(threads: [frozenThread], to: .trash) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftDeleted) + do { + try await mailboxManager.save(draft: draft) + } catch { + if error.shouldDisplay { + await IKSnackBar.showSnackBar(message: error.localizedDescription) } } + await draftQueue.endBackgroundTask(uuid: draft.localUUID) } - public func send(draft: UnmanagedDraft, mailboxManager: MailboxManager) async { + public func send(draft: Draft, mailboxManager: MailboxManager) async -> Date? { + var sendDate: Date? await draftQueue.cleanQueueElement(uuid: draft.localUUID) await draftQueue.beginBackgroundTask(withName: "Draft Sender", for: draft.localUUID) do { let cancelableResponse = try await mailboxManager.send(draft: draft) - await draftQueue.endBackgroundTask(uuid: draft.localUUID) await IKSnackBar.showCancelableSnackBar( message: MailResourcesStrings.Localizable.emailSentSnackbar, cancelSuccessMessage: MailResourcesStrings.Localizable.canceledEmailSendingConfirmationSnackbar, @@ -139,29 +97,106 @@ public class DraftManager { undoRedoAction: UndoRedoAction(undo: cancelableResponse, redo: nil), mailboxManager: mailboxManager ) + sendDate = cancelableResponse.scheduledDate } catch { - await draftQueue.endBackgroundTask(uuid: draft.localUUID) await IKSnackBar.showSnackBar(message: error.localizedDescription) } + await draftQueue.endBackgroundTask(uuid: draft.localUUID) + return sendDate } public func syncDraft(mailboxManager: MailboxManager) { + let drafts = mailboxManager.draftWithPendingAction().freezeIfNeeded() + let emptyDraftBody = emptyDraftBodyWithSignature(for: mailboxManager) Task { - let drafts = await mailboxManager.draftWithPendingAction() - for draft in drafts { - switch draft.action { - case .save: - Task { - await self.saveDraft(draft: draft, mailboxManager: mailboxManager) + let latestSendDate = await withTaskGroup(of: Date?.self, returning: Date?.self) { group in + for draft in drafts { + group.addTask { + var sendDate: Date? + + switch draft.action { + case .initialSave: + await self.initialSave(draft: draft, mailboxManager: mailboxManager, emptyDraftBody: emptyDraftBody) + case .save: + await self.saveDraft(draft: draft, mailboxManager: mailboxManager) + case .send: + sendDate = await self.send(draft: draft, mailboxManager: mailboxManager) + default: + break + } + return sendDate } - case .send: - Task { - await self.send(draft: draft, mailboxManager: mailboxManager) + } + + var latestSendDate: Date? + for await result in group { + latestSendDate = result + } + return latestSendDate + } + + try await refreshDraftFolder(latestSendDate: latestSendDate, mailboxManager: mailboxManager) + } + } + + private func initialSave(draft: Draft, mailboxManager: MailboxManager, emptyDraftBody: String) async { + guard draft.body != emptyDraftBody else { + deleteEmptyDraft(draft: draft) + return + } + + await saveDraft(draft: draft, mailboxManager: mailboxManager) + await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, + action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in + self?.deleteDraftSnackBarAction(draft: draft, mailboxManager: mailboxManager) + }) + } + + private func refreshDraftFolder(latestSendDate: Date?, mailboxManager: MailboxManager) async throws { + if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { + try await mailboxManager.threads(folder: draftFolder) + + if let latestSendDate = latestSendDate { + /* + We need to refresh the draft folder after the mail is sent to make it disappear, we wait at least 1.5 seconds + because the sending process is not synchronous + */ + let delay = latestSendDate.timeIntervalSinceNow + try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(delay), 1.5))) + try await mailboxManager.threads(folder: draftFolder) + } + + await mailboxManager.deleteOrphanDrafts() + } + } + + private func deleteDraftSnackBarAction(draft: Draft, mailboxManager: MailboxManager) { + Task { + await tryOrDisplayError { + if let liveDraft = draft.thaw() { + try await mailboxManager.delete(draft: liveDraft.freeze()) + await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftDeleted) + if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { + try await mailboxManager.threads(folder: draftFolder) } - default: - break } } } } + + private func deleteEmptyDraft(draft: Draft) { + guard let liveDraft = draft.thaw(), + let realm = liveDraft.realm else { return } + try? realm.write { + realm.delete(liveDraft) + } + } + + private func emptyDraftBodyWithSignature(for mailboxManager: MailboxManager) -> String { + let draft = Draft() + if let signature = mailboxManager.getSignatureResponse() { + draft.setSignature(signature) + } + return draft.body + } } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 742c39183..d741e7024 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -257,14 +257,19 @@ public class MailboxManager: ObservableObject { let messagesToDelete = realm.objects(Message.self).where { $0.uid.in(uids) } var threadsToUpdate = Set() var threadsToDelete = Set() + var draftsToDelete = Set() for message in messagesToDelete { + if let draft = self.draft(messageUid: message.uid, using: realm) { + draftsToDelete.insert(draft) + } for parent in message.parents { threadsToUpdate.insert(parent) } } try? realm.safeWrite { + realm.delete(draftsToDelete) realm.delete(messagesToDelete) for thread in threadsToUpdate { if thread.messageInFolderCount == 0 { @@ -841,8 +846,6 @@ public class MailboxManager: ObservableObject { var messages = [message] messages.append(contentsOf: message.duplicates) try await delete(messages: messages) - } else if message.isDraft { - try await deleteDraft(from: message) // Keep ? } else { var messages = [message] messages.append(contentsOf: message.duplicates) @@ -926,51 +929,31 @@ public class MailboxManager: ObservableObject { // MARK: - Draft - public func cleanDrafts() async { - await backgroundRealm.execute { realm in - guard let draftFolder = (realm.objects(Folder.self).where { $0.role == .draft }.first) else { return } - let draftMessagesUid = draftFolder.threads.map { $0.messages.first?.uid } - - let localDrafts = realm.objects(Draft.self) - - var localDraftToDelete: [Draft] = [] - - for draft in localDrafts { - if let messageUid = draft.messageUid, !messageUid.isEmpty, !draftMessagesUid.contains(messageUid) { - localDraftToDelete.append(draft) - } - } - try? realm.safeWrite { - realm.delete(localDraftToDelete) - } - } - } - - public func draftWithPendingAction() async -> [UnmanagedDraft] { + public func draftWithPendingAction() -> Results { let realm = getRealm() - let drafts = realm.objects(Draft.self).where { $0.action != nil } - let unmanagedDrafts = Array(drafts.compactMap { $0.asUnmanaged() }) - return unmanagedDrafts + realm.refresh() + return realm.objects(Draft.self).where { $0.action != nil } } - public func draft(from message: Message) async throws -> Draft { + public func draft(partialDraft: Draft) async throws -> Draft? { + guard let associatedMessage = getRealm().object(ofType: Message.self, forPrimaryKey: partialDraft.messageUid)?.freeze() + else { return nil } + // Get from API - let draft = try await apiFetcher.draft(from: message) + let draft = try await apiFetcher.draft(from: associatedMessage) await backgroundRealm.execute { realm in - // Get draft from Realm to keep local saved properties - if let savedDraft = self.draft(remoteUuid: draft.remoteUUID) { - draft.localUUID = savedDraft.localUUID - draft.messageUid = message.uid - } + draft.localUUID = partialDraft.localUUID + draft.action = .save + draft.identityId = partialDraft.identityId + draft.delay = partialDraft.delay - // Update draft in Realm try? realm.safeWrite { realm.add(draft.detached(), update: .modified) } } - return draft + return getRealm().object(ofType: Draft.self, forPrimaryKey: draft.localUUID)?.freeze() } public func draft(messageUid: String, using realm: Realm? = nil) -> Draft? { @@ -988,132 +971,66 @@ public class MailboxManager: ObservableObject { return realm.objects(Draft.self).where { $0.remoteUUID == remoteUuid }.first } - public func send(draft: UnmanagedDraft) async throws -> CancelResponse { - var draft = draft - draft.delay = UserDefaults.shared.cancelSendDelay.rawValue + public func send(draft: Draft) async throws -> SendResponse { let cancelableResponse = try await apiFetcher.send(mailbox: mailbox, draft: draft) // Once the draft has been sent, we can delete it from Realm - await delete(draft: draft) + try await deleteLocally(draft: draft) return cancelableResponse } - public func saveLocally(draft: UnmanagedDraft, action: SaveDraftOption = .save) async { - let managedDraft = draft.asManaged() - var copyDraft = managedDraft.detached() - copyDraft.action = action - // TODO: - Date needed ? - + public func save(draft: Draft) async throws { + let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft) await backgroundRealm.execute { realm in - // Update draft in realm + // Update draft in Realm + guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } try? realm.safeWrite { - realm.add(copyDraft, update: .modified) + liveDraft.remoteUUID = saveResponse.uuid + liveDraft.messageUid = saveResponse.uid + liveDraft.action = nil } } } - public func save(draft: UnmanagedDraft) async -> Error? { - do { - let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft) - - let managedDraft = draft.asManaged() - managedDraft.remoteUUID = saveResponse.uuid - managedDraft.messageUid = saveResponse.uid - managedDraft.action = nil - - let copyDraft = managedDraft.detached() - await backgroundRealm.execute { realm in - // Update draft in Realm - try? realm.safeWrite { - realm.add(copyDraft, update: .modified) - } - } - - return nil - } catch { - await saveLocally(draft: draft) - return error - } + public func delete(draft: Draft) async throws { + try await deleteLocally(draft: draft) + try await apiFetcher.deleteDraft(mailbox: mailbox, draftId: draft.remoteUUID) } - public func delete(draft: AbstractDraft) async { - await backgroundRealm.execute { realm in - if let draft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) { - try? realm.safeWrite { - realm.delete(draft) - } - } else { - print("No draft with localUuid \(draft.localUUID)") - } + public func delete(draftMessage: Message) async throws { + guard let draftResource = draftMessage.draftResource else { + throw MailError.resourceError } - } - public func deleteDraft(from message: Message) async throws { - guard let thread = message.originalParent else { return } - let deleteThread = thread.messages.count <= 1 - - if !message.uid.isEmpty { - _ = try await apiFetcher.delete(mailbox: mailbox, messages: [message]) + if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == draftResource }).first?.freeze() { + try await deleteLocally(draft: draft) } - await backgroundRealm.execute { realm in - var draft = self.draft(localUuid: thread.uid, using: realm) - if !message.uid.isEmpty { - draft = self.draft(messageUid: message.uid, using: realm) - } - - try? realm.safeWrite { - if let draft = draft { - realm.delete(draft) - } - if let message = message.fresh(using: realm) { - realm.delete(message) - } - if deleteThread, let thread = thread.fresh(using: realm) { - realm.delete(thread) - } - } - } + try await apiFetcher.deleteDraft(draftResource: draftResource) + try await refreshFolder(from: [draftMessage]) } - public func draftOffline() async { + public func deleteLocally(draft: Draft) async throws { await backgroundRealm.execute { realm in - let draftOffline = AnyRealmCollection(realm.objects(Draft.self).where { $0.remoteUUID == "" }) - - let offlineDraftThread = List() - - guard let folder = self.getFolder(with: .draft, using: realm) else { return } - - let messagesList = realm.objects(Message.self).where { $0.folderId == folder.id } - for draft in draftOffline where !messagesList.contains(where: { $0.uid == draft.messageUid }) { - let thread = Thread(draft: draft) - let from = Recipient(email: self.mailbox.email, name: self.mailbox.emailIdn) - thread.from.append(from) - offlineDraftThread.append(thread) - } - - // Update message in Realm + guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } try? realm.safeWrite { - realm.add(offlineDraftThread, update: .modified) - folder.threads.insert(objectsIn: offlineDraftThread) + realm.delete(liveDraft) } } } - /// Delete local draft from its associated thread - /// - Parameter thread: Thread associated to local draft - public func deleteLocalDraft(thread: Thread) async { + public func deleteOrphanDrafts() async { + guard let draftFolder = getFolder(with: .draft, shouldRefresh: true) else { return } + + let existingMessageUids = Set(draftFolder.threads.flatMap(\.messages).map(\.uid)) + await backgroundRealm.execute { realm in - if let message = thread.messages.first, - let draft = self.draft(messageUid: message.uid) { - try? realm.safeWrite { - realm.delete(draft) - } - } - // Delete thread - if let liveThread = thread.fresh(using: realm) { - try? realm.safeWrite { - realm.delete(liveThread.messages) - realm.delete(liveThread) + try? realm.safeWrite { + let noActionDrafts = realm.objects(Draft.self).where { $0.action == nil } + for draft in noActionDrafts { + if let messageUid = draft.messageUid, + !existingMessageUids.contains(messageUid) { + realm.delete(draft) + } } } } diff --git a/MailCore/Models/CancelableResponse.swift b/MailCore/Models/CancelableResponse.swift index b3e354fd3..014e6a109 100644 --- a/MailCore/Models/CancelableResponse.swift +++ b/MailCore/Models/CancelableResponse.swift @@ -22,6 +22,20 @@ public protocol CancelableResponse { var resource: String { get } } +public struct SendResponse: Decodable, CancelableResponse { + public let cancelResource: String + public let scheduledDate: Date + + public var resource: String { + return cancelResource + } + + enum CodingKeys: String, CodingKey { + case cancelResource + case scheduledDate = "etop" + } +} + public struct CancelResponse: Decodable, CancelableResponse { public let cancelResource: String diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 973fb9fa4..1a6b0d976 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -22,8 +22,19 @@ import RealmSwift import UniformTypeIdentifiers public enum SaveDraftOption: String, Codable, PersistableEnum { + case initialSave case save case send + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .initialSave, .save: + try container.encode(SaveDraftOption.save.rawValue) + case .send: + try container.encode(SaveDraftOption.send.rawValue) + } + } } public enum ReplyMode: Equatable { @@ -54,339 +65,31 @@ public struct DraftResponse: Codable { public var uid: String } -public protocol AbstractDraft { - var localUUID: String { get } -} - -@propertyWrapper public struct EmptyNilEncoded: Encodable, Equatable where Content: Encodable & Equatable { - public var wrappedValue: [Content] - - public init(wrappedValue: [Content]) { - self.wrappedValue = wrappedValue - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if wrappedValue.isEmpty { - try container.encodeNil() - } else { - try container.encode(wrappedValue) - } - } -} - -// We need two draft models because of a bug in Realm… -// https://github.com/realm/realm-swift/issues/7810 -public struct UnmanagedDraft: Equatable, Encodable, AbstractDraft { - public var localUUID: String - public var remoteUUID: String - public var subject: String - public var body: String - public var quote: String - public var mimeType: String - @EmptyNilEncoded public var from: [Recipient] - @EmptyNilEncoded public var replyTo: [Recipient] - @EmptyNilEncoded public var to: [Recipient] - @EmptyNilEncoded public var cc: [Recipient] - @EmptyNilEncoded public var bcc: [Recipient] - public var inReplyTo: String? - public var inReplyToUid: String? - public var forwardedUid: String? - public var attachments: [Attachment]? - public var identityId: String - public var messageUid: String? - public var ackRequest = false - public var stUuid: String? - // uid? - public var priority: MessagePriority - public var action: SaveDraftOption? - public var delay: Int? - public var didSetSignature: Bool { - return !identityId.isEmpty - } - - public var toValue: String { - get { - return recipientToValue(to) - } - set { - to = valueToRecipient(newValue) - } - } - - public var ccValue: String { - get { - return recipientToValue(cc) - } - set { - cc = valueToRecipient(newValue) - } - } - - public var bccValue: String { - get { - return recipientToValue(bcc) - } - set { - bcc = valueToRecipient(newValue) - } - } - - private init(localUUID: String = UUID().uuidString, - remoteUUID: String = "", - subject: String = "", - body: String = "", - quote: String = "", - mimeType: String = UTType.html.preferredMIMEType!, - from: [Recipient] = [], - replyTo: [Recipient] = [], - to: [Recipient] = [], - cc: [Recipient] = [], - bcc: [Recipient] = [], - inReplyTo: String? = nil, - inReplyToUid: String? = nil, - forwardedUid: String? = nil, - attachments: [Attachment]? = nil, - identityId: String = "", - messageUid: String? = nil, - ackRequest: Bool = false, - stUuid: String? = nil, - priority: MessagePriority = .normal, - action: SaveDraftOption? = nil, - delay: Int? = UserDefaults.shared.cancelSendDelay.rawValue) { - self.localUUID = localUUID - self.remoteUUID = remoteUUID - self.subject = subject - self.body = body - self.quote = quote - self.mimeType = mimeType - self.from = from - self.replyTo = replyTo - self.to = to - self.cc = cc - self.bcc = bcc - self.inReplyTo = inReplyTo - self.inReplyToUid = inReplyToUid - self.forwardedUid = forwardedUid - self.attachments = attachments - self.identityId = identityId - self.messageUid = messageUid - self.ackRequest = ackRequest - self.stUuid = stUuid - self.priority = priority - self.action = action - self.delay = delay - } - - private enum CodingKeys: String, CodingKey { - case remoteUUID = "uuid" - case date - case identityId - case inReplyToUid - case forwardedUid - case inReplyTo - case mimeType - case body - case quote - case to - case cc - case bcc - case subject - case ackRequest - case priority - case stUuid - case attachments - case action - case delay - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(remoteUUID, forKey: .remoteUUID) - try container.encode(identityId, forKey: .identityId) - try container.encode(inReplyToUid, forKey: .inReplyToUid) - try container.encode(forwardedUid, forKey: .forwardedUid) - try container.encode(inReplyTo, forKey: .inReplyTo) - try container.encode(mimeType, forKey: .mimeType) - try container.encode(body, forKey: .body) - try container.encode(quote, forKey: .quote) - if !to.isEmpty { - try container.encode(to, forKey: .to) - } - if !cc.isEmpty { - try container.encode(cc, forKey: .cc) - } - if !bcc.isEmpty { - try container.encode(bcc, forKey: .bcc) - } - try container.encode(subject, forKey: .subject) - try container.encode(ackRequest, forKey: .ackRequest) - try container.encode(priority, forKey: .priority) - try container.encode(stUuid, forKey: .stUuid) - let attachmentsArray = attachments?.map { attachment in - attachment.uuid - } - try container.encode(attachmentsArray, forKey: .attachments) - try container.encode(action, forKey: .action) - try container.encode(delay, forKey: .delay) - } - - private func valueToRecipient(_ value: String) -> [Recipient] { - guard !value.isEmpty else { return [] } - return value.components(separatedBy: ",").map { Recipient(email: $0, name: "") } - } - - private func recipientToValue(_ recipient: [Recipient]) -> String { - return recipient.map(\.email).joined(separator: ",") - } - - public static func mailTo(subject: String?, - body: String?, - to: [Recipient], cc: [Recipient], bcc: [Recipient]) -> UnmanagedDraft { - return UnmanagedDraft(subject: subject ?? "", - body: body ?? "", - to: to, - cc: cc, - bcc: bcc) - } - - public static func empty() -> UnmanagedDraft { - return UnmanagedDraft() - } - - public static func writing(to recipient: Recipient) -> UnmanagedDraft { - return UnmanagedDraft(to: [recipient.detached()]) - } - - public static func replying(to message: Message, mode: ReplyMode) -> UnmanagedDraft { - let subject: String - let quote: String - var attachments: [Attachment] = [] - switch mode { - case .reply, .replyAll: - subject = "Re: \(message.formattedSubject)" - quote = Constants.replyQuote(message: message) - case let .forward(attachmentsToForward): - subject = "Fwd: \(message.formattedSubject)" - quote = Constants.forwardQuote(message: message) - attachments = attachmentsToForward - } - - var to: [Recipient] = [] - var cc: [Recipient] = [] - - if mode.isReply { - let userEmail = AccountManager.instance.currentMailboxManager?.mailbox.email ?? "" - let cleanedFrom = Array(message.from.detached()).filter { $0.email != userEmail } - let cleanedTo = Array(message.to.detached()).filter { $0.email != userEmail } - let cleanedReplyTo = Array(message.replyTo.detached()).filter { $0.email != userEmail } - let cleanedCc = Array(message.cc.detached()).filter { $0.email != userEmail } - - to = cleanedReplyTo.isEmpty ? cleanedFrom : cleanedReplyTo - if to.isEmpty { - to = cleanedTo - } else if mode == .replyAll { - cc = cleanedTo - } - if to.isEmpty { - to = cleanedCc - } else if mode == .replyAll { - cc.append(contentsOf: cleanedCc) - } - } - - return UnmanagedDraft(subject: subject, - body: "

\(quote)", - quote: quote, - to: to, - cc: cc, - inReplyTo: message.messageId, - inReplyToUid: mode.isReply ? message.uid : nil, - forwardedUid: mode == .forward([]) ? message.uid : nil, - attachments: attachments) - } - - public static func toUnmanaged(managedDraft: Draft) -> UnmanagedDraft { - return UnmanagedDraft(localUUID: managedDraft.localUUID, - remoteUUID: managedDraft.remoteUUID, - subject: managedDraft.subject ?? "", - body: managedDraft.body, - quote: managedDraft.quote ?? "", - mimeType: managedDraft.mimeType, - to: Array(managedDraft.to.freezeIfNeeded()), - cc: Array(managedDraft.cc.freezeIfNeeded()), - bcc: Array(managedDraft.bcc.freezeIfNeeded()), - inReplyTo: managedDraft.inReplyTo, - inReplyToUid: managedDraft.inReplyToUid, - forwardedUid: managedDraft.forwardedUid, - identityId: managedDraft.identityId ?? "", - messageUid: managedDraft.messageUid, - ackRequest: managedDraft.ackRequest, - stUuid: managedDraft.stUuid, - priority: managedDraft.priority, - action: managedDraft.action) - } - - public func asManaged() -> Draft { - return Draft(localUUID: localUUID, - remoteUUID: remoteUUID, - identityId: identityId, - messageUid: messageUid, - inReplyToUid: inReplyToUid, - forwardedUid: forwardedUid, - inReplyTo: inReplyTo, - mimeType: mimeType, - body: body, - quote: quote, - to: to, - cc: cc, - bcc: bcc, - subject: subject, - ackRequest: ackRequest, - priority: priority, - stUuid: stUuid) - } - - public mutating func setSignature(_ signatureResponse: SignatureResponse) { - identityId = "\(signatureResponse.defaultSignatureId)" - guard let signature = signatureResponse.default else { - return - } - from = [Recipient(email: signature.sender, name: signature.fullName)] - replyTo = [Recipient(email: signature.replyTo, name: "")] - let html = "

\(signature.content)
" - switch signature.position { - case .beforeReplyMessage: - body.insert(contentsOf: html, at: body.startIndex) - case .afterReplyMessage: - body.append(contentsOf: html) - } - } -} - -public class Draft: Object, Decodable, Identifiable, AbstractDraft { - @Persisted(primaryKey: true) public var localUUID: String +public class Draft: Object, Decodable, Identifiable, Encodable { + @Persisted(primaryKey: true) public var localUUID = UUID().uuidString @Persisted public var remoteUUID = "" - @Persisted public var date: Date + @Persisted public var date = Date() @Persisted public var identityId: String? @Persisted public var messageUid: String? @Persisted public var inReplyToUid: String? @Persisted public var forwardedUid: String? @Persisted public var references: String? @Persisted public var inReplyTo: String? - @Persisted public var mimeType: String - @Persisted public var body: String + @Persisted public var mimeType: String = UTType.html.preferredMIMEType! + @Persisted public var body: String = "" @Persisted public var quote: String? + @Persisted public var from: List + @Persisted public var replyTo: List @Persisted public var to: List @Persisted public var cc: List @Persisted public var bcc: List - @Persisted public var subject: String? + @Persisted public var subject: String = "" @Persisted public var ackRequest = false - @Persisted public var priority: MessagePriority + @Persisted public var priority: MessagePriority = .normal @Persisted public var stUuid: String? @Persisted public var attachments: List @Persisted public var action: SaveDraftOption? + @Persisted public var delay: Int? private enum CodingKeys: String, CodingKey { case remoteUUID = "uuid" @@ -407,11 +110,11 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft { case priority case stUuid case attachments + case action + case delay } - override public init() { - mimeType = UTType.html.preferredMIMEType! - } + override public init() { /* Realm needs an empty constructor */ } public required init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -428,7 +131,7 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft { to = try values.decode(List.self, forKey: .to) cc = try values.decode(List.self, forKey: .cc) bcc = try values.decode(List.self, forKey: .bcc) - subject = try values.decodeIfPresent(String.self, forKey: .subject) + subject = try values.decodeIfPresent(String.self, forKey: .subject) ?? "" ackRequest = try values.decode(Bool.self, forKey: .ackRequest) priority = try values.decode(MessagePriority.self, forKey: .priority) stUuid = try values.decodeIfPresent(String.self, forKey: .stUuid) @@ -445,12 +148,12 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft { references: String? = nil, inReplyTo: String? = nil, mimeType: String = UTType.html.preferredMIMEType!, + subject: String = "", body: String = "", quote: String? = nil, to: [Recipient]? = nil, cc: [Recipient]? = nil, bcc: [Recipient]? = nil, - subject: String = "", ackRequest: Bool = false, priority: MessagePriority = .normal, stUuid: String? = nil, @@ -482,7 +185,122 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft { self.action = action } - public func asUnmanaged() -> UnmanagedDraft { - return .toUnmanaged(managedDraft: self) + public static func mailTo(subject: String?, + body: String?, + to: [Recipient], + cc: [Recipient], + bcc: [Recipient]) -> Draft { + return Draft(subject: subject ?? "", + body: body ?? "", + to: to, + cc: cc, + bcc: bcc) + } + + public static func writing(to recipient: Recipient) -> Draft { + return Draft(to: [recipient.detached()]) + } + + public static func replying(to message: Message, mode: ReplyMode, localDraftUUID: String) -> Draft { + var subject = "\(message.formattedSubject)" + let quote: String + var attachments: [Attachment] = [] + switch mode { + case .reply, .replyAll: + if !subject.starts(with: "Re: ") { + subject = "Re: \(subject)" + } + quote = Constants.replyQuote(message: message) + case let .forward(attachmentsToForward): + if !subject.starts(with: "Fwd: ") { + subject = "Fwd: \(subject)" + } + quote = Constants.forwardQuote(message: message) + attachments = attachmentsToForward + } + + var to: [Recipient] = [] + var cc: [Recipient] = [] + + if mode.isReply { + let userEmail = AccountManager.instance.currentMailboxManager?.mailbox.email ?? "" + let cleanedFrom = Array(message.from.detached()).filter { $0.email != userEmail } + let cleanedTo = Array(message.to.detached()).filter { $0.email != userEmail } + let cleanedReplyTo = Array(message.replyTo.detached()).filter { $0.email != userEmail } + let cleanedCc = Array(message.cc.detached()).filter { $0.email != userEmail } + + to = cleanedReplyTo.isEmpty ? cleanedFrom : cleanedReplyTo + if to.isEmpty { + to = cleanedTo + } else if mode == .replyAll { + cc = cleanedTo + } + if to.isEmpty { + to = cleanedCc + } else if mode == .replyAll { + cc.append(contentsOf: cleanedCc) + } + } + + return Draft(localUUID: localDraftUUID, + inReplyToUid: mode.isReply ? message.uid : nil, + forwardedUid: mode == .forward([]) ? message.uid : nil, + references: "\(message.references ?? "") \(message.messageId ?? "")", + inReplyTo: message.messageId, + subject: subject, + body: "

\(quote)", + quote: quote, + to: to, + cc: cc, + attachments: attachments) + } + + public func setSignature(_ signatureResponse: SignatureResponse) { + identityId = "\(signatureResponse.defaultSignatureId)" + guard let signature = signatureResponse.default else { + return + } + + from.append(Recipient(email: signature.sender, name: signature.fullName)) + replyTo.append(Recipient(email: signature.replyTo, name: "")) + + let html = "

\(signature.content)
" + switch signature.position { + case .beforeReplyMessage: + body.insert(contentsOf: html, at: body.startIndex) + case .afterReplyMessage: + body.append(contentsOf: html) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(remoteUUID, forKey: .remoteUUID) + try container.encode(identityId, forKey: .identityId) + try container.encode(inReplyToUid, forKey: .inReplyToUid) + try container.encode(forwardedUid, forKey: .forwardedUid) + try container.encode(inReplyTo, forKey: .inReplyTo) + try container.encode(references, forKey: .references) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(body, forKey: .body) + try container.encode(quote, forKey: .quote) + if !to.isEmpty { + try container.encode(to, forKey: .to) + } + if !cc.isEmpty { + try container.encode(cc, forKey: .cc) + } + if !bcc.isEmpty { + try container.encode(bcc, forKey: .bcc) + } + try container.encode(subject, forKey: .subject) + try container.encode(ackRequest, forKey: .ackRequest) + try container.encode(priority, forKey: .priority) + try container.encode(stUuid, forKey: .stUuid) + let attachmentsArray = Array(attachments.compactMap { $0.uuid }) + try container.encode(attachmentsArray, forKey: .attachments) + try container.encode(action, forKey: .action) + try container.encode(delay, forKey: .delay) } } diff --git a/MailCore/Models/Message.swift b/MailCore/Models/Message.swift index 6c35065dc..fa7a6ea41 100644 --- a/MailCore/Models/Message.swift +++ b/MailCore/Models/Message.swift @@ -330,28 +330,6 @@ public class Message: Object, Decodable, Identifiable { fullyDownloaded = true } - convenience init(draft: Draft) { - self.init() - - if let messageUid = draft.messageUid { - uid = messageUid - } - subject = draft.subject - priority = draft.priority - date = draft.date - size = 0 - to = draft.to.detached() - cc = draft.cc.detached() - bcc = draft.bcc.detached() - let messageBody = Body() - messageBody.value = draft.body - messageBody.type = draft.mimeType - body = messageBody - attachments = draft.attachments.detached() - references = draft.references - isDraft = true - } - public func toThread() -> Thread { let thread = Thread( uid: "\(folderId)_\(uid)", diff --git a/MailCore/Models/MessageReply.swift b/MailCore/Models/MessageReply.swift index cf4bb8dcc..fc92b9706 100644 --- a/MailCore/Models/MessageReply.swift +++ b/MailCore/Models/MessageReply.swift @@ -19,15 +19,17 @@ import Foundation public struct MessageReply: Identifiable { - public var id: ObjectIdentifier { - return message.id + public var id: String { + return localDraftUUID } + public let localDraftUUID: String public let message: Message public let replyMode: ReplyMode public init(message: Message, replyMode: ReplyMode) { self.message = message self.replyMode = replyMode + self.localDraftUUID = UUID().uuidString } } diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index f66327a05..735aa15a0 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -230,28 +230,6 @@ public class Thread: Object, Decodable, Identifiable { self.size = size self.folderId = folderId } - - public convenience init(draft: Draft) { - self.init() - - uid = draft.localUUID - messagesCount = 1 - deletedMessagesCount = 0 - messages = [Message(draft: draft)].toRealmList() - unseenMessages = 0 - to = draft.to.detached() - cc = draft.cc.detached() - bcc = draft.bcc.detached() - subject = draft.subject - date = draft.date - hasAttachments = false - hasStAttachments = false - hasDrafts = true - flagged = false - answered = false - forwarded = false - size = 0 - } } public enum Filter: String { diff --git a/MailCore/Utils/Array+Extension.swift b/MailCore/Utils/Array+Extension.swift index 313fcf01e..14591600e 100644 --- a/MailCore/Utils/Array+Extension.swift +++ b/MailCore/Utils/Array+Extension.swift @@ -25,10 +25,16 @@ public extension Array where Element: RealmCollectionValue { list.append(objectsIn: self) return list } - + func toRealmSet() -> MutableSet { let set = MutableSet() set.insert(objectsIn: self) return set } } + +public extension LazyFilterSequence { + func toArray() -> [Base.Element] { + return Array(self) + } +}