Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add address book and categories in contact autocompletion #1607

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a26984f
feat: Create GroupContact model
Lnamw Sep 19, 2024
779f71b
feat: Add groupContact property to AddressBook
Lnamw Sep 19, 2024
5015dc4
feat: Add groupContactId property to Contact
Lnamw Sep 19, 2024
4a071ef
fix: Add GroupContact.self to RealmConfiguration
Lnamw Sep 19, 2024
12e342f
feat: Update endpoint with categories
Lnamw Sep 19, 2024
8fb9d72
fix: Must be RealmFetchable for fetchResult() to run
Lnamw Oct 3, 2024
2a2596e
feat: Create func to get the groupContact
Lnamw Oct 3, 2024
c1fcad9
feat: Display also the groupContact when tapping recipient
Lnamw Oct 3, 2024
2c2d250
feat: WIP
Lnamw Oct 30, 2024
9bf7087
feat: Add addressBooks
Matthieu-dgl Dec 5, 2024
8338639
refactor: Remove todo
Matthieu-dgl Dec 5, 2024
444cf04
feat: Can add group and teams avatar
Matthieu-dgl Dec 6, 2024
cc4fd20
feat: Add limit 100 recipients + filter local remote + fix avatar + f…
Matthieu-dgl Dec 18, 2024
6d86fa3
refactor: Swiftformat
Matthieu-dgl Dec 18, 2024
b2a8ec0
refactor: Remove hard string code
Matthieu-dgl Dec 18, 2024
06e1e40
refactor: Remove unused code
Matthieu-dgl Dec 18, 2024
ecb0bf0
refactor: Trailing closure
Matthieu-dgl Dec 18, 2024
b1b2f11
fix: UITest
Matthieu-dgl Dec 18, 2024
d1f3e29
refactor: Remove whitespace
Matthieu-dgl Dec 18, 2024
7862646
refactor: Fix init
Matthieu-dgl Dec 18, 2024
c462eea
refactor: Fix the shouldAddUserProposal
Matthieu-dgl Dec 19, 2024
0741f5f
feat: Add subtitle and strings prefixs
Matthieu-dgl Jan 14, 2025
1971be5
feat: Remove empty address books
Matthieu-dgl Jan 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions Mail/Views/New Message/AutocompletionCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,77 @@ import MailResources
import SwiftUI

struct AutocompletionCell: View {
let addRecipient: @MainActor (Recipient) -> Void
let recipient: Recipient
var highlight: String?
@Environment(\.currentUser) private var currentUser
@EnvironmentObject private var mailboxManager: MailboxManager

let addRecipient: @MainActor (any ContactAutocompletable) -> Void
let autocompletion: any ContactAutocompletable
let alreadyAppend: Bool
let unknownRecipient: Bool
let title: String
@State private var subtitle: String
var highlight: 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,
highlight: String?,
alreadyAppend: Bool,
unknownRecipient: Bool
) {
self.addRecipient = { addRecipient($0) }
self.autocompletion = autocompletion
self.highlight = highlight
self.alreadyAppend = alreadyAppend
self.unknownRecipient = unknownRecipient

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 {
HStack(spacing: IKPadding.intermediate) {
Button {
addRecipient(recipient)
addRecipient(autocompletion)
} label: {
if unknownRecipient {
UnknownRecipientCell(recipient: recipient)
UnknownRecipientCell(email: autocompletion.autocompletableName)
} else {
RecipientCell(recipient: recipient, highlight: highlight)
RecipientCell(contact: autocompletion,
contactConfiguration: contactConfiguration,
highlight: highlight,
title: title,
subtitle: subtitle)
}
}
.allowsHitTesting(!alreadyAppend || unknownRecipient)
Expand All @@ -50,13 +106,21 @@ 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)
}
}
}
}

#Preview {
AutocompletionCell(
addRecipient: { _ in /* Preview */ },
recipient: PreviewHelper.sampleRecipient1,
autocompletion: PreviewHelper.sampleMergedContact,
highlight: "",
alreadyAppend: false,
unknownRecipient: false
)
Expand Down
65 changes: 49 additions & 16 deletions Mail/Views/New Message/AutocompletionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,44 @@ import MailCoreUI
import RealmSwift
import SwiftUI

