From 8e4233f3c6046fdf3e24b683d935a60af2aaa204 Mon Sep 17 00:00:00 2001
From: Roma Sosnovsky <roma.sosnovsky@gmail.com>
Date: Wed, 20 Oct 2021 13:43:29 +0300
Subject: [PATCH 1/4] issue #576 mix local and cloud contacts in search results

---
 .../Compose/ComposeViewController.swift       | 16 +++++-----
 .../CloudContactsProvider.swift               | 30 ++++++++++++-------
 .../ContactsService.swift                     |  4 +++
 .../LocalContactsProvider.swift               |  8 +++++
 4 files changed, 40 insertions(+), 18 deletions(-)

diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift
index 550d9b88d..7b818061a 100644
--- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift
+++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift
@@ -598,13 +598,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) {
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<RecipientWithPubKeys>
+    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)

From 051ebc35b3139dcfee3fbe736efdf160000d7828 Mon Sep 17 00:00:00 2001
From: Roma Sosnovsky <roma.sosnovsky@gmail.com>
Date: Wed, 20 Oct 2021 14:23:25 +0300
Subject: [PATCH 2/4] issue #576 fix tests

---
 FlowCryptAppTests/Mocks/ContactsServiceMock.swift | 1 +
 1 file changed, 1 insertion(+)

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<RecipientWithPubKeys> {
         Promise<RecipientWithPubKeys>.resolveAfter(with: searchContactResult)
     }
+    func searchContacts(query: String) -> [String] { [] }
 
     func removePubKey(with fingerprint: String, for email: String) {}
 }

From 59d7d0f84d32b214c8653cf24c8cd1915fe7a155 Mon Sep 17 00:00:00 2001
From: Roma Sosnovsky <roma.sosnovsky@gmail.com>
Date: Wed, 20 Oct 2021 21:08:51 +0300
Subject: [PATCH 3/4] issue #576 improve recipient email typing

---
 FlowCrypt/Controllers/Compose/ComposeViewController.swift | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift
index 7b818061a..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
         }
 
@@ -714,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
         }
     }
 }

From 2541c730d7bb0cfeb0a8b35d35681b0440684f93 Mon Sep 17 00:00:00 2001
From: Roma Sosnovsky <roma.sosnovsky@gmail.com>
Date: Thu, 21 Oct 2021 11:01:13 +0300
Subject: [PATCH 4/4] issue #576 fix recipients collection view layout issue

---
 FlowCrypt.xcodeproj/project.pbxproj           |  4 +++
 .../Cell Nodes/RecipientEmailNode.swift       | 12 ++-----
 .../Cell Nodes/RecipientEmailsCellNode.swift  |  5 +--
 .../LeftAlignedCollectionViewFlowLayout.swift | 36 +++++++++++++++++++
 4 files changed, 45 insertions(+), 12 deletions(-)
 create mode 100644 FlowCryptUI/Views/LeftAlignedCollectionViewFlowLayout.swift

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 = "<group>"; };
 		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 = "<group>"; };
 		50531BE32629B9A80039BAE9 /* AttachmentNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentNode.swift; sourceTree = "<group>"; };
+		5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = "<group>"; };
 		5133B66F2716320F00C95463 /* ContactKeyDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactKeyDetailViewController.swift; sourceTree = "<group>"; };
 		5133B6712716321F00C95463 /* ContactKeyDetailDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactKeyDetailDecorator.swift; sourceTree = "<group>"; };
 		5133B6732716E5EA00C95463 /* LabelCellNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCellNode.swift; sourceTree = "<group>"; };
@@ -1833,6 +1835,7 @@
 				9FD22A1E230FEFC6005067A6 /* NavigationBarActionButton.swift */,
 				D28655942423BFF60066F52E /* SideMenuOptionalView.swift */,
 				D24FAFA32520BF9100BF46C5 /* CheckBoxCircleView.swift */,
+				5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -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/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
+    }
+}
