diff --git a/Mail/Components/RecipientCell.swift b/Mail/Components/RecipientCell.swift index fb637b42c..c54d4dc12 100644 --- a/Mail/Components/RecipientCell.swift +++ b/Mail/Components/RecipientCell.swift @@ -19,8 +19,25 @@ import MailCore import SwiftUI +struct RecipientCellModifier: ViewModifier { + func body(content: Content) -> some View { + content + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } +} + +extension View { + func recipientCellModifier() -> some View { + modifier(RecipientCellModifier()) + } +} + struct RecipientCell: View { let recipient: Recipient + var highlight: String? var body: some View { HStack(spacing: 8) { @@ -28,21 +45,28 @@ struct RecipientCell: View { .accessibilityHidden(true) if recipient.name.isEmpty { - Text(recipient.email) + Text(highlightedAttributedString(from: recipient.email)) .textStyle(.bodyMedium) } else { VStack(alignment: .leading) { - Text(recipient.name) + Text(highlightedAttributedString(from: recipient.name)) .textStyle(.bodyMedium) - Text(recipient.email) + Text(highlightedAttributedString(from: recipient.email)) .textStyle(.bodySecondary) } } } - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) + .recipientCellModifier() + } + + private func highlightedAttributedString(from data: String) -> AttributedString { + var attributedString = AttributedString(data) + guard let highlight else { return attributedString } + + if let range = attributedString.range(of: highlight, options: .caseInsensitive) { + attributedString[range].foregroundColor = .accentColor + } + return attributedString } } diff --git a/Mail/Components/UnknownRecipientView.swift b/Mail/Components/UnknownRecipientView.swift new file mode 100644 index 000000000..b85944e08 --- /dev/null +++ b/Mail/Components/UnknownRecipientView.swift @@ -0,0 +1,47 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import MailResources +import SwiftUI + +struct UnknownRecipientView: View { + let size: CGFloat + + private var iconSize: CGFloat { + return size - 2 * UIConstants.unknownRecipientHorizontalPadding + } + + var body: some View { + Circle() + .fill(Color.accentColor) + .frame(width: size, height: size) + .overlay { + MailResourcesAsset.userBold.swiftUIImage + .resizable() + .foregroundColor(MailResourcesAsset.backgroundColor.swiftUIColor) + .frame(width: iconSize, height: iconSize) + } + } +} + +struct UnknownRecipientView_Previews: PreviewProvider { + static var previews: some View { + UnknownRecipientView(size: 40) + } +} diff --git a/Mail/Helpers/RichTextEditor.swift b/Mail/Helpers/RichTextEditor.swift index 337dffb70..6cc23662d 100644 --- a/Mail/Helpers/RichTextEditor.swift +++ b/Mail/Helpers/RichTextEditor.swift @@ -28,7 +28,7 @@ import WebKit struct RichTextEditor: UIViewRepresentable { typealias UIViewType = MailEditorView - @Binding var model: RichTextEditorModel + @ObservedObject var model: RichTextEditorModel @Binding var body: String @Binding var isShowingCamera: Bool @Binding var isShowingFileSelection: Bool @@ -37,12 +37,12 @@ struct RichTextEditor: UIViewRepresentable { let blockRemoteContent: Bool var alert: ObservedObject.Wrapper - init(model: Binding, body: Binding, + init(model: RichTextEditorModel, body: Binding, alert: ObservedObject.Wrapper, isShowingCamera: Binding, isShowingFileSelection: Binding, isShowingPhotoLibrary: Binding, becomeFirstResponder: Binding, blockRemoteContent: Bool) { - _model = model + _model = ObservedObject(wrappedValue: model) _body = body self.alert = alert _isShowingCamera = isShowingCamera @@ -146,9 +146,9 @@ extension SQTextEditorView { } } -struct RichTextEditorModel { - var cursorPosition: CGFloat = 0 - var height: CGFloat = 0 +class RichTextEditorModel: ObservableObject { + @Published var cursorPosition: CGFloat = 0 + @Published var height: CGFloat = 0 } class MailEditorView: SQTextEditorView { diff --git a/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift b/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift index f34ffc4bc..78914808d 100644 --- a/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift +++ b/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift @@ -37,8 +37,8 @@ struct AttachmentsHeaderView: View { } } .padding(.vertical, 1) + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } .customAlert(item: $attachmentsManager.globalError) { error in diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift new file mode 100644 index 000000000..d1f6d78b7 --- /dev/null +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -0,0 +1,63 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import MailResources +import SwiftUI + +struct AutocompletionCell: View { + let addRecipient: @MainActor (Recipient) -> Void + let recipient: Recipient + var highlight: String? + let alreadyAppend: Bool + let unknownRecipient: Bool + + var body: some View { + HStack(spacing: 12) { + Button { + addRecipient(recipient) + } label: { + if unknownRecipient { + UnknownRecipientCell(recipient: recipient) + } else { + RecipientCell(recipient: recipient, highlight: highlight) + } + } + .allowsHitTesting(!alreadyAppend || unknownRecipient) + .opacity(alreadyAppend && !unknownRecipient ? 0.5 : 1) + + if alreadyAppend && !unknownRecipient { + MailResourcesAsset.checked.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(MailResourcesAsset.textTertiaryColor.swiftUIColor) + } + } + } +} + +struct AutocompletionCell_Previews: PreviewProvider { + static var previews: some View { + AutocompletionCell( + addRecipient: { _ in /* Preview */ }, + recipient: PreviewHelper.sampleRecipient1, + alreadyAppend: false, + unknownRecipient: false + ) + } +} diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 4acecb730..aed0bd1fc 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -16,50 +16,84 @@ along with this program. If not, see . */ +import Combine import MailCore import MailResources +import RealmSwift import SwiftUI struct AutocompletionView: View { + @State private var shouldAddUserProposal = false + + @ObservedObject var textDebounce: TextDebounce + @Binding var autocompletion: [Recipient] - @Binding var unknownRecipientAutocompletion: String + @Binding var addedRecipients: RealmSwift.List - let onSelect: (Recipient) -> Void + let addRecipient: @MainActor (Recipient) -> Void var body: some View { - LazyVStack { + LazyVStack(spacing: UIConstants.autocompletionVerticalPadding) { ForEach(autocompletion) { recipient in - VStack(alignment: .leading, spacing: 8) { - Button { - onSelect(recipient) - } label: { - RecipientCell(recipient: recipient) - } - .padding(.horizontal, 8) + let isLastRecipient = autocompletion.last?.isSameRecipient(as: recipient) == true + let isUserProposal = shouldAddUserProposal && isLastRecipient - IKDivider() + VStack(alignment: .leading, spacing: UIConstants.autocompletionVerticalPadding) { + AutocompletionCell( + addRecipient: addRecipient, + recipient: recipient, + highlight: textDebounce.text, + alreadyAppend: addedRecipients.contains { $0.isSameRecipient(as: recipient) }, + unknownRecipient: isUserProposal + ) + + if !isLastRecipient { + IKDivider() + } } } + } + .onAppear { + updateAutocompletion(textDebounce.text) + } + .onReceive(textDebounce.$text.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)) { currentValue in + updateAutocompletion("\(currentValue)") + } + } - if !unknownRecipientAutocompletion.isEmpty { - Button { - onSelect(Recipient(email: unknownRecipientAutocompletion, name: "")) - } label: { - AddRecipientCell(recipientEmail: unknownRecipientAutocompletion) - } - .padding(.horizontal, 8) + private func updateAutocompletion(_ search: String) { + guard let contactManager = AccountManager.instance.currentContactManager else { + withAnimation { + autocompletion = [] + } + return + } + + let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines) + + let autocompleteContacts = contactManager.contacts(matching: trimmedSearch) + var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } + + let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } + + withAnimation { + shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text) + if shouldAddUserProposal { + autocompleteRecipients + .append(Recipient(email: textDebounce.text, name: "")) } + + autocompletion = autocompleteRecipients } - .padding(.top, 8) - .padding(.horizontal, 8) } } struct AutocompletionView_Previews: PreviewProvider { static var previews: some View { - AutocompletionView(autocompletion: .constant([ - PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 - ]), - unknownRecipientAutocompletion: .constant("")) { _ in /* Preview */ } + AutocompletionView( + textDebounce: TextDebounce(), + autocompletion: .constant([]), + addedRecipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()) + ) { _ in /* Preview */ } } } diff --git a/Mail/Views/New Message/ComposeMessageBodyView.swift b/Mail/Views/New Message/ComposeMessageBodyView.swift new file mode 100644 index 000000000..11f95de77 --- /dev/null +++ b/Mail/Views/New Message/ComposeMessageBodyView.swift @@ -0,0 +1,182 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCoreUI +import MailCore +import RealmSwift +import SwiftUI + +struct ComposeMessageBodyView: View { + @EnvironmentObject private var mailboxManager: MailboxManager + + @State private var isShowingCamera = false + @State private var isShowingFileSelection = false + @State private var isShowingPhotoLibrary = false + + @StateObject private var editorModel = RichTextEditorModel() + + @StateRealmObject var draft: Draft + + @Binding var isLoadingContent: Bool + @Binding var editorFocus: Bool + + @ObservedObject var attachmentsManager: AttachmentsManager + @ObservedObject var alert: NewMessageAlert + + let dismiss: DismissAction + let messageReply: MessageReply? + + private var isRemoteContentBlocked: Bool { + return UserDefaults.shared.displayExternalContent == .askMe && messageReply?.message.localSafeDisplay == false + } + + var body: some View { + VStack { + AttachmentsHeaderView(attachmentsManager: attachmentsManager) + + RichTextEditor( + model: editorModel, + body: $draft.body, + alert: $alert, + isShowingCamera: $isShowingCamera, + isShowingFileSelection: $isShowingFileSelection, + isShowingPhotoLibrary: $isShowingPhotoLibrary, + becomeFirstResponder: $editorFocus, + blockRemoteContent: isRemoteContentBlocked + ) + .ignoresSafeArea(.all, edges: .bottom) + .frame(height: editorModel.height + 20) + .padding(.vertical, 8) + } + .task { + await prepareCompleteDraft() + } + .task { + await prepareReplyForwardBodyAndAttachments() + await setSignature() + } + .fullScreenCover(isPresented: $isShowingCamera) { + CameraPicker { data in + attachmentsManager.importAttachments(attachments: [data]) + } + .ignoresSafeArea() + } + .sheet(isPresented: $isShowingFileSelection) { + DocumentPicker(pickerType: .selectContent([.item]) { urls in + attachmentsManager.importAttachments(attachments: urls) + }) + .ignoresSafeArea() + } + .sheet(isPresented: $isShowingPhotoLibrary) { + ImagePicker { results in + attachmentsManager.importAttachments(attachments: results) + } + .ignoresSafeArea() + } + } + + private func prepareCompleteDraft() async { + guard draft.messageUid != nil && draft.remoteUUID.isEmpty else { return } + + do { + if let fetchedDraft = try await mailboxManager.draft(partialDraft: draft), + let liveFetchedDraft = fetchedDraft.thaw() { + draft = liveFetchedDraft + } + isLoadingContent = false + } catch { + dismiss() + IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) + } + } + + private func prepareReplyForwardBodyAndAttachments() async { + guard let messageReply else { return } + + let prepareTask = Task.detached { + try await prepareBody(message: messageReply.message, replyMode: messageReply.replyMode) + try await prepareAttachments(message: messageReply.message, replyMode: messageReply.replyMode) + } + + do { + _ = try await prepareTask.value + + isLoadingContent = false + } catch { + dismiss() + IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) + } + } + + private func setSignature() async { + if draft.identityId == nil || draft.identityId?.isEmpty == true, + let signatureResponse = mailboxManager.getSignatureResponse() { + $draft.identityId.wrappedValue = "\(signatureResponse.defaultSignatureId)" + guard let signature = signatureResponse.default else { + return + } + + let html = "

\(signature.content)
" + switch signature.position { + case .beforeReplyMessage: + $draft.body.wrappedValue.insert(contentsOf: html, at: draft.body.startIndex) + case .afterReplyMessage: + $draft.body.wrappedValue.append(contentsOf: html) + } + } + } + + private func prepareBody(message: Message, replyMode: ReplyMode) async throws { + if !message.fullyDownloaded { + try await mailboxManager.message(message: message) + } + + guard let freshMessage = message.thaw() else { return } + freshMessage.realm?.refresh() + $draft.body.wrappedValue = Draft.replyingBody(message: freshMessage, replyMode: replyMode) + } + + private func prepareAttachments(message: Message, replyMode: ReplyMode) async throws { + guard replyMode == .forward else { return } + let attachments = try await mailboxManager.apiFetcher.attachmentsToForward( + mailbox: mailboxManager.mailbox, + message: message + ).attachments + + for attachment in attachments { + $draft.attachments.append(attachment) + } + attachmentsManager.completeUploadedAttachments() + } +} + +struct ComposeMessageBodyView_Previews: PreviewProvider { + static var previews: some View { + @Environment(\.dismiss) var dismiss + + ComposeMessageBodyView( + draft: Draft(), + isLoadingContent: .constant(false), + editorFocus: .constant(false), + attachmentsManager: AttachmentsManager(draft: Draft(), mailboxManager: PreviewHelper.sampleMailboxManager), + alert: NewMessageAlert(), + dismiss: dismiss, + messageReply: nil + ) + } +} diff --git a/Mail/Views/New Message/ComposeMessageHeaderView.swift b/Mail/Views/New Message/ComposeMessageHeaderView.swift new file mode 100644 index 000000000..0fcc92366 --- /dev/null +++ b/Mail/Views/New Message/ComposeMessageHeaderView.swift @@ -0,0 +1,86 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import RealmSwift +import SwiftUI + +struct ComposeMessageHeaderView: View { + @EnvironmentObject private var mailboxManager: MailboxManager + + @State private var showRecipientsFields = false + + @ObservedRealmObject var draft: Draft + + @FocusState var focusedField: ComposeViewFieldType? + + @Binding var autocompletionType: ComposeViewFieldType? + + var body: some View { + VStack(spacing: UIConstants.composeViewVerticalSpacing) { + ComposeMessageCellStaticText( + autocompletionType: $autocompletionType, + type: .from, + text: mailboxManager.mailbox.email + ) + + ComposeMessageCellRecipients( + recipients: $draft.to, + showRecipientsFields: $showRecipientsFields, + autocompletionType: $autocompletionType, + focusedField: _focusedField, + type: .to + ) + + if showRecipientsFields { + ComposeMessageCellRecipients( + recipients: $draft.cc, + showRecipientsFields: $showRecipientsFields, + autocompletionType: $autocompletionType, + focusedField: _focusedField, + type: .cc + ) + + ComposeMessageCellRecipients( + recipients: $draft.bcc, + showRecipientsFields: $showRecipientsFields, + autocompletionType: $autocompletionType, + focusedField: _focusedField, + type: .bcc + ) + } + + ComposeMessageCellTextField( + text: $draft.subject, + autocompletionType: $autocompletionType, + focusedField: _focusedField, + type: .subject + ) + } + .padding(.horizontal, 16) + .onAppear { + showRecipientsFields = !draft.bcc.isEmpty || !draft.cc.isEmpty + } + } +} + +struct ComposeMessageHeaderView_Previews: PreviewProvider { + static var previews: some View { + ComposeMessageHeaderView(draft: Draft(), autocompletionType: .constant(nil)) + } +} diff --git a/Mail/Views/New Message/ComposeMessageView+Init.swift b/Mail/Views/New Message/ComposeMessageView+Init.swift new file mode 100644 index 000000000..02f5d58be --- /dev/null +++ b/Mail/Views/New Message/ComposeMessageView+Init.swift @@ -0,0 +1,51 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCoreUI +import InfomaniakDI +import MailCore +import RealmSwift + +extension ComposeMessageView { + static func newMessage(mailboxManager: MailboxManager) -> ComposeMessageView { + let draft = Draft(localUUID: UUID().uuidString) + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager) + } + + static func replyOrForwardMessage(messageReply: MessageReply, mailboxManager: MailboxManager) -> ComposeMessageView { + let draft = Draft.replying(reply: messageReply) + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager, messageReply: messageReply) + } + + static func edit(draft: Draft, mailboxManager: MailboxManager) -> ComposeMessageView { + @InjectService var matomo: MatomoUtils + matomo.track(eventWithCategory: .newMessage, name: "openFromDraft") + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager) + } + + static func writingTo(recipient: Recipient, mailboxManager: MailboxManager) -> ComposeMessageView { + let draft = Draft.writing(to: recipient) + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager) + } + + static func mailTo(urlComponents: URLComponents, mailboxManager: MailboxManager) -> ComposeMessageView { + let draft = Draft.mailTo(urlComponents: urlComponents) + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager) + } +} diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index fedfc295b..7805ce431 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -16,19 +16,16 @@ along with this program. If not, see . */ -import InfomaniakCore import InfomaniakCoreUI import InfomaniakDI import Introspect import MailCore import MailResources -import PhotosUI import RealmSwift -import Sentry import SwiftUI enum ComposeViewFieldType: Hashable { - case from, to, cc, bcc, subject, editor + case from, to, cc, bcc, subject, editor, autocomplete case chip(Int, Recipient) var title: String { @@ -47,6 +44,8 @@ enum ComposeViewFieldType: Hashable { return "editor" case .chip: return "Recipient Chip" + case .autocomplete: + return "autocomplete" } } } @@ -63,12 +62,17 @@ struct ComposeMessageView: View { @LazyInjectService private var matomo: MatomoUtils - @State private var mailboxManager: MailboxManager - - @StateRealmObject var draft: Draft - @State private var editor = RichTextEditorModel() - @State private var showCc = false @State private var isLoadingContent: Bool + @State private var isShowingCancelAttachmentsError = false + @State private var autocompletionType: ComposeViewFieldType? + @State private var editorFocus = false + + @StateObject private var mailboxManager: MailboxManager + @StateObject private var attachmentsManager: AttachmentsManager + @StateObject private var alert = NewMessageAlert() + + @StateRealmObject private var draft: Draft + @FocusState private var focusedField: ComposeViewFieldType? { willSet { let editorInFocus = (newValue == .editor) @@ -76,104 +80,57 @@ struct ComposeMessageView: View { } } - @State private var editorFocus = false - - @State private var addRecipientHandler: ((Recipient) -> Void)? - @State private var autocompletion: [Recipient] = [] - @State private var unknownRecipientAutocompletion = "" - - @State private var isShowingCamera = false - @State private var isShowingFileSelection = false - @State private var isShowingPhotoLibrary = false - @StateObject private var attachmentsManager: AttachmentsManager - @State private var isShowingCancelAttachmentsError = false - - @State var scrollView: UIScrollView? - - @StateObject private var alert = NewMessageAlert() - let messageReply: MessageReply? private var isSendButtonDisabled: Bool { - return draft.identityId?.isEmpty == true - || (draft.to.isEmpty && draft.cc.isEmpty && draft.bcc.isEmpty) - || !attachmentsManager.allAttachmentsUploaded - } - - private var shouldDisplayAutocompletion: Bool { - return (!autocompletion.isEmpty || !unknownRecipientAutocompletion.isEmpty) && focusedField != nil + return draft.identityId?.isEmpty == true || draft.recipientsAreEmpty || !attachmentsManager.allAttachmentsUploaded } - private var isRemoteContentBlocked: Bool { - return UserDefaults.shared.displayExternalContent == .askMe && messageReply?.message.localSafeDisplay == false - } - - private init(mailboxManager: MailboxManager, draft: Draft, messageReply: MessageReply? = nil) { + init(draft: Draft, mailboxManager: MailboxManager, messageReply: MessageReply? = nil) { self.messageReply = messageReply - _mailboxManager = State(initialValue: mailboxManager) - 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) - } + Self.saveNewDraftInRealm(mailboxManager.getRealm(), draft: draft) _draft = StateRealmObject(wrappedValue: draft) - _showCc = State(initialValue: !draft.bcc.isEmpty || !draft.cc.isEmpty) + + _isLoadingContent = State(wrappedValue: (draft.messageUid != nil && draft.remoteUUID.isEmpty) || messageReply != nil) + + _mailboxManager = StateObject(wrappedValue: mailboxManager) _attachmentsManager = StateObject(wrappedValue: AttachmentsManager(draft: draft, mailboxManager: mailboxManager)) - _isLoadingContent = State(initialValue: (draft.messageUid != nil && draft.remoteUUID.isEmpty) || messageReply != nil) } var body: some View { NavigationView { - ScrollView(.vertical, showsIndicators: true) { + ScrollView { VStack(spacing: 0) { - if !shouldDisplayAutocompletion { - NewMessageCell(type: .from, - isFirstCell: true) { - Text(mailboxManager.mailbox.email) - .textStyle(.body) - } - } - - recipientCell(type: .to) - - if showCc { - recipientCell(type: .cc) - recipientCell(type: .bcc) - } - - // Show the rest of the view, or the autocompletion list - if shouldDisplayAutocompletion { - AutocompletionView(autocompletion: $autocompletion, - unknownRecipientAutocompletion: $unknownRecipientAutocompletion) { recipient in - matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") - addRecipientHandler?(recipient) - } - } else { - NewMessageCell(type: .subject, - focusedField: _focusedField) { - TextField("", text: $draft.subject) - .focused($focusedField, equals: .subject) - } - - AttachmentsHeaderView(attachmentsManager: attachmentsManager) - - RichTextEditor(model: $editor, - body: $draft.body, - alert: $alert, - isShowingCamera: $isShowingCamera, - isShowingFileSelection: $isShowingFileSelection, - isShowingPhotoLibrary: $isShowingPhotoLibrary, - becomeFirstResponder: $editorFocus, - blockRemoteContent: isRemoteContentBlocked) - .ignoresSafeArea(.all, edges: .bottom) - .frame(height: editor.height + 20) - .padding([.vertical], 10) + ComposeMessageHeaderView(draft: draft, focusedField: _focusedField, autocompletionType: $autocompletionType) + + if autocompletionType == nil { + ComposeMessageBodyView( + draft: draft, + isLoadingContent: $isLoadingContent, + editorFocus: $editorFocus, + attachmentsManager: attachmentsManager, + alert: alert, + dismiss: dismiss, + messageReply: messageReply + ) } } } + .background(MailResourcesAsset.backgroundColor.swiftUIColor) + .onAppear { + switch messageReply?.replyMode { + case .reply, .replyAll: + focusedField = .editor + default: + focusedField = .to + } + } + .onDisappear { + Task { + DraftManager.shared.syncDraft(mailboxManager: mailboxManager) + } + } .overlay { if isLoadingContent { ProgressView() @@ -182,69 +139,31 @@ struct ComposeMessageView: View { } } .introspectScrollView { scrollView in - guard self.scrollView != scrollView else { return } - self.scrollView = scrollView scrollView.keyboardDismissMode = .interactive } - .onChange(of: editor.height) { _ in - guard let scrollView = scrollView else { return } - - let fullSize = scrollView.contentSize.height - let realPosition = (fullSize - editor.height) + editor.cursorPosition - - let rect = CGRect(x: 0, y: realPosition, width: 1, height: 1) - scrollView.scrollRectToVisible(rect, animated: true) - } + .navigationTitle(MailResourcesStrings.Localizable.buttonNewMessage) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .navigationBarItems( - leading: Button(action: closeDraft) { - Label(MailResourcesStrings.Localizable.buttonClose, systemImage: "xmark") - }, - trailing: Button(action: sendDraft) { - MailResourcesAsset.send.swiftUIImage + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: didTouchDismiss) { + Label(MailResourcesStrings.Localizable.buttonClose, systemImage: "xmark") + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: didTouchSend) { + Label(MailResourcesStrings.Localizable.send, image: MailResourcesAsset.send.name) + } + .disabled(isSendButtonDisabled) } - .disabled(isSendButtonDisabled) - ) - .background(MailResourcesAsset.backgroundColor.swiftUIColor) - } - .onAppear { - switch messageReply?.replyMode { - case .reply, .replyAll: - focusedField = .editor - default: - focusedField = .to - } - } - .onDisappear { - Task { - DraftManager.shared.syncDraft(mailboxManager: mailboxManager) } } .interactiveDismissDisabled() - .fullScreenCover(isPresented: $isShowingCamera) { - CameraPicker { data in - attachmentsManager.importAttachments(attachments: [data]) - } - .ignoresSafeArea() - } - .sheet(isPresented: $isShowingFileSelection) { - DocumentPicker(pickerType: .selectContent([.item]) { urls in - attachmentsManager.importAttachments(attachments: urls) - }) - .ignoresSafeArea() - } - .sheet(isPresented: $isShowingPhotoLibrary) { - ImagePicker { results in - attachmentsManager.importAttachments(attachments: results) - } - .ignoresSafeArea() - } .customAlert(isPresented: $alert.isShowing) { switch alert.state { - case .link(let handler): + case let .link(handler): AddLinkView(actionHandler: handler) - case .emptySubject(let handler): + case let .emptySubject(handler): EmptySubjectView(actionHandler: handler) case .none: EmptyView() @@ -255,70 +174,27 @@ struct ComposeMessageView: View { dismiss() } } - .task { - await prepareCompleteDraft() - } - .task { - await prepareReplyForwardBodyAndAttachments() - await setSignature() - } - .navigationViewStyle(.stack) - .defaultAppStorage(.shared) .matomoView(view: ["ComposeMessage"]) } - @ViewBuilder - private func recipientCell(type: ComposeViewFieldType) -> some View { - let shouldDisplayField = !shouldDisplayAutocompletion || focusedField == type - if shouldDisplayField { - NewMessageCell(type: type, - focusedField: _focusedField, - showCc: type == .to ? $showCc : nil) { - RecipientField(recipients: binding(for: type), - autocompletion: $autocompletion, - unknownRecipientAutocompletion: $unknownRecipientAutocompletion, - addRecipientHandler: $addRecipientHandler, - focusedField: _focusedField, - type: type) - } - } - } - - private func binding(for type: ComposeViewFieldType) -> Binding> { - let binding: Binding> - switch type { - case .to: - binding = $draft.to - case .cc: - binding = $draft.cc - case .bcc: - binding = $draft.bcc - default: - fatalError("Unhandled binding \(type)") - } - return binding - } - - private func closeDraft() { + private func didTouchDismiss() { guard attachmentsManager.allAttachmentsUploaded else { isShowingCancelAttachmentsError = true return } - dismiss() } - private func sendDraft() { + private func didTouchSend() { guard !draft.subject.isEmpty else { matomo.track(eventWithCategory: .newMessage, name: "sendWithoutSubject") - alert.state = .emptySubject(handler: send) + alert.state = .emptySubject(handler: sendDraft) return } - - send() + sendDraft() } - private func send() { + private func sendDraft() { matomo.trackSendMessage(numberOfTo: draft.to.count, numberOfCc: draft.cc.count, numberOfBcc: draft.bcc.count) if let liveDraft = draft.thaw() { try? liveDraft.realm?.write { @@ -328,130 +204,13 @@ struct ComposeMessageView: View { dismiss() } - private func prepareCompleteDraft() async { - guard draft.messageUid != nil && draft.remoteUUID.isEmpty else { return } - - do { - if let fetchedDraft = try await mailboxManager.draft(partialDraft: draft), - let liveFetchedDraft = fetchedDraft.thaw() { - draft = liveFetchedDraft - } - isLoadingContent = false - } catch { - dismiss() - IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) - SentrySDK.capture(message: "Error thrown in prepareCompleteDraft()") { scope in - scope.setLevel(.error) - scope.setContext(value: ["uid": "\(String(describing: draft.messageUid))", - "error": error], - key: "message") - } - } - } - - private func prepareReplyForwardBodyAndAttachments() async { - guard let messageReply else { return } - - let prepareTask = Task.detached { - try await prepareBody(message: messageReply.message, replyMode: messageReply.replyMode) - try await prepareAttachments(message: messageReply.message, replyMode: messageReply.replyMode) - } - - do { - _ = try await prepareTask.value - - isLoadingContent = false - } catch { - dismiss() - IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) - SentrySDK.capture(message: "Error thrown in prepareReplyForwardBodyAndAttachments()") { scope in - scope.setLevel(.error) - scope.setContext(value: ["uid": "\(String(describing: messageReply.message.uid))", - "error": error], - key: "message") - } - } - } - - private func setSignature() async { - if draft.identityId == nil || draft.identityId?.isEmpty == true, - let signatureResponse = mailboxManager.getSignatureResponse() { - $draft.identityId.wrappedValue = "\(signatureResponse.defaultSignatureId)" - guard let signature = signatureResponse.default else { - return - } - - let html = "

\(signature.content)
" - var signaturePosition = draft.body.endIndex - if messageReply != nil { - switch signature.position { - case .beforeReplyMessage: - signaturePosition = draft.body.startIndex - case .afterReplyMessage: - signaturePosition = draft.body.endIndex - } - } - $draft.body.wrappedValue.insert(contentsOf: html, at: signaturePosition) - } - } - - private func prepareBody(message: Message, replyMode: ReplyMode) async throws { - if !message.fullyDownloaded { - try await mailboxManager.message(message: message) - } - - guard let freshMessage = message.thaw() else { return } - freshMessage.realm?.refresh() - $draft.body.wrappedValue = Draft.replyingBody(message: freshMessage, replyMode: replyMode) - } - - private func prepareAttachments(message: Message, replyMode: ReplyMode) async throws { - guard replyMode == .forward else { return } - let attachments = try await mailboxManager.apiFetcher.attachmentsToForward( - mailbox: mailboxManager.mailbox, - message: message - ).attachments + private static func saveNewDraftInRealm(_ realm: Realm, draft: Draft) { + try? realm.write { + draft.action = draft.action == nil && draft.remoteUUID.isEmpty ? .initialSave : .save + draft.delay = UserDefaults.shared.cancelSendDelay.rawValue - for attachment in attachments { - $draft.attachments.append(attachment) + realm.add(draft, update: .modified) } - attachmentsManager.completeUploadedAttachments() - } -} - -extension ComposeMessageView { - static func newMessage(mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView(mailboxManager: mailboxManager, draft: Draft(localUUID: UUID().uuidString)) - } - - static func replyOrForwardMessage(messageReply: MessageReply, mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView( - mailboxManager: mailboxManager, - draft: .replying(reply: messageReply), - messageReply: messageReply - ) - } - - static func editDraft(draft: Draft, mailboxManager: MailboxManager) -> ComposeMessageView { - @InjectService var matomo: MatomoUtils - matomo.track(eventWithCategory: .newMessage, name: "openFromDraft") - return ComposeMessageView(mailboxManager: mailboxManager, draft: draft) - } - - static func writingTo(recipient: Recipient, mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView(mailboxManager: mailboxManager, draft: .writing(to: recipient)) - } - - static func mailTo(urlComponents: URLComponents, mailboxManager: MailboxManager) -> ComposeMessageView { - let draft = Draft.mailTo(subject: urlComponents.getQueryItem(named: "subject"), - body: urlComponents.getQueryItem(named: "body")? - .replacingOccurrences(of: "\r", with: "") - .replacingOccurrences(of: "\n", with: "
"), - to: Recipient.createListUsing(listOfAddresses: urlComponents.path) - + Recipient.createListUsing(from: urlComponents, name: "to"), - cc: Recipient.createListUsing(from: urlComponents, name: "cc"), - bcc: Recipient.createListUsing(from: urlComponents, name: "bcc")) - return ComposeMessageView(mailboxManager: mailboxManager, draft: draft) } } diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift new file mode 100644 index 000000000..de0ecae23 --- /dev/null +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -0,0 +1,132 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCoreUI +import InfomaniakDI +import MailCore +import MailResources +import RealmSwift +import SwiftUI + +extension VerticalAlignment { + struct NewMessageCellAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[.firstTextBaseline] + } + } + + static let newMessageCellAlignment = VerticalAlignment(NewMessageCellAlignment.self) +} + +class TextDebounce: ObservableObject { + @Published var text = "" +} + +struct ComposeMessageCellRecipients: View { + @StateObject private var textDebounce = TextDebounce() + + @State private var autocompletion = [Recipient]() + + @Binding var recipients: RealmSwift.List + @Binding var showRecipientsFields: Bool + @Binding var autocompletionType: ComposeViewFieldType? + + @FocusState var focusedField: ComposeViewFieldType? + + let type: ComposeViewFieldType + + var body: some View { + VStack(spacing: 0) { + if autocompletionType == nil || autocompletionType == type { + HStack(alignment: .newMessageCellAlignment) { + Text(type.title) + .textStyle(.bodySecondary) + + RecipientField( + currentText: $textDebounce.text, + recipients: $recipients, + focusedField: _focusedField, + type: type + ) { + if let bestMatch = autocompletion.first { + addNewRecipient(bestMatch) + } + } + + if type == .to && autocompletionType == nil { + Spacer() + ChevronButton(isExpanded: $showRecipientsFields) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + IKDivider() + .padding(.top, UIConstants.composeViewVerticalSpacing) + } + + if autocompletionType == type { + AutocompletionView( + textDebounce: textDebounce, + autocompletion: $autocompletion, + addedRecipients: $recipients, + addRecipient: addNewRecipient + ) + .padding(.top, 8) + } + } + .onTapGesture { + focusedField = type + } + .onChange(of: textDebounce.text) { newValue in + withAnimation { + if newValue.isEmpty { + autocompletionType = nil + } else { + autocompletionType = type + } + } + } + } + + @MainActor private func addNewRecipient(_ recipient: Recipient) { + @InjectService var matomo: MatomoUtils + matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") + + guard Constants.isEmailAddress(recipient.email) else { + IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) + return + } + + guard !recipients.contains(where: { $0.isSameRecipient(as: recipient) }) else { + IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) + return + } + + withAnimation { + $recipients.append(recipient) + } + textDebounce.text = "" + } +} + +struct ComposeMessageCellRecipients_Previews: PreviewProvider { + static var previews: some View { + ComposeMessageCellRecipients(recipients: .constant([ + PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 + ].toRealmList()), showRecipientsFields: .constant(false), autocompletionType: .constant(nil), type: .bcc) + } +} diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellStaticText.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellStaticText.swift new file mode 100644 index 000000000..aaf27c89e --- /dev/null +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellStaticText.swift @@ -0,0 +1,49 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import SwiftUI + +struct ComposeMessageCellStaticText: View { + @Binding var autocompletionType: ComposeViewFieldType? + + let type: ComposeViewFieldType + let text: String + + var body: some View { + if autocompletionType == nil { + VStack(spacing: UIConstants.composeViewVerticalSpacing) { + HStack { + Text(type.title) + .textStyle(.bodySecondary) + + Text(text) + .textStyle(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + IKDivider() + } + } + } +} + +struct ComposeMessageStaticText_Previews: PreviewProvider { + static var previews: some View { + ComposeMessageCellStaticText(autocompletionType: .constant(nil), type: .from, text: "myaddress@email.com") + } +} diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellTextField.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellTextField.swift new file mode 100644 index 000000000..85bc51caa --- /dev/null +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellTextField.swift @@ -0,0 +1,54 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import SwiftUI + +struct ComposeMessageCellTextField: View { + @Binding var text: String + @Binding var autocompletionType: ComposeViewFieldType? + + @FocusState var focusedField: ComposeViewFieldType? + + let type: ComposeViewFieldType + + var body: some View { + if autocompletionType == nil { + VStack(spacing: UIConstants.composeViewVerticalSpacing) { + HStack { + Text(type.title) + .textStyle(.bodySecondary) + + TextField("", text: $text) + .focused($focusedField, equals: .subject) + } + .frame(maxWidth: .infinity, alignment: .leading) + IKDivider() + } + .onTapGesture { + focusedField = type + } + } + } +} + +struct ComposeMessageCellTextField_Previews: PreviewProvider { + static var previews: some View { + ComposeMessageCellTextField(text: .constant(""), autocompletionType: .constant(nil), type: .subject) + } +} diff --git a/Mail/Views/New Message/NewMessageCell.swift b/Mail/Views/New Message/NewMessageCell.swift deleted file mode 100644 index fdd8f1520..000000000 --- a/Mail/Views/New Message/NewMessageCell.swift +++ /dev/null @@ -1,97 +0,0 @@ -/* - Infomaniak Mail - iOS App - Copyright (C) 2022 Infomaniak Network SA - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - */ - -import MailCore -import MailResources -import RealmSwift -import SwiftUI - -extension VerticalAlignment { - private struct NewMessageCellAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[.firstTextBaseline] - } - } - - static let newMessageCellAlignment = VerticalAlignment(NewMessageCellAlignment.self) -} - -struct NewMessageCell: View where Content: View { - let type: ComposeViewFieldType - let focusedField: FocusState? - let showCc: Binding? - let isFirstCell: Bool - let content: Content - - let verticalPadding: CGFloat = 12 - - init(type: ComposeViewFieldType, - focusedField: FocusState? = nil, - showCc: Binding? = nil, - isFirstCell: Bool = false, - @ViewBuilder _ content: () -> Content) { - self.type = type - self.focusedField = focusedField - self.showCc = showCc - self.isFirstCell = isFirstCell - self.content = content() - } - - var body: some View { - HStack(alignment: .newMessageCellAlignment) { - Text(type.title) - .textStyle(.bodySecondary) - - content - - Spacer() - - if let showCc = showCc { - ChevronButton(isExpanded: showCc) - } - } - .padding(.horizontal, 16) - .padding(.top, isFirstCell ? 0 : verticalPadding) - .padding(.bottom, verticalPadding) - .onTapGesture { - focusedField?.wrappedValue = type - } - - IKDivider() - .padding(.horizontal, 8) - } -} - -struct NewMessageCell_Previews: PreviewProvider { - static var previews: some View { - VStack { - NewMessageCell(type: .to, - showCc: .constant(false)) { - RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()), - autocompletion: .constant([]), - unknownRecipientAutocompletion: .constant(""), - addRecipientHandler: .constant { _ in /* Preview */ }, - focusedField: .init(), - type: .to) - } - NewMessageCell(type: .subject) { - TextField("", text: .constant("")) - } - } - } -} diff --git a/Mail/Components/RecipientField.swift b/Mail/Views/New Message/RecipientField.swift similarity index 55% rename from Mail/Components/RecipientField.swift rename to Mail/Views/New Message/RecipientField.swift index 11a746e36..d230e619d 100644 --- a/Mail/Components/RecipientField.swift +++ b/Mail/Views/New Message/RecipientField.swift @@ -26,17 +26,15 @@ import SwiftUI import WrappingHStack struct RecipientField: View { + @State private var keyboardHeight: CGFloat = 0 + + @Binding var currentText: String @Binding var recipients: RealmSwift.List - @Binding var autocompletion: [Recipient] - @Binding var unknownRecipientAutocompletion: String - @MainActor @Binding var addRecipientHandler: ((Recipient) -> Void)? @FocusState var focusedField: ComposeViewFieldType? let type: ComposeViewFieldType - - @State private var currentText = "" - @State private var keyboardHeight: CGFloat = 0 + var onSubmit: (() -> Void)? /// A trimmed view on `currentText` private var trimmedInputText: String { @@ -57,13 +55,9 @@ struct RecipientField: View { .alignmentGuide(.newMessageCellAlignment) { d in d[.top] + 21 } } - RecipientsTextFieldView(text: $currentText, onSubmit: submitTextField, onBackspace: handleBackspaceTextField) + RecipientsTextField(text: $currentText, onSubmit: onSubmit, onBackspace: handleBackspaceTextField) .focused($focusedField, equals: type) } - .onChange(of: currentText) { _ in - updateAutocompletion() - addRecipientHandler = add(recipient:) - } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { output in if let userInfo = output.userInfo, let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { @@ -75,34 +69,6 @@ struct RecipientField: View { } } - @MainActor private func submitTextField() { - // use first autocompletion result or try to validate current input - guard let recipient = autocompletion.first else { - let guessRecipient = Recipient(email: trimmedInputText, name: "") - add(recipient: guessRecipient) - return - } - - add(recipient: recipient) - } - - @MainActor private func add(recipient: Recipient) { - @InjectService var matomo: MatomoUtils - matomo.track(eventWithCategory: .newMessage, action: .input, name: "addNewRecipient") - - if Constants.isEmailAddress(recipient.email) { - withAnimation { - $recipients.append(recipient) - } - currentText = "" - } else { - IKSnackBar.showSnackBar( - message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail, - anchor: keyboardHeight - ) - } - } - @MainActor private func remove(recipientAt: Int) { withAnimation { $recipients.remove(at: recipientAt) @@ -115,27 +81,8 @@ struct RecipientField: View { } } - private func updateAutocompletion() { - let trimmedCurrentText = trimmedInputText - - let contactManager = AccountManager.instance.currentContactManager - let autocompleteContacts = contactManager?.contacts(matching: trimmedCurrentText) ?? [] - let autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } - - withAnimation { - autocompletion = autocompleteRecipients.filter { !recipients.map(\.email).contains($0.email) } - - if !trimmedCurrentText.isEmpty && !autocompletion - .contains(where: { $0.email.caseInsensitiveCompare(trimmedCurrentText) == .orderedSame }) { - unknownRecipientAutocompletion = trimmedCurrentText - } else { - unknownRecipientAutocompletion = "" - } - } - } - private func switchFocus() { - guard case .chip(let hash, let recipient) = focusedField else { return } + guard case let .chip(hash, recipient) = focusedField else { return } if recipient == recipients.last { focusedField = type @@ -147,13 +94,8 @@ struct RecipientField: View { struct RecipientField_Previews: PreviewProvider { static var previews: some View { - RecipientField(recipients: .constant([ + RecipientField(currentText: .constant(""), recipients: .constant([ PreviewHelper.sampleRecipient1, PreviewHelper.sampleRecipient2, PreviewHelper.sampleRecipient3 - ].toRealmList()), - autocompletion: .constant([]), - unknownRecipientAutocompletion: .constant(""), - addRecipientHandler: .constant { _ in /* Preview */ }, - focusedField: .init(), - type: .to) + ].toRealmList()), type: .to) } } diff --git a/Mail/Views/New Message/RecipientsTextField.swift b/Mail/Views/New Message/RecipientsTextField.swift index b32f52f02..d5ba7d662 100644 --- a/Mail/Views/New Message/RecipientsTextField.swift +++ b/Mail/Views/New Message/RecipientsTextField.swift @@ -19,16 +19,18 @@ import SwiftUI import UIKit -struct RecipientsTextFieldView: UIViewRepresentable { +struct RecipientsTextField: UIViewRepresentable { @Binding var text: String - let onSubmit: () -> Void + var onSubmit: (() -> Void)? let onBackspace: (Bool) -> Void func makeUIView(context: Context) -> UITextField { - let textField = RecipientsTextField() + let textField = UIRecipientsTextField() textField.delegate = context.coordinator textField.addTarget(context.coordinator, action: #selector(context.coordinator.textDidChanged(_:)), for: .editingChanged) + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.setContentHuggingPriority(.defaultHigh, for: .vertical) textField.onBackspace = onBackspace return textField } @@ -43,9 +45,9 @@ struct RecipientsTextFieldView: UIViewRepresentable { } class Coordinator: NSObject, UITextFieldDelegate { - let parent: RecipientsTextFieldView + let parent: RecipientsTextField - init(_ parent: RecipientsTextFieldView) { + init(_ parent: RecipientsTextField) { self.parent = parent } @@ -55,7 +57,7 @@ struct RecipientsTextFieldView: UIViewRepresentable { return true } - parent.onSubmit() + parent.onSubmit?() return true } @@ -68,7 +70,7 @@ struct RecipientsTextFieldView: UIViewRepresentable { /* * We need to create our own UITextField to benefit from the `deleteBackward()` function */ -class RecipientsTextField: UITextField { +class UIRecipientsTextField: UITextField { var onBackspace: ((Bool) -> Void)? override init(frame: CGRect) { diff --git a/Mail/Views/New Message/AddRecipientCell.swift b/Mail/Views/New Message/UnknownRecipientCell.swift similarity index 56% rename from Mail/Views/New Message/AddRecipientCell.swift rename to Mail/Views/New Message/UnknownRecipientCell.swift index ba8c6c650..acb3a05d8 100644 --- a/Mail/Views/New Message/AddRecipientCell.swift +++ b/Mail/Views/New Message/UnknownRecipientCell.swift @@ -17,41 +17,30 @@ */ import MailCore -import SwiftUI import MailResources +import SwiftUI -struct AddRecipientCell: View { - @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor - - let recipientEmail: String +struct UnknownRecipientCell: View { + let recipient: Recipient var body: some View { HStack(spacing: 8) { - Circle() - .fill(accentColor.primary.swiftUIColor) - .frame(width: 40, height: 40) - .overlay { - MailResourcesAsset.userBold.swiftUIImage - .resizable() - .foregroundColor(accentColor.onAccent.swiftUIColor) - .frame(width: 24, height: 24) - } - - VStack(alignment: .leading, spacing: 0) { + UnknownRecipientView(size: 40) + .accessibilityHidden(true) + + VStack(alignment: .leading) { Text(MailResourcesStrings.Localizable.addUnknownRecipientTitle) .textStyle(.bodyMedium) - Text(recipientEmail) + Text(recipient.email) .textStyle(.bodySecondary) } - - Spacer() } - .lineLimit(1) + .recipientCellModifier() } } -struct AddRecipientCell_Previews: PreviewProvider { +struct UnknownRecipientCell_Previews: PreviewProvider { static var previews: some View { - AddRecipientCell(recipientEmail: "") + UnknownRecipientCell(recipient: PreviewHelper.sampleRecipient1) } } diff --git a/Mail/Views/Thread/MessageHeaderView.swift b/Mail/Views/Thread/MessageHeaderView.swift index 0d6eec308..e1d2dc553 100644 --- a/Mail/Views/Thread/MessageHeaderView.swift +++ b/Mail/Views/Thread/MessageHeaderView.swift @@ -61,7 +61,7 @@ struct MessageHeaderView: View { } } .sheet(item: $editedDraft) { editedDraft in - ComposeMessageView.editDraft(draft: editedDraft, mailboxManager: mailboxManager) + ComposeMessageView.edit(draft: editedDraft, mailboxManager: mailboxManager) } } diff --git a/Mail/Views/ThreadListManagerView.swift b/Mail/Views/ThreadListManagerView.swift index b838b9af0..54c18eced 100644 --- a/Mail/Views/ThreadListManagerView.swift +++ b/Mail/Views/ThreadListManagerView.swift @@ -54,7 +54,7 @@ struct ThreadListManagerView: View { } .animation(.easeInOut(duration: 0.25), value: splitViewManager.showSearch) .sheet(item: $editedMessageDraft) { draft in - ComposeMessageView.editDraft(draft: draft, mailboxManager: mailboxManager) + ComposeMessageView.edit(draft: draft, mailboxManager: mailboxManager) } } } diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index 475b6a44d..766e06d45 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -95,6 +95,10 @@ public class Draft: Object, Codable, Identifiable { /// Store compressed data to reduce realm size. @Persisted var bodyData: Data? + public var recipientsAreEmpty: Bool { + to.isEmpty && cc.isEmpty && bcc.isEmpty + } + private enum CodingKeys: String, CodingKey { case remoteUUID = "uuid" case date @@ -191,16 +195,17 @@ public class Draft: Object, Codable, Identifiable { self.action = action } - 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 mailTo(urlComponents: URLComponents) -> Draft { + let subject = urlComponents.getQueryItem(named: "subject") + let body = urlComponents.getQueryItem(named: "body")? + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\n", with: "
") + let to = Recipient.createListUsing(listOfAddresses: urlComponents.path) + + Recipient.createListUsing(from: urlComponents, name: "to") + let cc = Recipient.createListUsing(from: urlComponents, name: "cc") + let bcc = Recipient.createListUsing(from: urlComponents, name: "bcc") + + return Draft(subject: subject ?? "", body: body ?? "", to: to, cc: cc, bcc: bcc) } public static func writing(to recipient: Recipient) -> Draft { diff --git a/MailCore/Models/Recipient.swift b/MailCore/Models/Recipient.swift index b50518a35..fd1bc0f57 100644 --- a/MailCore/Models/Recipient.swift +++ b/MailCore/Models/Recipient.swift @@ -125,6 +125,10 @@ public class Recipient: EmbeddedObject, Codable { return "\(name) \(emailString)" } } + + public func isSameRecipient(as recipient: Recipient) -> Bool { + return email == recipient.email && name == recipient.name + } } extension Recipient: AvatarDisplayable { diff --git a/MailCore/UI/UIConstants.swift b/MailCore/UI/UIConstants.swift index 3c5fb0a56..846d1e3d4 100644 --- a/MailCore/UI/UIConstants.swift +++ b/MailCore/UI/UIConstants.swift @@ -101,12 +101,18 @@ public enum UIConstants { public static let buttonsRadius: CGFloat = 16 public static let buttonsIconSize: CGFloat = 16 + public static let composeViewVerticalSpacing: CGFloat = 12 + public static let bottomBarVerticalPadding: CGFloat = 8 public static let bottomBarSmallVerticalPadding: CGFloat = 4 public static let bottomBarHorizontalMinimumSpace: CGFloat = 8 public static let bottomSheetHorizontalPadding: CGFloat = 24 + public static let unknownRecipientHorizontalPadding: CGFloat = 8 + + public static let autocompletionVerticalPadding: CGFloat = 8 + public static let componentsMaxWidth: CGFloat = 496 public static let chipInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) diff --git a/MailResources/Assets.xcassets/checked.imageset/checked.svg b/MailResources/Assets.xcassets/checked.imageset/checked.svg index 47ce8050b..2f93067c8 100644 --- a/MailResources/Assets.xcassets/checked.imageset/checked.svg +++ b/MailResources/Assets.xcassets/checked.imageset/checked.svg @@ -1,4 +1,3 @@ - - + diff --git a/MailResources/Localizable/de.lproj/Localizable.strings b/MailResources/Localizable/de.lproj/Localizable.strings index ec1051dfb..159944cf3 100644 Binary files a/MailResources/Localizable/de.lproj/Localizable.strings and b/MailResources/Localizable/de.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/en.lproj/Localizable.strings b/MailResources/Localizable/en.lproj/Localizable.strings index 01a145073..9e952252b 100644 Binary files a/MailResources/Localizable/en.lproj/Localizable.strings and b/MailResources/Localizable/en.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/es.lproj/Localizable.strings b/MailResources/Localizable/es.lproj/Localizable.strings index 98a47474b..a802f9e02 100644 Binary files a/MailResources/Localizable/es.lproj/Localizable.strings and b/MailResources/Localizable/es.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/fr.lproj/Localizable.strings b/MailResources/Localizable/fr.lproj/Localizable.strings index c74e5036b..89d13553e 100644 Binary files a/MailResources/Localizable/fr.lproj/Localizable.strings and b/MailResources/Localizable/fr.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/it.lproj/Localizable.strings b/MailResources/Localizable/it.lproj/Localizable.strings index 49e68f7f4..6e030dc71 100644 Binary files a/MailResources/Localizable/it.lproj/Localizable.strings and b/MailResources/Localizable/it.lproj/Localizable.strings differ