struct AutocompletionView: View {
private static let maxAutocompleteCount = 10
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 {
@EnvironmentObject private var mailboxManager: MailboxManager

@State private var shouldAddUserProposal = false

@ObservedObject var textDebounce: TextDebounce

@Binding var autocompletion: [Recipient]
@Binding var autocompletion: [any ContactAutocompletable]
@Binding var addedRecipients: RealmSwift.List<Recipient>

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
ForEach(autocompletion, id: \.contactId) { contact in
let isLastRecipient = autocompletion.last?.isSameContactAutocompletable(as: contact) == 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: addedRecipients.contains { $0.id == contact.contactId },
unknownRecipient: isUserProposal
)

Expand All @@ -70,22 +82,43 @@ struct AutocompletionView: View {

private func updateAutocompletion(_ search: String) async {
let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines)
let counter = 10

Task { @MainActor in
let autocompleteContacts = await mailboxManager.contactManager.frozenContactsAsync(
let autocompleteContacts = await Array(mailboxManager.contactManager.frozenContactsAsync(
matching: trimmedSearch,
fetchLimit: Self.maxAutocompleteCount,
fetchLimit: counter,
sorted: sortByRemoteAndName
)
var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) }
))

let realResults = autocompleteRecipients.filter { !addedRecipients.map(\.email).contains($0.email) }
let autocompleteGroupContacts = Array(mailboxManager.contactManager.frozenGroupContacts(
matching: trimmedSearch,
fetchLimit: counter
))

let autocompleteAddressBookContacts = Array(mailboxManager.contactManager.frozenAddressBookContacts(
matching: trimmedSearch,
fetchLimit: counter
))

var combinedResults: [any ContactAutocompletable] = autocompleteContacts + autocompleteGroupContacts +
autocompleteAddressBookContacts

let realResults = autocompleteGroupContacts.filter {
!addedRecipients.map(\.email).contains($0.autocompletableName)
}

shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.autocompletableName == textDebounce.text)

var result = combinedResults.prefix(10)

shouldAddUserProposal = !(realResults.count == 1 && realResults.first?.email == textDebounce.text)
if shouldAddUserProposal {
autocompleteRecipients.append(Recipient(email: textDebounce.text, name: ""))
let mergedContact = MergedContact(email: textDebounce.text, local: nil, remote: nil)
mergedContact.name = textDebounce.text
result.append(mergedContact)
}
autocompletion = autocompleteRecipients

autocompletion = Array(result)
}
}

Expand Down
17 changes: 14 additions & 3 deletions Mail/Views/New Message/ComposeMessageHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -49,7 +57,8 @@ struct ComposeMessageHeaderView: View {
autocompletionType: $autocompletionType,
focusedField: _focusedField,
type: .to,
areCCAndBCCEmpty: draft.cc.isEmpty && draft.bcc.isEmpty
areCCAndBCCEmpty: draft.cc.isEmpty && draft.bcc.isEmpty,
isRecipientLimitExceeded: isRecipientLimitExceeded
)
.accessibilityLabel(MailResourcesStrings.Localizable.toTitle)

Expand All @@ -59,7 +68,8 @@ struct ComposeMessageHeaderView: View {
showRecipientsFields: $showRecipientsFields,
autocompletionType: $autocompletionType,
focusedField: _focusedField,
type: .cc
type: .cc,
isRecipientLimitExceeded: isRecipientLimitExceeded
)
.accessibilityLabel(MailResourcesStrings.Localizable.ccTitle)

Expand All @@ -68,7 +78,8 @@ struct ComposeMessageHeaderView: View {
showRecipientsFields: $showRecipientsFields,
autocompletionType: $autocompletionType,
focusedField: _focusedField,
type: .bcc
type: .bcc,
isRecipientLimitExceeded: isRecipientLimitExceeded
)
.accessibilityLabel(MailResourcesStrings.Localizable.bccTitle)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Recipient>
@Binding var showRecipientsFields: Bool
Expand All @@ -63,6 +65,8 @@ struct ComposeMessageCellRecipients: View {
let type: ComposeViewFieldType
var areCCAndBCCEmpty = false

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
Expand Down Expand Up @@ -130,33 +134,80 @@ 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)
if isRecipientLimitExceeded {
snackbarPresenter.show(message: MailResourcesStrings.Localizable.errorTooManyRecipients)
return
}

guard !recipients.contains(where: { $0.isSameCorrespondent(as: recipient) }) else {
do {
let mergedContacts = extractContacts(contact)
let validContacts = try recipientCheck(mergedContacts: mergedContacts)
convertMergedContactsToRecipients(validContacts)
} catch RecipientError.invalidEmail {
snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail)
} catch RecipientError.duplicateContact {
snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed)
return
} catch {
snackbarPresenter.show(message: MailResourcesStrings.Localizable.errorUnknown)
}

withAnimation {
recipient.isAddedByMe = true
$recipients.append(recipient)
}
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
}

private func recipientCheck(mergedContacts: [MergedContact]) throws -> [MergedContact] {
let invalidEmailContacts = mergedContacts.filter { !Constants.isEmailAddress($0.email) }
if !invalidEmailContacts.isEmpty {
throw RecipientError.invalidEmail
}

let uniqueContacts = mergedContacts.filter { contact in
!recipients.contains { $0.email == contact.email }
}

if uniqueContacts.count < mergedContacts.count {
throw RecipientError.duplicateContact
}

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]) {
for mergedContact in mergedContacts {
let newRecipient = Recipient(email: mergedContact.email, name: mergedContact.name)
$recipients.append(newRecipient)
}
}
}

#Preview {
ComposeMessageCellRecipients(
recipients: .constant(PreviewHelper.sampleRecipientsList),
showRecipientsFields: .constant(false),
autocompletionType: .constant(nil),
type: .bcc
type: .bcc,
isRecipientLimitExceeded: false
)
.environmentObject(PreviewHelper.sampleMailboxManager)
}
Loading
Loading