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