From 73d6557ac5a15416d00c60633ee3b13d3f21d6e5 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Tue, 20 Dec 2022 13:10:19 +0100 Subject: [PATCH 01/32] refactor(Draft): Remove UnmanagedDraft --- Mail/Components/RecipientField.swift | 29 +- .../New Message/ComposeMessageView.swift | 80 +- Mail/Views/New Message/NewMessageCell.swift | 3 +- MailCore/API/MailApiFetcher.swift | 4 +- MailCore/Cache/DraftManager.swift | 23 +- MailCore/Cache/MailboxManager.swift | 23 +- MailCore/Models/Draft.swift | 688 ++++++++++-------- 7 files changed, 451 insertions(+), 399 deletions(-) diff --git a/Mail/Components/RecipientField.swift b/Mail/Components/RecipientField.swift index f4b489b08..d0f31b7cf 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 } @@ -95,18 +94,30 @@ struct RecipientField: View { } private func add(recipient: Recipient) { - withAnimation { - recipients.append(recipient) + guard let liveRecipients = recipients.thaw() else { return } + + try? liveRecipients.realm?.write { + withAnimation { + liveRecipients.append(recipient) + } } currentText = "" } + + private func remove(recipientAt: Int) { + guard let liveRecipients = recipients.thaw() else { return } + + try? liveRecipients.realm?.write { + withAnimation { + liveRecipients.remove(at: recipientAt) + } + } + } } struct RecipientField_Previews: PreviewProvider { static var previews: some View { - RecipientField(recipients: .constant([ - PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 - ]), + RecipientField(recipients: .constant(RealmSwift.List()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index bfbed7bc9..1faa44517 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -53,7 +53,7 @@ struct ComposeMessageView: View { @Environment(\.dismiss) private var dismiss @State private var mailboxManager: MailboxManager - @State private var draft: UnmanagedDraft + @StateRealmObject var draft: Draft @State private var originalBody: String @State private var editor = RichTextEditorModel() @State private var showCc = false @@ -76,22 +76,28 @@ struct ComposeMessageView: View { 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 = mailboxManager.getSignatureResponse() == nil - initialDraft.delay = UserDefaults.shared.cancelSendDelay.rawValue - _draft = State(initialValue: initialDraft) - _showCc = State(initialValue: !initialDraft.bcc.isEmpty || !initialDraft.cc.isEmpty) - _originalBody = State(initialValue: initialDraft.body) + draft.delay = UserDefaults.shared.cancelSendDelay.rawValue + + let realm = mailboxManager.getRealm() + + try? realm.write { + realm.add(draft) + } + + _draft = StateRealmObject(wrappedValue: draft) + _showCc = State(initialValue: !draft.bcc.isEmpty || !draft.cc.isEmpty) + _originalBody = State(initialValue: draft.body) } 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 { @@ -107,7 +113,7 @@ struct ComposeMessageView: View { } 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 +121,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,10 +160,10 @@ 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 }, !attachments.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(attachments) { attachment in + ForEach(Array(attachments)) { attachment in AttachmentCell(attachment: attachment, isNewMessage: true) { attachmentRemoved in removeAttachment(attachmentRemoved) } @@ -200,10 +206,7 @@ struct ComposeMessageView: View { trailing: Button(action: { sendDraft = true originalBody = draft.body - Task { - await DraftManager.shared.instantSaveDraftLocally(draft: draft, mailboxManager: mailboxManager, action: .send) - dismiss() - } + dismiss() }, label: { Image(resource: MailResourcesAsset.send) }) @@ -211,29 +214,12 @@ struct ComposeMessageView: View { ) .background(MailResourcesAsset.backgroundColor.swiftUiColor) } - .onChange(of: draft) { _ in - Task { - await DraftManager.shared.saveDraftLocally(draft: draft, mailboxManager: mailboxManager, action: .save) - } - } .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) { @@ -286,8 +272,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 @@ -296,7 +282,7 @@ struct ComposeMessageView: View { case .bcc: binding = $draft.bcc default: - binding = .constant([]) + fatalError("Unhandled binding \(type)") } return binding } @@ -433,16 +419,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..4c4047263 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(RealmSwift.List()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 4d68c89ff..d16639b7e 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 -> CancelResponse { 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, diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 8dee4a2fc..8109e026d 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -67,26 +67,7 @@ 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, + private func saveDraft(draft: Draft, mailboxManager: MailboxManager, showSnackBar: Bool = false) async { await draftQueue.cleanQueueElement(uuid: draft.localUUID) @@ -125,7 +106,7 @@ public class DraftManager { } } - public func send(draft: UnmanagedDraft, mailboxManager: MailboxManager) async { + public func send(draft: Draft, mailboxManager: MailboxManager) async { await draftQueue.cleanQueueElement(uuid: draft.localUUID) await draftQueue.beginBackgroundTask(withName: "Draft Sender", for: draft.localUUID) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index a65754346..8d9f9de39 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -1229,10 +1229,10 @@ public class MailboxManager: ObservableObject { } } - public func draftWithPendingAction() async -> [UnmanagedDraft] { + public func draftWithPendingAction() async -> [Draft] { let realm = getRealm() let drafts = realm.objects(Draft.self).where { $0.action != nil } - let unmanagedDrafts = Array(drafts.compactMap { $0.asUnmanaged() }) + let unmanagedDrafts = Array(drafts.compactMap { $0 }) return unmanagedDrafts } @@ -1271,8 +1271,7 @@ 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 + public func send(draft: Draft) async throws -> CancelResponse { draft.delay = UserDefaults.shared.cancelSendDelay.rawValue let cancelableResponse = try await apiFetcher.send(mailbox: mailbox, draft: draft) // Once the draft has been sent, we can delete it from Realm @@ -1280,9 +1279,8 @@ public class MailboxManager: ObservableObject { return cancelableResponse } - public func saveLocally(draft: UnmanagedDraft, action: SaveDraftOption = .save) async { - let managedDraft = draft.asManaged() - var copyDraft = managedDraft.detached() + public func saveLocally(draft: Draft, action: SaveDraftOption = .save) async { + let copyDraft = draft.detached() copyDraft.action = action // TODO: - Date needed ? @@ -1294,16 +1292,15 @@ public class MailboxManager: ObservableObject { } } - public func save(draft: UnmanagedDraft) async -> Error? { + public func save(draft: Draft) 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 + draft.remoteUUID = saveResponse.uuid + draft.messageUid = saveResponse.uid + draft.action = nil - let copyDraft = managedDraft.detached() + let copyDraft = draft.detached() await backgroundRealm.execute { realm in // Update draft in Realm try? realm.safeWrite { diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 76577e642..0503ef49f 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -75,319 +75,325 @@ public protocol AbstractDraft { } } -// 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.msgId, - 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 +/* + // 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.msgId, + 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, + subject: subject, + body: body, + quote: quote, + to: to, + cc: cc, + bcc: bcc, + 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, 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? + public var delay: Int? + private enum CodingKeys: String, CodingKey { case remoteUUID = "uuid" case date @@ -409,9 +415,7 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft { case attachments } - 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 +432,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 +449,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 +486,83 @@ 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) -> Draft { + 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 Draft(inReplyToUid: mode.isReply ? message.uid : nil, + forwardedUid: mode == .forward([]) ? message.uid : nil, + inReplyTo: message.msgId, + 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) + } } } From 14ed02c8e94a092f4737c5d0dea6991f3b516842 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Tue, 20 Dec 2022 14:15:02 +0100 Subject: [PATCH 02/32] fix(Daft): Correctly encode draft for API --- .../New Message/ComposeMessageView.swift | 5 +-- MailCore/Cache/DraftManager.swift | 16 ++++----- MailCore/Cache/MailboxManager.swift | 27 +++----------- MailCore/Models/Draft.swift | 35 +++++++++++++++++-- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 1faa44517..7dc3ae2a1 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -82,11 +82,12 @@ struct ComposeMessageView: View { let signature = mailboxManager.getSignatureResponse() { draft.setSignature(signature) } - sendDisabled = mailboxManager.getSignatureResponse() == nil + draft.action = .save draft.delay = UserDefaults.shared.cancelSendDelay.rawValue - let realm = mailboxManager.getRealm() + sendDisabled = mailboxManager.getSignatureResponse() == nil + let realm = mailboxManager.getRealm() try? realm.write { realm.add(draft) } diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 8109e026d..5c3b08f26 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -127,18 +127,14 @@ public class DraftManager { } public func syncDraft(mailboxManager: MailboxManager) { - Task { - let drafts = await mailboxManager.draftWithPendingAction() - for draft in drafts { - switch draft.action { + let drafts = mailboxManager.draftWithPendingAction() + for draft in drafts { + Task { [frozenDraft = draft.freeze()] in + switch frozenDraft.action { case .save: - Task { - await self.saveDraft(draft: draft, mailboxManager: mailboxManager) - } + await self.saveDraft(draft: frozenDraft, mailboxManager: mailboxManager) case .send: - Task { - await self.send(draft: draft, mailboxManager: mailboxManager) - } + await self.send(draft: frozenDraft, mailboxManager: mailboxManager) default: break } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 8d9f9de39..e9cfa38cb 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -1229,7 +1229,7 @@ public class MailboxManager: ObservableObject { } } - public func draftWithPendingAction() async -> [Draft] { + public func draftWithPendingAction() -> [Draft] { let realm = getRealm() let drafts = realm.objects(Draft.self).where { $0.action != nil } let unmanagedDrafts = Array(drafts.compactMap { $0 }) @@ -1279,38 +1279,21 @@ public class MailboxManager: ObservableObject { return cancelableResponse } - public func saveLocally(draft: Draft, action: SaveDraftOption = .save) async { - let copyDraft = draft.detached() - copyDraft.action = action - // TODO: - Date needed ? - - await backgroundRealm.execute { realm in - // Update draft in realm - try? realm.safeWrite { - realm.add(copyDraft, update: .modified) - } - } - } - public func save(draft: Draft) async -> Error? { do { let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft) - - draft.remoteUUID = saveResponse.uuid - draft.messageUid = saveResponse.uid - draft.action = nil - - let copyDraft = draft.detached() await backgroundRealm.execute { realm in // 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 } } return nil } catch { - await saveLocally(draft: draft) return error } } diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 0503ef49f..4be904635 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -391,8 +391,7 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft, Encodable { @Persisted public var stUuid: String? @Persisted public var attachments: List @Persisted public var action: SaveDraftOption? - - public var delay: Int? + @Persisted public var delay: Int? private enum CodingKeys: String, CodingKey { case remoteUUID = "uuid" @@ -413,6 +412,8 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft, Encodable { case priority case stUuid case attachments + case action + case delay } override public init() { /* Realm needs an empty constructor */ } @@ -565,4 +566,34 @@ public class Draft: Object, Decodable, Identifiable, AbstractDraft, Encodable { 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(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) + } } From 2bcaeb50208989677375c5a511dce4332857deb2 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 08:46:37 +0100 Subject: [PATCH 03/32] refactor(Draft): Use TaskGroup to sync drafts --- MailCore/Cache/DraftManager.swift | 47 ++++++++++++++--------------- MailCore/Cache/MailboxManager.swift | 30 +++++++----------- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 5c3b08f26..6c99f659e 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -67,22 +67,18 @@ public class DraftManager { private init() {} - private func saveDraft(draft: Draft, - 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) - }) + do { + try await mailboxManager.save(draft: draft) + } catch { + if error.shouldDisplay { + await IKSnackBar.showSnackBar(message: error.localizedDescription) + } } + await draftQueue.endBackgroundTask(uuid: draft.localUUID) } private func deleteDraft(localUuid: String, mailboxManager: MailboxManager) { @@ -112,7 +108,6 @@ public class DraftManager { 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, @@ -121,22 +116,26 @@ public class DraftManager { mailboxManager: mailboxManager ) } catch { - await draftQueue.endBackgroundTask(uuid: draft.localUUID) await IKSnackBar.showSnackBar(message: error.localizedDescription) } + await draftQueue.endBackgroundTask(uuid: draft.localUUID) } public func syncDraft(mailboxManager: MailboxManager) { - let drafts = mailboxManager.draftWithPendingAction() - for draft in drafts { - Task { [frozenDraft = draft.freeze()] in - switch frozenDraft.action { - case .save: - await self.saveDraft(draft: frozenDraft, mailboxManager: mailboxManager) - case .send: - await self.send(draft: frozenDraft, mailboxManager: mailboxManager) - default: - break + let drafts = mailboxManager.draftWithPendingAction().freezeIfNeeded() + Task { + await withTaskGroup(of: Void.self) { group in + for draft in drafts { + group.addTask { + switch draft.action { + case .save: + await self.saveDraft(draft: draft, mailboxManager: mailboxManager) + case .send: + await self.send(draft: draft, mailboxManager: mailboxManager) + default: + break + } + } } } } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index e9cfa38cb..384bc3750 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -1229,11 +1229,9 @@ public class MailboxManager: ObservableObject { } } - public func draftWithPendingAction() -> [Draft] { + public func draftWithPendingAction() -> Results { let realm = getRealm() - let drafts = realm.objects(Draft.self).where { $0.action != nil } - let unmanagedDrafts = Array(drafts.compactMap { $0 }) - return unmanagedDrafts + return realm.objects(Draft.self).where { $0.action != nil } } public func draft(from message: Message) async throws -> Draft { @@ -1279,22 +1277,16 @@ public class MailboxManager: ObservableObject { return cancelableResponse } - public func save(draft: Draft) async -> Error? { - do { - let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft) - await backgroundRealm.execute { realm in - // Update draft in Realm - guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } - try? realm.safeWrite { - liveDraft.remoteUUID = saveResponse.uuid - liveDraft.messageUid = saveResponse.uid - liveDraft.action = nil - } + 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 + guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } + try? realm.safeWrite { + liveDraft.remoteUUID = saveResponse.uuid + liveDraft.messageUid = saveResponse.uid + liveDraft.action = nil } - - return nil - } catch { - return error } } From edc5f9ce39a6bf92129b6e41402f582c34bf460c Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 09:08:45 +0100 Subject: [PATCH 04/32] fix(Draft): Display snackbar for initial save --- Mail/Views/New Message/ComposeMessageView.swift | 5 +++-- MailCore/Cache/DraftManager.swift | 6 ++++++ MailCore/Models/Draft.swift | 11 +++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 7dc3ae2a1..70e40c057 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -82,14 +82,15 @@ struct ComposeMessageView: View { let signature = mailboxManager.getSignatureResponse() { draft.setSignature(signature) } - draft.action = .save + + draft.action = draft.action == nil ? .initialSave : .save draft.delay = UserDefaults.shared.cancelSendDelay.rawValue sendDisabled = mailboxManager.getSignatureResponse() == nil let realm = mailboxManager.getRealm() try? realm.write { - realm.add(draft) + realm.add(draft, update: .modified) } _draft = StateRealmObject(wrappedValue: draft) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 6c99f659e..861142dc2 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -128,6 +128,12 @@ public class DraftManager { for draft in drafts { group.addTask { switch draft.action { + case .initialSave: + await self.saveDraft(draft: draft, mailboxManager: mailboxManager) + await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, + action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in + self?.deleteDraft(localUuid: draft.localUUID, mailboxManager: mailboxManager) + }) case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) case .send: diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 4be904635..eed5222c6 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 { From ae08b88335c9952bcd260dbf84cb9b92d8f7dd0e Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 10:03:29 +0100 Subject: [PATCH 05/32] fix(Draft): Correctly update recipients --- Mail/Components/RecipientField.swift | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/Mail/Components/RecipientField.swift b/Mail/Components/RecipientField.swift index d0f31b7cf..7a6524a02 100644 --- a/Mail/Components/RecipientField.swift +++ b/Mail/Components/RecipientField.swift @@ -94,23 +94,15 @@ struct RecipientField: View { } private func add(recipient: Recipient) { - guard let liveRecipients = recipients.thaw() else { return } - - try? liveRecipients.realm?.write { - withAnimation { - liveRecipients.append(recipient) - } + withAnimation { + $recipients.append(recipient) } currentText = "" } private func remove(recipientAt: Int) { - guard let liveRecipients = recipients.thaw() else { return } - - try? liveRecipients.realm?.write { - withAnimation { - liveRecipients.remove(at: recipientAt) - } + withAnimation { + $recipients.remove(at: recipientAt) } } } From b8168d5716fabb6113571aa2c160216edb0314be Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 10:13:17 +0100 Subject: [PATCH 06/32] fix(Draft): Set send action --- Mail/Views/New Message/ComposeMessageView.swift | 17 ++++++++++------- MailCore/Cache/MailboxManager.swift | 1 - 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 70e40c057..379ed26eb 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -54,7 +54,6 @@ struct ComposeMessageView: View { @State private var mailboxManager: MailboxManager @StateRealmObject var draft: Draft - @State private var originalBody: String @State private var editor = RichTextEditorModel() @State private var showCc = false @FocusState private var focusedField: ComposeViewFieldType? @@ -64,8 +63,6 @@ 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() @@ -95,7 +92,6 @@ struct ComposeMessageView: View { _draft = StateRealmObject(wrappedValue: draft) _showCc = State(initialValue: !draft.bcc.isEmpty || !draft.cc.isEmpty) - _originalBody = State(initialValue: draft.body) } static func newMessage(mailboxManager: MailboxManager) -> ComposeMessageView { @@ -206,9 +202,7 @@ struct ComposeMessageView: View { Label(MailResourcesStrings.Localizable.buttonClose, systemImage: "xmark") }, trailing: Button(action: { - sendDraft = true - originalBody = draft.body - dismiss() + sendDraft() }, label: { Image(resource: MailResourcesAsset.send) }) @@ -289,6 +283,15 @@ struct ComposeMessageView: View { 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 { diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 384bc3750..1e3f51465 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -1270,7 +1270,6 @@ public class MailboxManager: ObservableObject { } public func send(draft: Draft) async throws -> CancelResponse { - draft.delay = UserDefaults.shared.cancelSendDelay.rawValue 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) From 47483ec62edaebe6c36fe97e394f948e5435ac75 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 10:15:55 +0100 Subject: [PATCH 07/32] fix(Draft): Fix previews --- Mail/Components/RecipientField.swift | 4 +++- Mail/Views/New Message/NewMessageCell.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Mail/Components/RecipientField.swift b/Mail/Components/RecipientField.swift index 7a6524a02..0a0e2540a 100644 --- a/Mail/Components/RecipientField.swift +++ b/Mail/Components/RecipientField.swift @@ -109,7 +109,9 @@ struct RecipientField: View { struct RecipientField_Previews: PreviewProvider { static var previews: some View { - RecipientField(recipients: .constant(RealmSwift.List()), + RecipientField(recipients: .constant([ + PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 + ].toRealmList()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), diff --git a/Mail/Views/New Message/NewMessageCell.swift b/Mail/Views/New Message/NewMessageCell.swift index 4c4047263..4b0564ca7 100644 --- a/Mail/Views/New Message/NewMessageCell.swift +++ b/Mail/Views/New Message/NewMessageCell.swift @@ -81,7 +81,7 @@ struct RecipientCellView_Previews: PreviewProvider { static var previews: some View { NewMessageCell(type: .to, showCc: .constant(false)) { - RecipientField(recipients: .constant(RealmSwift.List()), + RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()), autocompletion: .constant([]), addRecipientHandler: .constant { _ in /* Preview */ }, focusedField: .init(), From 5440d5cb5f1668680613a9cecb216d05c9865b23 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 10:46:50 +0100 Subject: [PATCH 08/32] fix(Draft): Prevent saving empty draft --- MailCore/Cache/DraftManager.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 861142dc2..09cc24300 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -123,12 +123,18 @@ public class DraftManager { public func syncDraft(mailboxManager: MailboxManager) { let drafts = mailboxManager.draftWithPendingAction().freezeIfNeeded() + let emptyDraftBody = emptyDraftBodyWithSignature(for: mailboxManager) Task { await withTaskGroup(of: Void.self) { group in for draft in drafts { group.addTask { switch draft.action { case .initialSave: + guard draft.body != emptyDraftBody else { + self.deleteEmptyDraft(draft: draft) + return + } + await self.saveDraft(draft: draft, mailboxManager: mailboxManager) await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in @@ -146,4 +152,20 @@ public class DraftManager { } } } + + 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 + } } From 584ba1e94d15663a96fee088302d9d5ec4ea6f8f Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 10:54:36 +0100 Subject: [PATCH 09/32] fix(Draft): Remove sendDisabled --- Mail/Views/New Message/ComposeMessageView.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 92afb6f79..f184de535 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -67,8 +67,6 @@ struct ComposeMessageView: View { @StateObject private var alert = NewMessageAlert() - @State private var sendDisabled: Bool - private var shouldDisplayAutocompletion: Bool { return !autocompletion.isEmpty && focusedField != nil } @@ -88,7 +86,6 @@ struct ComposeMessageView: View { realm.add(draft, update: .modified) } - _sendDisabled = State(initialValue: mailboxManager.getSignatureResponse() == nil || draft.to.isEmpty) _draft = StateRealmObject(wrappedValue: draft) _showCc = State(initialValue: !draft.bcc.isEmpty || !draft.cc.isEmpty) } @@ -205,13 +202,10 @@ struct ComposeMessageView: View { }, label: { Image(resource: MailResourcesAsset.send) }) - .disabled(sendDisabled) + .disabled(draft.identityId?.isEmpty == true || draft.to.isEmpty) ) .background(MailResourcesAsset.backgroundColor.swiftUiColor) } - .onChange(of: draft) { _ in - sendDisabled = draft.to.isEmpty - } .onAppear { focusedField = .to } From 6d7158ab87f68182409d6507d5adf1520f56213c Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 11:28:18 +0100 Subject: [PATCH 10/32] refactor(Draft): Cleanup UnmanagedDraft --- MailCore/Cache/MailboxManager.swift | 11 +- MailCore/Models/Draft.swift | 315 +--------------------------- 2 files changed, 5 insertions(+), 321 deletions(-) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 0254de905..c17adbfdd 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -989,14 +989,11 @@ public class MailboxManager: ObservableObject { } } - public func delete(draft: AbstractDraft) async { + public func delete(draft: Draft) 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)") + guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } + try? realm.safeWrite { + realm.delete(liveDraft) } } } diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index cd69b6866..1eb8b67d6 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -65,320 +65,7 @@ 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.msgId, - 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, - subject: subject, - body: body, - quote: quote, - to: to, - cc: cc, - bcc: bcc, - 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, Encodable { +public class Draft: Object, Decodable, Identifiable, Encodable { @Persisted(primaryKey: true) public var localUUID = UUID().uuidString @Persisted public var remoteUUID = "" @Persisted public var date = Date() From 1c49f6a60400ec607e7a2c6da6edf3364e81d804 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 21 Dec 2022 15:14:32 +0100 Subject: [PATCH 11/32] fix(Draft): Ensure we have the latest version the draft --- MailCore/Cache/MailboxManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index c17adbfdd..e9b1a2b17 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -931,6 +931,7 @@ public class MailboxManager: ObservableObject { public func draftWithPendingAction() -> Results { let realm = getRealm() + realm.refresh() return realm.objects(Draft.self).where { $0.action != nil } } From 987d1afc824f088def6273e0abb54663f176501d Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 4 Jan 2023 09:42:03 +0100 Subject: [PATCH 12/32] fix(Draft): Ensure correct draft is displayed --- Mail/Utils/DraftUtils.swift | 26 ++++++++-------- .../New Message/ComposeMessageView.swift | 30 ++++++++++++++----- Mail/Views/Thread/MessageHeaderView.swift | 20 +------------ MailCore/Cache/DraftManager.swift | 12 ++++++++ MailCore/Cache/MailboxManager.swift | 19 ++++++------ 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/Mail/Utils/DraftUtils.swift b/Mail/Utils/DraftUtils.swift index f66740705..11536264d 100644 --- a/Mail/Utils/DraftUtils.swift +++ b/Mail/Utils/DraftUtils.swift @@ -21,26 +21,24 @@ import MailCore import SwiftUI 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 + // Maybe it was an offline draft (If offline draft is created with draft.uuid = thread.uid) + } 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 f184de535..398e4e1d7 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -77,12 +77,11 @@ struct ComposeMessageView: View { let signature = mailboxManager.getSignatureResponse() { draft.setSignature(signature) } - - draft.action = draft.action == nil ? .initialSave : .save - draft.delay = UserDefaults.shared.cancelSendDelay.rawValue - 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) } @@ -97,9 +96,7 @@ struct ComposeMessageView: View { 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) @@ -179,6 +176,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 } @@ -244,6 +248,18 @@ struct ComposeMessageView: View { EmptyView() } } + .task { + if draft.messageUid != nil && draft.remoteUUID.isEmpty { + do { + if let fetchedDraft = try await mailboxManager.draft(partialDraft: draft), + let liveFetchedDraft = fetchedDraft.thaw() { + self.draft = liveFetchedDraft + } + } catch { + // Fail silently + } + } + } .navigationViewStyle(.stack) .defaultAppStorage(.shared) } diff --git a/Mail/Views/Thread/MessageHeaderView.swift b/Mail/Views/Thread/MessageHeaderView.swift index 8045c4b0d..eaaf9429d 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,24 +72,6 @@ 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 { diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 596e9a69e..6e312841c 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -54,6 +54,7 @@ actor DraftQueue { if let identifier = identifierQueue[uuid], identifier != .invalid { Task { await UIApplication.shared.endBackgroundTask(identifier) + identifierQueue[uuid] = .invalid } } } @@ -150,6 +151,17 @@ public class DraftManager { } } } + + if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { + await mailboxManager.draftOffline() + try await mailboxManager.threads(folder: draftFolder) + + if let maxDelaySeconds = drafts.filter({ $0.action == .send }).compactMap({ $0.delay }).sorted().first { + // We need to refresh the draft folder after the mail is sent to make it disappear + try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(maxDelaySeconds), 1.5))) + try await mailboxManager.threads(folder: draftFolder) + } + } } } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index e9b1a2b17..cd5ed7814 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -935,24 +935,25 @@ public class MailboxManager: ObservableObject { 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? { From 6faadce18cd70f35668835de8f8cf34152491fa9 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 4 Jan 2023 16:30:43 +0100 Subject: [PATCH 13/32] fix(Draft): Remove wrong offline logic --- .../Thread List/ThreadListViewModel.swift | 1 - Mail/Views/Thread/MessageHeaderView.swift | 6 +- MailCore/API/MailApiFetcher.swift | 7 +- MailCore/Cache/DraftManager.swift | 31 ++----- MailCore/Cache/MailboxManager.swift | 83 ++----------------- MailCore/Models/Message.swift | 22 ----- MailCore/Models/Thread.swift | 22 ----- 7 files changed, 21 insertions(+), 151 deletions(-) diff --git a/Mail/Views/Thread List/ThreadListViewModel.swift b/Mail/Views/Thread List/ThreadListViewModel.swift index 3be05380d..a19fb7477 100644 --- a/Mail/Views/Thread List/ThreadListViewModel.swift +++ b/Mail/Views/Thread List/ThreadListViewModel.swift @@ -165,7 +165,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 eaaf9429d..e769f6042 100644 --- a/Mail/Views/Thread/MessageHeaderView.swift +++ b/Mail/Views/Thread/MessageHeaderView.swift @@ -75,7 +75,11 @@ struct MessageHeaderView: View { private func deleteDraft() { Task { await tryOrDisplayError { - try await mailboxManager.deleteDraft(from: message) + if let draftResource = message.draftResource { + try await mailboxManager.delete(remoteDraftResource: draftResource) + } else { + throw MailError.resourceError + } } } } diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index d16639b7e..62acd924f 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -210,12 +210,7 @@ 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 - } - + func deleteDraft(resource: String) async throws -> Empty? { return try await perform(request: authenticatedRequest(.resource(resource), method: .delete)).data } diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 6e312841c..67767ffa1 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -82,27 +82,6 @@ public class DraftManager { await draftQueue.endBackgroundTask(uuid: draft.localUUID) } - 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) - } - } - } - public func send(draft: Draft, mailboxManager: MailboxManager) async { await draftQueue.cleanQueueElement(uuid: draft.localUUID) await draftQueue.beginBackgroundTask(withName: "Draft Sender", for: draft.localUUID) @@ -138,8 +117,13 @@ public class DraftManager { await self.saveDraft(draft: draft, mailboxManager: mailboxManager) await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, - action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in - self?.deleteDraft(localUuid: draft.localUUID, mailboxManager: mailboxManager) + action: .init(title: MailResourcesStrings.Localizable.actionDelete) { + Task { + await tryOrDisplayError { + try await mailboxManager.delete(remoteDraftResource: draft.remoteUUID) + await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftDeleted) + } + } }) case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) @@ -153,7 +137,6 @@ public class DraftManager { } if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { - await mailboxManager.draftOffline() try await mailboxManager.threads(folder: draftFolder) if let maxDelaySeconds = drafts.filter({ $0.action == .send }).compactMap({ $0.delay }).sorted().first { diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index cd5ed7814..e4a82a6fc 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -831,8 +831,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) @@ -974,7 +972,7 @@ public class MailboxManager: ObservableObject { public func send(draft: Draft) async throws -> CancelResponse { 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 } @@ -991,83 +989,18 @@ public class MailboxManager: ObservableObject { } } - public func delete(draft: Draft) async { - await backgroundRealm.execute { realm in - guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } - try? realm.safeWrite { - realm.delete(liveDraft) - } - } - } - - 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]) - } - - 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) - } - } + public func delete(remoteDraftResource: String) async throws { + if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == remoteDraftResource }).first { + try await deleteLocally(draft: draft) } + try await apiFetcher.deleteDraft(resource: remoteDraftResource) } - 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) - } - } - } - - /// Delete local draft from its associated thread - /// - Parameter thread: Thread associated to local draft - public func deleteLocalDraft(thread: Thread) async { - 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) - } + realm.delete(liveDraft) } } } diff --git a/MailCore/Models/Message.swift b/MailCore/Models/Message.swift index 10c5c9668..14adad154 100644 --- a/MailCore/Models/Message.swift +++ b/MailCore/Models/Message.swift @@ -326,28 +326,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/Thread.swift b/MailCore/Models/Thread.swift index 67742dce3..cfdd85140 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -224,28 +224,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 { From aa8dccbfe9d815a0a0786f9337ef07e0e70bf93b Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 4 Jan 2023 17:34:35 +0100 Subject: [PATCH 14/32] fix(Draft): Improve deletion --- Mail/Views/Thread/MessageHeaderView.swift | 6 +----- MailCore/API/MailApiFetcher.swift | 11 +++++++++-- MailCore/Cache/DraftManager.swift | 23 ++++++++++++++++------- MailCore/Cache/MailboxManager.swift | 19 ++++++++++++++++--- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/Mail/Views/Thread/MessageHeaderView.swift b/Mail/Views/Thread/MessageHeaderView.swift index e769f6042..7f2a60dce 100644 --- a/Mail/Views/Thread/MessageHeaderView.swift +++ b/Mail/Views/Thread/MessageHeaderView.swift @@ -75,11 +75,7 @@ struct MessageHeaderView: View { private func deleteDraft() { Task { await tryOrDisplayError { - if let draftResource = message.draftResource { - try await mailboxManager.delete(remoteDraftResource: draftResource) - } else { - throw MailError.resourceError - } + try await mailboxManager.delete(draftMessage: message) } } } diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 62acd924f..7078a995c 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -210,8 +210,15 @@ public class MailApiFetcher: ApiFetcher { } @discardableResult - func deleteDraft(resource: String) async throws -> Empty? { - 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 67767ffa1..47a2058ea 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -117,13 +117,8 @@ public class DraftManager { await self.saveDraft(draft: draft, mailboxManager: mailboxManager) await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, - action: .init(title: MailResourcesStrings.Localizable.actionDelete) { - Task { - await tryOrDisplayError { - try await mailboxManager.delete(remoteDraftResource: draft.remoteUUID) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftDeleted) - } - } + action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in + self?.deleteDraftSnackBarAction(mailboxManager: mailboxManager, draft: draft) }) case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) @@ -148,6 +143,20 @@ public class DraftManager { } } + private func deleteDraftSnackBarAction(mailboxManager: MailboxManager, draft: Draft) { + 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) + } + } + } + } + } + private func deleteEmptyDraft(draft: Draft) { guard let liveDraft = draft.thaw(), let realm = liveDraft.realm else { return } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index e4a82a6fc..00931c082 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -989,17 +989,30 @@ public class MailboxManager: ObservableObject { } } - public func delete(remoteDraftResource: String) async throws { - if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == remoteDraftResource }).first { + public func delete(draft: Draft) async throws { + try await deleteLocally(draft: draft) + try await apiFetcher.deleteDraft(mailbox: mailbox, draftId: draft.remoteUUID) + } + + public func delete(draftMessage: Message) async throws { + guard let draftResource = draftMessage.draftResource else { + throw MailError.resourceError + } + + if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == draftResource }).first?.freeze() { try await deleteLocally(draft: draft) } - try await apiFetcher.deleteDraft(resource: remoteDraftResource) + try await apiFetcher.deleteDraft(draftResource: draftResource) } public func deleteLocally(draft: Draft) async throws { await backgroundRealm.execute { realm in guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } try? realm.safeWrite { + if let associatedMessageUid = liveDraft.messageUid { + let associatedMessages = realm.objects(Message.self).where { $0.messageId == associatedMessageUid } + realm.delete(associatedMessages) + } realm.delete(liveDraft) } } From b1aa507f3a68cf486d2b80574af204de23f12b88 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 5 Jan 2023 08:59:35 +0100 Subject: [PATCH 15/32] refactor(DraftManager): Move out refresh draft folder --- MailCore/Cache/DraftManager.swift | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 47a2058ea..36e76ad79 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 { @@ -118,7 +119,7 @@ public class DraftManager { await self.saveDraft(draft: draft, mailboxManager: mailboxManager) await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackBarDraftSaved, action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in - self?.deleteDraftSnackBarAction(mailboxManager: mailboxManager, draft: draft) + self?.deleteDraftSnackBarAction(draft: draft, mailboxManager: mailboxManager) }) case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) @@ -131,19 +132,26 @@ public class DraftManager { } } - if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { - try await mailboxManager.threads(folder: draftFolder) + try await refreshDraftFolder(pendingDrafts: drafts, mailboxManager: mailboxManager) + } + } - if let maxDelaySeconds = drafts.filter({ $0.action == .send }).compactMap({ $0.delay }).sorted().first { - // We need to refresh the draft folder after the mail is sent to make it disappear - try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(maxDelaySeconds), 1.5))) - try await mailboxManager.threads(folder: draftFolder) - } + private func refreshDraftFolder(pendingDrafts: Results, mailboxManager: MailboxManager) async throws { + if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { + try await mailboxManager.threads(folder: draftFolder) + + if let maxDelaySeconds = pendingDrafts.filter({ $0.action == .send }).compactMap({ $0.delay }).sorted().first { + /* + 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 + */ + try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(maxDelaySeconds), 1.5))) + try await mailboxManager.threads(folder: draftFolder) } } } - private func deleteDraftSnackBarAction(mailboxManager: MailboxManager, draft: Draft) { + private func deleteDraftSnackBarAction(draft: Draft, mailboxManager: MailboxManager) { Task { await tryOrDisplayError { if let liveDraft = draft.thaw() { From 5e574a5d8ca3b6cb8f0247677b7f1fa53cb1afca Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 5 Jan 2023 14:53:25 +0100 Subject: [PATCH 16/32] fix(Draft): Refresh associated folders when deleting messages --- MailCore/Cache/MailboxManager.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 00931c082..94a966dc5 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -1002,17 +1002,15 @@ public class MailboxManager: ObservableObject { if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == draftResource }).first?.freeze() { try await deleteLocally(draft: draft) } + try await apiFetcher.deleteDraft(draftResource: draftResource) + try await refreshFolder(from: [draftMessage]) } public func deleteLocally(draft: Draft) async throws { await backgroundRealm.execute { realm in guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return } try? realm.safeWrite { - if let associatedMessageUid = liveDraft.messageUid { - let associatedMessages = realm.objects(Message.self).where { $0.messageId == associatedMessageUid } - realm.delete(associatedMessages) - } realm.delete(liveDraft) } } From 9020158f92cbeeedb11d31efeb43ca286e125328 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 5 Jan 2023 14:53:57 +0100 Subject: [PATCH 17/32] refactor(MailboxManager): Remove unused code --- MailCore/Cache/MailboxManager.swift | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 94a966dc5..675758525 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -907,26 +907,6 @@ 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() -> Results { let realm = getRealm() realm.refresh() From 02f47fb7ee14fb32c6587077cf55932cd31ef099 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 5 Jan 2023 16:03:29 +0100 Subject: [PATCH 18/32] fix(Draft): Prevent duplicate replies for draft --- Mail/Views/New Message/ComposeMessageView.swift | 2 +- MailCore/Models/Draft.swift | 5 +++-- MailCore/Models/MessageReply.swift | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 398e4e1d7..ef8839257 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -99,7 +99,7 @@ struct ComposeMessageView: View { 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) ) } diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 1eb8b67d6..b71ada0dc 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -199,7 +199,7 @@ public class Draft: Object, Decodable, Identifiable, Encodable { return Draft(to: [recipient.detached()]) } - public static func replying(to message: Message, mode: ReplyMode) -> Draft { + public static func replying(to message: Message, mode: ReplyMode, localDraftUUID: String) -> Draft { let subject: String let quote: String var attachments: [Attachment] = [] @@ -236,7 +236,8 @@ public class Draft: Object, Decodable, Identifiable, Encodable { } } - return Draft(inReplyToUid: mode.isReply ? message.uid : nil, + return Draft(localUUID: localDraftUUID, + inReplyToUid: mode.isReply ? message.uid : nil, forwardedUid: mode == .forward([]) ? message.uid : nil, inReplyTo: message.messageId, subject: subject, 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 } } From 30567e041056621f4242edfcdfb8015d40498c45 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 5 Jan 2023 16:25:33 +0100 Subject: [PATCH 19/32] fix(Draft): Ensure we do not keep orphan drafts in realm --- MailCore/Cache/DraftManager.swift | 2 ++ MailCore/Cache/MailboxManager.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 36e76ad79..b72277a76 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -148,6 +148,8 @@ public class DraftManager { try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(maxDelaySeconds), 1.5))) try await mailboxManager.threads(folder: draftFolder) } + + await mailboxManager.deleteOrphanDrafts() } } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 675758525..ca71634b5 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -996,6 +996,24 @@ public class MailboxManager: ObservableObject { } } + 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 + 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) + } + } + } + } + } + // MARK: - Utilities struct MessagePropertiesOptions: OptionSet { From 6431f6af6ab3eb439c21e9e60e821dccba7e2db9 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Fri, 6 Jan 2023 13:02:49 +0100 Subject: [PATCH 20/32] refactor(DraftUtils): Remove old comment --- Mail/Utils/DraftUtils.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Mail/Utils/DraftUtils.swift b/Mail/Utils/DraftUtils.swift index 11536264d..914892b3d 100644 --- a/Mail/Utils/DraftUtils.swift +++ b/Mail/Utils/DraftUtils.swift @@ -26,7 +26,6 @@ class DraftUtils { // If we already have the draft locally, present it directly if let draft = mailboxManager.draft(messageUid: message.uid)?.detached() { editedMessageDraft.wrappedValue = draft - // Maybe it was an offline draft (If offline draft is created with draft.uuid = thread.uid) } else { DraftUtils.editDraft(from: message, mailboxManager: mailboxManager, editedMessageDraft: editedMessageDraft) } From 646d814dabe65741ab93568ecf47b37a1f0dc47c Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 11 Jan 2023 14:30:53 +0100 Subject: [PATCH 21/32] fix(Draft): Delete associated draft when deleting message --- MailCore/Cache/MailboxManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index ca71634b5..f02b4407c 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -254,14 +254,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.messages.isEmpty { From 3771eaeca2a0fdfd1f7eee960c37cf738a5aa05e Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 11 Jan 2023 15:59:52 +0100 Subject: [PATCH 22/32] fix(Draft): Add reference when replying --- MailCore/Models/Draft.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index b71ada0dc..e4e78e5bc 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -239,6 +239,7 @@ public class Draft: Object, Decodable, Identifiable, Encodable { return Draft(localUUID: localDraftUUID, inReplyToUid: mode.isReply ? message.uid : nil, forwardedUid: mode == .forward([]) ? message.uid : nil, + references: message.messageId, inReplyTo: message.messageId, subject: subject, body: "

\(quote)", @@ -274,6 +275,7 @@ public class Draft: Object, Decodable, Identifiable, Encodable { 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) From 798a930412e57e03abf682166330169c6add8e73 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 11 Jan 2023 16:00:47 +0100 Subject: [PATCH 23/32] fix(Draft): Refresh after etop date --- MailCore/API/MailApiFetcher.swift | 2 +- MailCore/Cache/DraftManager.swift | 31 +++++++++++++++++------- MailCore/Cache/MailboxManager.swift | 2 +- MailCore/Models/CancelableResponse.swift | 9 +++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 7078a995c..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: Draft) 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, diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index b72277a76..f6c13fd40 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -83,7 +83,8 @@ public class DraftManager { await draftQueue.endBackgroundTask(uuid: draft.localUUID) } - public func send(draft: Draft, 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) @@ -96,24 +97,28 @@ public class DraftManager { undoRedoAction: UndoRedoAction(undo: cancelableResponse, redo: nil), mailboxManager: mailboxManager ) + sendDate = cancelableResponse.etop } catch { 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 { - await withTaskGroup(of: Void.self) { group in + 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: guard draft.body != emptyDraftBody else { self.deleteEmptyDraft(draft: draft) - return + return nil } await self.saveDraft(draft: draft, mailboxManager: mailboxManager) @@ -124,32 +129,40 @@ public class DraftManager { case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) case .send: - await self.send(draft: draft, mailboxManager: mailboxManager) + sendDate = await self.send(draft: draft, mailboxManager: mailboxManager) default: break } + return sendDate } } + + var latestSendDate: Date? + for await result in group { + latestSendDate = result + } + return latestSendDate } - try await refreshDraftFolder(pendingDrafts: drafts, mailboxManager: mailboxManager) + try await refreshDraftFolder(pendingDrafts: drafts, latestSendDate: latestSendDate, mailboxManager: mailboxManager) } } - private func refreshDraftFolder(pendingDrafts: Results, mailboxManager: MailboxManager) async throws { + private func refreshDraftFolder(pendingDrafts: Results, latestSendDate: Date?, mailboxManager: MailboxManager) async throws { if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { try await mailboxManager.threads(folder: draftFolder) - if let maxDelaySeconds = pendingDrafts.filter({ $0.action == .send }).compactMap({ $0.delay }).sorted().first { + 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 */ - try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * max(Double(maxDelaySeconds), 1.5))) + 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() + await mailboxManager.deleteOrphanDrafts() } } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index f02b4407c..c43e1ead0 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -954,7 +954,7 @@ public class MailboxManager: ObservableObject { return realm.objects(Draft.self).where { $0.remoteUUID == remoteUuid }.first } - public func send(draft: Draft) async throws -> CancelResponse { + 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 try await deleteLocally(draft: draft) diff --git a/MailCore/Models/CancelableResponse.swift b/MailCore/Models/CancelableResponse.swift index b3e354fd3..e3573108b 100644 --- a/MailCore/Models/CancelableResponse.swift +++ b/MailCore/Models/CancelableResponse.swift @@ -22,6 +22,15 @@ public protocol CancelableResponse { var resource: String { get } } +public struct SendResponse: Decodable, CancelableResponse { + public let cancelResource: String + public let etop: Date + + public var resource: String { + return cancelResource + } +} + public struct CancelResponse: Decodable, CancelableResponse { public let cancelResource: String From efe5cb4343708f7015774d18aa8d0c77162d6a40 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Wed, 11 Jan 2023 16:30:41 +0100 Subject: [PATCH 24/32] refactor(Draft): Rename etop to scheduledDate --- MailCore/Cache/DraftManager.swift | 2 +- MailCore/Models/CancelableResponse.swift | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index f6c13fd40..9f26f7462 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -97,7 +97,7 @@ public class DraftManager { undoRedoAction: UndoRedoAction(undo: cancelableResponse, redo: nil), mailboxManager: mailboxManager ) - sendDate = cancelableResponse.etop + sendDate = cancelableResponse.scheduledDate } catch { await IKSnackBar.showSnackBar(message: error.localizedDescription) } diff --git a/MailCore/Models/CancelableResponse.swift b/MailCore/Models/CancelableResponse.swift index e3573108b..014e6a109 100644 --- a/MailCore/Models/CancelableResponse.swift +++ b/MailCore/Models/CancelableResponse.swift @@ -24,11 +24,16 @@ public protocol CancelableResponse { public struct SendResponse: Decodable, CancelableResponse { public let cancelResource: String - public let etop: Date + public let scheduledDate: Date public var resource: String { return cancelResource } + + enum CodingKeys: String, CodingKey { + case cancelResource + case scheduledDate = "etop" + } } public struct CancelResponse: Decodable, CancelableResponse { From db0cbfe58ad13fc65d24c0309b15c3dc7cab2b95 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 08:25:48 +0100 Subject: [PATCH 25/32] fix(Draft): References when replying to message --- MailCore/Models/Draft.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index e4e78e5bc..0b22ea796 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -239,7 +239,7 @@ public class Draft: Object, Decodable, Identifiable, Encodable { return Draft(localUUID: localDraftUUID, inReplyToUid: mode.isReply ? message.uid : nil, forwardedUid: mode == .forward([]) ? message.uid : nil, - references: message.messageId, + references: "\(message.references ?? "") \(message.messageId ?? "")", inReplyTo: message.messageId, subject: subject, body: "

\(quote)", From 9dddc7a46949ad4479dfc9d359a4603fbfb03bcb Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 09:54:06 +0100 Subject: [PATCH 26/32] fix(Draft): Only add Re/Fwd if it doesn't exist --- MailCore/Models/Draft.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 0b22ea796..95df25876 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -200,15 +200,19 @@ public class Draft: Object, Decodable, Identifiable, Encodable { } public static func replying(to message: Message, mode: ReplyMode, localDraftUUID: String) -> Draft { - let subject: String + var subject = "\(message.formattedSubject)" let quote: String var attachments: [Attachment] = [] switch mode { case .reply, .replyAll: - subject = "Re: \(message.formattedSubject)" + if !subject.starts(with: "Re: ") { + subject = "Re: \(subject)" + } quote = Constants.replyQuote(message: message) case let .forward(attachmentsToForward): - subject = "Fwd: \(message.formattedSubject)" + if !subject.starts(with: "Fwd: ") { + subject = "Fwd: \(subject)" + } quote = Constants.forwardQuote(message: message) attachments = attachmentsToForward } From 4eb573002ad36708db593bafaf12685acd6579d7 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 14:03:08 +0100 Subject: [PATCH 27/32] refactor(Draft): Fix code smells --- MailCore/Cache/DraftManager.swift | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index 9f26f7462..52b9eee65 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -116,16 +116,7 @@ public class DraftManager { switch draft.action { case .initialSave: - guard draft.body != emptyDraftBody else { - self.deleteEmptyDraft(draft: draft) - return nil - } - - await self.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) - }) + await self.initialSave(draft: draft, mailboxManager: mailboxManager, emptyDraftBody: emptyDraftBody) case .save: await self.saveDraft(draft: draft, mailboxManager: mailboxManager) case .send: @@ -144,11 +135,24 @@ public class DraftManager { return latestSendDate } - try await refreshDraftFolder(pendingDrafts: drafts, latestSendDate: latestSendDate, mailboxManager: mailboxManager) + 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(pendingDrafts: Results, latestSendDate: Date?, mailboxManager: MailboxManager) async throws { + private func refreshDraftFolder(latestSendDate: Date?, mailboxManager: MailboxManager) async throws { if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { try await mailboxManager.threads(folder: draftFolder) From a1fd1ec55dd903c03d8f13a13d393c438edcc742 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 15:32:09 +0100 Subject: [PATCH 28/32] fix(DraftUtils): Add @MainActor back --- Mail/Utils/DraftUtils.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mail/Utils/DraftUtils.swift b/Mail/Utils/DraftUtils.swift index 914892b3d..9ce272464 100644 --- a/Mail/Utils/DraftUtils.swift +++ b/Mail/Utils/DraftUtils.swift @@ -20,6 +20,7 @@ import Foundation import MailCore import SwiftUI +@MainActor class DraftUtils { public static func editDraft(from thread: Thread, mailboxManager: MailboxManager, editedMessageDraft: Binding) { guard let message = thread.messages.first else { return } From 7ae8e60363d2e8cb10c86e0b738628ddbb18a346 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 15:53:20 +0100 Subject: [PATCH 29/32] fix(ComposeMessageView): Use guard before starting task --- Mail/Views/New Message/ComposeMessageView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index e8e176d98..283d4272b 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -249,15 +249,15 @@ struct ComposeMessageView: View { } } .task { - if draft.messageUid != nil && draft.remoteUUID.isEmpty { - do { - if let fetchedDraft = try await mailboxManager.draft(partialDraft: draft), - let liveFetchedDraft = fetchedDraft.thaw() { - self.draft = liveFetchedDraft - } - } catch { - // Fail silently + 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) From 6c1788d0c8ca53a65c3ffa71def4a2d8dd4644a3 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 15:54:47 +0100 Subject: [PATCH 30/32] refactor(Draft): Fix indent --- MailCore/Models/Draft.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 95df25876..1a6b0d976 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -187,7 +187,9 @@ public class Draft: Object, Decodable, Identifiable, Encodable { public static func mailTo(subject: String?, body: String?, - to: [Recipient], cc: [Recipient], bcc: [Recipient]) -> Draft { + to: [Recipient], + cc: [Recipient], + bcc: [Recipient]) -> Draft { return Draft(subject: subject ?? "", body: body ?? "", to: to, From 889f4602cb28d40d12100bb23d356b691ebfca48 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Thu, 12 Jan 2023 16:51:00 +0100 Subject: [PATCH 31/32] feat(Array+Extension): Use toArray to transform LazyFilterSequence --- Mail/Views/New Message/ComposeMessageView.swift | 5 +++-- MailCore/Utils/Array+Extension.swift | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 283d4272b..4835a9927 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -151,10 +151,11 @@ 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(Array(attachments)) { attachment in + ForEach(attachments) { attachment in AttachmentCell(attachment: attachment, isNewMessage: true) { attachmentRemoved in removeAttachment(attachmentRemoved) } 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) + } +} From 71dbe0663d696e2ceaafe903719ec21b5eac437f Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Fri, 13 Jan 2023 15:00:32 +0100 Subject: [PATCH 32/32] fix(RichTextEditor): Remove old code --- Mail/Helpers/RichTextEditor.swift | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 } }