diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index be9fec8ba..30399c2a8 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 32DCAF95A6A329C3136B1C8E /* Imap+msg.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DCA55C094E9745AA1FD210 /* Imap+msg.swift */; }; 32DCAF9DA9EC47798DF8BB73 /* SignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DCA9701B2D5052225A0414 /* SignInViewController.swift */; }; 50531BE42629B9A80039BAE9 /* AttachmentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50531BE32629B9A80039BAE9 /* AttachmentNode.swift */; }; + 5109A77C272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */; }; 512C1414271077F8002DE13F /* GoogleAPIClientForREST_PeopleService in Frameworks */ = {isa = PBXBuildFile; productRef = 512C1413271077F8002DE13F /* GoogleAPIClientForREST_PeopleService */; }; 5133B6702716320F00C95463 /* ContactKeyDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5133B66F2716320F00C95463 /* ContactKeyDetailViewController.swift */; }; 5133B6722716321F00C95463 /* ContactKeyDetailDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5133B6712716321F00C95463 /* ContactKeyDetailDecorator.swift */; }; @@ -466,6 +467,7 @@ 4C5032E4FC5685A224F61785 /* Pods-FlowCryptUI.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptUI.testflight.xcconfig"; path = "Target Support Files/Pods-FlowCryptUI/Pods-FlowCryptUI.testflight.xcconfig"; sourceTree = ""; }; 4F928D493732294B4E521900 /* Pods-FlowCryptUIApplication.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptUIApplication.release.xcconfig"; path = "Target Support Files/Pods-FlowCryptUIApplication/Pods-FlowCryptUIApplication.release.xcconfig"; sourceTree = ""; }; 50531BE32629B9A80039BAE9 /* AttachmentNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentNode.swift; sourceTree = ""; }; + 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; 5133B66F2716320F00C95463 /* ContactKeyDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactKeyDetailViewController.swift; sourceTree = ""; }; 5133B6712716321F00C95463 /* ContactKeyDetailDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactKeyDetailDecorator.swift; sourceTree = ""; }; 5133B6732716E5EA00C95463 /* LabelCellNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCellNode.swift; sourceTree = ""; }; @@ -1833,6 +1835,7 @@ 9FD22A1E230FEFC6005067A6 /* NavigationBarActionButton.swift */, D28655942423BFF60066F52E /* SideMenuOptionalView.swift */, D24FAFA32520BF9100BF46C5 /* CheckBoxCircleView.swift */, + 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */, ); path = Views; sourceTree = ""; @@ -2735,6 +2738,7 @@ D26F132724509EB6009175BA /* RecipientEmailsCellNodeInput.swift in Sources */, D23C46F623FB44D8008211FB /* DividerCellNode.swift in Sources */, D27177512425678F00BDA9A9 /* KeySettingCellNode.swift in Sources */, + 5109A77C272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift in Sources */, D27177462424D59800BDA9A9 /* InboxCellNode.swift in Sources */, D27177472424D59800BDA9A9 /* TextCellNode.swift in Sources */, D211CE6E23FC354200D1CE38 /* CellNode.swift in Sources */, diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 550d9b88d..07db0072a 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -583,7 +583,6 @@ extension ComposeViewController { private func handleEditingChanged(with text: String?) { guard let text = text, text.isNotEmpty else { search.send("") - updateState(with: .main) return } @@ -598,13 +597,15 @@ extension ComposeViewController { // MARK: - Action Handling extension ComposeViewController { private func searchEmail(with query: String) { - cloudContactProvider.searchContacts(query: query) - .then(on: .main) { [weak self] emails in - let state: State = emails.isNotEmpty - ? .searchEmails(emails) - : .main - self?.updateState(with: state) - } + Task { + let localEmails = contactsService.searchContacts(query: query) + let cloudEmails = try? await cloudContactProvider.searchContacts(query: query) + let emails = Set([localEmails, cloudEmails].compactMap { $0 }.flatMap { $0 }) + let state: State = emails.isNotEmpty + ? .searchEmails(Array(emails)) + : .main + updateState(with: state) + } } private func evaluate(recipient: ComposeMessageRecipient) { @@ -712,11 +713,13 @@ extension ComposeViewController { private func updateState(with newState: State) { state = newState + node.reloadSections([1], with: .automatic) + switch state { case .main: - node.reloadSections([0, 1], with: .fade) + node.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) case .searchEmails: - node.reloadSections([1], with: .fade) + break } } } diff --git a/FlowCrypt/Functionality/Mail Provider/Contacts Provider/CloudContactsProvider.swift b/FlowCrypt/Functionality/Mail Provider/Contacts Provider/CloudContactsProvider.swift index 036c130be..d57fbb978 100644 --- a/FlowCrypt/Functionality/Mail Provider/Contacts Provider/CloudContactsProvider.swift +++ b/FlowCrypt/Functionality/Mail Provider/Contacts Provider/CloudContactsProvider.swift @@ -7,11 +7,10 @@ // import FlowCryptCommon -import Promises import GoogleAPIClientForREST_PeopleService protocol CloudContactsProvider { - func searchContacts(query: String) -> Promise<[String]> + func searchContacts(query: String) async throws -> [String] } enum CloudContactsProviderError: Error { @@ -37,30 +36,39 @@ final class UserContactsProvider { init(userService: GoogleUserServiceType = GoogleUserService()) { self.userService = userService - - // Warmup query for contacts cache - _ = self.searchContacts(query: "") + + runWarmupQuery() + } + + private func runWarmupQuery() { + Task { + // Warmup query for google contacts cache + _ = try? await searchContacts(query: "") + } } } extension UserContactsProvider: CloudContactsProvider { - func searchContacts(query: String) -> Promise<[String]> { + func searchContacts(query: String) async throws -> [String] { let searchQuery = GTLRPeopleServiceQuery_PeopleSearchContacts.query() searchQuery.readMask = "names,emailAddresses" searchQuery.query = query - return Promise<[String]> { resolve, reject in + return try await withCheckedThrowingContinuation { continuation in self.peopleService.executeQuery(searchQuery) { _, data, error in if let error = error { - return reject(CloudContactsProviderError.providerError(error)) + continuation.resume(throwing: CloudContactsProviderError.providerError(error)) + return } guard let response = data as? GTLRPeopleService_SearchResponse else { - return reject(AppErr.cast("GTLRPeopleService_SearchResponse")) + continuation.resume(throwing: AppErr.cast("GTLRPeopleService_SearchResponse")) + return } guard let contacts = response.results else { - return reject(CloudContactsProviderError.failedToParseData(data)) + continuation.resume(throwing: CloudContactsProviderError.failedToParseData(data)) + return } let emails = contacts @@ -68,7 +76,7 @@ extension UserContactsProvider: CloudContactsProvider { .flatMap { $0 } .compactMap { $0.value } - resolve(emails) + continuation.resume(returning: emails) } } } diff --git a/FlowCrypt/Functionality/Services/Local Pub Key Services/ContactsService.swift b/FlowCrypt/Functionality/Services/Local Pub Key Services/ContactsService.swift index 8e9647791..178d8d439 100644 --- a/FlowCrypt/Functionality/Services/Local Pub Key Services/ContactsService.swift +++ b/FlowCrypt/Functionality/Services/Local Pub Key Services/ContactsService.swift @@ -19,6 +19,7 @@ protocol ContactsServiceType: PublicKeyProvider, ContactsProviderType { protocol ContactsProviderType { func searchContact(with email: String) -> Promise + func searchContacts(query: String) -> [String] } protocol PublicKeyProvider { @@ -54,6 +55,9 @@ extension ContactsService: ContactsProviderType { return Promise(contact) } + func searchContacts(query: String) -> [String] { + localContactsProvider.searchEmails(query: query) + } } extension ContactsService: PublicKeyProvider { diff --git a/FlowCrypt/Functionality/Services/Local Pub Key Services/LocalContactsProvider.swift b/FlowCrypt/Functionality/Services/Local Pub Key Services/LocalContactsProvider.swift index 0c67b184b..82c3fd5e3 100644 --- a/FlowCrypt/Functionality/Services/Local Pub Key Services/LocalContactsProvider.swift +++ b/FlowCrypt/Functionality/Services/Local Pub Key Services/LocalContactsProvider.swift @@ -13,6 +13,7 @@ import RealmSwift protocol LocalContactsProviderType: PublicKeyProvider { func updateLastUsedDate(for email: String) func searchRecipient(with email: String) -> RecipientWithPubKeys? + func searchEmails(query: String) -> [String] func save(recipient: RecipientWithPubKeys) func remove(recipient: RecipientWithPubKeys) func updateKeys(for recipient: RecipientWithPubKeys) @@ -78,6 +79,13 @@ extension LocalContactsProvider: LocalContactsProviderType { return RecipientWithPubKeys(recipientObject) } + func searchEmails(query: String) -> [String] { + localContactsCache.realm + .objects(RecipientObject.self) + .filter("email contains[c] %@", query) + .map(\.email) + } + func getAllRecipients() -> [RecipientWithPubKeys] { localContactsCache.realm .objects(RecipientObject.self) diff --git a/FlowCryptAppTests/Mocks/ContactsServiceMock.swift b/FlowCryptAppTests/Mocks/ContactsServiceMock.swift index 5d0ee9147..63bbb95f9 100644 --- a/FlowCryptAppTests/Mocks/ContactsServiceMock.swift +++ b/FlowCryptAppTests/Mocks/ContactsServiceMock.swift @@ -20,6 +20,7 @@ class ContactsServiceMock: ContactsServiceType { func searchContact(with email: String) -> Promise { Promise.resolveAfter(with: searchContactResult) } + func searchContacts(query: String) -> [String] { [] } func removePubKey(with fingerprint: String, for email: String) {} } diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift index 7ce1eaad0..d149ade84 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift @@ -27,7 +27,6 @@ final class RecipientEmailNode: CellNode { let titleNode = ASTextNode() let input: Input - let displayNode = ASDisplayNode() let imageNode = ASImageNode() private var onTap: ((Tap) -> Void)? @@ -44,7 +43,6 @@ final class RecipientEmailNode: CellNode { titleNode.borderColor = input.recipient.state.borderColor.cgColor titleNode.textContainerInset = RecipientEmailNode.Constants.titleInsets - displayNode.backgroundColor = .clear imageNode.image = input.recipient.state.stateImage imageNode.alpha = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -98,18 +96,12 @@ final class RecipientEmailNode: CellNode { } override func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec { - displayNode.style.preferredSize.width = input.width - displayNode.style.preferredSize.height = 1 - let spec = ASStackLayoutSpec() - spec.children = [displayNode, titleNode] - spec.direction = .vertical - spec.alignItems = .baselineFirst let elements: [ASLayoutElement] if imageNode.image == nil { - elements = [spec] + elements = [titleNode] } else { - elements = [imageNode, spec] + elements = [imageNode, titleNode] imageNode.hitTestSlop = UIEdgeInsets(top: -8, left: -8, bottom: -8, right: -20) } diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift index 9a75cbf03..12408f06f 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift @@ -18,14 +18,14 @@ final public class RecipientEmailsCellNode: CellNode { } private enum Constants { - static let sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 0, right: 8) + static let sectionInset = UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0) static let minimumLineSpacing: CGFloat = 4 } private var onAction: RecipientTap? public lazy var collectionNode: ASCollectionNode = { - let layout = UICollectionViewFlowLayout() + let layout = LeftAlignedCollectionViewFlowLayout() layout.scrollDirection = .vertical layout.minimumInteritemSpacing = 1 layout.minimumLineSpacing = Constants.minimumLineSpacing @@ -42,6 +42,7 @@ final public class RecipientEmailsCellNode: CellNode { super.init() collectionNode.dataSource = self collectionNode.delegate = self + automaticallyManagesSubnodes = true } diff --git a/FlowCryptUI/Views/LeftAlignedCollectionViewFlowLayout.swift b/FlowCryptUI/Views/LeftAlignedCollectionViewFlowLayout.swift new file mode 100644 index 000000000..67d8a3e40 --- /dev/null +++ b/FlowCryptUI/Views/LeftAlignedCollectionViewFlowLayout.swift @@ -0,0 +1,36 @@ +// +// LeftAlignedCollectionViewFlowLayout.swift +// FlowCryptUI +// +// Created by Roma Sosnovsky on 21/10/21 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + + +import UIKit + +class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributes = super.layoutAttributesForElements(in: rect) + + var leftMargin = sectionInset.left + var prevMaxY: CGFloat = -1.0 + + attributes?.forEach { layoutAttribute in + guard layoutAttribute.representedElementCategory == .cell else { + return + } + + if layoutAttribute.frame.origin.y >= prevMaxY { + leftMargin = sectionInset.left + } + + layoutAttribute.frame.origin.x = leftMargin + + leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing + prevMaxY = layoutAttribute.frame.maxY + } + + return attributes + } +}