diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 616856b66..1fa8bac71 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -30,10 +30,9 @@ blocks: - cache restore && make dependencies && cache store - mv ~/appium-env ~/git/flowcrypt-ios/appium/.env - sem-version node 16 && cache restore appium-npm && cd ./appium && npm i && cd .. && cache store appium-npm appium/node_modules - # temporary disabled because of https://github.com/ios-control/simctl/issues/30 - # - brew install ideviceinstaller - # - npm install ios-deploy -g --unsafe-perm=true --allow-root - # - npm install ios-sim -g --unsafe-perm=true --allow-root + - brew install ideviceinstaller + - npm install ios-deploy -g --unsafe-perm=true --allow-root + - npm install ios-sim -g --unsafe-perm=true --allow-root jobs: - name: Appium UI tests commands: diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..42b7b0039 --- /dev/null +++ b/.swiftformat @@ -0,0 +1 @@ +--exclude appium,Core,fastlane,Pods,vendor \ No newline at end of file diff --git a/BuildTools/Package.resolved b/BuildTools/Package.resolved index fd1fd9336..e93a7ca4b 100644 --- a/BuildTools/Package.resolved +++ b/BuildTools/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", "state": { "branch": null, - "revision": "415c08ce2d63ff8bca95228939c92375882ea538", - "version": "0.49.2" + "revision": "f14f4f717e7e1d275acd7557d64c94cfef5723e6", + "version": "0.49.4" } } ] diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift index 7569f3ce6..567f2cfbd 100644 --- a/BuildTools/Package.swift +++ b/BuildTools/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "BuildTools", platforms: [.macOS(.v10_11)], dependencies: [ - .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.0"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.4"), ], targets: [.target(name: "BuildTools", path: "")] ) diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index 2120c6d4d..0b185c4bc 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -60,6 +60,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 */; }; + 51074E6427BD0C5800FBB124 /* RecipientToggleButtonNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51074E6327BD0C5800FBB124 /* RecipientToggleButtonNode.swift */; }; 5109A77C272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */; }; 511D07E12769FBBA0050417B /* MessagePasswordCellNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511D07E02769FBBA0050417B /* MessagePasswordCellNode.swift */; }; 511D07E3276A2DF80050417B /* ButtonWithPaddingNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511D07E2276A2DF80050417B /* ButtonWithPaddingNode.swift */; }; @@ -71,6 +72,7 @@ 514C34DD276CE1C000FCAB79 /* ComposeMessageRecipient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514C34DC276CE1C000FCAB79 /* ComposeMessageRecipient.swift */; }; 514C34DF276CE20700FCAB79 /* ComposeMessageService+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514C34DE276CE20700FCAB79 /* ComposeMessageService+State.swift */; }; 5152F196277E5AED00BE8A5B /* MessageUploadDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152F195277E5AED00BE8A5B /* MessageUploadDetails.swift */; }; + 5165ABCC27B526D100CCC379 /* RecipientEmailTextFieldNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165ABCB27B526D100CCC379 /* RecipientEmailTextFieldNode.swift */; }; 51689F3F2795C1D90050A9B8 /* ProcessedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51689F3E2795C1D90050A9B8 /* ProcessedMessage.swift */; }; 5168FB0B274F94D300131072 /* MessageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5168FB0A274F94D300131072 /* MessageAttachment.swift */; }; 51775C32270B01C200D7C944 /* PrvKeyInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51775C31270B01C200D7C944 /* PrvKeyInfoTests.swift */; }; @@ -500,6 +502,7 @@ 4753E9A27694B4D34C980FFA /* Pods_FlowCrypt.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FlowCrypt.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + 51074E6327BD0C5800FBB124 /* RecipientToggleButtonNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientToggleButtonNode.swift; sourceTree = ""; }; 5109A77B272153B400D2CEB9 /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; 511D07E02769FBBA0050417B /* MessagePasswordCellNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePasswordCellNode.swift; sourceTree = ""; }; 511D07E2276A2DF80050417B /* ButtonWithPaddingNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonWithPaddingNode.swift; sourceTree = ""; }; @@ -510,6 +513,7 @@ 514C34DC276CE1C000FCAB79 /* ComposeMessageRecipient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeMessageRecipient.swift; sourceTree = ""; }; 514C34DE276CE20700FCAB79 /* ComposeMessageService+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeMessageService+State.swift"; sourceTree = ""; }; 5152F195277E5AED00BE8A5B /* MessageUploadDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageUploadDetails.swift; sourceTree = ""; }; + 5165ABCB27B526D100CCC379 /* RecipientEmailTextFieldNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientEmailTextFieldNode.swift; sourceTree = ""; }; 51689F3E2795C1D90050A9B8 /* ProcessedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessedMessage.swift; sourceTree = ""; }; 5168FB0A274F94D300131072 /* MessageAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageAttachment.swift; sourceTree = ""; }; 51775C31270B01C200D7C944 /* PrvKeyInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrvKeyInfoTests.swift; sourceTree = ""; }; @@ -2047,6 +2051,8 @@ D24ABA6223FDB4FF002EE9DD /* RecipientEmailsCellNode.swift */, D26F132624509EB6009175BA /* RecipientEmailsCellNodeInput.swift */, D2531F3523FFEDA2007E5198 /* RecipientEmailNode.swift */, + 5165ABCB27B526D100CCC379 /* RecipientEmailTextFieldNode.swift */, + 51074E6327BD0C5800FBB124 /* RecipientToggleButtonNode.swift */, 9F1797692368EE90002BF770 /* ButtonCellNode.swift */, 9F4453C3236B96F9005D7D05 /* DividerCellNode.swift */, 9F23EA4D237216FA0017DFED /* TextViewCellNode.swift */, @@ -2519,7 +2525,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n#swift package update #Uncomment this line temporarily to update the version used to the latest matching your BuildTools/Package.swift file\nswift run -c release swiftformat \"$SRCROOT\"\n"; + shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n#swift package update #Uncomment this line temporarily to update the version used to the latest matching your BuildTools/Package.swift file\nswift run -c release swiftformat \"$SRCROOT\" --exclude appium,fastlane,Pods,vendor\n"; }; E531C3B50A9C90454C72F878 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -2812,6 +2818,7 @@ D28655952423BFF60066F52E /* SideMenuOptionalView.swift in Sources */, D2717754242568A600BDA9A9 /* NavigationBarActionButton.swift in Sources */, D2A9CA3A2426198600E1D898 /* SignInDescriptionNode.swift in Sources */, + 51074E6427BD0C5800FBB124 /* RecipientToggleButtonNode.swift in Sources */, 5133B6742716E5EA00C95463 /* LabelCellNode.swift in Sources */, D211CE7123FC35AC00D1CE38 /* TextFieldCellNode.swift in Sources */, 9FA19890253C841F008C9CF2 /* TableViewController.swift in Sources */, @@ -2825,6 +2832,7 @@ D2A9CA38242618DF00E1D898 /* LinkButtonNode.swift in Sources */, D24FAFA42520BF9100BF46C5 /* CheckBoxCircleView.swift in Sources */, D2CDC3D72404704D002B045F /* RecipientEmailsCellNode.swift in Sources */, + 5165ABCC27B526D100CCC379 /* RecipientEmailTextFieldNode.swift in Sources */, D2717752242567EB00BDA9A9 /* KeyTextCellNode.swift in Sources */, 511D07E12769FBBA0050417B /* MessagePasswordCellNode.swift in Sources */, D211CE7B23FC59ED00D1CE38 /* InfoCellNode.swift in Sources */, diff --git a/FlowCrypt/AppDelegate.swift b/FlowCrypt/AppDelegate.swift index 9aef7d467..e8792bfaf 100644 --- a/FlowCrypt/AppDelegate.swift +++ b/FlowCrypt/AppDelegate.swift @@ -12,7 +12,7 @@ import FlowCryptCommon class AppDelegate: UIResponder, UIApplicationDelegate, AppDelegateGoogleSesssionContainer { var blurViewController: BlurViewController? var googleAuthSession: OIDExternalUserAgentSession? - let window: UIWindow = UIWindow(frame: UIScreen.main.bounds) + let window = UIWindow(frame: UIScreen.main.bounds) func application(_ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if application.isRunningTests { diff --git a/FlowCrypt/Controllers/Attachment/AttachmentViewController.swift b/FlowCrypt/Controllers/Attachment/AttachmentViewController.swift index d0389abdd..bea5fd7f7 100644 --- a/FlowCrypt/Controllers/Attachment/AttachmentViewController.swift +++ b/FlowCrypt/Controllers/Attachment/AttachmentViewController.swift @@ -9,7 +9,6 @@ import UIKit import WebKit import FlowCryptUI -import Combine import FlowCryptCommon @MainActor diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 07b3500da..1f495f076 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -10,21 +10,25 @@ import Foundation import PhotosUI // swiftlint:disable file_length -private struct ComposedDraft: Equatable { - let email: String - let input: ComposeMessageInput - let contextToSend: ComposeMessageContext -} - /** * View controller to compose the message and send it * - User can be redirected here from *InboxViewController* by tapping on *+* * - Or from *ThreadDetailsViewController* controller by tapping on *reply* or *forward* **/ final class ComposeViewController: TableNodeViewController { - var calculatedRecipientsPartHeight: CGFloat? { + private var calculatedRecipientsToPartHeight: CGFloat? { + didSet { + reload(sections: [.recipients(.to), .password]) + } + } + private var calculatedRecipientsCcPartHeight: CGFloat? { + didSet { + reload(sections: [.recipients(.to), .recipients(.cc), .password]) + } + } + private var calculatedRecipientsBccPartHeight: CGFloat? { didSet { - node.reloadRows(at: [recipientsIndexPath], with: .fade) + reload(sections: [.recipients(.to), .recipients(.bcc), .password]) } } @@ -33,12 +37,22 @@ final class ComposeViewController: TableNodeViewController { static let minRecipientsPartHeight: CGFloat = 44 } - enum State { + private struct ComposedDraft: Equatable { + let email: String + let input: ComposeMessageInput + let contextToSend: ComposeMessageContext + } + + private enum State { case main, searchEmails([String]) } - private enum Section: Int, CaseIterable { - case recipient, password, compose, attachments + private enum Section: Hashable { + case recipients(RecipientType), password, compose, attachments, searchResults, contacts + + static var recipientsSections: [Section] { + RecipientType.allCases.map { Section.recipients($0) } + } } private enum RecipientPart: Int, CaseIterable { @@ -85,6 +99,11 @@ final class ComposeViewController: TableNodeViewController { navigationController?.navigationBar.frame.maxY ?? 0 } + private var selectedRecipientType: RecipientType? = .to + private var shouldShowAllRecipientTypes = false + + private var sectionsList: [Section] = [] + init( appContext: AppContextWithUser, notificationCenter: NotificationCenter = .default, @@ -173,7 +192,7 @@ final class ComposeViewController: TableNodeViewController { cancellable.forEach { $0.cancel() } setupSearch() - evaluateIfNeeded() + evaluateAllRecipients() } override func viewDidLayoutSubviews() { @@ -189,20 +208,22 @@ final class ComposeViewController: TableNodeViewController { NotificationCenter.default.removeObserver(self) } - private func evaluateIfNeeded() { - guard contextToSend.recipients.isNotEmpty else { - return - } - + private func evaluateAllRecipients() { for recipient in contextToSend.recipients { - evaluate(recipient: recipient) - } + evaluate(recipient: recipient) + } } func update(with message: Message) { self.contextToSend.subject = message.subject self.contextToSend.message = message.raw - self.contextToSend.recipients = [ComposeMessageRecipient(email: "tom@flowcrypt.com", state: decorator.recipientIdleState)] + self.contextToSend.recipients = [ + ComposeMessageRecipient( + email: "tom@flowcrypt.com", + type: .to, + state: decorator.recipientIdleState + ) + ] } private func observeComposeUpdates() { @@ -285,7 +306,6 @@ extension ComposeViewController { } // MARK: - Setup UI - extension ComposeViewController { private func setupNavigationBar() { navigationItem.rightBarButtonItem = NavigationBarItemsView( @@ -317,14 +337,16 @@ extension ComposeViewController { $0.view.contentInsetAdjustmentBehavior = .never $0.view.keyboardDismissMode = .interactive } + + updateState(with: .main) } private func setupQuote() { guard input.isQuote else { return } input.quoteRecipients.forEach { email in - let recipient = ComposeMessageRecipient(email: email, state: decorator.recipientIdleState) - contextToSend.recipients.append(recipient) + let recipient = ComposeMessageRecipient(email: email, type: .to, state: decorator.recipientIdleState) + contextToSend.add(recipient: recipient) evaluate(recipient: recipient) } } @@ -552,22 +574,28 @@ extension ComposeViewController { extension ComposeViewController: ASTableDelegate, ASTableDataSource { func numberOfSections(in _: ASTableNode) -> Int { - Section.allCases.count + sectionsList.count } func tableNode(_: ASTableNode, numberOfRowsInSection section: Int) -> Int { - switch (state, section) { - case (_, Section.recipient.rawValue): + guard let sectionItem = sectionsList[safe: section] else { return 0 } + + switch (state, sectionItem) { + case (.main, .recipients(.to)): return RecipientPart.allCases.count - case (.main, Section.password.rawValue): + case (.main, .recipients(.cc)), (.main, .recipients(.bcc)): + return shouldShowAllRecipientTypes ? RecipientPart.allCases.count : 0 + case (.main, .password): return isMessagePasswordSupported && contextToSend.hasRecipientsWithoutPubKey ? 1 : 0 - case (.main, Section.compose.rawValue): + case (.main, .compose): return ComposePart.allCases.count - case (.main, Section.attachments.rawValue): + case (.main, .attachments): return contextToSend.attachments.count - case let (.searchEmails(emails), 1): + case (.searchEmails, .recipients(let type)): + return selectedRecipientType == type ? RecipientPart.allCases.count : 0 + case let (.searchEmails(emails), .searchResults): return emails.isNotEmpty ? emails.count + 1 : 2 - case (.searchEmails, 2): + case (.searchEmails, .contacts): return cloudContactProvider.isContactsScopeEnabled ? 0 : 2 default: return 0 @@ -577,34 +605,37 @@ extension ComposeViewController: ASTableDelegate, ASTableDataSource { // swiftlint:disable cyclomatic_complexity func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { return { [weak self] in - guard let self = self else { return ASCellNode() } - - switch (self.state, indexPath.section) { - case (_, Section.recipient.rawValue): - guard let part = RecipientPart(rawValue: indexPath.row) else { return ASCellNode() } - switch part { - case .input: return self.recipientInput() - case .list: return self.recipientsNode() + guard let self = self, + let section = self.sectionsList[safe: indexPath.section] + else { return ASCellNode() } + + switch (self.state, section) { + case (_, .recipients(.to)), (_, .recipients(.cc)), (_, .recipients(.bcc)): + let recipientType = RecipientType.allCases[indexPath.section] + if indexPath.row == 0 { + return self.recipientsNode(type: recipientType) + } else { + return self.recipientInput(type: recipientType) } - case (.main, Section.password.rawValue): + case (.main, .password): return self.messagePasswordNode() - case (.main, Section.compose.rawValue): + case (.main, .compose): guard let part = ComposePart(rawValue: indexPath.row) else { return ASCellNode() } switch part { case .subject: return self.subjectNode() case .text: return self.textNode() case .topDivider, .subjectDivider: return DividerCellNode() } - case (.main, Section.attachments.rawValue): + case (.main, .attachments): guard !self.contextToSend.attachments.isEmpty else { return ASCellNode() } return self.attachmentNode(for: indexPath.row) - case let (.searchEmails(emails), 1): + case let (.searchEmails(emails), .searchResults): guard indexPath.row > 0 else { return DividerCellNode() } guard emails.isNotEmpty else { return self.noSearchResultsNode() } return InfoCellNode(input: self.decorator.styledRecipientInfo(with: emails[indexPath.row-1])) - case (.searchEmails, 2): + case (.searchEmails, .contacts): return indexPath.row == 0 ? DividerCellNode() : self.enableGoogleContactsNode() default: return ASCellNode() @@ -613,12 +644,14 @@ extension ComposeViewController: ASTableDelegate, ASTableDataSource { } func tableNode(_ tableNode: ASTableNode, didSelectRowAt indexPath: IndexPath) { - if case let .searchEmails(emails) = state { - switch indexPath.section { - case 1: + if case let .searchEmails(emails) = state, let recipientType = selectedRecipientType { + guard let section = sectionsList[safe: indexPath.section] else { return } + + switch section { + case .searchResults: let selectedEmail = emails[safe: indexPath.row-1] - handleEndEditingAction(with: selectedEmail) - case 2: + handleEndEditingAction(with: selectedEmail, for: recipientType) + case .contacts: askForContactsPermission() default: break @@ -628,19 +661,26 @@ extension ComposeViewController: ASTableDelegate, ASTableDataSource { file: contextToSend.attachments[indexPath.row], shouldShowDownloadButton: false ) - navigationController?.pushViewController(controller, animated: true ) + navigationController?.pushViewController(controller, animated: true) + } + } + + private func reload(sections: [Section]) { + let indexes = sectionsList.enumerated().compactMap { index, section in + sections.contains(section) ? index : nil } + + node.reloadSections(IndexSet(indexes), with: .automatic) } } // MARK: - Nodes - extension ComposeViewController { private func subjectNode() -> ASCellNode { TextFieldCellNode( input: decorator.styledTextFieldInput( with: "compose_subject".localized, - accessibilityIdentifier: "subjectTextField" + accessibilityIdentifier: "aid-subject-text-field" ) ) { [weak self] event in switch event { @@ -681,7 +721,7 @@ extension ComposeViewController { return TextViewCellNode( decorator.styledTextViewInput( with: height, - accessibilityIdentifier: "messageTextView" + accessibilityIdentifier: "aid-message-text-view" ) ) { [weak self] event in guard let self = self else { return } @@ -724,46 +764,94 @@ extension ComposeViewController { } } - private func recipientsNode() -> RecipientEmailsCellNode { + private func recipientsNode(type: RecipientType) -> ASCellNode { + let recipients = contextToSend.recipients(type: type) + + let shouldShowToggleButton = type == .to + && recipients.isNotEmpty + && !contextToSend.hasCcOrBccRecipients + return RecipientEmailsCellNode( recipients: recipients.map(RecipientEmailsCellNode.Input.init), - height: calculatedRecipientsPartHeight ?? Constants.minRecipientsPartHeight - ) + type: type.rawValue, + height: recipientsNodeHeight(type: type) ?? Constants.minRecipientsPartHeight, + isToggleButtonRotated: shouldShowAllRecipientTypes, + toggleButtonAction: shouldShowToggleButton ? { [weak self] in + guard type == .to else { return } + self?.toggleRecipientsList() + } : nil) .onLayoutHeightChanged { [weak self] layoutHeight in - guard self?.calculatedRecipientsPartHeight != layoutHeight, layoutHeight > 0 else { - return - } - self?.calculatedRecipientsPartHeight = layoutHeight + self?.updateRecipientsNode( + layoutHeight: layoutHeight, + type: type + ) } .onItemSelect { [weak self] (action: RecipientEmailsCellNode.RecipientEmailTapAction) in switch action { case let .imageTap(indexPath): - self?.handleRecipientAction(with: indexPath) + self?.handleRecipientAction(with: indexPath, type: type) case let .select(indexPath): - self?.handleRecipientSelection(with: indexPath) + self?.handleRecipientSelection(with: indexPath, type: type) } } } - private func recipientInput() -> TextFieldCellNode { - TextFieldCellNode( + private func recipientsNodeHeight(type: RecipientType) -> CGFloat? { + switch type { + case .to: + return calculatedRecipientsToPartHeight + case .cc: + return calculatedRecipientsCcPartHeight + case .bcc: + return calculatedRecipientsBccPartHeight + } + } + + private func updateRecipientsNode(layoutHeight: CGFloat, type: RecipientType) { + let currentHeight = self.recipientsNodeHeight(type: type) + + guard currentHeight != layoutHeight, layoutHeight > 0 else { + return + } + + switch type { + case .to: + self.calculatedRecipientsToPartHeight = layoutHeight + case .cc: + self.calculatedRecipientsCcPartHeight = layoutHeight + case .bcc: + self.calculatedRecipientsBccPartHeight = layoutHeight + } + } + + private func recipientInput(type: RecipientType) -> ASCellNode { + let shouldShowToggleButton = type == .to + && contextToSend.recipients(type: .to).isEmpty + && !contextToSend.hasCcOrBccRecipients + + return RecipientEmailTextFieldNode( input: decorator.styledTextFieldInput( - with: "compose_recipient".localized, + with: type.inputPlaceholder, keyboardType: .emailAddress, - accessibilityIdentifier: "aid-recipient-text-field") - ) { [weak self] action in - self?.handleTextFieldAction(with: action) - } - .onShouldReturn { textField -> Bool in - textField.resignFirstResponder() + accessibilityIdentifier: "aid-recipients-text-field-\(type.rawValue)" + ), + action: { [weak self] action in + self?.handle(textFieldAction: action, for: type) + }, + isToggleButtonRotated: shouldShowAllRecipientTypes, + toggleButtonAction: shouldShowToggleButton ? { [weak self] in + self?.toggleRecipientsList() + } : nil + ) + .onShouldReturn { + $0.resignFirstResponder() return true } .onShouldChangeCharacters { [weak self] textField, character -> (Bool) in - self?.shouldChange(with: textField, and: character) ?? true + self?.shouldChange(with: textField, and: character, for: type) ?? true } .then { - $0.isLowercased = true - if self.input.isForward || self.input.isIdle { + if type == selectedRecipientType { $0.becomeFirstResponder() } } @@ -777,7 +865,7 @@ extension ComposeViewController { ), onDeleteTap: { [weak self] in self?.contextToSend.attachments.safeRemove(at: index) - self?.node.reloadSections([Section.attachments.rawValue], with: .automatic) + self?.reload(sections: [.attachments]) } ) } @@ -805,23 +893,7 @@ extension ComposeViewController { // MARK: - Recipients Input extension ComposeViewController { - private var textField: TextFieldNode? { - let indexPath = IndexPath( - row: RecipientPart.input.rawValue, - section: Section.recipient.rawValue - ) - return (node.nodeForRow(at: indexPath) as? TextFieldCellNode)?.textField - } - - private var recipientsIndexPath: IndexPath { - IndexPath(row: RecipientPart.list.rawValue, section: Section.recipient.rawValue) - } - - private var recipients: [ComposeMessageRecipient] { - contextToSend.recipients - } - - private func shouldChange(with textField: UITextField, and character: String) -> Bool { + private func shouldChange(with textField: UITextField, and character: String, for recipientType: RecipientType) -> Bool { func nextResponder() { guard let node = node.visibleNodes[safe: ComposePart.subject.rawValue] as? TextFieldCellNode else { return } node.becomeFirstResponder() @@ -835,11 +907,11 @@ extension ComposeViewController { let recipients = character.components(separatedBy: characterSet) guard recipients.count > 1 else { return true } recipients.forEach { - handleEndEditingAction(with: $0) + handleEndEditingAction(with: $0, for: recipientType) } return false } else if Constants.endTypingCharacters.contains(character) { - handleEndEditingAction(with: textField.text) + handleEndEditingAction(with: textField.text, for: recipientType) nextResponder() return false } else { @@ -847,22 +919,27 @@ extension ComposeViewController { } } - private func handleTextFieldAction(with action: TextFieldActionType) { - switch action { - case let .deleteBackward(textField): handleBackspaceAction(with: textField) - case let .didEndEditing(text): handleEndEditingAction(with: text) + private func handle(textFieldAction: TextFieldActionType, for recipientType: RecipientType) { + switch textFieldAction { + case let .deleteBackward(textField): handleBackspaceAction(with: textField, for: recipientType) + case let .didEndEditing(text): handleEndEditingAction(with: text, for: recipientType) case let .editingChanged(text): handleEditingChanged(with: text) - case .didBeginEditing: handleDidBeginEditing() + case .didBeginEditing: handleDidBeginEditing(recipientType: recipientType) } } - private func handleEndEditingAction(with text: String?) { + private func handleEndEditingAction(with text: String?, for recipientType: RecipientType) { guard shouldEvaluateRecipientInput, let text = text, text.isNotEmpty else { return } - // Set all recipients to idle state - contextToSend.recipients = recipients.map { recipient in + let recipients = contextToSend.recipients(type: recipientType) + + let textField = recipientsTextField(type: recipientType) + textField?.reset() + + // Set all selected recipients to idle state + let idleRecipients: [ComposeMessageRecipient] = recipients.map { recipient in var recipient = recipient if recipient.state.isSelected { recipient.state = self.decorator.recipientIdleState @@ -870,61 +947,86 @@ extension ComposeViewController { return recipient } - let newRecipient = ComposeMessageRecipient(email: text, state: decorator.recipientIdleState) + contextToSend.set(recipients: idleRecipients, for: recipientType) + + let newRecipient = ComposeMessageRecipient(email: text, type: recipientType, state: decorator.recipientIdleState) let indexOfRecipient: Int - if let index = contextToSend.recipients.firstIndex(where: { $0.email == newRecipient.email }) { + let indexPath = recipientsIndexPath(type: recipientType, part: .list) + + if let index = idleRecipients.firstIndex(where: { $0.email == newRecipient.email }) { // recipient already in list evaluate(recipient: newRecipient) indexOfRecipient = index } else { // add new recipient - contextToSend.recipients.append(newRecipient) - node.reloadRows(at: [recipientsIndexPath], with: .fade) + contextToSend.add(recipient: newRecipient) + + if let indexPath = indexPath { + node.reloadRows(at: [indexPath], with: .automatic) + } + evaluate(recipient: newRecipient) // scroll to the latest recipient indexOfRecipient = recipients.endIndex - 1 } - let collectionNode = (node.nodeForRow(at: recipientsIndexPath) as? RecipientEmailsCellNode)?.collectionNode - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - collectionNode?.scrollToItem( - at: IndexPath(row: indexOfRecipient, section: 0), - at: .bottom, - animated: true - ) + if let indexPath = indexPath, + let emailsNode = node.nodeForRow(at: indexPath) as? RecipientEmailsCellNode { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + emailsNode.collectionNode.scrollToItem( + at: IndexPath(row: indexOfRecipient, section: 0), + at: .bottom, + animated: true + ) + } } - // reset textfield - textField?.reset() node.view.keyboardDismissMode = .interactive search.send("") updateState(with: .main) } - private func handleBackspaceAction(with textField: UITextField) { + private func recipientsIndexPath(type: RecipientType, part: RecipientPart) -> IndexPath? { + guard let section = sectionsList.firstIndex(of: .recipients(type)) else { return nil } + return IndexPath(row: part.rawValue, section: section) + } + + private func recipientsTextField(type: RecipientType) -> TextFieldNode? { + guard let indexPath = recipientsIndexPath(type: type, part: .input) else { return nil } + return (node.nodeForRow(at: indexPath) as? RecipientEmailTextFieldNode)?.textField + } + + private func handleBackspaceAction(with textField: UITextField, for recipientType: RecipientType) { guard textField.text == "" else { return } - let selectedRecipients = recipients - .filter { $0.state.isSelected } + var recipients = contextToSend.recipients(type: recipientType) + + let selectedRecipients = recipients.filter { $0.state.isSelected } guard selectedRecipients.isEmpty else { - // remove selected recipients - contextToSend.recipients = recipients.filter { !$0.state.isSelected } - node.reloadSections([Section.recipient.rawValue, Section.password.rawValue], - with: .automatic) + let notSelectedRecipients = recipients.filter { !$0.state.isSelected } + contextToSend.set(recipients: notSelectedRecipients, for: recipientType) + reload(sections: [.recipients(.to), .password]) + + if let indexPath = recipientsIndexPath(type: recipientType, part: .list) { + node.reloadRows(at: [indexPath], with: .automatic) + } + return } - if let lastRecipient = contextToSend.recipients.popLast() { + if var lastRecipient = recipients.popLast() { // select last recipient in a list - var last = lastRecipient - last.state = self.decorator.recipientSelectedState - contextToSend.recipients.append(last) - node.reloadRows(at: [recipientsIndexPath], with: .fade) - node.reloadSections([Section.password.rawValue], with: .automatic) + lastRecipient.state = self.decorator.recipientSelectedState + recipients.append(lastRecipient) + contextToSend.set(recipients: recipients, for: recipientType) + + if let indexPath = recipientsIndexPath(type: recipientType, part: .list) { + node.reloadRows(at: [indexPath], with: .automatic) + } } else { // dismiss keyboard if no recipients left textField.resignFirstResponder() @@ -935,9 +1037,15 @@ extension ComposeViewController { search.send(text ?? "") } - private func handleDidBeginEditing() { + private func handleDidBeginEditing(recipientType: RecipientType) { + selectedRecipientType = recipientType node.view.keyboardDismissMode = .none } + + private func toggleRecipientsList() { + shouldShowAllRecipientTypes.toggle() + reload(sections: [.recipients(.cc), .recipients(.bcc)]) + } } // MARK: - Action Handling @@ -957,7 +1065,10 @@ extension ComposeViewController { private func evaluate(recipient: ComposeMessageRecipient) { guard recipient.email.isValidEmail else { - handleEvaluation(for: recipient, with: decorator.recipientInvalidEmailState) + updateRecipient( + email: recipient.email, + state: decorator.recipientInvalidEmailState + ) return } @@ -970,7 +1081,7 @@ extension ComposeViewController { let contactWithFetchedKeys = try await service.fetchContact(with: recipient.email) handleEvaluation(for: contactWithFetchedKeys) } catch { - handleEvaluation(error: error, with: recipient) + handleEvaluation(error: error, with: recipient.email) } } } @@ -978,13 +1089,11 @@ extension ComposeViewController { private func handleEvaluation(for recipient: RecipientWithSortedPubKeys) { let state = getRecipientState(from: recipient) - let composeRecipient = ComposeMessageRecipient( + updateRecipient( email: recipient.email, state: state, keyState: recipient.keyState ) - - handleEvaluation(for: composeRecipient) } private func getRecipientState(from recipient: RecipientWithSortedPubKeys) -> RecipientState { @@ -1000,15 +1109,7 @@ extension ComposeViewController { } } - private func handleEvaluation(for composeRecipient: ComposeMessageRecipient, with state: RecipientState? = nil) { - updateRecipientWithNew( - state: state ?? composeRecipient.state, - keyState: composeRecipient.keyState, - for: .left(composeRecipient) - ) - } - - private func handleEvaluation(error: Error, with recipient: ComposeMessageRecipient) { + private func handleEvaluation(error: Error, with email: String) { let recipientState: RecipientState = { switch error { case ContactsError.keyMissing: @@ -1018,76 +1119,81 @@ extension ComposeViewController { } }() - updateRecipientWithNew( + updateRecipient( + email: email, state: recipientState, - keyState: nil, - for: .left(recipient) + keyState: nil ) } - private func updateRecipientWithNew( + private func updateRecipient( + email: String, state: RecipientState, - keyState: PubKeyState?, - for context: Either + keyState: PubKeyState? = nil ) { - let index: Int? = { - switch context { - case let .left(recipient): - return recipients.firstIndex(of: recipient) - case let .right(index): - return index.row - } - }() + contextToSend.recipients.indices.forEach { index in + guard contextToSend.recipients[index].email == email else { return } - guard let recipientIndex = index else { return } + let recipient = contextToSend.recipients[index] + let needsReload = recipient.state != state || recipient.keyState != keyState - let recipient = contextToSend.recipients[recipientIndex] - let needsReload = recipient.state != state || recipient.keyState != keyState + contextToSend.recipients[index].state = state + contextToSend.recipients[index].keyState = keyState - guard needsReload else { return } + if needsReload, selectedRecipientType == nil || selectedRecipientType == recipient.type { + reload(sections: [.password]) - contextToSend.recipients[recipientIndex].state = state - contextToSend.recipients[recipientIndex].keyState = keyState - - node.reloadSections([Section.password.rawValue], with: .automatic) - node.reloadRows(at: [recipientsIndexPath], with: .automatic) + if let listIndexPath = recipientsIndexPath(type: recipient.type, part: .list) { + node.reloadRows(at: [listIndexPath], with: .automatic) + } + } + } } - private func handleRecipientSelection(with indexPath: IndexPath) { - var recipient = contextToSend.recipients[indexPath.row] + private func handleRecipientSelection(with indexPath: IndexPath, type: RecipientType) { + guard let recipient = contextToSend.recipient(at: indexPath.row, type: type) else { return } + + let isSelected = recipient.state.isSelected + let state = isSelected ? decorator.recipientIdleState : decorator.recipientSelectedState + contextToSend.update(recipient: recipient.email, type: type, state: state) - if recipient.state.isSelected { - recipient.state = decorator.recipientIdleState - contextToSend.recipients[indexPath.row].state = decorator.recipientIdleState + if isSelected { evaluate(recipient: recipient) - } else { - contextToSend.recipients[indexPath.row].state = decorator.recipientSelectedState } - node.reloadRows(at: [recipientsIndexPath], with: .automatic) + if let listIndexPath = recipientsIndexPath(type: type, part: .list) { + node.reloadRows(at: [listIndexPath], with: .automatic) + } + let textField = recipientsTextField(type: type) if !(textField?.isFirstResponder() ?? true) { textField?.becomeFirstResponder() } textField?.reset() } - private func handleRecipientAction(with indexPath: IndexPath) { - let recipient = contextToSend.recipients[indexPath.row] + private func handleRecipientAction(with indexPath: IndexPath, type: RecipientType) { + guard let recipient = contextToSend.recipient(at: indexPath.row, type: type) else { return } + switch recipient.state { case .idle: - handleRecipientSelection(with: indexPath) + handleRecipientSelection(with: indexPath, type: type) case .keyFound, .keyExpired, .keyRevoked, .keyNotFound, .invalidEmail, .selected: break case let .error(_, isRetryError): if isRetryError { - updateRecipientWithNew(state: decorator.recipientIdleState, - keyState: nil, - for: .right(indexPath)) + updateRecipient( + email: recipient.email, + state: decorator.recipientIdleState, + keyState: nil + ) evaluate(recipient: recipient) } else { - contextToSend.recipients.remove(at: indexPath.row) - node.reloadRows(at: [recipientsIndexPath], with: .fade) + contextToSend.remove(recipient: recipient.email, type: type) + + if let listIndexPath = recipientsIndexPath(type: type, part: .list) { + node.reloadRows(at: [listIndexPath], with: .automatic) + } } } } @@ -1096,7 +1202,7 @@ extension ComposeViewController { private func setMessagePassword() { Task { contextToSend.messagePassword = await enterMessagePassword() - node.reloadSections([Section.password.rawValue], with: .automatic) + reload(sections: [.password]) } } @@ -1150,10 +1256,29 @@ extension ComposeViewController { switch state { case .main: + sectionsList = Section.recipientsSections + [.password, .compose, .attachments] node.reloadData() case .searchEmails: - let sections: [Section] = [.password, .compose, .attachments] - node.reloadSections(IndexSet(sections.map(\.rawValue)), with: .automatic) + let previousSectionsCount = sectionsList.count + sectionsList = Section.recipientsSections + [.searchResults, .contacts] + + let deletedSectionsCount = previousSectionsCount - sectionsList.count + + let sectionsToReload: [Section] + if let type = selectedRecipientType { + sectionsToReload = sectionsList.filter { $0 != .recipients(type) } + } else { + sectionsToReload = sectionsList + } + + node.performBatchUpdates { + if deletedSectionsCount > 0 { + let sectionsToDelete = sectionsList.count.. [ComposeMessageRecipient] { + recipients.filter { $0.type == type } + } + + func recipientEmails(type: RecipientType) -> [String] { + recipients(type: type).map(\.email) + } + + func recipient(at index: Int, type: RecipientType) -> ComposeMessageRecipient? { + recipients(type: type)[safe: index] + } + + mutating func add(recipient: ComposeMessageRecipient) { + recipients.append(recipient) + } + + mutating func set(recipients: [ComposeMessageRecipient], for recipientType: RecipientType) { + self.recipients.removeAll(where: { $0.type == recipientType }) + self.recipients += recipients + } + + mutating func update(recipient: String, type: RecipientType, state: RecipientState) { + guard let index = recipients.firstIndex(where: { + $0.email == recipient && $0.type == type + }) else { return } + + recipients[index].state = state + } + + mutating func updateRecipient(email: String, state: RecipientState, keyState: PubKeyState?) { + recipients.indices.forEach { + guard recipients[$0].email == email else { return } + recipients[$0].state = state + recipients[$0].keyState = keyState + } + } + + mutating func remove(recipient: String, type: RecipientType) { + recipients = recipients.filter { $0.email != recipient && $0.type != type } + } + + mutating func update(recipient: String, state: RecipientState, keyState: PubKeyState?) { + recipients.indices.forEach { + guard recipients[$0].email == recipient else { return } + + recipients[$0].state = state + recipients[$0].keyState = keyState + } } } diff --git a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageRecipient.swift b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageRecipient.swift index 18094f5f9..4134f6bd0 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageRecipient.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageRecipient.swift @@ -10,12 +10,23 @@ import Foundation struct ComposeMessageRecipient { let email: String + let type: RecipientType var state: RecipientState var keyState: PubKeyState? } extension ComposeMessageRecipient: Equatable { static func == (lhs: ComposeMessageRecipient, rhs: ComposeMessageRecipient) -> Bool { - return lhs.email == rhs.email + return lhs.email == rhs.email && lhs.type == rhs.type + } +} + +enum RecipientType: String, CaseIterable, Hashable { + case to, cc, bcc +} + +extension RecipientType { + var inputPlaceholder: String { + "compose_recipient_\(rawValue)".localized } } diff --git a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift index b52a3d929..d07ce7934 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift @@ -134,9 +134,9 @@ final class ComposeMessageService { return SendableMsg( text: text, html: nil, - to: recipients.map(\.email), - cc: [], - bcc: [], + to: contextToSend.recipientEmails(type: .to), + cc: contextToSend.recipientEmails(type: .cc), + bcc: contextToSend.recipientEmails(type: .bcc), from: sender, subject: subject, replyToMimeMsg: replyToMimeMsg, diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index 20cd613d8..d68853256 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -115,7 +115,9 @@ "compose_message_sent" = "Message sent"; "compose_enter_subject" = "Enter subject"; "compose_enter_secure" = "Enter secure message"; -"compose_recipient" = "Add Recipient"; +"compose_recipient_to" = "Add Recipient"; +"compose_recipient_cc" = "Add CC Recipient"; +"compose_recipient_bcc" = "Add BCC Recipient"; "compose_subject" = "Subject"; "compose_enable_google_contacts_search" = "Enable Google Contact Search"; "compose_no_contacts_found" = "No contacts found"; diff --git a/FlowCryptAppTests/Functionality/Services/ComposeMessageServiceTests.swift b/FlowCryptAppTests/Functionality/Services/ComposeMessageServiceTests.swift index da644e63b..c38753bc7 100644 --- a/FlowCryptAppTests/Functionality/Services/ComposeMessageServiceTests.swift +++ b/FlowCryptAppTests/Functionality/Services/ComposeMessageServiceTests.swift @@ -16,9 +16,9 @@ class ComposeMessageServiceTests: XCTestCase { var sut: ComposeMessageService! let recipients: [ComposeMessageRecipient] = [ - ComposeMessageRecipient(email: "test@gmail.com", state: recipientIdleState), - ComposeMessageRecipient(email: "test2@gmail.com", state: recipientIdleState), - ComposeMessageRecipient(email: "test3@gmail.com", state: recipientIdleState) + ComposeMessageRecipient(email: "test@gmail.com", type: .to, state: recipientIdleState), + ComposeMessageRecipient(email: "test2@gmail.com", type: .to, state: recipientIdleState), + ComposeMessageRecipient(email: "test3@gmail.com", type: .to, state: recipientIdleState) ] let validKeyDetails = EncryptedStorageMock.createFakeKeyDetails(expiration: nil) let keypair = Keypair( @@ -77,9 +77,9 @@ class ComposeMessageServiceTests: XCTestCase { func testValidateMessageInputWithWhitespaceRecipients() async { let recipients: [ComposeMessageRecipient] = [ - ComposeMessageRecipient(email: " ", state: recipientIdleState), - ComposeMessageRecipient(email: " ", state: recipientIdleState), - ComposeMessageRecipient(email: "sdfff", state: recipientIdleState) + ComposeMessageRecipient(email: " ", type: .to, state: recipientIdleState), + ComposeMessageRecipient(email: " ", type: .to, state: recipientIdleState), + ComposeMessageRecipient(email: "sdfff", type: .to, state: recipientIdleState) ] do { _ = try await sut.validateAndProduceSendableMsg( diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift index 13f4b35f8..04d684a01 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailNode.swift @@ -36,12 +36,12 @@ final class RecipientEmailNode: CellNode { super.init() if let stateAccessibilityIdentifier = input.recipient.state.accessibilityIdentifier { - accessibilityIdentifier = "aid-to-\(index)-\(stateAccessibilityIdentifier)" + accessibilityIdentifier = "aid-\(input.recipient.type)-\(index)-\(stateAccessibilityIdentifier)" } titleNode.attributedText = " ".attributed() + input.recipient.email + " ".attributed() titleNode.backgroundColor = input.recipient.state.backgroundColor - titleNode.accessibilityIdentifier = "aid-to-\(index)-label" + titleNode.accessibilityIdentifier = "aid-\(input.recipient.type)-\(index)-label" titleNode.cornerRadius = 8 titleNode.clipsToBounds = true diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailTextFieldNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailTextFieldNode.swift new file mode 100644 index 000000000..9dbd76eb0 --- /dev/null +++ b/FlowCryptUI/Cell Nodes/RecipientEmailTextFieldNode.swift @@ -0,0 +1,60 @@ +// +// RecipientEmailTextFieldNode.swift +// FlowCryptUI +// +// Created by Roma Sosnovsky on 10/02/22 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit + +public final class RecipientEmailTextFieldNode: TextFieldCellNode, RecipientToggleButtonNode { + var toggleButtonAction: (() -> Void)? + + lazy var toggleButtonNode: ASButtonNode = { + createToggleButton() + }() + + var isToggleButtonRotated = false { + didSet { + updateToggleButton(animated: true) + } + } + + public init( + input: TextFieldCellNode.Input, + action: TextFieldAction? = nil, + isToggleButtonRotated: Bool, + toggleButtonAction: (() -> Void)? + ) { + super.init(input: input, action: action) + + self.isLowercased = true + self.isToggleButtonRotated = isToggleButtonRotated + self.toggleButtonAction = toggleButtonAction + } + + public override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { + let textFieldWidth = input.width ?? (constrainedSize.max.width - input.insets.width) + let textFieldSize = CGSize(width: textFieldWidth, height: input.height) + let buttonSize = CGSize(width: input.height, height: input.height) + + return createLayout( + contentNode: textField, + contentSize: textFieldSize, + insets: input.insets, + buttonSize: buttonSize + ) + } + + func onToggleButtonTap() { + isToggleButtonRotated.toggle() + toggleButtonAction?() + } + + @discardableResult + public override func becomeFirstResponder() -> Bool { + textField.becomeFirstResponder() + return true + } +} diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift index ad52f08bb..d42812358 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift @@ -9,7 +9,7 @@ import AsyncDisplayKit import FlowCryptCommon -final public class RecipientEmailsCellNode: CellNode { +final public class RecipientEmailsCellNode: CellNode, RecipientToggleButtonNode { public typealias RecipientTap = (RecipientEmailTapAction) -> Void public enum RecipientEmailTapAction { @@ -24,6 +24,16 @@ final public class RecipientEmailsCellNode: CellNode { private var onAction: RecipientTap? + lazy var toggleButtonNode: ASButtonNode = { + createToggleButton() + }() + var toggleButtonAction: (() -> Void)? + var isToggleButtonRotated = false { + didSet { + updateToggleButton(animated: true) + } + } + private lazy var layout: LeftAlignedCollectionViewFlowLayout = { let layout = LeftAlignedCollectionViewFlowLayout() layout.scrollDirection = .vertical @@ -35,18 +45,25 @@ final public class RecipientEmailsCellNode: CellNode { public lazy var collectionNode: ASCollectionNode = { let node = ASCollectionNode(collectionViewLayout: layout) - node.accessibilityIdentifier = "aid-recipients-list" + node.accessibilityIdentifier = "aid-recipients-list-\(type)" node.backgroundColor = .clear return node }() - private var collectionLayoutHeight: CGFloat private var recipients: [Input] = [] + private let type: String - public init(recipients: [Input], height: CGFloat) { + public init(recipients: [Input], + type: String, + height: CGFloat, + isToggleButtonRotated: Bool, + toggleButtonAction: (() -> Void)?) { self.recipients = recipients + self.type = type self.collectionLayoutHeight = height + super.init() + collectionNode.dataSource = self collectionNode.delegate = self @@ -55,21 +72,31 @@ final public class RecipientEmailsCellNode: CellNode { } automaticallyManagesSubnodes = true + + self.isToggleButtonRotated = isToggleButtonRotated + self.toggleButtonAction = toggleButtonAction } public override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { - guard recipients.isNotEmpty else { - return ASInsetLayoutSpec(insets: .zero, child: collectionNode) - } - - collectionNode.style.preferredSize.height = collectionLayoutHeight - collectionNode.style.preferredSize.width = constrainedSize.max.width - - return ASInsetLayoutSpec( - insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8), - child: collectionNode + let collectionNodeHeight = recipients.isEmpty ? 0 : collectionLayoutHeight + let collectionNodeSize = CGSize(width: constrainedSize.max.width, height: collectionNodeHeight) + let buttonSize = CGSize(width: 40, height: 50) + + var insets = UIEdgeInsets.deviceSpecificTextInsets(top: 0, bottom: 0) + insets.left -= 8 + + return createLayout( + contentNode: collectionNode, + contentSize: collectionNodeSize, + insets: insets, + buttonSize: buttonSize ) } + + func onToggleButtonTap() { + isToggleButtonRotated.toggle() + toggleButtonAction?() + } } extension RecipientEmailsCellNode { diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNodeInput.swift b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNodeInput.swift index 660e9b084..1a594dfb9 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNodeInput.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNodeInput.swift @@ -97,13 +97,16 @@ extension RecipientEmailsCellNode { } public let email: NSAttributedString + public let type: String public var state: State public init( email: NSAttributedString, + type: String, state: State ) { self.email = email + self.type = type self.state = state } } diff --git a/FlowCryptUI/Cell Nodes/RecipientToggleButtonNode.swift b/FlowCryptUI/Cell Nodes/RecipientToggleButtonNode.swift new file mode 100644 index 000000000..13d655a6a --- /dev/null +++ b/FlowCryptUI/Cell Nodes/RecipientToggleButtonNode.swift @@ -0,0 +1,68 @@ +// +// RecipientToggleButtonNode.swift +// FlowCryptUI +// +// Created by Roma Sosnovsky on 16/02/22 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit + +@objc protocol RecipientToggleButtonNode: AnyObject { + var isToggleButtonRotated: Bool { get } + var toggleButtonNode: ASButtonNode { get } + var toggleButtonAction: (() -> Void)? { get } + func onToggleButtonTap() +} + +extension RecipientToggleButtonNode { + func createToggleButton() -> ASButtonNode { + let configuration = UIImage.SymbolConfiguration(pointSize: 14, weight: .light) + let image = UIImage(systemName: "chevron.down", withConfiguration: configuration) + let button = ASButtonNode() + button.accessibilityIdentifier = "aid-recipients-toggle-button" + button.setImage(image, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0) + button.imageNode.imageModificationBlock = ASImageNodeTintColorModificationBlock(.secondaryLabel) + button.addTarget(self, action: #selector(RecipientToggleButtonNode.onToggleButtonTap), forControlEvents: .touchUpInside) + return button + } + + func updateToggleButton(animated: Bool) { + func rotateButton(angle: CGFloat) { + toggleButtonNode.view.transform = CGAffineTransform(rotationAngle: angle) + } + + let angle = self.isToggleButtonRotated ? .pi : 0 + if animated { + UIView.animate(withDuration: 0.3) { + rotateButton(angle: angle) + } + } else { + rotateButton(angle: angle) + } + } + + func createLayout(contentNode: ASDisplayNode, contentSize: CGSize, insets: UIEdgeInsets, buttonSize: CGSize) -> ASInsetLayoutSpec { + if toggleButtonAction != nil { + toggleButtonNode.style.preferredSize = buttonSize + + DispatchQueue.main.async { + self.updateToggleButton(animated: false) + } + + let contentWidth = contentSize.width - buttonSize.width - insets.width / 2 - 4 + contentNode.style.preferredSize = CGSize( + width: max(0, contentWidth), + height: contentSize.height + ) + + let stack = ASStackLayoutSpec.horizontal() + stack.children = [contentNode, toggleButtonNode] + return ASInsetLayoutSpec(insets: insets, child: stack) + } else { + contentNode.style.preferredSize = contentSize + return ASInsetLayoutSpec(insets: insets, child: contentNode) + } + } +} diff --git a/FlowCryptUI/Cell Nodes/TextFieldCellNode.swift b/FlowCryptUI/Cell Nodes/TextFieldCellNode.swift index f75ff6826..5c194ac4e 100644 --- a/FlowCryptUI/Cell Nodes/TextFieldCellNode.swift +++ b/FlowCryptUI/Cell Nodes/TextFieldCellNode.swift @@ -8,7 +8,7 @@ import AsyncDisplayKit -public final class TextFieldCellNode: CellNode { +public class TextFieldCellNode: CellNode { public struct Input { public var placeholder: NSAttributedString? public var isSecureTextEntry = false @@ -51,7 +51,7 @@ public final class TextFieldCellNode: CellNode { private var textFieldAction: TextFieldAction? - private let input: Input + let input: Input public let textField: TextFieldNode diff --git a/Gemfile.lock b/Gemfile.lock index c00c3acbf..7cf828032 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,7 +17,7 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.554.0) + aws-partitions (1.555.0) aws-sdk-core (3.126.2) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) @@ -87,7 +87,7 @@ GEM ethon (0.15.0) ffi (>= 1.15.0) excon (0.91.0) - faraday (1.9.3) + faraday (1.10.0) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) diff --git a/Podfile.lock b/Podfile.lock index c2c55c34e..5cde9aa36 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -15,7 +15,7 @@ PODS: - PINRemoteImage/PINCache (3.0.3): - PINCache (~> 3.0.3) - PINRemoteImage/Core - - SwiftFormat/CLI (0.49.3) + - SwiftFormat/CLI (0.49.4) - SwiftLint (0.46.2) - SwiftyRSA (1.7.0): - SwiftyRSA/ObjC (= 1.7.0) @@ -64,7 +64,7 @@ SPEC CHECKSUMS: PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 PINRemoteImage: f1295b29f8c5e640e25335a1b2bd9d805171bd01 - SwiftFormat: a3b79e8b5f8ecdec7a716b998aee230d08512894 + SwiftFormat: 8acc16efcecb563206cbe90b4cb047cfdf9aafdb SwiftLint: 6bc52a21f0fd44cab9aa2dc8e534fb9f5e3ec507 SwiftyRSA: 8c6dd1ea7db1b8dc4fb517a202f88bb1354bc2c6 Texture: 2e8ab2519452515f7f5a520f5a8f7e0a413abfa3 diff --git a/appium/tests/screenobjects/email-provider.screen.ts b/appium/tests/screenobjects/email-provider.screen.ts index 0a1a49ca2..ad9c1f4d3 100644 --- a/appium/tests/screenobjects/email-provider.screen.ts +++ b/appium/tests/screenobjects/email-provider.screen.ts @@ -6,8 +6,8 @@ const SELECTORS = { BACK_BTN: '~aid-back-button', EMAIL_PROVIDER_HEADER: '~navigationItemEmail Provider', CONNECT_BUTTON: '~Connect', - EMAIL_FIELD: '-ios class chain:**/XCUIElementTypeTextField[`name == "Email"`]', - PASSWORD_FIELD: '-ios class chain:**/XCUIElementTypeSecureTextField[`name == "Password"`]', + EMAIL_FIELD: '~aid-email-textfield', + PASSWORD_FIELD: '~aid-password-textfield', RETURN_BUTTON: '~Return', }; diff --git a/appium/tests/screenobjects/new-message.screen.ts b/appium/tests/screenobjects/new-message.screen.ts index 63453627d..de20fa897 100644 --- a/appium/tests/screenobjects/new-message.screen.ts +++ b/appium/tests/screenobjects/new-message.screen.ts @@ -2,9 +2,9 @@ import BaseScreen from './base.screen'; import ElementHelper from "../helpers/ElementHelper"; const SELECTORS = { - ADD_RECIPIENT_FIELD: '~aid-recipient-text-field', - SUBJECT_FIELD: '~subjectTextField', - COMPOSE_SECURITY_MESSAGE: '~messageTextView', + TOGGLE_RECIPIENTS_BUTTON: '~aid-recipients-toggle-button', + SUBJECT_FIELD: '~aid-subject-text-field', + COMPOSE_SECURITY_MESSAGE: '~aid-message-text-view', RECIPIENTS_LIST: '~aid-recipients-list', PASSWORD_CELL: '~aid-message-password-cell', ATTACHMENT_CELL: '~aid-attachment-cell-0', @@ -22,11 +22,11 @@ const SELECTORS = { class NewMessageScreen extends BaseScreen { constructor() { - super(SELECTORS.ADD_RECIPIENT_FIELD); + super(SELECTORS.RECIPIENTS_LIST); } - get addRecipientField() { - return $(SELECTORS.ADD_RECIPIENT_FIELD); + get toggleRecipientsButton() { + return $(SELECTORS.TOGGLE_RECIPIENTS_BUTTON); } get subjectField() { @@ -37,10 +37,6 @@ class NewMessageScreen extends BaseScreen { return $(SELECTORS.COMPOSE_SECURITY_MESSAGE); } - get recipientsList() { - return $(SELECTORS.RECIPIENTS_LIST); - } - get attachmentCell() { return $(SELECTORS.ATTACHMENT_CELL); } @@ -85,10 +81,20 @@ class NewMessageScreen extends BaseScreen { return $(SELECTORS.CANCEL_BUTTON); } - setAddRecipient = async (recipient: string) => { - await (await this.addRecipientField).setValue(recipient); - await browser.pause(500); - await (await $(SELECTORS.RETURN_BUTTON)).click() + getRecipientsList = async (type: string) => { + return $(`~aid-recipients-list-${type}`); + } + + getRecipientsTextField = async (type: string) => { + return $(`~aid-recipients-text-field-${type}`); + } + + setAddRecipient = async (recipient?: string, type = 'to') => { + if (recipient) { + await (await this.getRecipientsTextField(type)).setValue(recipient); + await browser.pause(500); + await (await $(SELECTORS.RETURN_BUTTON)).click(); + } }; setSubject = async (subject: string) => { @@ -106,60 +112,73 @@ class NewMessageScreen extends BaseScreen { return await $(`-ios class chain:${selector}`); }; - composeEmail = async (recipient: string, subject: string, message: string) => { + composeEmail = async (recipient: string, subject: string, message: string, cc?: string, bcc?: string) => { await this.setAddRecipient(recipient); + if (cc || bcc) { + await this.clickToggleRecipientsButton(); + await this.setAddRecipient(cc, 'cc'); + await this.setAddRecipient(bcc, 'bcc'); + } await this.setComposeSecurityMessage(message); await this.setSubject(subject); }; - setAddRecipientByName = async (name: string, email: string) => { + setAddRecipientByName = async (name: string, email: string, type = 'to') => { await browser.pause(500); // stability fix for transition animation - await (await this.addRecipientField).setValue(name); + await (await this.getRecipientsTextField(type)).setValue(name); await ElementHelper.waitAndClick(await $(`~${email}`)); }; - checkFilledComposeEmailInfo = async (recipients: string[], subject: string, message: string, attachmentName?: string) => { - expect(this.composeSecurityMessage).toHaveTextContaining(message); - + checkFilledComposeEmailInfo = async (recipients: string[], subject: string, message: string, attachmentName?: string, cc?: string[], bcc?: string[]) => { + expect(await this.composeSecurityMessage).toHaveTextContaining(message); + const element = await this.filledSubject(subject); await element.waitForDisplayed(); await this.checkRecipientsList(recipients); + if (cc) { + await this.checkRecipientsList(cc, 'cc'); + } + + if (bcc) { + await this.checkRecipientsList(bcc, 'bcc'); + } + if (attachmentName !== undefined) { await this.checkAddedAttachment(attachmentName); } }; - checkRecipientsTextFieldIsInvisible = async () => { - await ElementHelper.waitElementInvisible(await this.addRecipientField); + checkRecipientsTextFieldIsInvisible = async (type = 'to') => { + await ElementHelper.waitElementInvisible(await this.getRecipientsTextField(type)); } - checkRecipientsList = async(recipients: string[]) => { + checkRecipientsList = async(recipients: string[], type = 'to') => { if (recipients.length === 0) { - await ElementHelper.waitElementInvisible(await $(`~aid-to-0-label`)); + await ElementHelper.waitElementInvisible(await $(`~aid-${type}-0-label`)); } else { for (const [index, recipient] of recipients.entries()) { - await this.checkAddedRecipient(recipient, index); + await this.checkAddedRecipient(recipient, index, type); } } } - checkAddedRecipient = async (recipient: string, order = 0) => { - const recipientCell = await $(`~aid-to-${order}-label`); + checkAddedRecipient = async (recipient: string, order = 0, type = 'to') => { + const recipientCell = await $(`~aid-${type}-${order}-label`); await ElementHelper.waitElementVisible(recipientCell); const name = await recipientCell.getValue(); expect(name).toEqual(` ${recipient} `); } - checkAddedRecipientColor = async (recipient: string, order: number, color: string) => { - const addedRecipientEl = await $(`~aid-to-${order}-${color}`); + checkAddedRecipientColor = async (recipient: string, order: number, color: string, type = 'to') => { + const addedRecipientEl = await $(`~aid-${type}-${order}-${color}`); await ElementHelper.waitElementVisible(addedRecipientEl); await this.checkAddedRecipient(recipient, order); } - deleteAddedRecipient = async (order: number) => { - const addedRecipientEl = await $(`~aid-to-${order}-label`); + deleteAddedRecipient = async (order: number, type = 'to') => { + const addedRecipientEl = await $(`~aid-${type}-${order}-label`); await ElementHelper.waitAndClick(addedRecipientEl); await driver.sendKeys(['\b']); // backspace } @@ -184,6 +203,10 @@ class NewMessageScreen extends BaseScreen { await ElementHelper.waitAndClick(await this.sendButton); } + clickToggleRecipientsButton =async () => { + await ElementHelper.waitAndClick(await this.toggleRecipientsButton); + } + clickSetPasswordButton = async () => { await ElementHelper.waitAndClick(await this.setPasswordButton); } diff --git a/appium/tests/specs/live/composeEmail/CheckComposeEmailAfterReopening.spec.ts b/appium/tests/specs/live/composeEmail/CheckComposeEmailAfterReopening.spec.ts index c1c67ffaf..b0f4205e2 100644 --- a/appium/tests/specs/live/composeEmail/CheckComposeEmailAfterReopening.spec.ts +++ b/appium/tests/specs/live/composeEmail/CheckComposeEmailAfterReopening.spec.ts @@ -12,6 +12,8 @@ describe('COMPOSE EMAIL: ', () => { it('check filled compose email after reopening app and text autoscroll', async () => { const recipientEmail = CommonData.contact.email; + const ccRecipientEmail = CommonData.secondContact.email; + const bccRecipientEmail = CommonData.recipient.email; const emailSubject = CommonData.simpleEmail.subject; const emailText = CommonData.simpleEmail.message; const longEmailText = CommonData.longEmail.message; @@ -27,8 +29,8 @@ describe('COMPOSE EMAIL: ', () => { await NewMessageScreen.clickBackButton(); await MailFolderScreen.checkInboxScreen(); await MailFolderScreen.clickCreateEmail(); - await NewMessageScreen.composeEmail(recipientEmail, emailSubject, emailText); - await NewMessageScreen.checkFilledComposeEmailInfo([recipientEmail], emailSubject, emailText); + await NewMessageScreen.composeEmail(recipientEmail, emailSubject, emailText, ccRecipientEmail, bccRecipientEmail); + await NewMessageScreen.checkFilledComposeEmailInfo([recipientEmail], emailSubject, emailText, undefined, [ccRecipientEmail], [bccRecipientEmail]); await driver.background(3);