From a26984f2af4bdcc0e0480a45de6f5ca820fcf9e1 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 19 Sep 2024 15:06:44 +0200 Subject: [PATCH 01/23] feat: Create GroupContact model --- MailCore/Models/GroupContact.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 MailCore/Models/GroupContact.swift diff --git a/MailCore/Models/GroupContact.swift b/MailCore/Models/GroupContact.swift new file mode 100644 index 000000000..b17eda3e3 --- /dev/null +++ b/MailCore/Models/GroupContact.swift @@ -0,0 +1,26 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import RealmSwift + +// AddressBook Categories +public class GroupContact: EmbeddedObject, Codable { + @Persisted public var id: Int + @Persisted public var name: String +} From 779f71b111d057a2fdd1c888804173a6f7f4f1ab Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 19 Sep 2024 15:09:21 +0200 Subject: [PATCH 02/23] feat: Add groupContact property to AddressBook --- MailCore/Models/AddressBook.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MailCore/Models/AddressBook.swift b/MailCore/Models/AddressBook.swift index 09af02dcc..a800dd1b7 100644 --- a/MailCore/Models/AddressBook.swift +++ b/MailCore/Models/AddressBook.swift @@ -27,10 +27,12 @@ public class AddressBook: Object, Codable, Identifiable { @Persisted public var id: Int @Persisted(primaryKey: true) public var uuid: String @Persisted public var isDefault: Bool + @Persisted public var groupContact: List enum CodingKeys: String, CodingKey { case id case uuid case isDefault = "default" + case groupContact = "categories" } } From 5015dc480239383ae37ab8b8ff0db11e48464423 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 19 Sep 2024 15:15:33 +0200 Subject: [PATCH 03/23] feat: Add groupContactId property to Contact --- MailCore/Models/Contact/Contact.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MailCore/Models/Contact/Contact.swift b/MailCore/Models/Contact/Contact.swift index 21074364e..7623d2876 100644 --- a/MailCore/Models/Contact/Contact.swift +++ b/MailCore/Models/Contact/Contact.swift @@ -34,6 +34,7 @@ public struct InfomaniakContact: Codable, Identifiable { public var favorite: Bool? public var nickname: String? public var organization: String? + public var groupContactId: [Int] enum CodingKeys: String, CodingKey { case id @@ -49,6 +50,7 @@ public struct InfomaniakContact: Codable, Identifiable { case favorite case nickname case organization + case groupContactId = "categories" } public init(from decoder: Decoder) throws { @@ -71,5 +73,6 @@ public struct InfomaniakContact: Codable, Identifiable { favorite = try values.decodeIfPresent(Bool.self, forKey: .favorite) nickname = try values.decodeIfPresent(String.self, forKey: .nickname) organization = try values.decodeIfPresent(String.self, forKey: .organization) + groupContactId = try values.decode([Int].self, forKey: .groupContactId) } } From 4a071ef8616f8dd6ec67a4cd947dc62b7714bfcc Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 19 Sep 2024 15:16:16 +0200 Subject: [PATCH 04/23] fix: Add GroupContact.self to RealmConfiguration --- MailCore/Cache/ContactManager/ContactManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MailCore/Cache/ContactManager/ContactManager.swift b/MailCore/Cache/ContactManager/ContactManager.swift index 1b70d316a..1c616a486 100644 --- a/MailCore/Cache/ContactManager/ContactManager.swift +++ b/MailCore/Cache/ContactManager/ContactManager.swift @@ -82,7 +82,8 @@ public final class ContactManager: ObservableObject, ContactManageable { deleteRealmIfMigrationNeeded: true, objectTypes: [ MergedContact.self, - AddressBook.self + AddressBook.self, + GroupContact.self ] ) let realmAccessor = MailCoreRealmAccessor(realmConfiguration: realmConfiguration) From 12e342f076c3fcbed5fcc685ed525170385ac382 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 19 Sep 2024 15:17:05 +0200 Subject: [PATCH 05/23] feat: Update endpoint with categories --- MailCore/API/Endpoint.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MailCore/API/Endpoint.swift b/MailCore/API/Endpoint.swift index 7368600a8..64cbe65c4 100644 --- a/MailCore/API/Endpoint.swift +++ b/MailCore/API/Endpoint.swift @@ -75,7 +75,10 @@ public extension Endpoint { } static var addressBooks: Endpoint { - return .base.appending(path: "/pim/addressbook") + return .base.appending( + path: "/pim/addressbook", + queryItems: [URLQueryItem(name: "with", value: "categories")] + ) } static var contacts: Endpoint { From 8fb9d722b5a37aee08b8ff166aa8f0d511b43102 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 3 Oct 2024 10:58:54 +0200 Subject: [PATCH 06/23] fix: Must be RealmFetchable for fetchResult() to run --- MailCore/Models/GroupContact.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MailCore/Models/GroupContact.swift b/MailCore/Models/GroupContact.swift index b17eda3e3..9b9a46888 100644 --- a/MailCore/Models/GroupContact.swift +++ b/MailCore/Models/GroupContact.swift @@ -20,7 +20,7 @@ import Foundation import RealmSwift // AddressBook Categories -public class GroupContact: EmbeddedObject, Codable { +public class GroupContact: Object, Codable { @Persisted public var id: Int @Persisted public var name: String } From 2a2596ec86b944261a4ffc88d8ef32360d2d5a52 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 3 Oct 2024 11:00:14 +0200 Subject: [PATCH 07/23] feat: Create func to get the groupContact --- .../ContactManager/ContactManager+DB.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index 466d7e94c..a9791a3d4 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -31,6 +31,13 @@ public protocol ContactFetchable { func frozenContactsAsync(matching string: String, fetchLimit: Int?, sorted: ((MergedContact, MergedContact) -> Bool)?) async -> any Collection + /// Case and diacritic insensitive search for a `GroupContact` + /// - Parameters: + /// - string: input string to match against email and name + /// - fetchLimit: limit the query by default to limit memory footprint + /// - Returns: The collection of matching contacts. + func frozenGroupContacts(matching string: String, fetchLimit: Int?) -> any Collection + /// Get a contact from a given transactionable func getContact(for correspondent: any Correspondent, transactionable: Transactionable) -> MergedContact? @@ -43,6 +50,7 @@ public protocol ContactFetchable { public extension ContactManager { /// Both *case* insensitive __and__ *diacritic* (accents) insensitive static let searchContactInsensitivePredicate = "name contains[cd] %@ OR email contains[cd] %@" + static let searchGroupContactInsensitivePredicate = "name contains[cd] %@" /// Making sure, that by default, we do not overflow memory with too much contacts private static let contactFetchLimit = 120 @@ -76,6 +84,25 @@ public extension ContactManager { return frozenContacts(matching: string, fetchLimit: fetchLimit, sorted: sorted) } + /// Case and diacritic insensitive search for a `GroupContact` + /// - Parameters: + /// - string: input string to match against email and name + /// - fetchLimit: limit the query by default to limit memory footprint + /// - Returns: The collection of matching contacts. Frozen. + func frozenGroupContacts(matching string: String, fetchLimit: Int?) -> any Collection { + var lazyResults = fetchResults(ofType: GroupContact.self) { partial in + partial + } + lazyResults = lazyResults + .filter(Self.searchGroupContactInsensitivePredicate, string, string) + .freeze() + + let fetchLimit = min(lazyResults.count, fetchLimit ?? Self.contactFetchLimit) + + let limitedResults = lazyResults[0 ..< fetchLimit] + return limitedResults + } + func getContact(for correspondent: any Correspondent) -> MergedContact? { getContact(for: correspondent, transactionable: self) } From c1fcad980782b4135d2b9fee2b31af7da4376ee3 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Thu, 3 Oct 2024 11:08:51 +0200 Subject: [PATCH 08/23] feat: Display also the groupContact when tapping recipient --- Mail/Views/New Message/AutocompletionView.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 75f0c48ff..89a6bdfae 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -79,7 +79,17 @@ struct AutocompletionView: View { ) var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } - let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } + let autocompleteGroupContact = mailboxManager.contactManager.frozenGroupContacts(matching: trimmedSearch, fetchLimit: nil) + + // TODO: Find group or organisation name + var autocompleteGroupRecipients = autocompleteGroupContact.map { Recipient( + email: "Afficher nom groupe ou organisation ?", + name: "Groupe/carnet ? \($0.name)" + ) } + + var result = autocompleteRecipients + autocompleteGroupRecipients + + let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text) if shouldAddUserProposal { From 2c2d2509f7bfb5fcff3be447b6f0b00d633446b6 Mon Sep 17 00:00:00 2001 From: Elena Willen Date: Wed, 30 Oct 2024 13:31:53 +0100 Subject: [PATCH 09/23] feat: WIP --- .../New Message/AutocompletionCell.swift | 14 +++--- .../New Message/AutocompletionView.swift | 43 +++++++++-------- .../ComposeMessageCellRecipients.swift | 40 +++++++++++----- .../New Message/UnknownRecipientCell.swift | 10 ++-- Mail/Views/Search/SearchViewModel.swift | 3 +- .../ContactManager/ContactManager+DB.swift | 15 ++++++ .../ContactManager/ContactManager+Merge.swift | 3 +- .../Models/Contact/CommonContactCache.swift | 3 ++ .../Models/Contact/ContactConfiguration.swift | 6 +++ MailCore/Models/GroupContact.swift | 15 +++++- MailCore/Models/MergedContact.swift | 8 +++- MailCoreUI/Components/RecipientCell.swift | 48 +++++++++++++------ 12 files changed, 141 insertions(+), 67 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 1295c5fad..3ee28607e 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -23,8 +23,8 @@ import MailResources import SwiftUI struct AutocompletionCell: View { - let addRecipient: @MainActor (Recipient) -> Void - let recipient: Recipient + let addRecipient: @MainActor (any ContactAutocompletable) -> Void + let autocompletion: any ContactAutocompletable var highlight: String? let alreadyAppend: Bool let unknownRecipient: Bool @@ -32,12 +32,12 @@ struct AutocompletionCell: View { var body: some View { HStack(spacing: IKPadding.intermediate) { Button { - addRecipient(recipient) + addRecipient(autocompletion) } label: { - if unknownRecipient { - UnknownRecipientCell(recipient: recipient) + if unknownRecipient, let email = autocompletion.email { + UnknownRecipientCell(email: email) } else { - RecipientCell(recipient: recipient, highlight: highlight) + RecipientCell(contact: autocompletion, highlight: highlight) } } .allowsHitTesting(!alreadyAppend || unknownRecipient) @@ -56,7 +56,7 @@ struct AutocompletionCell: View { #Preview { AutocompletionCell( addRecipient: { _ in /* Preview */ }, - recipient: PreviewHelper.sampleRecipient1, + autocompletion: PreviewHelper.sampleMergedContact, alreadyAppend: false, unknownRecipient: false ) diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 89a6bdfae..1922e8dec 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -32,23 +32,26 @@ struct AutocompletionView: View { @ObservedObject var textDebounce: TextDebounce - @Binding var autocompletion: [Recipient] + @Binding var autocompletion: [any ContactAutocompletable] @Binding var addedRecipients: RealmSwift.List - let addRecipient: @MainActor (Recipient) -> Void + let addRecipient: @MainActor (any ContactAutocompletable) -> Void var body: some View { LazyVStack(spacing: IKPadding.small) { - ForEach(autocompletion) { recipient in - let isLastRecipient = autocompletion.last?.isSameCorrespondent(as: recipient) == true + // TODO: Fix conformance Identifiable + ForEach(autocompletion, id: \.stringId) { contact in + let isLastRecipient = + false // TODO: A implémenter -> autocompletion.last?.isSameCorrespondent(as: recipient) == true let isUserProposal = shouldAddUserProposal && isLastRecipient VStack(alignment: .leading, spacing: IKPadding.small) { AutocompletionCell( addRecipient: addRecipient, - recipient: recipient, + autocompletion: contact, highlight: textDebounce.text, - alreadyAppend: addedRecipients.contains { $0.isSameCorrespondent(as: recipient) }, + alreadyAppend: false, + // TODO: A implémenter -> addedRecipients.contains { $0.isSameCorrespondent(as: recipient) }, unknownRecipient: isUserProposal ) @@ -77,25 +80,23 @@ struct AutocompletionView: View { fetchLimit: Self.maxAutocompleteCount, sorted: sortByRemoteAndName ) - var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } - let autocompleteGroupContact = mailboxManager.contactManager.frozenGroupContacts(matching: trimmedSearch, fetchLimit: nil) + let autocompleteGroupContacts = mailboxManager.contactManager.frozenGroupContacts( + matching: trimmedSearch, + fetchLimit: nil + ) - // TODO: Find group or organisation name - var autocompleteGroupRecipients = autocompleteGroupContact.map { Recipient( - email: "Afficher nom groupe ou organisation ?", - name: "Groupe/carnet ? \($0.name)" - ) } + let result: [any ContactAutocompletable] = Array(autocompleteContacts) + Array(autocompleteGroupContacts) - var result = autocompleteRecipients + autocompleteGroupRecipients +// TODO: A implémenter +// let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } +// +// shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text) +// if shouldAddUserProposal { +// autocompleteRecipients.append(Recipient(email: textDebounce.text, name: "")) +// } - let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } - - shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text) - if shouldAddUserProposal { - autocompleteRecipients.append(Recipient(email: textDebounce.text, name: "")) - } - autocompletion = autocompleteRecipients + autocompletion = result } } diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 20f937b44..3ce7eccf7 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -49,7 +49,9 @@ class TextDebounce: ObservableObject { struct ComposeMessageCellRecipients: View { @StateObject private var textDebounce = TextDebounce() - @State private var autocompletion = [Recipient]() + @EnvironmentObject private var mailboxManager: MailboxManager + + @State private var autocompletion = [any ContactAutocompletable]() @Binding var recipients: RealmSwift.List @Binding var showRecipientsFields: Bool @@ -130,23 +132,35 @@ struct ComposeMessageCellRecipients: View { } } - @MainActor private func addNewRecipient(_ recipient: Recipient) { + @MainActor private func addNewRecipient(_ contact: any ContactAutocompletable) { matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") - guard Constants.isEmailAddress(recipient.email) else { - snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) - return - } + if let mergedContact = contact as? MergedContact { + $recipients.append(Recipient(email: mergedContact.email ?? "", name: mergedContact.name)) + } else if let groupeContact = contact as? GroupContact { + let groupeContacts = mailboxManager.contactManager.getContacts(with: groupeContact.id) - guard !recipients.contains(where: { $0.isSameCorrespondent(as: recipient) }) else { - snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) - return + var groupContactRecipients = groupeContacts.map { + $recipients.append(Recipient(email: $0.email ?? "", name: $0.name)) + } } - withAnimation { - recipient.isAddedByMe = true - $recipients.append(recipient) - } + // TODO: à implémenter + // guard Constants.isEmailAddress(recipient.email) else { + // snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) + // return + // } + // + // guard !recipients.contains(where: { $0.isSameCorrespondent(as: recipient) }) else { + // snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) + // return + // } + // + // withAnimation { + // recipient.isAddedByMe = true + // $recipients.append(recipient) + // } + textDebounce.text = "" } } diff --git a/Mail/Views/New Message/UnknownRecipientCell.swift b/Mail/Views/New Message/UnknownRecipientCell.swift index 7ef06d77a..18e394c5b 100644 --- a/Mail/Views/New Message/UnknownRecipientCell.swift +++ b/Mail/Views/New Message/UnknownRecipientCell.swift @@ -22,11 +22,7 @@ import MailResources import SwiftUI public struct UnknownRecipientCell: View { - let recipient: Recipient - - public init(recipient: Recipient) { - self.recipient = recipient - } + let email: String public var body: some View { HStack(spacing: 8) { @@ -36,7 +32,7 @@ public struct UnknownRecipientCell: View { VStack(alignment: .leading) { Text(MailResourcesStrings.Localizable.addUnknownRecipientTitle) .textStyle(.bodyMedium) - Text(recipient.email) + Text(email) .textStyle(.bodySecondary) } } @@ -45,5 +41,5 @@ public struct UnknownRecipientCell: View { } #Preview { - UnknownRecipientCell(recipient: PreviewHelper.sampleRecipient1) + UnknownRecipientCell(email: PreviewHelper.sampleRecipient1.email) } diff --git a/Mail/Views/Search/SearchViewModel.swift b/Mail/Views/Search/SearchViewModel.swift index 25146405e..9d0bb73c1 100644 --- a/Mail/Views/Search/SearchViewModel.swift +++ b/Mail/Views/Search/SearchViewModel.swift @@ -148,7 +148,8 @@ final class SearchViewModel: ObservableObject, ThreadListable { fetchLimit: nil, sorted: nil ) - var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name).freezeIfNeeded() } + // TODO: Handle optional email + var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email ?? "", name: $0.name).freezeIfNeeded() } // Append typed email if Constants.isEmailAddress(searchValue) && !frozenContacts diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index a9791a3d4..b2464b6fe 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -45,6 +45,9 @@ public protocol ContactFetchable { func getContact(for correspondent: any Correspondent) -> MergedContact? func addressBook(with id: Int) -> AddressBook? func addContact(recipient: Recipient) async throws + + /// Get a contact from group contact (categories) + func getContacts(with groupContactId: Int) -> [MergedContact] } public extension ContactManager { @@ -139,4 +142,16 @@ public extension ContactManager { writableRealm.add(mergedContact, update: .modified) } } + + func getContacts(with groupContactId: Int) -> [MergedContact] { + // TODO: To implement +// let frozenContacts = fetchResults(ofType: MergedContact.self) { partial in +// partial +// .where { $0.groupContactId == groupContactId } +// .freezeIfNeeded() +// } +// +// return Array(frozenContacts) + return [] + } } diff --git a/MailCore/Cache/ContactManager/ContactManager+Merge.swift b/MailCore/Cache/ContactManager/ContactManager+Merge.swift index f1ef90b1b..72e55a9ff 100644 --- a/MailCore/Cache/ContactManager/ContactManager+Merge.swift +++ b/MailCore/Cache/ContactManager/ContactManager+Merge.swift @@ -175,7 +175,8 @@ extension ContactManager { continue } - let id = MergedContact.computeId(email: mergedContact.email, name: mergedContact.name) + // TODO: Handle optional emai + let id = MergedContact.computeId(email: mergedContact.email ?? "", name: mergedContact.name) if newMergedContacts[id] == nil { idsToDelete.append(id) } diff --git a/MailCore/Models/Contact/CommonContactCache.swift b/MailCore/Models/Contact/CommonContactCache.swift index 9ef6a8a63..74f38df40 100644 --- a/MailCore/Models/Contact/CommonContactCache.swift +++ b/MailCore/Models/Contact/CommonContactCache.swift @@ -76,6 +76,9 @@ public enum CommonContactCache { contact = wrappedContact case .emptyContact: contact = CommonContact.emptyContact + case .groupContact: + // TODO: To implement + contact = CommonContact.emptyContact } // Store the object in cache diff --git a/MailCore/Models/Contact/ContactConfiguration.swift b/MailCore/Models/Contact/ContactConfiguration.swift index ac3b58bb6..f28ee26d7 100644 --- a/MailCore/Models/Contact/ContactConfiguration.swift +++ b/MailCore/Models/Contact/ContactConfiguration.swift @@ -28,6 +28,8 @@ public enum ContactConfiguration: CustomDebugStringConvertible { return ".user:\(user.displayName) \(user.email)" case .contact(let contact): return ".contact:\(contact.fullName) \(contact.email)" + case .groupContact: + return ".groupContact" case .emptyContact: return ".emptyContact" } @@ -41,6 +43,7 @@ public enum ContactConfiguration: CustomDebugStringConvertible { ) case user(user: UserProfile) case contact(contact: CommonContact) + case groupContact case emptyContact public func freezeIfNeeded() -> Self { @@ -82,6 +85,9 @@ extension ContactConfiguration: Identifiable { return wrappedContact.id case .emptyContact: return CommonContact.emptyContact.id + case .groupContact: + // TODO: To implement + return CommonContact.emptyContact.id } } } diff --git a/MailCore/Models/GroupContact.swift b/MailCore/Models/GroupContact.swift index 9b9a46888..cf365be09 100644 --- a/MailCore/Models/GroupContact.swift +++ b/MailCore/Models/GroupContact.swift @@ -20,7 +20,20 @@ import Foundation import RealmSwift // AddressBook Categories -public class GroupContact: Object, Codable { +public class GroupContact: Object, Codable, ContactAutocompletable { @Persisted public var id: Int @Persisted public var name: String + + public var stringId: String { + return String(id) + } + + public var email: String? +} + +// TODO: A déplacer +public protocol ContactAutocompletable: Identifiable { + var stringId: String { get } + var name: String { get } + var email: String? { get } } diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index a64c4cd02..a2d5a643f 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -41,12 +41,16 @@ extension CNContact { } } -public final class MergedContact: Object, Identifiable { +public final class MergedContact: Object, Identifiable, ContactAutocompletable { + public var stringId: String { + return id + } + private static let contactFormatter = CNContactFormatter() /// Shared @Persisted(primaryKey: true) public var id: String - @Persisted public var email: String + @Persisted public var email: String? @Persisted public var name: String /// Remote diff --git a/MailCoreUI/Components/RecipientCell.swift b/MailCoreUI/Components/RecipientCell.swift index 951474f5f..5537a9ede 100644 --- a/MailCoreUI/Components/RecipientCell.swift +++ b/MailCoreUI/Components/RecipientCell.swift @@ -41,11 +41,35 @@ public struct RecipientCell: View { @Environment(\.currentUser) private var currentUser @EnvironmentObject private var mailboxManager: MailboxManager - let recipient: Recipient + let title: String + let subtitle: String + let avatarConfiguration: ContactConfiguration + let highlight: String? - public init(recipient: Recipient, highlight: String? = nil) { - self.recipient = recipient + // TODO: Change contactConfig + public init(recipient: Recipient, contactConfiguration: ContactConfiguration = .emptyContact, highlight: String? = nil) { + title = recipient.name + subtitle = recipient.email + avatarConfiguration = contactConfiguration + + self.highlight = highlight + } + + // TODO: Change contactConfig + subtitle + public init( + contact: any ContactAutocompletable, + contactConfiguration: ContactConfiguration = .emptyContact, + highlight: String? = nil + ) { + title = contact.name + if let email = contact.email { + subtitle = email + } else { + subtitle = "Nom de l'organisation" + } + avatarConfiguration = contactConfiguration + self.highlight = highlight } @@ -53,23 +77,19 @@ public struct RecipientCell: View { HStack(spacing: IKPadding.small) { AvatarView( mailboxManager: mailboxManager, - contactConfiguration: .correspondent( - correspondent: recipient.freezeIfNeeded(), - contextUser: currentUser.value, - contextMailboxManager: mailboxManager - ), + contactConfiguration: avatarConfiguration, size: 40 ) .accessibilityHidden(true) - if recipient.name.isEmpty { - Text(highlightedAttributedString(from: recipient.email)) + if title.isEmpty { + Text(highlightedAttributedString(from: subtitle)) .textStyle(.bodyMedium) } else { VStack(alignment: .leading) { - Text(highlightedAttributedString(from: recipient.name)) + Text(highlightedAttributedString(from: title)) .textStyle(.bodyMedium) - Text(highlightedAttributedString(from: recipient.email)) + Text(highlightedAttributedString(from: subtitle)) .textStyle(.bodySecondary) } } @@ -89,9 +109,9 @@ public struct RecipientCell: View { } #Preview { - RecipientCell(recipient: PreviewHelper.sampleRecipient1) + RecipientCell(recipient: PreviewHelper.sampleRecipient1, contactConfiguration: .emptyContact) } #Preview { - RecipientCell(recipient: PreviewHelper.sampleRecipient3) + RecipientCell(recipient: PreviewHelper.sampleRecipient3, contactConfiguration: .emptyContact) } From 9bf70873c8f892a64cf72cf18caafb0a0d5a8553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Thu, 5 Dec 2024 13:22:59 +0100 Subject: [PATCH 10/23] feat: Add addressBooks --- .../New Message/AutocompletionCell.swift | 4 +- .../New Message/AutocompletionView.swift | 62 ++++++++++------ .../ComposeMessageCellRecipients.swift | 70 +++++++++++++------ .../ContactManager/ContactManager+DB.swift | 59 ++++++++++++---- .../ContactManager/ContactManager+Merge.swift | 2 +- MailCore/Models/AddressBook.swift | 22 ++++++ .../Models/Contact/CommonContactCache.swift | 4 +- MailCore/Models/Contact/Contact.swift | 4 +- .../Models/Contact/ContactConfiguration.swift | 14 ++-- MailCore/Models/ContactAutocompletable.swift | 33 +++++++++ MailCore/Models/Correspondent.swift | 4 -- MailCore/Models/GroupContact.swift | 23 +++--- MailCore/Models/MergedContact.swift | 27 +++++-- MailCore/Models/Recipient.swift | 9 +++ MailCoreUI/Components/RecipientCell.swift | 6 +- 15 files changed, 251 insertions(+), 92 deletions(-) create mode 100644 MailCore/Models/ContactAutocompletable.swift diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 3ee28607e..0a97f472c 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -34,8 +34,8 @@ struct AutocompletionCell: View { Button { addRecipient(autocompletion) } label: { - if unknownRecipient, let email = autocompletion.email { - UnknownRecipientCell(email: email) + if unknownRecipient { + UnknownRecipientCell(email: autocompletion.autocompletableName) } else { RecipientCell(contact: autocompletion, highlight: highlight) } diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 1922e8dec..3cca808af 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -23,6 +23,20 @@ import MailCoreUI import RealmSwift import SwiftUI +extension Recipient: @retroactive ContactAutocompletable { + public var contactId: String { + return id + } + + public var autocompletableName: String { + return name + } + + public func isSameContactAutocompletable(as contactAutoCompletable: any ContactAutocompletable) -> Bool { + return contactId == contactAutoCompletable.contactId + } +} + struct AutocompletionView: View { private static let maxAutocompleteCount = 10 @@ -40,9 +54,8 @@ struct AutocompletionView: View { var body: some View { LazyVStack(spacing: IKPadding.small) { // TODO: Fix conformance Identifiable - ForEach(autocompletion, id: \.stringId) { contact in - let isLastRecipient = - false // TODO: A implémenter -> autocompletion.last?.isSameCorrespondent(as: recipient) == true + ForEach(autocompletion, id: \.contactId) { contact in + let isLastRecipient = autocompletion.last?.isSameContactAutocompletable(as: contact) == true let isUserProposal = shouldAddUserProposal && isLastRecipient VStack(alignment: .leading, spacing: IKPadding.small) { @@ -50,8 +63,7 @@ struct AutocompletionView: View { addRecipient: addRecipient, autocompletion: contact, highlight: textDebounce.text, - alreadyAppend: false, - // TODO: A implémenter -> addedRecipients.contains { $0.isSameCorrespondent(as: recipient) }, + alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, unknownRecipient: isUserProposal ) @@ -75,26 +87,32 @@ struct AutocompletionView: View { let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines) Task { @MainActor in - let autocompleteContacts = await mailboxManager.contactManager.frozenContactsAsync( + let autocompleteContacts = await Array(mailboxManager.contactManager.frozenContactsAsync( matching: trimmedSearch, fetchLimit: Self.maxAutocompleteCount, sorted: sortByRemoteAndName - ) - - let autocompleteGroupContacts = mailboxManager.contactManager.frozenGroupContacts( - matching: trimmedSearch, - fetchLimit: nil - ) - - let result: [any ContactAutocompletable] = Array(autocompleteContacts) + Array(autocompleteGroupContacts) - -// TODO: A implémenter -// let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) } -// -// shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text) -// if shouldAddUserProposal { -// autocompleteRecipients.append(Recipient(email: textDebounce.text, name: "")) -// } + )) + + let autocompleteGroupContacts = Array(mailboxManager.contactManager.frozenGroupContacts( + matching: trimmedSearch, + fetchLimit: nil + )) + + let autocompleteAddressBookContacts = Array(mailboxManager.contactManager.frozenAddressBookContacts( + matching: trimmedSearch, + fetchLimit: nil + )) + + var result: [any ContactAutocompletable] = autocompleteContacts + autocompleteGroupContacts + + autocompleteAddressBookContacts + + let realResults = autocompleteGroupContacts.filter { !addedRecipients.map(\.email).contains($0.autocompletableName) } + + shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.autocompletableName == textDebounce.text) + + if shouldAddUserProposal { + result.append(MergedContact(email: textDebounce.text, local: nil, remote: nil)) + } autocompletion = result } diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 3ce7eccf7..987bae9cb 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -135,33 +135,59 @@ struct ComposeMessageCellRecipients: View { @MainActor private func addNewRecipient(_ contact: any ContactAutocompletable) { matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") - if let mergedContact = contact as? MergedContact { - $recipients.append(Recipient(email: mergedContact.email ?? "", name: mergedContact.name)) - } else if let groupeContact = contact as? GroupContact { + do { + let mergedContacts = extractContacts(contact) + let validContacts = try recipientCheck(mergedContacts: mergedContacts) + convertMergedContactsToRecipients(validContacts) + + print(recipients) + } catch RecipientError.invalidEmail { + snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) + } catch RecipientError.duplicateContact { + snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) + } catch { + print("") + } + + textDebounce.text = "" + } + + private func extractContacts(_ contacts: any ContactAutocompletable) -> [MergedContact] { + var mergedContacts: [MergedContact] = [] + if let mergedContact = contacts as? MergedContact { + mergedContacts.append(mergedContact) + } else if let groupeContact = contacts as? GroupContact { let groupeContacts = mailboxManager.contactManager.getContacts(with: groupeContact.id) + mergedContacts.append(contentsOf: groupeContacts) + } else if let addressBookContact = contacts as? AddressBook { + let addressBookContacts = mailboxManager.contactManager.getContacts(for: addressBookContact.id) + mergedContacts.append(contentsOf: addressBookContacts) + } + return mergedContacts + } - var groupContactRecipients = groupeContacts.map { - $recipients.append(Recipient(email: $0.email ?? "", name: $0.name)) - } + private func recipientCheck(mergedContacts: [MergedContact]) throws -> [MergedContact] { + let invalidEmailContacts = mergedContacts.filter { !Constants.isEmailAddress($0.email) } + if !invalidEmailContacts.isEmpty { + throw RecipientError.invalidEmail } - // TODO: à implémenter - // guard Constants.isEmailAddress(recipient.email) else { - // snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) - // return - // } - // - // guard !recipients.contains(where: { $0.isSameCorrespondent(as: recipient) }) else { - // snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) - // return - // } - // - // withAnimation { - // recipient.isAddedByMe = true - // $recipients.append(recipient) - // } + let validContacts = mergedContacts.filter { contact in + !recipients.contains(where: { $0.email == contact.email }) + } - textDebounce.text = "" + if validContacts.count < mergedContacts.count { + throw RecipientError.duplicateContact + } + + return validContacts + } + + private func convertMergedContactsToRecipients(_ mergedContacts: [MergedContact]) { + for mergedContact in mergedContacts { + let newRecipient = Recipient(email: mergedContact.email, name: mergedContact.name) + $recipients.append(newRecipient) + } } } diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index b2464b6fe..ca04e7bcc 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -38,6 +38,13 @@ public protocol ContactFetchable { /// - Returns: The collection of matching contacts. func frozenGroupContacts(matching string: String, fetchLimit: Int?) -> any Collection + /// Case and diacritic insensitive search for a `AddressBookContact` + /// - Parameters: + /// - string: input string to match against email and name + /// - fetchLimit: limit the query by default to limit memory footprint + /// - Returns: The collection of matching contacts. + func frozenAddressBookContacts(matching string: String, fetchLimit: Int?) -> any Collection + /// Get a contact from a given transactionable func getContact(for correspondent: any Correspondent, transactionable: Transactionable) -> MergedContact? @@ -48,6 +55,9 @@ public protocol ContactFetchable { /// Get a contact from group contact (categories) func getContacts(with groupContactId: Int) -> [MergedContact] + + /// Get a contact from adressbook + func getContacts(for addressbookId: Int) -> [MergedContact] } public extension ContactManager { @@ -106,6 +116,25 @@ public extension ContactManager { return limitedResults } + /// Case and diacritic insensitive search for a `AddressBookContact` + /// - Parameters: + /// - string: input string to match against email and name + /// - fetchLimit: limit the query by default to limit memory footprint + /// - Returns: The collection of matching contacts. + func frozenAddressBookContacts(matching string: String, fetchLimit: Int?) -> any Collection { + var lazyResults = fetchResults(ofType: AddressBook.self) { partial in + partial + } + lazyResults = lazyResults + .filter(Self.searchGroupContactInsensitivePredicate, string, string) + .freeze() + + let fetchLimit = min(lazyResults.count, fetchLimit ?? Self.contactFetchLimit) + + let limitedResults = lazyResults[0 ..< fetchLimit] + return limitedResults + } + func getContact(for correspondent: any Correspondent) -> MergedContact? { getContact(for: correspondent, transactionable: self) } @@ -118,6 +147,24 @@ public extension ContactManager { } } + func getContacts(with groupContactId: Int) -> [MergedContact] { + // TODO: To implement + let frozenContacts = fetchResults(ofType: MergedContact.self) { partial in + partial + .where { $0.groupContactId == [groupContactId] } + } + return Array(frozenContacts.freezeIfNeeded()) + } + + func getContacts(for addressbookId: Int) -> [MergedContact] { + let contacts = fetchResults(ofType: MergedContact.self) { partial in + partial + .where { $0.remoteAddressBookId == addressbookId } + } + + return Array(contacts.freezeIfNeeded()) + } + func addressBook(with id: Int) -> AddressBook? { fetchObject(ofType: AddressBook.self, forPrimaryKey: id) } @@ -142,16 +189,4 @@ public extension ContactManager { writableRealm.add(mergedContact, update: .modified) } } - - func getContacts(with groupContactId: Int) -> [MergedContact] { - // TODO: To implement -// let frozenContacts = fetchResults(ofType: MergedContact.self) { partial in -// partial -// .where { $0.groupContactId == groupContactId } -// .freezeIfNeeded() -// } -// -// return Array(frozenContacts) - return [] - } } diff --git a/MailCore/Cache/ContactManager/ContactManager+Merge.swift b/MailCore/Cache/ContactManager/ContactManager+Merge.swift index 72e55a9ff..e3b576afd 100644 --- a/MailCore/Cache/ContactManager/ContactManager+Merge.swift +++ b/MailCore/Cache/ContactManager/ContactManager+Merge.swift @@ -175,7 +175,7 @@ extension ContactManager { continue } - // TODO: Handle optional emai + // TODO: Handle optional email let id = MergedContact.computeId(email: mergedContact.email ?? "", name: mergedContact.name) if newMergedContacts[id] == nil { idsToDelete.append(id) diff --git a/MailCore/Models/AddressBook.swift b/MailCore/Models/AddressBook.swift index a800dd1b7..9c4213ede 100644 --- a/MailCore/Models/AddressBook.swift +++ b/MailCore/Models/AddressBook.swift @@ -26,13 +26,35 @@ public struct AddressBookResult: Codable { public class AddressBook: Object, Codable, Identifiable { @Persisted public var id: Int @Persisted(primaryKey: true) public var uuid: String + @Persisted public var name: String @Persisted public var isDefault: Bool @Persisted public var groupContact: List +// @Persisted public var isDynamicOrganisationMemberDirectory: Bool enum CodingKeys: String, CodingKey { case id case uuid + case name case isDefault = "default" case groupContact = "categories" +// case isDynamicOrganisationMemberDirectory = "dynamic_organisation_member_directory" + } + +// var displayName: String { +// if isDynamicOrganisationMemberDirectory { +// return "Dynamic Organisation Member Directory" +// } else { +// return name +// } +// } +} + +extension AddressBook: ContactAutocompletable { + public var contactId: String { + return String(id) + } + + public var autocompletableName: String { + return name } } diff --git a/MailCore/Models/Contact/CommonContactCache.swift b/MailCore/Models/Contact/CommonContactCache.swift index 74f38df40..2dca83924 100644 --- a/MailCore/Models/Contact/CommonContactCache.swift +++ b/MailCore/Models/Contact/CommonContactCache.swift @@ -76,9 +76,11 @@ public enum CommonContactCache { contact = wrappedContact case .emptyContact: contact = CommonContact.emptyContact - case .groupContact: + case .groupContact(let groupContact): // TODO: To implement contact = CommonContact.emptyContact + case .addressBook(let addressBook): + contact = CommonContact.emptyContact } // Store the object in cache diff --git a/MailCore/Models/Contact/Contact.swift b/MailCore/Models/Contact/Contact.swift index 7623d2876..b782327b6 100644 --- a/MailCore/Models/Contact/Contact.swift +++ b/MailCore/Models/Contact/Contact.swift @@ -34,7 +34,7 @@ public struct InfomaniakContact: Codable, Identifiable { public var favorite: Bool? public var nickname: String? public var organization: String? - public var groupContactId: [Int] + public var groupContactId: RealmSwift.List? enum CodingKeys: String, CodingKey { case id @@ -73,6 +73,6 @@ public struct InfomaniakContact: Codable, Identifiable { favorite = try values.decodeIfPresent(Bool.self, forKey: .favorite) nickname = try values.decodeIfPresent(String.self, forKey: .nickname) organization = try values.decodeIfPresent(String.self, forKey: .organization) - groupContactId = try values.decode([Int].self, forKey: .groupContactId) + groupContactId = try values.decodeIfPresent(RealmSwift.List.self, forKey: .groupContactId) } } diff --git a/MailCore/Models/Contact/ContactConfiguration.swift b/MailCore/Models/Contact/ContactConfiguration.swift index f28ee26d7..03043b332 100644 --- a/MailCore/Models/Contact/ContactConfiguration.swift +++ b/MailCore/Models/Contact/ContactConfiguration.swift @@ -28,8 +28,10 @@ public enum ContactConfiguration: CustomDebugStringConvertible { return ".user:\(user.displayName) \(user.email)" case .contact(let contact): return ".contact:\(contact.fullName) \(contact.email)" - case .groupContact: + case .groupContact(let groupContactId): return ".groupContact" + case .addressBook(let addressBookId): + return ".addressBook" case .emptyContact: return ".emptyContact" } @@ -43,7 +45,8 @@ public enum ContactConfiguration: CustomDebugStringConvertible { ) case user(user: UserProfile) case contact(contact: CommonContact) - case groupContact + case groupContact(group: GroupContact) + case addressBook(adressbook: AddressBook) case emptyContact public func freezeIfNeeded() -> Self { @@ -85,9 +88,10 @@ extension ContactConfiguration: Identifiable { return wrappedContact.id case .emptyContact: return CommonContact.emptyContact.id - case .groupContact: - // TODO: To implement - return CommonContact.emptyContact.id + case .groupContact(let groupContact): + return groupContact.id + case .addressBook(let addressBook): + return addressBook.id } } } diff --git a/MailCore/Models/ContactAutocompletable.swift b/MailCore/Models/ContactAutocompletable.swift new file mode 100644 index 000000000..ba6a07730 --- /dev/null +++ b/MailCore/Models/ContactAutocompletable.swift @@ -0,0 +1,33 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCoreCommonUI + +public protocol ContactAutocompletable { + var contactId: String { get } + var autocompletableName: String { get } + + func isSameContactAutocompletable(as contactAutoCompletable: any ContactAutocompletable) -> Bool +} + +public extension ContactAutocompletable { + func isSameContactAutocompletable(as contactAutoCompletable: any ContactAutocompletable) -> Bool { + return false + } +} diff --git a/MailCore/Models/Correspondent.swift b/MailCore/Models/Correspondent.swift index bb0a28ef0..4ce442d66 100644 --- a/MailCore/Models/Correspondent.swift +++ b/MailCore/Models/Correspondent.swift @@ -46,8 +46,4 @@ public extension Correspondent { func isMe(currentMailboxEmail: String) -> Bool { return currentMailboxEmail == email } - - func isSameCorrespondent(as correspondent: any Correspondent) -> Bool { - return email == correspondent.email && name == correspondent.name - } } diff --git a/MailCore/Models/GroupContact.swift b/MailCore/Models/GroupContact.swift index cf365be09..ef3ffb5ff 100644 --- a/MailCore/Models/GroupContact.swift +++ b/MailCore/Models/GroupContact.swift @@ -16,24 +16,25 @@ along with this program. If not, see . */ -import Foundation import RealmSwift // AddressBook Categories -public class GroupContact: Object, Codable, ContactAutocompletable { +public class GroupContact: Object, Codable { @Persisted public var id: Int + @Persisted public var name: String - public var stringId: String { - return String(id) + public func isSameContactAutocompletable(as contactAutoCompletable: any ContactAutocompletable) -> Bool { + return name == contactAutoCompletable.autocompletableName } - - public var email: String? } -// TODO: A déplacer -public protocol ContactAutocompletable: Identifiable { - var stringId: String { get } - var name: String { get } - var email: String? { get } +extension GroupContact: ContactAutocompletable { + public var contactId: String { + return String(id) + } + + public var autocompletableName: String { + return name + } } diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index a2d5a643f..069ffa486 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -22,7 +22,6 @@ import InfomaniakCore import InfomaniakDI import Nuke import RealmSwift -import SwiftUI import UIKit extension CNContact { @@ -41,22 +40,20 @@ extension CNContact { } } -public final class MergedContact: Object, Identifiable, ContactAutocompletable { - public var stringId: String { - return id - } - +public final class MergedContact: Object, Identifiable { private static let contactFormatter = CNContactFormatter() /// Shared @Persisted(primaryKey: true) public var id: String - @Persisted public var email: String? + @Persisted public var email: String @Persisted public var name: String /// Remote @Persisted public var remoteColorHex: String? @Persisted public var remoteAvatarURL: String? @Persisted public var remoteIdentifier: String? + @Persisted public var remoteAddressBookId: Int? + public var groupContactId: [Int]? /// Local @Persisted public var localIdentifier: String? @@ -131,6 +128,8 @@ public final class MergedContact: Object, Identifiable, ContactAutocompletable { remoteColorHex = contact.color remoteAvatarURL = contact.avatar remoteIdentifier = contact.id + remoteAddressBookId = contact.addressbookId +// remoteGroupContactId = contact.groupContactId } static func computeId(email: String, name: String?) -> String { @@ -138,3 +137,17 @@ public final class MergedContact: Object, Identifiable, ContactAutocompletable { return name + email } } + +extension MergedContact: ContactAutocompletable { + public var contactId: String { + return String(id) + } + + public var autocompletableName: String { + return name + } + + public func isSameContactAutocompletable(as contactAutoCompletable: any ContactAutocompletable) -> Bool { + return contactId == contactAutoCompletable.contactId + } +} diff --git a/MailCore/Models/Recipient.swift b/MailCore/Models/Recipient.swift index affe22ae1..59ff364dd 100644 --- a/MailCore/Models/Recipient.swift +++ b/MailCore/Models/Recipient.swift @@ -36,6 +36,11 @@ public struct RecipientHolder { var bcc = [Recipient]() } +public enum RecipientError: Error { + case invalidEmail + case duplicateContact +} + public final class Recipient: EmbeddedObject, Correspondent, Codable { @Persisted public var email: String @Persisted public var name: String @@ -105,4 +110,8 @@ public final class Recipient: EmbeddedObject, Correspondent, Codable { return !isKnownDomain && !isMailerDeamon && !isAnAlias && !isContact } + + public func isSameRecipient(recipient: Recipient) -> Bool { + return email == recipient.email + } } diff --git a/MailCoreUI/Components/RecipientCell.swift b/MailCoreUI/Components/RecipientCell.swift index 5537a9ede..8bf7ae463 100644 --- a/MailCoreUI/Components/RecipientCell.swift +++ b/MailCoreUI/Components/RecipientCell.swift @@ -62,9 +62,9 @@ public struct RecipientCell: View { contactConfiguration: ContactConfiguration = .emptyContact, highlight: String? = nil ) { - title = contact.name - if let email = contact.email { - subtitle = email + title = contact.autocompletableName + if title == contact.autocompletableName { + subtitle = title } else { subtitle = "Nom de l'organisation" } From 8338639ab72f6a1890799a36d97d55e73c27987f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Thu, 5 Dec 2024 13:30:39 +0100 Subject: [PATCH 11/23] refactor: Remove todo --- Mail/Views/New Message/AutocompletionView.swift | 1 - .../Header Cells/ComposeMessageCellRecipients.swift | 2 +- Mail/Views/Search/SearchViewModel.swift | 3 +-- MailCore/Cache/ContactManager/ContactManager+DB.swift | 1 - MailCore/Cache/ContactManager/ContactManager+Merge.swift | 3 +-- MailCore/Models/Contact/CommonContactCache.swift | 1 - MailCoreUI/Components/RecipientCell.swift | 2 -- 7 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 3cca808af..4157d787c 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -53,7 +53,6 @@ struct AutocompletionView: View { var body: some View { LazyVStack(spacing: IKPadding.small) { - // TODO: Fix conformance Identifiable ForEach(autocompletion, id: \.contactId) { contact in let isLastRecipient = autocompletion.last?.isSameContactAutocompletable(as: contact) == true let isUserProposal = shouldAddUserProposal && isLastRecipient diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 987bae9cb..26a69b763 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -173,7 +173,7 @@ struct ComposeMessageCellRecipients: View { } let validContacts = mergedContacts.filter { contact in - !recipients.contains(where: { $0.email == contact.email }) + !recipients.contains(where: { $0.email == contact.email } ) } if validContacts.count < mergedContacts.count { diff --git a/Mail/Views/Search/SearchViewModel.swift b/Mail/Views/Search/SearchViewModel.swift index 9d0bb73c1..25146405e 100644 --- a/Mail/Views/Search/SearchViewModel.swift +++ b/Mail/Views/Search/SearchViewModel.swift @@ -148,8 +148,7 @@ final class SearchViewModel: ObservableObject, ThreadListable { fetchLimit: nil, sorted: nil ) - // TODO: Handle optional email - var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email ?? "", name: $0.name).freezeIfNeeded() } + var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name).freezeIfNeeded() } // Append typed email if Constants.isEmailAddress(searchValue) && !frozenContacts diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index ca04e7bcc..6aedcc272 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -148,7 +148,6 @@ public extension ContactManager { } func getContacts(with groupContactId: Int) -> [MergedContact] { - // TODO: To implement let frozenContacts = fetchResults(ofType: MergedContact.self) { partial in partial .where { $0.groupContactId == [groupContactId] } diff --git a/MailCore/Cache/ContactManager/ContactManager+Merge.swift b/MailCore/Cache/ContactManager/ContactManager+Merge.swift index e3b576afd..f1ef90b1b 100644 --- a/MailCore/Cache/ContactManager/ContactManager+Merge.swift +++ b/MailCore/Cache/ContactManager/ContactManager+Merge.swift @@ -175,8 +175,7 @@ extension ContactManager { continue } - // TODO: Handle optional email - let id = MergedContact.computeId(email: mergedContact.email ?? "", name: mergedContact.name) + let id = MergedContact.computeId(email: mergedContact.email, name: mergedContact.name) if newMergedContacts[id] == nil { idsToDelete.append(id) } diff --git a/MailCore/Models/Contact/CommonContactCache.swift b/MailCore/Models/Contact/CommonContactCache.swift index 2dca83924..769487de9 100644 --- a/MailCore/Models/Contact/CommonContactCache.swift +++ b/MailCore/Models/Contact/CommonContactCache.swift @@ -77,7 +77,6 @@ public enum CommonContactCache { case .emptyContact: contact = CommonContact.emptyContact case .groupContact(let groupContact): - // TODO: To implement contact = CommonContact.emptyContact case .addressBook(let addressBook): contact = CommonContact.emptyContact diff --git a/MailCoreUI/Components/RecipientCell.swift b/MailCoreUI/Components/RecipientCell.swift index 8bf7ae463..087eb93eb 100644 --- a/MailCoreUI/Components/RecipientCell.swift +++ b/MailCoreUI/Components/RecipientCell.swift @@ -47,7 +47,6 @@ public struct RecipientCell: View { let highlight: String? - // TODO: Change contactConfig public init(recipient: Recipient, contactConfiguration: ContactConfiguration = .emptyContact, highlight: String? = nil) { title = recipient.name subtitle = recipient.email @@ -56,7 +55,6 @@ public struct RecipientCell: View { self.highlight = highlight } - // TODO: Change contactConfig + subtitle public init( contact: any ContactAutocompletable, contactConfiguration: ContactConfiguration = .emptyContact, From 444cf04ae5024307ba34754d68e3d8631f0912d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Fri, 6 Dec 2024 11:38:28 +0100 Subject: [PATCH 12/23] feat: Can add group and teams avatar --- .../New Message/AutocompletionCell.swift | 12 ++++- .../ContactManager/ContactManager+DB.swift | 4 +- MailCore/Models/Contact/Contact.swift | 4 +- .../Models/Contact/ContactConfiguration.swift | 6 +-- MailCore/Models/MergedContact.swift | 4 +- MailCoreUI/Components/AvatarView.swift | 4 ++ .../Components/GroupRecipientsView.swift | 50 +++++++++++++++++++ .../teams-user.imageset/Contents.json | 15 ++++++ .../teams-user.imageset/teams-user.svg | 11 ++++ 9 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 MailCoreUI/Components/GroupRecipientsView.swift create mode 100644 MailResources/Assets.xcassets/teams-user.imageset/Contents.json create mode 100644 MailResources/Assets.xcassets/teams-user.imageset/teams-user.svg diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 0a97f472c..86ecc4b9e 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -29,6 +29,16 @@ struct AutocompletionCell: View { let alreadyAppend: Bool let unknownRecipient: Bool + var contactConfiguration: ContactConfiguration { + if let groupContact = autocompletion as? GroupContact { + return .groupContact(group: groupContact) + } else if let addressBook = autocompletion as? AddressBook { + return .addressBook(addressbook: addressBook) + } else { + return .emptyContact + } + } + var body: some View { HStack(spacing: IKPadding.intermediate) { Button { @@ -37,7 +47,7 @@ struct AutocompletionCell: View { if unknownRecipient { UnknownRecipientCell(email: autocompletion.autocompletableName) } else { - RecipientCell(contact: autocompletion, highlight: highlight) + RecipientCell(contact: autocompletion, contactConfiguration: contactConfiguration, highlight: highlight) } } .allowsHitTesting(!alreadyAppend || unknownRecipient) diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index 6aedcc272..98f946591 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -149,8 +149,7 @@ public extension ContactManager { func getContacts(with groupContactId: Int) -> [MergedContact] { let frozenContacts = fetchResults(ofType: MergedContact.self) { partial in - partial - .where { $0.groupContactId == [groupContactId] } + partial.filter("ANY remoteGroupContactId == %@", groupContactId) } return Array(frozenContacts.freezeIfNeeded()) } @@ -160,7 +159,6 @@ public extension ContactManager { partial .where { $0.remoteAddressBookId == addressbookId } } - return Array(contacts.freezeIfNeeded()) } diff --git a/MailCore/Models/Contact/Contact.swift b/MailCore/Models/Contact/Contact.swift index b782327b6..4320fed3f 100644 --- a/MailCore/Models/Contact/Contact.swift +++ b/MailCore/Models/Contact/Contact.swift @@ -34,7 +34,7 @@ public struct InfomaniakContact: Codable, Identifiable { public var favorite: Bool? public var nickname: String? public var organization: String? - public var groupContactId: RealmSwift.List? + public var groupContactId: List? enum CodingKeys: String, CodingKey { case id @@ -73,6 +73,6 @@ public struct InfomaniakContact: Codable, Identifiable { favorite = try values.decodeIfPresent(Bool.self, forKey: .favorite) nickname = try values.decodeIfPresent(String.self, forKey: .nickname) organization = try values.decodeIfPresent(String.self, forKey: .organization) - groupContactId = try values.decodeIfPresent(RealmSwift.List.self, forKey: .groupContactId) + groupContactId = try values.decodeIfPresent(List.self, forKey: .groupContactId) } } diff --git a/MailCore/Models/Contact/ContactConfiguration.swift b/MailCore/Models/Contact/ContactConfiguration.swift index 03043b332..285d46a0c 100644 --- a/MailCore/Models/Contact/ContactConfiguration.swift +++ b/MailCore/Models/Contact/ContactConfiguration.swift @@ -28,9 +28,9 @@ public enum ContactConfiguration: CustomDebugStringConvertible { return ".user:\(user.displayName) \(user.email)" case .contact(let contact): return ".contact:\(contact.fullName) \(contact.email)" - case .groupContact(let groupContactId): + case .groupContact: return ".groupContact" - case .addressBook(let addressBookId): + case .addressBook: return ".addressBook" case .emptyContact: return ".emptyContact" @@ -46,7 +46,7 @@ public enum ContactConfiguration: CustomDebugStringConvertible { case user(user: UserProfile) case contact(contact: CommonContact) case groupContact(group: GroupContact) - case addressBook(adressbook: AddressBook) + case addressBook(addressbook: AddressBook) case emptyContact public func freezeIfNeeded() -> Self { diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index 069ffa486..87720e082 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -53,7 +53,7 @@ public final class MergedContact: Object, Identifiable { @Persisted public var remoteAvatarURL: String? @Persisted public var remoteIdentifier: String? @Persisted public var remoteAddressBookId: Int? - public var groupContactId: [Int]? + @Persisted public var remoteGroupContactId: List /// Local @Persisted public var localIdentifier: String? @@ -129,7 +129,7 @@ public final class MergedContact: Object, Identifiable { remoteAvatarURL = contact.avatar remoteIdentifier = contact.id remoteAddressBookId = contact.addressbookId -// remoteGroupContactId = contact.groupContactId + remoteGroupContactId = contact.groupContactId ?? List() } static func computeId(email: String, name: String?) -> String { diff --git a/MailCoreUI/Components/AvatarView.swift b/MailCoreUI/Components/AvatarView.swift index 84918ae98..d0d95ca39 100644 --- a/MailCoreUI/Components/AvatarView.swift +++ b/MailCoreUI/Components/AvatarView.swift @@ -61,6 +61,10 @@ public struct AvatarView: View { Group { if case .emptyContact = contactConfiguration { UnknownRecipientView(size: size) + } else if case .addressBook = contactConfiguration { + GroupRecipientsView(size: size) + } else if case .groupContact = contactConfiguration { + GroupRecipientsView(size: size) } else if let avatarImageRequest = getAvatarImageRequest() { LazyImage(request: avatarImageRequest) { state in if let image = state.image { diff --git a/MailCoreUI/Components/GroupRecipientsView.swift b/MailCoreUI/Components/GroupRecipientsView.swift new file mode 100644 index 000000000..41c530712 --- /dev/null +++ b/MailCoreUI/Components/GroupRecipientsView.swift @@ -0,0 +1,50 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCoreSwiftUI +import MailCore +import MailResources +import SwiftUI + +struct GroupRecipientsView: View { + let size: CGFloat + + private var iconSize: CGFloat { + return size - 2 * IKPadding.small + } + + public init(size: CGFloat) { + self.size = size + } + + public var body: some View { + Circle() + .fill(Color.accentColor) + .frame(width: size, height: size) + .overlay { + MailResourcesAsset.teamsUser.swiftUIImage + .resizable() + .foregroundStyle(MailResourcesAsset.backgroundColor) + .frame(width: iconSize, height: iconSize) + } + } +} + +#Preview { + GroupRecipientsView(size: 40) +} diff --git a/MailResources/Assets.xcassets/teams-user.imageset/Contents.json b/MailResources/Assets.xcassets/teams-user.imageset/Contents.json new file mode 100644 index 000000000..adaaacfe5 --- /dev/null +++ b/MailResources/Assets.xcassets/teams-user.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "teams-user.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MailResources/Assets.xcassets/teams-user.imageset/teams-user.svg b/MailResources/Assets.xcassets/teams-user.imageset/teams-user.svg new file mode 100644 index 000000000..849c48e78 --- /dev/null +++ b/MailResources/Assets.xcassets/teams-user.imageset/teams-user.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From cc4fd20239f1c9b317fe71155dea6099359f1a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 10:44:26 +0100 Subject: [PATCH 13/23] feat: Add limit 100 recipients + filter local remote + fix avatar + fix email recipientCell --- .../New Message/AutocompletionCell.swift | 92 ++++++++++++++++++- .../New Message/AutocompletionView.swift | 62 ++++++++++--- .../ComposeMessageHeaderView.swift | 20 +++- .../ComposeMessageCellRecipients.swift | 29 ++++-- MailCore/Models/Contact/CommonContact.swift | 2 +- MailCore/Models/MergedContact.swift | 2 +- MailCoreUI/Components/AvatarView.swift | 13 +++ MailCoreUI/Components/RecipientCell.swift | 16 ++-- 8 files changed, 197 insertions(+), 39 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 86ecc4b9e..40b4d7393 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -23,22 +23,102 @@ import MailResources import SwiftUI struct AutocompletionCell: View { + @Environment(\.currentUser) private var currentUser + + @EnvironmentObject private var mailboxManager: MailboxManager + let addRecipient: @MainActor (any ContactAutocompletable) -> Void let autocompletion: any ContactAutocompletable var highlight: String? let alreadyAppend: Bool let unknownRecipient: Bool + let title: String + let subtitle: String var contactConfiguration: ContactConfiguration { if let groupContact = autocompletion as? GroupContact { return .groupContact(group: groupContact) } else if let addressBook = autocompletion as? AddressBook { return .addressBook(addressbook: addressBook) + } else if let mergedContact = autocompletion as? MergedContact { + let contact = CommonContact( + correspondent: mergedContact, + associatedBimi: nil, + contextUser: currentUser.value, + contextMailboxManager: mailboxManager + ) + return .contact(contact: contact) } else { return .emptyContact } } + init( + addRecipient: @escaping @MainActor (any ContactAutocompletable) -> Void, + autocompletion: any ContactAutocompletable, + hightlight: String?, + alreadyAppend: Bool, + unknownRecipient: Bool, + title: String, + subtitle: String + ) { + self.addRecipient = addRecipient + self.autocompletion = autocompletion + self.alreadyAppend = alreadyAppend + self.unknownRecipient = unknownRecipient + self.title = title + self.subtitle = subtitle + } + + init( + addRecipient: @escaping @MainActor (MergedContact) -> Void, + autocompletion: MergedContact, + highlight: String?, + alreadyAppend: Bool, + unknownRecipient: Bool + ) { + self.addRecipient = { addRecipient($0 as! MergedContact) } + self.autocompletion = autocompletion + self.alreadyAppend = alreadyAppend + self.unknownRecipient = unknownRecipient + self.title = autocompletion.name + self.subtitle = autocompletion.email + } + + init( + addRecipient: @escaping @MainActor (AddressBook) -> Void, + autocompletion: AddressBook, + highlight: String?, + alreadyAppend: Bool, + unknownRecipient: Bool, + title: String, + subtitle: String + ) { + self.addRecipient = { addRecipient($0 as! AddressBook) } + self.autocompletion = autocompletion + self.alreadyAppend = alreadyAppend + self.unknownRecipient = unknownRecipient + self.title = title + self.subtitle = "Organisation " + autocompletion.name + } + + init( + addRecipient: @escaping @MainActor (GroupContact) -> Void, + autocompletion: GroupContact, + highlight: String?, + alreadyAppend: Bool, + unknownRecipient: Bool, + title: String, + subtitle: String + ) { + self.addRecipient = { addRecipient($0 as! GroupContact) } + self.autocompletion = autocompletion + self.alreadyAppend = alreadyAppend + self.unknownRecipient = unknownRecipient + self.title = title + self.subtitle = "Carnet d'adresse de " + autocompletion.name + } + var body: some View { HStack(spacing: IKPadding.intermediate) { Button { @@ -47,7 +127,12 @@ struct AutocompletionCell: View { if unknownRecipient { UnknownRecipientCell(email: autocompletion.autocompletableName) } else { - RecipientCell(contact: autocompletion, contactConfiguration: contactConfiguration, highlight: highlight) + RecipientCell(contact: autocompletion, + contactConfiguration: contactConfiguration, + highlight: highlight, + title: title, + subtitle: subtitle + ) } } .allowsHitTesting(!alreadyAppend || unknownRecipient) @@ -67,8 +152,11 @@ struct AutocompletionCell: View { AutocompletionCell( addRecipient: { _ in /* Preview */ }, autocompletion: PreviewHelper.sampleMergedContact, + hightlight: "", alreadyAppend: false, - unknownRecipient: false + unknownRecipient: false, + title: "", + subtitle: "" ) .environmentObject(PreviewHelper.sampleMailboxManager) } diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 4157d787c..675e76d6a 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -58,14 +58,35 @@ struct AutocompletionView: View { let isUserProposal = shouldAddUserProposal && isLastRecipient VStack(alignment: .leading, spacing: IKPadding.small) { - AutocompletionCell( - addRecipient: addRecipient, - autocompletion: contact, - highlight: textDebounce.text, - alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, - unknownRecipient: isUserProposal - ) - + if let mergedContact = contact as? MergedContact { + AutocompletionCell( + addRecipient: addRecipient, + autocompletion: mergedContact, + highlight: textDebounce.text, + alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, + unknownRecipient: isUserProposal + ) + } else if let groupContact = contact as? GroupContact { + AutocompletionCell( + addRecipient: addRecipient, + autocompletion: groupContact, + hightlight: textDebounce.text, + alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, + unknownRecipient: isUserProposal, + title: groupContact.name, + subtitle: groupContact.autocompletableName + ) + } else if let addressBookContact = contact as? AddressBook { + AutocompletionCell( + addRecipient: addRecipient, + autocompletion: addressBookContact, + hightlight: textDebounce.text, + alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, + unknownRecipient: isUserProposal, + title: addressBookContact.name, + subtitle: addressBookContact.autocompletableName + ) + } if !isLastRecipient { IKDivider() } @@ -84,36 +105,47 @@ struct AutocompletionView: View { private func updateAutocompletion(_ search: String) async { let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines) + let counter = 10 Task { @MainActor in let autocompleteContacts = await Array(mailboxManager.contactManager.frozenContactsAsync( matching: trimmedSearch, - fetchLimit: Self.maxAutocompleteCount, + fetchLimit: counter, sorted: sortByRemoteAndName )) let autocompleteGroupContacts = Array(mailboxManager.contactManager.frozenGroupContacts( matching: trimmedSearch, - fetchLimit: nil + fetchLimit: counter )) let autocompleteAddressBookContacts = Array(mailboxManager.contactManager.frozenAddressBookContacts( matching: trimmedSearch, - fetchLimit: nil + fetchLimit: counter )) - var result: [any ContactAutocompletable] = autocompleteContacts + autocompleteGroupContacts + + var combinedResults: [any ContactAutocompletable] = autocompleteContacts + autocompleteGroupContacts + autocompleteAddressBookContacts - let realResults = autocompleteGroupContacts.filter { !addedRecipients.map(\.email).contains($0.autocompletableName) } + let realResults = autocompleteGroupContacts.filter { + !addedRecipients.map(\.email).contains($0.autocompletableName) + } shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.autocompletableName == textDebounce.text) if shouldAddUserProposal { - result.append(MergedContact(email: textDebounce.text, local: nil, remote: nil)) + combinedResults.append(MergedContact(email: textDebounce.text, local: nil, remote: nil)) } - autocompletion = result + combinedResults.sort { lhs, rhs in + guard let lhsContact = lhs as? MergedContact, + let rhsContact = rhs as? MergedContact else { return false } + return sortByRemoteAndName(lhs: lhsContact, rhs: rhsContact) + } + + let result = combinedResults.prefix(10) + + autocompletion = Array(result) } } diff --git a/Mail/Views/New Message/ComposeMessageHeaderView.swift b/Mail/Views/New Message/ComposeMessageHeaderView.swift index 131fa157d..4624eefb9 100644 --- a/Mail/Views/New Message/ComposeMessageHeaderView.swift +++ b/Mail/Views/New Message/ComposeMessageHeaderView.swift @@ -34,6 +34,14 @@ struct ComposeMessageHeaderView: View { @Binding var autocompletionType: ComposeViewFieldType? @Binding var currentSignature: Signature? + private var totalRecipients: Int { + return draft.to.count + draft.cc.count + draft.bcc.count + } + + private var isRecipientLimitExceeded: Bool { + return totalRecipients > 99 + } + var body: some View { VStack(spacing: 0) { ComposeMessageSenderMenu( @@ -49,7 +57,9 @@ struct ComposeMessageHeaderView: View { autocompletionType: $autocompletionType, focusedField: _focusedField, type: .to, - areCCAndBCCEmpty: draft.cc.isEmpty && draft.bcc.isEmpty + areCCAndBCCEmpty: draft.cc.isEmpty && draft.bcc.isEmpty, + totalRecipients: totalRecipients, + isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.toTitle) @@ -59,7 +69,9 @@ struct ComposeMessageHeaderView: View { showRecipientsFields: $showRecipientsFields, autocompletionType: $autocompletionType, focusedField: _focusedField, - type: .cc + type: .cc, + totalRecipients: totalRecipients, + isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.ccTitle) @@ -68,7 +80,9 @@ struct ComposeMessageHeaderView: View { showRecipientsFields: $showRecipientsFields, autocompletionType: $autocompletionType, focusedField: _focusedField, - type: .bcc + type: .bcc, + totalRecipients: totalRecipients, + isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.bccTitle) } diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 26a69b763..93a08af51 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -65,6 +65,9 @@ struct ComposeMessageCellRecipients: View { let type: ComposeViewFieldType var areCCAndBCCEmpty = false + let totalRecipients: Int + let isRecipientLimitExceeded: Bool + /// It should be displayed only for the field to if cc and bcc are empty and when autocompletion is not displayed private var shouldDisplayChevron: Bool { return type == .to && autocompletionType == nil && areCCAndBCCEmpty @@ -135,18 +138,21 @@ struct ComposeMessageCellRecipients: View { @MainActor private func addNewRecipient(_ contact: any ContactAutocompletable) { matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") + if isRecipientLimitExceeded { + snackbarPresenter.show(message: MailResourcesStrings.Localizable.errorTooManyRecipients) + return + } + do { let mergedContacts = extractContacts(contact) let validContacts = try recipientCheck(mergedContacts: mergedContacts) convertMergedContactsToRecipients(validContacts) - - print(recipients) } catch RecipientError.invalidEmail { snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) } catch RecipientError.duplicateContact { snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) } catch { - print("") + snackbarPresenter.show(message: MailResourcesStrings.Localizable.errorUnknown) } textDebounce.text = "" @@ -172,15 +178,20 @@ struct ComposeMessageCellRecipients: View { throw RecipientError.invalidEmail } - let validContacts = mergedContacts.filter { contact in - !recipients.contains(where: { $0.email == contact.email } ) + let uniqueContacts = mergedContacts.filter { contact in + !recipients.contains(where: { $0.email == contact.email }) } - if validContacts.count < mergedContacts.count { + if uniqueContacts.count < mergedContacts.count { throw RecipientError.duplicateContact } - return validContacts + let remainingCapacity = 100 - recipients.count + if recipients.count + mergedContacts.count > 100 { + snackbarPresenter.show(message: MailResourcesStrings.Localizable.errorTooManyRecipients) + } + + return Array(uniqueContacts.prefix(max(remainingCapacity, 0))) } private func convertMergedContactsToRecipients(_ mergedContacts: [MergedContact]) { @@ -196,7 +207,9 @@ struct ComposeMessageCellRecipients: View { recipients: .constant(PreviewHelper.sampleRecipientsList), showRecipientsFields: .constant(false), autocompletionType: .constant(nil), - type: .bcc + type: .bcc, + totalRecipients: 0, + isRecipientLimitExceeded: false ) .environmentObject(PreviewHelper.sampleMailboxManager) } diff --git a/MailCore/Models/Contact/CommonContact.swift b/MailCore/Models/Contact/CommonContact.swift index 5f3ee6211..7e69fa213 100644 --- a/MailCore/Models/Contact/CommonContact.swift +++ b/MailCore/Models/Contact/CommonContact.swift @@ -44,7 +44,7 @@ public final class CommonContact: Identifiable { } /// Init form a `Correspondent` in the context of a mailbox - init( + public init( correspondent: any Correspondent, associatedBimi: Bimi?, contextUser: UserProfile, diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index 87720e082..a41a6a699 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -40,7 +40,7 @@ extension CNContact { } } -public final class MergedContact: Object, Identifiable { +public final class MergedContact: Object, Identifiable, Correspondent { private static let contactFormatter = CNContactFormatter() /// Shared diff --git a/MailCoreUI/Components/AvatarView.swift b/MailCoreUI/Components/AvatarView.swift index d0d95ca39..9fd8c3e56 100644 --- a/MailCoreUI/Components/AvatarView.swift +++ b/MailCoreUI/Components/AvatarView.swift @@ -65,6 +65,19 @@ public struct AvatarView: View { GroupRecipientsView(size: size) } else if case .groupContact = contactConfiguration { GroupRecipientsView(size: size) + } else if case .contact(let contact) = contactConfiguration { + let avatarImageRequest = getAvatarImageRequest() + LazyImage(request: avatarImageRequest) { state in + if let image = state.image { + ContactImage(image: image, size: size) + } else { + InitialsView( + initials: displayablePerson.formatted(style: .initials), + color: displayablePerson.color, + size: size + ) + } + } } else if let avatarImageRequest = getAvatarImageRequest() { LazyImage(request: avatarImageRequest) { state in if let image = state.image { diff --git a/MailCoreUI/Components/RecipientCell.swift b/MailCoreUI/Components/RecipientCell.swift index 087eb93eb..8f0561214 100644 --- a/MailCoreUI/Components/RecipientCell.swift +++ b/MailCoreUI/Components/RecipientCell.swift @@ -41,8 +41,8 @@ public struct RecipientCell: View { @Environment(\.currentUser) private var currentUser @EnvironmentObject private var mailboxManager: MailboxManager - let title: String - let subtitle: String + var title: String + var subtitle: String let avatarConfiguration: ContactConfiguration let highlight: String? @@ -58,14 +58,12 @@ public struct RecipientCell: View { public init( contact: any ContactAutocompletable, contactConfiguration: ContactConfiguration = .emptyContact, - highlight: String? = nil + highlight: String? = nil, + title: String, + subtitle: String ) { - title = contact.autocompletableName - if title == contact.autocompletableName { - subtitle = title - } else { - subtitle = "Nom de l'organisation" - } + self.title = title + self.subtitle = subtitle avatarConfiguration = contactConfiguration self.highlight = highlight From 6d86fa36dd285bc3a9bc79997686e44d6435e7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 10:48:52 +0100 Subject: [PATCH 14/23] refactor: Swiftformat --- Mail/Views/New Message/AutocompletionCell.swift | 7 +++---- .../Header Cells/ComposeMessageCellRecipients.swift | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 40b4d7393..aea171668 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -81,8 +81,8 @@ struct AutocompletionCell: View { self.autocompletion = autocompletion self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient - self.title = autocompletion.name - self.subtitle = autocompletion.email + title = autocompletion.name + subtitle = autocompletion.email } init( @@ -131,8 +131,7 @@ struct AutocompletionCell: View { contactConfiguration: contactConfiguration, highlight: highlight, title: title, - subtitle: subtitle - ) + subtitle: subtitle) } } .allowsHitTesting(!alreadyAppend || unknownRecipient) diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 93a08af51..720bc8bf7 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -179,7 +179,7 @@ struct ComposeMessageCellRecipients: View { } let uniqueContacts = mergedContacts.filter { contact in - !recipients.contains(where: { $0.email == contact.email }) + !recipients.contains(where: { $0.email == contact.email } ) } if uniqueContacts.count < mergedContacts.count { From b2a8ec0fcd8c6dfec9ef1913e1a74dca92afa48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 10:51:40 +0100 Subject: [PATCH 15/23] refactor: Remove hard string code --- Mail/Views/New Message/AutocompletionCell.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index aea171668..4d971d135 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -99,7 +99,7 @@ struct AutocompletionCell: View { self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient self.title = title - self.subtitle = "Organisation " + autocompletion.name + self.subtitle = autocompletion.name } init( @@ -116,7 +116,7 @@ struct AutocompletionCell: View { self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient self.title = title - self.subtitle = "Carnet d'adresse de " + autocompletion.name + self.subtitle = autocompletion.name } var body: some View { From 06e1e406187c26b26001d7e4c5711cd0a501a32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 10:53:22 +0100 Subject: [PATCH 16/23] refactor: Remove unused code --- .../Header Cells/ComposeMessageCellRecipients.swift | 2 +- MailCore/Models/AddressBook.swift | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 720bc8bf7..93a08af51 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -179,7 +179,7 @@ struct ComposeMessageCellRecipients: View { } let uniqueContacts = mergedContacts.filter { contact in - !recipients.contains(where: { $0.email == contact.email } ) + !recipients.contains(where: { $0.email == contact.email }) } if uniqueContacts.count < mergedContacts.count { diff --git a/MailCore/Models/AddressBook.swift b/MailCore/Models/AddressBook.swift index 9c4213ede..a38204d3d 100644 --- a/MailCore/Models/AddressBook.swift +++ b/MailCore/Models/AddressBook.swift @@ -29,7 +29,6 @@ public class AddressBook: Object, Codable, Identifiable { @Persisted public var name: String @Persisted public var isDefault: Bool @Persisted public var groupContact: List -// @Persisted public var isDynamicOrganisationMemberDirectory: Bool enum CodingKeys: String, CodingKey { case id @@ -37,16 +36,7 @@ public class AddressBook: Object, Codable, Identifiable { case name case isDefault = "default" case groupContact = "categories" -// case isDynamicOrganisationMemberDirectory = "dynamic_organisation_member_directory" } - -// var displayName: String { -// if isDynamicOrganisationMemberDirectory { -// return "Dynamic Organisation Member Directory" -// } else { -// return name -// } -// } } extension AddressBook: ContactAutocompletable { From ecb0bf02c20b54174a3bfe05d5e7768d863a62ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 11:18:44 +0100 Subject: [PATCH 17/23] refactor: Trailing closure --- .../New Message/Header Cells/ComposeMessageCellRecipients.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 93a08af51..d318892bf 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -179,7 +179,7 @@ struct ComposeMessageCellRecipients: View { } let uniqueContacts = mergedContacts.filter { contact in - !recipients.contains(where: { $0.email == contact.email }) + !recipients.contains { $0.email == contact.email } } if uniqueContacts.count < mergedContacts.count { From b1b2f114c468b07ddee1354785c574a66dff8fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 12:40:50 +0100 Subject: [PATCH 18/23] fix: UITest --- MailTests/Folders/ITFolderListViewModel.swift | 8 ++++++++ MailTests/Search/ITSearchViewModel.swift | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/MailTests/Folders/ITFolderListViewModel.swift b/MailTests/Folders/ITFolderListViewModel.swift index 1186709a4..0709a82ac 100644 --- a/MailTests/Folders/ITFolderListViewModel.swift +++ b/MailTests/Folders/ITFolderListViewModel.swift @@ -44,6 +44,14 @@ struct MCKContactManageable_FolderListViewModel: ContactManageable, MCKTransacti func getContact(for correspondent: any Correspondent, transactionable: Transactionable) -> MergedContact? { nil } + func frozenGroupContacts(matching string: String, fetchLimit: Int?) -> any Collection { [] } + + func frozenAddressBookContacts(matching string: String, fetchLimit: Int?) -> any Collection { [] } + + func getContacts(with groupContactId: Int) -> [MailCore.MergedContact] { [] } + + func getContacts(for addressbookId: Int) -> [MailCore.MergedContact] { [] } + func addressBook(with id: Int) -> MailCore.AddressBook? { nil } func addContact(recipient: MailCore.Recipient) async throws {} diff --git a/MailTests/Search/ITSearchViewModel.swift b/MailTests/Search/ITSearchViewModel.swift index f7edafafd..d0eaa888f 100644 --- a/MailTests/Search/ITSearchViewModel.swift +++ b/MailTests/Search/ITSearchViewModel.swift @@ -51,6 +51,14 @@ struct MCKContactManageable_SearchViewModel: ContactManageable, MCKTransactionab func getContact(for correspondent: any Correspondent, transactionable: Transactionable) -> MergedContact? { nil } + func frozenGroupContacts(matching string: String, fetchLimit: Int?) -> any Collection { [] } + + func frozenAddressBookContacts(matching string: String, fetchLimit: Int?) -> any Collection { [] } + + func getContacts(with groupContactId: Int) -> [MailCore.MergedContact] { [] } + + func getContacts(for addressbookId: Int) -> [MailCore.MergedContact] { [] } + func addressBook(with id: Int) -> MailCore.AddressBook? { nil } func addContact(recipient: MailCore.Recipient) async throws {} From d1f3e291d3c665352bb6460979925651e48ecaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 12:58:21 +0100 Subject: [PATCH 19/23] refactor: Remove whitespace --- MailCoreUI/Components/RecipientCell.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MailCoreUI/Components/RecipientCell.swift b/MailCoreUI/Components/RecipientCell.swift index 8f0561214..d31e2f206 100644 --- a/MailCoreUI/Components/RecipientCell.swift +++ b/MailCoreUI/Components/RecipientCell.swift @@ -51,7 +51,6 @@ public struct RecipientCell: View { title = recipient.name subtitle = recipient.email avatarConfiguration = contactConfiguration - self.highlight = highlight } From 7862646a3cc527340fc8d2db4ecfee4cbebb6183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 18 Dec 2024 14:21:25 +0100 Subject: [PATCH 20/23] refactor: Fix init --- .../Views/New Message/AutocompletionCell.swift | 18 ++++++++++-------- .../Views/New Message/AutocompletionView.swift | 6 ++---- .../New Message/ComposeMessageHeaderView.swift | 3 --- .../ComposeMessageCellRecipients.swift | 2 -- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 4d971d135..0d526d826 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -56,7 +56,7 @@ struct AutocompletionCell: View { init( addRecipient: @escaping @MainActor (any ContactAutocompletable) -> Void, autocompletion: any ContactAutocompletable, - hightlight: String?, + highlight: String?, alreadyAppend: Bool, unknownRecipient: Bool, title: String, @@ -64,6 +64,7 @@ struct AutocompletionCell: View { ) { self.addRecipient = addRecipient self.autocompletion = autocompletion + self.highlight = highlight self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient self.title = title @@ -86,15 +87,15 @@ struct AutocompletionCell: View { } init( - addRecipient: @escaping @MainActor (AddressBook) -> Void, - autocompletion: AddressBook, + addRecipient: @escaping @MainActor (GroupContact) -> Void, + autocompletion: GroupContact, highlight: String?, alreadyAppend: Bool, unknownRecipient: Bool, title: String, subtitle: String ) { - self.addRecipient = { addRecipient($0 as! AddressBook) } + self.addRecipient = { addRecipient($0 as! GroupContact) } self.autocompletion = autocompletion self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient @@ -103,16 +104,17 @@ struct AutocompletionCell: View { } init( - addRecipient: @escaping @MainActor (GroupContact) -> Void, - autocompletion: GroupContact, + addRecipient: @escaping @MainActor (AddressBook) -> Void, + autocompletion: AddressBook, highlight: String?, alreadyAppend: Bool, unknownRecipient: Bool, title: String, subtitle: String ) { - self.addRecipient = { addRecipient($0 as! GroupContact) } + self.addRecipient = { addRecipient($0 as! AddressBook) } self.autocompletion = autocompletion + self.highlight = highlight self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient self.title = title @@ -151,7 +153,7 @@ struct AutocompletionCell: View { AutocompletionCell( addRecipient: { _ in /* Preview */ }, autocompletion: PreviewHelper.sampleMergedContact, - hightlight: "", + highlight: "", alreadyAppend: false, unknownRecipient: false, title: "", diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 675e76d6a..161671946 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -38,8 +38,6 @@ extension Recipient: @retroactive ContactAutocompletable { } struct AutocompletionView: View { - private static let maxAutocompleteCount = 10 - @EnvironmentObject private var mailboxManager: MailboxManager @State private var shouldAddUserProposal = false @@ -70,7 +68,7 @@ struct AutocompletionView: View { AutocompletionCell( addRecipient: addRecipient, autocompletion: groupContact, - hightlight: textDebounce.text, + highlight: textDebounce.text, alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, unknownRecipient: isUserProposal, title: groupContact.name, @@ -80,7 +78,7 @@ struct AutocompletionView: View { AutocompletionCell( addRecipient: addRecipient, autocompletion: addressBookContact, - hightlight: textDebounce.text, + highlight: textDebounce.text, alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, unknownRecipient: isUserProposal, title: addressBookContact.name, diff --git a/Mail/Views/New Message/ComposeMessageHeaderView.swift b/Mail/Views/New Message/ComposeMessageHeaderView.swift index 4624eefb9..05ffd0293 100644 --- a/Mail/Views/New Message/ComposeMessageHeaderView.swift +++ b/Mail/Views/New Message/ComposeMessageHeaderView.swift @@ -58,7 +58,6 @@ struct ComposeMessageHeaderView: View { focusedField: _focusedField, type: .to, areCCAndBCCEmpty: draft.cc.isEmpty && draft.bcc.isEmpty, - totalRecipients: totalRecipients, isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.toTitle) @@ -70,7 +69,6 @@ struct ComposeMessageHeaderView: View { autocompletionType: $autocompletionType, focusedField: _focusedField, type: .cc, - totalRecipients: totalRecipients, isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.ccTitle) @@ -81,7 +79,6 @@ struct ComposeMessageHeaderView: View { autocompletionType: $autocompletionType, focusedField: _focusedField, type: .bcc, - totalRecipients: totalRecipients, isRecipientLimitExceeded: isRecipientLimitExceeded ) .accessibilityLabel(MailResourcesStrings.Localizable.bccTitle) diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index d318892bf..d132c097f 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -65,7 +65,6 @@ struct ComposeMessageCellRecipients: View { let type: ComposeViewFieldType var areCCAndBCCEmpty = false - let totalRecipients: Int let isRecipientLimitExceeded: Bool /// It should be displayed only for the field to if cc and bcc are empty and when autocompletion is not displayed @@ -208,7 +207,6 @@ struct ComposeMessageCellRecipients: View { showRecipientsFields: .constant(false), autocompletionType: .constant(nil), type: .bcc, - totalRecipients: 0, isRecipientLimitExceeded: false ) .environmentObject(PreviewHelper.sampleMailboxManager) From c462eea84d599c74e4cd0cf2047cf67239f43dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Thu, 19 Dec 2024 10:46:47 +0100 Subject: [PATCH 21/23] refactor: Fix the shouldAddUserProposal --- Mail/Views/New Message/AutocompletionCell.swift | 4 +++- Mail/Views/New Message/AutocompletionView.swift | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 0d526d826..1dc65b223 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -80,9 +80,10 @@ struct AutocompletionCell: View { ) { self.addRecipient = { addRecipient($0 as! MergedContact) } self.autocompletion = autocompletion + self.highlight = highlight self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient - title = autocompletion.name + title = autocompletion.email subtitle = autocompletion.email } @@ -97,6 +98,7 @@ struct AutocompletionCell: View { ) { self.addRecipient = { addRecipient($0 as! GroupContact) } self.autocompletion = autocompletion + self.highlight = highlight self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient self.title = title diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 161671946..f15b078bb 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -131,17 +131,19 @@ struct AutocompletionView: View { shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.autocompletableName == textDebounce.text) - if shouldAddUserProposal { - combinedResults.append(MergedContact(email: textDebounce.text, local: nil, remote: nil)) - } - combinedResults.sort { lhs, rhs in guard let lhsContact = lhs as? MergedContact, let rhsContact = rhs as? MergedContact else { return false } return sortByRemoteAndName(lhs: lhsContact, rhs: rhsContact) } - let result = combinedResults.prefix(10) + var result = combinedResults.prefix(10) + + if shouldAddUserProposal { + let mergedContact = MergedContact(email: textDebounce.text, local: nil, remote: nil) + mergedContact.name = textDebounce.text + result.append(mergedContact) + } autocompletion = Array(result) } From 0741f5fa0e7fbde7cec2f5fbec504e740d9d4510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Tue, 14 Jan 2025 11:11:30 +0100 Subject: [PATCH 22/23] feat: Add subtitle and strings prefixs --- .../New Message/AutocompletionCell.swift | 87 ++++++------------- .../New Message/AutocompletionView.swift | 43 ++------- .../New Message/UnknownRecipientCell.swift | 4 +- .../ContactManager/ContactManager+DB.swift | 12 +++ .../Localizable/de.lproj/Localizable.strings | 6 ++ .../Localizable/en.lproj/Localizable.strings | 6 ++ .../Localizable/es.lproj/Localizable.strings | 6 ++ .../Localizable/fr.lproj/Localizable.strings | 6 ++ .../Localizable/it.lproj/Localizable.strings | 6 ++ MailTests/Folders/ITFolderListViewModel.swift | 2 + MailTests/Search/ITSearchViewModel.swift | 2 + 11 files changed, 81 insertions(+), 99 deletions(-) diff --git a/Mail/Views/New Message/AutocompletionCell.swift b/Mail/Views/New Message/AutocompletionCell.swift index 1dc65b223..2bb745f48 100644 --- a/Mail/Views/New Message/AutocompletionCell.swift +++ b/Mail/Views/New Message/AutocompletionCell.swift @@ -24,16 +24,15 @@ import SwiftUI struct AutocompletionCell: View { @Environment(\.currentUser) private var currentUser - @EnvironmentObject private var mailboxManager: MailboxManager let addRecipient: @MainActor (any ContactAutocompletable) -> Void let autocompletion: any ContactAutocompletable - var highlight: String? let alreadyAppend: Bool let unknownRecipient: Bool let title: String - let subtitle: String + @State private var subtitle: String + var highlight: String? var contactConfiguration: ContactConfiguration { if let groupContact = autocompletion as? GroupContact { @@ -58,69 +57,28 @@ struct AutocompletionCell: View { autocompletion: any ContactAutocompletable, highlight: String?, alreadyAppend: Bool, - unknownRecipient: Bool, - title: String, - subtitle: String - ) { - self.addRecipient = addRecipient - self.autocompletion = autocompletion - self.highlight = highlight - self.alreadyAppend = alreadyAppend - self.unknownRecipient = unknownRecipient - self.title = title - self.subtitle = subtitle - } - - init( - addRecipient: @escaping @MainActor (MergedContact) -> Void, - autocompletion: MergedContact, - highlight: String?, - alreadyAppend: Bool, unknownRecipient: Bool ) { - self.addRecipient = { addRecipient($0 as! MergedContact) } + self.addRecipient = { addRecipient($0) } self.autocompletion = autocompletion self.highlight = highlight self.alreadyAppend = alreadyAppend self.unknownRecipient = unknownRecipient - title = autocompletion.email - subtitle = autocompletion.email - } - init( - addRecipient: @escaping @MainActor (GroupContact) -> Void, - autocompletion: GroupContact, - highlight: String?, - alreadyAppend: Bool, - unknownRecipient: Bool, - title: String, - subtitle: String - ) { - self.addRecipient = { addRecipient($0 as! GroupContact) } - self.autocompletion = autocompletion - self.highlight = highlight - self.alreadyAppend = alreadyAppend - self.unknownRecipient = unknownRecipient - self.title = title - self.subtitle = autocompletion.name - } - - init( - addRecipient: @escaping @MainActor (AddressBook) -> Void, - autocompletion: AddressBook, - highlight: String?, - alreadyAppend: Bool, - unknownRecipient: Bool, - title: String, - subtitle: String - ) { - self.addRecipient = { addRecipient($0 as! AddressBook) } - self.autocompletion = autocompletion - self.highlight = highlight - self.alreadyAppend = alreadyAppend - self.unknownRecipient = unknownRecipient - self.title = title - self.subtitle = autocompletion.name + switch autocompletion { + case let mergedContact as MergedContact: + title = mergedContact.name + _subtitle = State(initialValue: mergedContact.email) + case let groupContact as GroupContact: + title = MailResourcesStrings.Localizable.groupContactsTitle(groupContact.name) + _subtitle = State(initialValue: "") + case let addressBook as AddressBook: + title = MailResourcesStrings.Localizable.addressBookTitle(addressBook.name) + _subtitle = State(initialValue: addressBook.name) + default: + title = "" + _subtitle = State(initialValue: "") + } } var body: some View { @@ -148,6 +106,13 @@ struct AutocompletionCell: View { } } .padding(.horizontal, value: .medium) + .task { + if let groupContact = autocompletion as? GroupContact { + subtitle = MailResourcesStrings.Localizable + .addressBookTitle(mailboxManager.contactManager.getAddressBook(for: groupContact.id)?.name ?? groupContact + .name) + } + } } } @@ -157,9 +122,7 @@ struct AutocompletionCell: View { autocompletion: PreviewHelper.sampleMergedContact, highlight: "", alreadyAppend: false, - unknownRecipient: false, - title: "", - subtitle: "" + unknownRecipient: false ) .environmentObject(PreviewHelper.sampleMailboxManager) } diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index f15b078bb..12457e9f6 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -56,35 +56,14 @@ struct AutocompletionView: View { let isUserProposal = shouldAddUserProposal && isLastRecipient VStack(alignment: .leading, spacing: IKPadding.small) { - if let mergedContact = contact as? MergedContact { - AutocompletionCell( - addRecipient: addRecipient, - autocompletion: mergedContact, - highlight: textDebounce.text, - alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, - unknownRecipient: isUserProposal - ) - } else if let groupContact = contact as? GroupContact { - AutocompletionCell( - addRecipient: addRecipient, - autocompletion: groupContact, - highlight: textDebounce.text, - alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, - unknownRecipient: isUserProposal, - title: groupContact.name, - subtitle: groupContact.autocompletableName - ) - } else if let addressBookContact = contact as? AddressBook { - AutocompletionCell( - addRecipient: addRecipient, - autocompletion: addressBookContact, - highlight: textDebounce.text, - alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, - unknownRecipient: isUserProposal, - title: addressBookContact.name, - subtitle: addressBookContact.autocompletableName - ) - } + AutocompletionCell( + addRecipient: addRecipient, + autocompletion: contact, + highlight: textDebounce.text, + alreadyAppend: addedRecipients.contains { $0.id == contact.contactId }, + unknownRecipient: isUserProposal + ) + if !isLastRecipient { IKDivider() } @@ -131,12 +110,6 @@ struct AutocompletionView: View { shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.autocompletableName == textDebounce.text) - combinedResults.sort { lhs, rhs in - guard let lhsContact = lhs as? MergedContact, - let rhsContact = rhs as? MergedContact else { return false } - return sortByRemoteAndName(lhs: lhsContact, rhs: rhsContact) - } - var result = combinedResults.prefix(10) if shouldAddUserProposal { diff --git a/Mail/Views/New Message/UnknownRecipientCell.swift b/Mail/Views/New Message/UnknownRecipientCell.swift index 18e394c5b..555f14bcd 100644 --- a/Mail/Views/New Message/UnknownRecipientCell.swift +++ b/Mail/Views/New Message/UnknownRecipientCell.swift @@ -21,10 +21,10 @@ import MailCoreUI import MailResources import SwiftUI -public struct UnknownRecipientCell: View { +struct UnknownRecipientCell: View { let email: String - public var body: some View { + var body: some View { HStack(spacing: 8) { UnknownRecipientView(size: 40) .accessibilityHidden(true) diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index 98f946591..a661aa60c 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -58,6 +58,11 @@ public protocol ContactFetchable { /// Get a contact from adressbook func getContacts(for addressbookId: Int) -> [MergedContact] + + /// Get the `AddressBook` by the `GroupContact`'s ID + /// - Parameter groupContactId: The ID of the `GroupContact` to look up + /// - Returns: The `AddressBook` that contains the `GroupContact`, or `nil` if not found + func getAddressBook(for groupContactId: Int) -> AddressBook? } public extension ContactManager { @@ -172,6 +177,13 @@ public extension ContactManager { } } + func getAddressBook(for groupContactId: Int) -> AddressBook? { + let addressBooks = fetchResults(ofType: AddressBook.self) { partial in + partial.filter("ANY groupContact.id == %@", groupContactId) + } + return addressBooks.first + } + func addContact(recipient: Recipient) async throws { guard let addressBook = getDefaultAddressBook() else { throw MailError.addressBookNotFound } diff --git a/MailResources/Localizable/de.lproj/Localizable.strings b/MailResources/Localizable/de.lproj/Localizable.strings index 61d100187..2fa959530 100644 --- a/MailResources/Localizable/de.lproj/Localizable.strings +++ b/MailResources/Localizable/de.lproj/Localizable.strings @@ -112,6 +112,9 @@ /* loco:63ecd31247f7a57fc228b532 */ "addUnknownRecipientTitle" = "Einen Empfänger hinzufügen"; +/* loco:6784cd26a547d567fc0a7222 */ +"addressBookTitle" = "Adressbuch: %@"; + /* loco:65081b4d5787de874c0dec32 */ "aiButtonInsert" = "Einfügen"; @@ -892,6 +895,9 @@ /* loco:62665f7d647475580e06e912 */ "fromTitle" = "Von:"; +/* loco:6784cd82593faaee6e0cb7b2 */ +"groupContactsTitle" = "Gruppe: %@"; + /* loco:62c466f080a5557baa2d0d84 */ "helpChatbot" = "Chatbot"; diff --git a/MailResources/Localizable/en.lproj/Localizable.strings b/MailResources/Localizable/en.lproj/Localizable.strings index 534185a2d..521955b7d 100644 --- a/MailResources/Localizable/en.lproj/Localizable.strings +++ b/MailResources/Localizable/en.lproj/Localizable.strings @@ -112,6 +112,9 @@ /* loco:63ecd31247f7a57fc228b532 */ "addUnknownRecipientTitle" = "Add a recipient"; +/* loco:6784cd26a547d567fc0a7222 */ +"addressBookTitle" = "Address Book: %@"; + /* loco:65081b4d5787de874c0dec32 */ "aiButtonInsert" = "Insert"; @@ -892,6 +895,9 @@ /* loco:62665f7d647475580e06e912 */ "fromTitle" = "From:"; +/* loco:6784cd82593faaee6e0cb7b2 */ +"groupContactsTitle" = "Group: %@"; + /* loco:62c466f080a5557baa2d0d84 */ "helpChatbot" = "Chatbot"; diff --git a/MailResources/Localizable/es.lproj/Localizable.strings b/MailResources/Localizable/es.lproj/Localizable.strings index 5af2fd976..105afc05f 100644 --- a/MailResources/Localizable/es.lproj/Localizable.strings +++ b/MailResources/Localizable/es.lproj/Localizable.strings @@ -112,6 +112,9 @@ /* loco:63ecd31247f7a57fc228b532 */ "addUnknownRecipientTitle" = "Añadir un destinatario"; +/* loco:6784cd26a547d567fc0a7222 */ +"addressBookTitle" = "Libreta de direcciones: %@"; + /* loco:65081b4d5787de874c0dec32 */ "aiButtonInsert" = "Inserte"; @@ -892,6 +895,9 @@ /* loco:62665f7d647475580e06e912 */ "fromTitle" = "De:"; +/* loco:6784cd82593faaee6e0cb7b2 */ +"groupContactsTitle" = "Grupo: %@"; + /* loco:62c466f080a5557baa2d0d84 */ "helpChatbot" = "Chatbot"; diff --git a/MailResources/Localizable/fr.lproj/Localizable.strings b/MailResources/Localizable/fr.lproj/Localizable.strings index 571caa7b6..0a0ad8133 100644 --- a/MailResources/Localizable/fr.lproj/Localizable.strings +++ b/MailResources/Localizable/fr.lproj/Localizable.strings @@ -112,6 +112,9 @@ /* loco:63ecd31247f7a57fc228b532 */ "addUnknownRecipientTitle" = "Ajouter un destinataire"; +/* loco:6784cd26a547d567fc0a7222 */ +"addressBookTitle" = "Carnet d'adresses : %@"; + /* loco:65081b4d5787de874c0dec32 */ "aiButtonInsert" = "Insérer"; @@ -892,6 +895,9 @@ /* loco:62665f7d647475580e06e912 */ "fromTitle" = "De :"; +/* loco:6784cd82593faaee6e0cb7b2 */ +"groupContactsTitle" = "Groupe : %@"; + /* loco:62c466f080a5557baa2d0d84 */ "helpChatbot" = "Chatbot"; diff --git a/MailResources/Localizable/it.lproj/Localizable.strings b/MailResources/Localizable/it.lproj/Localizable.strings index 1dfa4706c..ad8425e1c 100644 --- a/MailResources/Localizable/it.lproj/Localizable.strings +++ b/MailResources/Localizable/it.lproj/Localizable.strings @@ -112,6 +112,9 @@ /* loco:63ecd31247f7a57fc228b532 */ "addUnknownRecipientTitle" = "Aggiungi un destinatario"; +/* loco:6784cd26a547d567fc0a7222 */ +"addressBookTitle" = "Rubrica: %@"; + /* loco:65081b4d5787de874c0dec32 */ "aiButtonInsert" = "Inserisci"; @@ -892,6 +895,9 @@ /* loco:62665f7d647475580e06e912 */ "fromTitle" = "Da:"; +/* loco:6784cd82593faaee6e0cb7b2 */ +"groupContactsTitle" = "Gruppo: %@"; + /* loco:62c466f080a5557baa2d0d84 */ "helpChatbot" = "Chatbot"; diff --git a/MailTests/Folders/ITFolderListViewModel.swift b/MailTests/Folders/ITFolderListViewModel.swift index 0709a82ac..e517ba175 100644 --- a/MailTests/Folders/ITFolderListViewModel.swift +++ b/MailTests/Folders/ITFolderListViewModel.swift @@ -56,6 +56,8 @@ struct MCKContactManageable_FolderListViewModel: ContactManageable, MCKTransacti func addContact(recipient: MailCore.Recipient) async throws {} + func getAddressBook(for groupContactId: Int) -> MailCore.AddressBook? { nil } + func refreshContactsAndAddressBooksIfNeeded() async throws {} func refreshContactsAndAddressBooks() async throws {} diff --git a/MailTests/Search/ITSearchViewModel.swift b/MailTests/Search/ITSearchViewModel.swift index d0eaa888f..8dffdd5d3 100644 --- a/MailTests/Search/ITSearchViewModel.swift +++ b/MailTests/Search/ITSearchViewModel.swift @@ -63,6 +63,8 @@ struct MCKContactManageable_SearchViewModel: ContactManageable, MCKTransactionab func addContact(recipient: MailCore.Recipient) async throws {} + func getAddressBook(for groupContactId: Int) -> MailCore.AddressBook? { nil } + func refreshContactsAndAddressBooksIfNeeded() async throws {} func refreshContactsAndAddressBooks() async throws {} From 1971be564d6b8a5dacd2ea0db2a17ebc5b74b0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20D=C3=A9glon?= Date: Wed, 15 Jan 2025 07:00:22 +0100 Subject: [PATCH 23/23] feat: Remove empty address books --- MailCore/Cache/ContactManager/ContactManager+DB.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/MailCore/Cache/ContactManager/ContactManager+DB.swift b/MailCore/Cache/ContactManager/ContactManager+DB.swift index a661aa60c..bf67f7bf8 100644 --- a/MailCore/Cache/ContactManager/ContactManager+DB.swift +++ b/MailCore/Cache/ContactManager/ContactManager+DB.swift @@ -127,9 +127,16 @@ public extension ContactManager { /// - fetchLimit: limit the query by default to limit memory footprint /// - Returns: The collection of matching contacts. func frozenAddressBookContacts(matching string: String, fetchLimit: Int?) -> any Collection { - var lazyResults = fetchResults(ofType: AddressBook.self) { partial in + let mergedContacts = fetchResults(ofType: MergedContact.self) { partial in partial } + + let mergedContactIds = Array(mergedContacts.compactMap { $0.remoteAddressBookId }) + + var lazyResults = fetchResults(ofType: AddressBook.self) { partial in + partial.where { $0.id.in(mergedContactIds) } + } + lazyResults = lazyResults .filter(Self.searchGroupContactInsensitivePredicate, string, string) .freeze()