diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index 4558e6300..53b27943a 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 5180CB97273724E9001FC7EF /* ThreadMessageSenderCellNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5180CB96273724E9001FC7EF /* ThreadMessageSenderCellNode.swift */; }; 518389C82726D7DD00131B2C /* UIViewController+Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518389C72726D7DD00131B2C /* UIViewController+Spinner.swift */; }; 518389CA2726D8F700131B2C /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518389C92726D8F700131B2C /* UIApplicationExtension.swift */; }; + 51938DC1274CC291007AD57B /* MessageQuoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51938DC0274CC291007AD57B /* MessageQuoteType.swift */; }; 51B0C7712729861C00124663 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B0C7702729861C00124663 /* String+Extension.swift */; }; 51B0C774272AB61000124663 /* StringTestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B0C773272AB61000124663 /* StringTestExtension.swift */; }; 51B4AE51271444580001F33B /* PubKeyRealmObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B4AE50271444580001F33B /* PubKeyRealmObject.swift */; }; @@ -491,6 +492,7 @@ 5180CB96273724E9001FC7EF /* ThreadMessageSenderCellNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMessageSenderCellNode.swift; sourceTree = ""; }; 518389C72726D7DD00131B2C /* UIViewController+Spinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Spinner.swift"; sourceTree = ""; }; 518389C92726D8F700131B2C /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; + 51938DC0274CC291007AD57B /* MessageQuoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageQuoteType.swift; sourceTree = ""; }; 51B0C7702729861C00124663 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; 51B0C773272AB61000124663 /* StringTestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTestExtension.swift; sourceTree = ""; }; 51B4AE50271444580001F33B /* PubKeyRealmObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PubKeyRealmObject.swift; sourceTree = ""; }; @@ -1555,6 +1557,7 @@ 9F9ABC8623AC1EAA00D560E3 /* MessageContext.swift */, 9FE1B39F2565B0CD00D6D086 /* Message.swift */, 9F5C2A76257D705100DE9B4B /* MessageLabel.swift */, + 51938DC0274CC291007AD57B /* MessageQuoteType.swift */, ); path = Model; sourceTree = ""; @@ -2550,6 +2553,7 @@ 9FDF3654235A218E00614596 /* main.swift in Sources */, D2E26F6C24F25B1F00612AF1 /* KeyAlgo.swift in Sources */, D2891AC424C62446008918E3 /* ErrorHandler.swift in Sources */, + 51938DC1274CC291007AD57B /* MessageQuoteType.swift in Sources */, 9F41FA2F253B7624003B970D /* BackupSelectKeyDecorator.swift in Sources */, D227C0E8250538A90070F805 /* FoldersService.swift in Sources */, 5ADEDCB623A426E300EC495E /* KeyDetailViewController.swift in Sources */, diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index ecfb996b6..9da1e1aed 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -19,7 +19,7 @@ private struct ComposedDraft: Equatable { /** * 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 from *ThreadDetailsViewController* controller by tapping on *reply* or *forward* **/ final class ComposeViewController: TableNodeViewController { private lazy var logger = Logger.nested(Self.self) @@ -119,7 +119,7 @@ final class ComposeViewController: TableNodeViewController { setupNavigationBar() observeKeyboardNotifications() observerAppStates() - setupReply() + setupQuote() } override func viewWillDisappear(_ animated: Bool) { @@ -250,12 +250,14 @@ extension ComposeViewController { } } - private func setupReply() { - guard input.isReply, let email = input.recipientReplyTitle else { return } + private func setupQuote() { + guard input.isQuote else { return } - let recipient = ComposeMessageRecipient(email: email, state: decorator.recipientIdleState) - contextToSend.recipients.append(recipient) - evaluate(recipient: recipient) + input.quoteRecipients.forEach { email in + let recipient = ComposeMessageRecipient(email: email, state: decorator.recipientIdleState) + contextToSend.recipients.append(recipient) + evaluate(recipient: recipient) + } } } @@ -549,7 +551,7 @@ extension ComposeViewController { } .onShouldReturn { [weak self] _ in guard let self = self else { return true } - if !self.input.isReply, let node = self.node.visibleNodes.compactMap({ $0 as? TextViewCellNode }).first { + if !self.input.isQuote, let node = self.node.visibleNodes.compactMap({ $0 as? TextViewCellNode }).first { node.becomeFirstResponder() } else { self.node.view.endEditing(true) @@ -557,14 +559,14 @@ extension ComposeViewController { return true } .then { - let subject = input.isReply ? input.subjectReplyTitle : contextToSend.subject + let subject = input.isQuote ? input.subjectQuoteTitle : contextToSend.subject $0.attributedText = decorator.styledTitle(with: subject) } } private func textNode() -> ASCellNode { - let replyQuote = decorator.styledReplyQuote(with: input) - let height = max(decorator.frame(for: replyQuote).height, 40) + let styledQuote = decorator.styledQuote(with: input) + let height = max(decorator.frame(for: styledQuote).height, 40) return TextViewCellNode( decorator.styledTextViewInput(with: height) @@ -579,9 +581,9 @@ extension ComposeViewController { .then { let messageText = decorator.styledMessage(with: contextToSend.message ?? "") - if input.isReply && !messageText.string.contains(replyQuote.string) { + if input.isQuote && !messageText.string.contains(styledQuote.string) { let mutableString = NSMutableAttributedString(attributedString: messageText) - mutableString.append(replyQuote) + mutableString.append(styledQuote) $0.textView.attributedText = mutableString $0.becomeFirstResponder() } else { @@ -615,7 +617,7 @@ extension ComposeViewController { } .then { $0.isLowercased = true - if !self.input.isReply { + if !self.input.isQuote { $0.becomeFirstResponder() } } diff --git a/FlowCrypt/Controllers/Compose/ComposeViewControllerInput.swift b/FlowCrypt/Controllers/Compose/ComposeViewControllerInput.swift index 2bf30f5be..c430191e2 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewControllerInput.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewControllerInput.swift @@ -11,8 +11,9 @@ import Foundation struct ComposeMessageInput: Equatable { static let empty = ComposeMessageInput(type: .idle) - struct ReplyInfo: Equatable { - let recipient: String? + struct MessageQuoteInfo: Equatable { + let recipients: [String] + let sender: String? let subject: String? let mime: Data? let sentDate: Date @@ -22,47 +23,52 @@ struct ComposeMessageInput: Equatable { enum InputType: Equatable { case idle - case reply(ReplyInfo) + case quote(MessageQuoteInfo) } let type: InputType - var isReply: Bool { + var isQuote: Bool { switch type { case .idle: return false - case .reply: return true + case .quote: return true } } - var recipientReplyTitle: String? { - guard case let .reply(info) = type else { return nil } - return info.recipient + var quoteRecipients: [String] { + guard case let .quote(info) = type else { return [] } + return info.recipients } - var subjectReplyTitle: String? { - guard case let .reply(info) = type else { return nil } - return "Re: \(info.subject ?? "(no subject)")" + var subjectQuoteTitle: String? { + guard case let .quote(info) = type else { return nil } + return info.subject } var successfullySentToast: String { switch type { case .idle: return "compose_encrypted_sent".localized - case .reply: return "compose_reply_successfull".localized + case .quote(let info): + if info.recipients.isEmpty { + return "compose_forward_successful".localized + } else { + return "compose_reply_successful".localized + } } } var subject: String? { - guard case let .reply(info) = type else { return nil } + guard case let .quote(info) = type else { return nil } return info.subject } var replyToMime: Data? { - guard case let .reply(info) = type else { return nil } + guard case let .quote(info) = type else { return nil } return info.mime } var threadId: String? { - guard case let .reply(info) = type else { return nil } + guard case let .quote(info) = type else { return nil } return info.threadId } } diff --git a/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift b/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift index b834c04cc..f8f09cd65 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift @@ -72,8 +72,8 @@ struct ComposeViewDecorator { text.attributed(.regular(17)) } - func styledReplyQuote(with input: ComposeMessageInput) -> NSAttributedString { - guard case let .reply(info) = input.type else { return NSAttributedString(string: "") } + func styledQuote(with input: ComposeMessageInput) -> NSAttributedString { + guard case let .quote(info) = input.type else { return NSAttributedString(string: "") } let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short @@ -85,10 +85,10 @@ struct ComposeViewDecorator { dateFormatter.timeStyle = .short let time = dateFormatter.string(from: info.sentDate) - let from = info.recipient ?? "unknown sender" + let from = info.sender ?? "unknown sender" let text: String = "\n\n" - + "compose_reply_from".localizeWithArguments(date, time, from) + + "compose_quote_from".localizeWithArguments(date, time, from) + "\n" let message = " > " + info.message.replacingOccurrences(of: "\n", with: "\n > ") diff --git a/FlowCrypt/Controllers/Inbox/InboxProviders.swift b/FlowCrypt/Controllers/Inbox/InboxProviders.swift index a3c256c16..15cfd372b 100644 --- a/FlowCrypt/Controllers/Inbox/InboxProviders.swift +++ b/FlowCrypt/Controllers/Inbox/InboxProviders.swift @@ -5,7 +5,7 @@ // Created by Anton Kharchevskyi on 11.10.2021 // Copyright © 2017-present FlowCrypt a. s. All rights reserved. // - + import Foundation struct InboxContext { diff --git a/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailDecorator.swift b/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailDecorator.swift index a80651ea4..1fababdb2 100644 --- a/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailDecorator.swift +++ b/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailDecorator.swift @@ -5,7 +5,7 @@ // Created by Roma Sosnovsky on 13/10/21 // Copyright © 2017-present FlowCrypt a. s. All rights reserved. // - + import FlowCryptUI import Foundation diff --git a/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailViewController.swift b/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailViewController.swift index 7f258598a..cb7c88caf 100644 --- a/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailViewController.swift +++ b/FlowCrypt/Controllers/Settings/Contacts/Contacts List/ContactKeyDetailViewController.swift @@ -5,7 +5,7 @@ // Created by Roma Sosnovsky on 13/10/21 // Copyright © 2017-present FlowCrypt a. s. All rights reserved. // - + import AsyncDisplayKit import FlowCryptCommon import FlowCryptUI diff --git a/FlowCrypt/Controllers/Threads/MessageAction.swift b/FlowCrypt/Controllers/Threads/MessageAction.swift index ef5fb2074..0506bacd3 100644 --- a/FlowCrypt/Controllers/Threads/MessageAction.swift +++ b/FlowCrypt/Controllers/Threads/MessageAction.swift @@ -6,7 +6,6 @@ // Copyright © 2017-present FlowCrypt a. s. All rights reserved. // - import Foundation typealias MessageActionCompletion = (MessageAction, InboxRenderable) -> Void diff --git a/FlowCrypt/Controllers/Threads/ThreadDetailsDecorator.swift b/FlowCrypt/Controllers/Threads/ThreadDetailsDecorator.swift index bf914053c..3a8e6221b 100644 --- a/FlowCrypt/Controllers/Threads/ThreadDetailsDecorator.swift +++ b/FlowCrypt/Controllers/Threads/ThreadDetailsDecorator.swift @@ -33,7 +33,8 @@ extension ThreadMessageSenderCellNode.Input { sender: NSAttributedString.text(from: sender, style: style, color: textColor), date: NSAttributedString.text(from: date, style: style, color: dateColor), isExpanded: threadMessage.isExpanded, - buttonColor: .messageButtonColor + buttonColor: .messageButtonColor, + nodeInsets: UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 8) ) } } diff --git a/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift b/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift index b1cd5fc7d..540cb7c95 100644 --- a/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift +++ b/FlowCrypt/Controllers/Threads/ThreadDetailsViewController.swift @@ -99,40 +99,75 @@ extension ThreadDetailsViewController { return } - UIView.animate( - withDuration: 0.3, - animations: { - threadNode.replyNode.view.alpha = self.input[indexPath.section-1].isExpanded ? 0 : 1 - threadNode.expandNode.view.transform = CGAffineTransform(rotationAngle: .pi) - }, - completion: { [weak self] _ in - guard let self = self else { return } + input[indexPath.section - 1].isExpanded.toggle() - if let processedMessage = self.input[indexPath.section-1].processedMessage { - self.handleReceived(message: processedMessage, at: indexPath) - } else { - self.fetchDecryptAndRenderMsg(at: indexPath) + if input[indexPath.section-1].isExpanded { + UIView.animate( + withDuration: 0.3, + animations: { + threadNode.expandNode.view.alpha = 0 + }, + completion: { [weak self] _ in + guard let self = self else { return } + + if let processedMessage = self.input[indexPath.section-1].processedMessage { + self.handleReceived(message: processedMessage, at: indexPath) + } else { + self.fetchDecryptAndRenderMsg(at: indexPath) + } } + ) + } else { + UIView.animate(withDuration: 0.3) { + self.node.reloadSections(IndexSet(integer: indexPath.section), with: .automatic) } - ) + } } private func handleReplyTap(at indexPath: IndexPath) { + composeNewMessage(at: indexPath, quoteType: .reply) + } + + private func handleMenuTap(at indexPath: IndexPath) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction( + UIAlertAction( + title: "forward".localized, + style: .default) { [weak self] _ in + self?.composeNewMessage(at: indexPath, quoteType: .forward) + } + ) + alert.addAction(UIAlertAction(title: "cancel".localized, style: .cancel)) + present(alert, animated: true, completion: nil) + } + + private func composeNewMessage(at indexPath: IndexPath, quoteType: MessageQuoteType) { guard let email = DataService.shared.email, let input = input[safe: indexPath.section-1], let processedMessage = input.processedMessage else { return } - let replyInfo = ComposeMessageInput.ReplyInfo( - recipient: input.rawMessage.sender, - subject: input.rawMessage.subject, + let recipients: [String] + switch quoteType { + case .reply: + recipients = [input.rawMessage.sender].compactMap { $0 } + case .forward: + recipients = [] + } + + let subject = input.rawMessage.subject ?? "(no subject)" + + let replyInfo = ComposeMessageInput.MessageQuoteInfo( + recipients: recipients, + sender: input.rawMessage.sender, + subject: "\(quoteType.subjectPrefix)\(subject)", mime: processedMessage.rawMimeData, sentDate: input.rawMessage.date, message: processedMessage.text, threadId: input.rawMessage.threadId ) - let composeInput = ComposeMessageInput(type: .reply(replyInfo)) + let composeInput = ComposeMessageInput(type: .quote(replyInfo)) navigationController?.pushViewController( ComposeViewController(email: email, input: composeInput), animated: true @@ -379,8 +414,8 @@ extension ThreadDetailsViewController: ASTableDelegate, ASTableDataSource { func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int { guard section > 0, input[section-1].isExpanded else { return 1 } - let count = input[section-1].processedMessage?.attachments.count ?? 0 - return Parts.allCases.count + count + let attachmentsCount = input[section-1].processedMessage?.attachments.count ?? 0 + return Parts.allCases.count + attachmentsCount } func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { @@ -397,7 +432,8 @@ extension ThreadDetailsViewController: ASTableDelegate, ASTableDataSource { if indexPath.row == 0 { return ThreadMessageSenderCellNode( input: .init(threadMessage: section), - onReplyTap: { [weak self] _ in self?.handleReplyTap(at: indexPath) } + onReplyTap: { [weak self] _ in self?.handleReplyTap(at: indexPath) }, + onMenuTap: { [weak self] _ in self?.handleMenuTap(at: indexPath) } ) } @@ -425,6 +461,24 @@ extension ThreadDetailsViewController: ASTableDelegate, ASTableDataSource { } handleExpandTap(at: indexPath) } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + dividerView() + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + section > 0 && section < input.count ? 1 / UIScreen.main.nativeScale : 0 + } + + private func dividerView() -> UIView { + UIView().then { + let frame = CGRect(x: 8, y: 0, width: view.frame.width - 16, height: 1 / UIScreen.main.nativeScale) + let divider = UIView(frame: frame) + $0.addSubview(divider) + $0.backgroundColor = .clear + divider.backgroundColor = .borderColor + } + } } extension ThreadDetailsViewController: NavigationChildController { diff --git a/FlowCrypt/Functionality/Mail Provider/Imap/Imap+retry.swift b/FlowCrypt/Functionality/Mail Provider/Imap/Imap+retry.swift index d450a2fc6..75f5c8274 100644 --- a/FlowCrypt/Functionality/Mail Provider/Imap/Imap+retry.swift +++ b/FlowCrypt/Functionality/Mail Provider/Imap/Imap+retry.swift @@ -109,7 +109,8 @@ extension Imap { try await renewSession() // todo - log time return true case .connection: - // the connection has dropped, so it's probably ok to not officially "close" it. but maybe there could be a cleaner way to dispose of the connection? + // the connection has dropped, so it's probably ok to not officially "close" it. + // but maybe there could be a cleaner way to dispose of the connection? imapSess = nil smtpSess = nil // this is a mess, neads a real refactor. use DI diff --git a/FlowCrypt/Functionality/Mail Provider/MessagesList Provider/Model/MessageQuoteType.swift b/FlowCrypt/Functionality/Mail Provider/MessagesList Provider/Model/MessageQuoteType.swift new file mode 100644 index 000000000..7a91fc4b8 --- /dev/null +++ b/FlowCrypt/Functionality/Mail Provider/MessagesList Provider/Model/MessageQuoteType.swift @@ -0,0 +1,24 @@ +// +// MessageQuoteType.swift +// FlowCrypt +// +// Created by Roma Sosnovsky on 23/11/21 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import Foundation + +enum MessageQuoteType { + case reply, forward +} + +extension MessageQuoteType { + var subjectPrefix: String { + switch self { + case .reply: + return "Re: " + case .forward: + return "Fwd: " + } + } +} diff --git a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift index f32ca67f3..12caf68e4 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift @@ -78,7 +78,7 @@ final class ComposeMessageService { throw MessageValidationError.invalidEmailRecipient } - guard input.isReply || contextToSend.subject?.hasContent ?? false else { + guard input.isQuote || contextToSend.subject?.hasContent ?? false else { throw MessageValidationError.emptySubject } @@ -86,7 +86,7 @@ final class ComposeMessageService { throw MessageValidationError.emptyMessage } - let subject = input.subjectReplyTitle + let subject = input.subjectQuoteTitle ?? contextToSend.subject ?? "(no subject)" diff --git a/FlowCrypt/Functionality/Services/Local Private Key Services/PassPhraseService.swift b/FlowCrypt/Functionality/Services/Local Private Key Services/PassPhraseService.swift index eb29f1380..7fecbb17a 100644 --- a/FlowCrypt/Functionality/Services/Local Private Key Services/PassPhraseService.swift +++ b/FlowCrypt/Functionality/Services/Local Private Key Services/PassPhraseService.swift @@ -30,7 +30,9 @@ struct PassPhrase: Codable, Hashable, Equatable { } // (tom) todo - this is a confusing thing to do - // when comparing pass phrases to one another, you would expect that it's compared by the pass phrase string itself, and not by primary fingerprint of the associated key. I understand this is being used somewhere, but I suggest to refactor it to avoid defining this == overload. + // when comparing pass phrases to one another, you would expect that it's compared by the pass phrase string + // itself, and not by primary fingerprint of the associated key. I understand this is being used somewhere, + // but I suggest to refactor it to avoid defining this == overload. static func == (lhs: PassPhrase, rhs: PassPhrase) -> Bool { lhs.primaryFingerprintOfAssociatedKey == rhs.primaryFingerprintOfAssociatedKey } diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index 75f03359b..b04e574a5 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -14,6 +14,7 @@ "error" = "Error"; "allow" = "Allow"; "later" = "Later"; +"forward" = "Forward"; // EMAIL "email_removed" = "Email moved to Trash"; @@ -69,8 +70,9 @@ "compose_recipient_expired" = "One or more of your recipients have expired public keys (marked in orange).\n\nPlease ask them to send you updated public key. If this is an enterprise installation, please ask your systems admin."; "compose_recipient_invalid_email" = "One or more of your recipients have invalid email address (marked in red)"; "compose_error" = "Could not compose message"; -"compose_reply_successfull" = "Reply successfully sent"; -"compose_reply_from" = "On %@ at %@ %@ wrote:"; // Date, time, sender +"compose_reply_successful" = "Reply successfully sent"; +"compose_quote_from" = "On %@ at %@ %@ wrote:"; // Date, time, sender +"compose_forward_successful" = "Message forwarded successfully"; "compose_encrypted_sent" = "Encrypted message sent"; "compose_enter_subject" = "Enter subject"; "compose_enter_secure" = "Enter secure message"; diff --git a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift index 12408f06f..6addc6928 100644 --- a/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift +++ b/FlowCryptUI/Cell Nodes/RecipientEmailsCellNode.swift @@ -31,6 +31,7 @@ final public class RecipientEmailsCellNode: CellNode { layout.minimumLineSpacing = Constants.minimumLineSpacing layout.sectionInset = Constants.sectionInset let collectionNode = ASCollectionNode(collectionViewLayout: layout) + collectionNode.accessibilityIdentifier = "recipientsList" collectionNode.backgroundColor = .clear return collectionNode }() diff --git a/FlowCryptUI/Cell Nodes/ThreadMessageSenderCellNode.swift b/FlowCryptUI/Cell Nodes/ThreadMessageSenderCellNode.swift index 285dba6c0..9ebf5512a 100644 --- a/FlowCryptUI/Cell Nodes/ThreadMessageSenderCellNode.swift +++ b/FlowCryptUI/Cell Nodes/ThreadMessageSenderCellNode.swift @@ -25,7 +25,7 @@ public final class ThreadMessageSenderCellNode: CellNode { date: NSAttributedString, isExpanded: Bool, buttonColor: UIColor, - nodeInsets: UIEdgeInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 12)) { + nodeInsets: UIEdgeInsets) { self.encryptionBadge = encryptionBadge self.signatureBadge = signatureBadge self.sender = sender @@ -35,15 +35,11 @@ public final class ThreadMessageSenderCellNode: CellNode { self.nodeInsets = nodeInsets } - var replyImage: UIImage? { - return createButtonImage(systemName: "arrowshape.turn.up.left") - } - var expandImage: UIImage? { - let systemName = isExpanded ? "chevron.up" : "chevron.down" - return createButtonImage(systemName: systemName) - } + var replyImage: UIImage? { createButtonImage("arrow.turn.up.left") } + var menuImage: UIImage? { createButtonImage("ellipsis") } + var expandImage: UIImage? { createButtonImage("chevron.down") } - private func createButtonImage(systemName: String, pointSize: CGFloat = 18) -> UIImage? { + private func createButtonImage(_ systemName: String, pointSize: CGFloat = 18) -> UIImage? { let configuration = UIImage.SymbolConfiguration(pointSize: pointSize) return UIImage(systemName: systemName, withConfiguration: configuration) } @@ -61,15 +57,19 @@ public final class ThreadMessageSenderCellNode: CellNode { private let dateNode = ASTextNode2() public private(set) var replyNode = ASButtonNode() + public private(set) var menuNode = ASButtonNode() public private(set) var expandNode = ASImageNode() private let input: ThreadMessageSenderCellNode.Input private var onReplyTap: ((ThreadMessageSenderCellNode) -> Void)? + private var onMenuTap: ((ThreadMessageSenderCellNode) -> Void)? public init(input: ThreadMessageSenderCellNode.Input, - onReplyTap: ((ThreadMessageSenderCellNode) -> Void)?) { + onReplyTap: ((ThreadMessageSenderCellNode) -> Void)?, + onMenuTap: ((ThreadMessageSenderCellNode) -> Void)?) { self.input = input self.onReplyTap = onReplyTap + self.onMenuTap = onMenuTap super.init() automaticallyManagesSubnodes = true @@ -79,31 +79,52 @@ public final class ThreadMessageSenderCellNode: CellNode { setupReplyNode() + setupMenuNode() setupExpandNode() } private func setupReplyNode() { - replyNode.setImage(input.replyImage, for: .normal) - replyNode.imageNode.imageModificationBlock = ASImageNodeTintColorModificationBlock(input.buttonColor) - replyNode.contentMode = .center - replyNode.alpha = input.isExpanded ? 1 : 0 - replyNode.addTarget(self, action: #selector(onReplyNodeTap), forControlEvents: .touchUpInside) - replyNode.accessibilityIdentifier = "replyButton" + setup(buttonNode: replyNode, + with: input.replyImage, + action: #selector(onReplyNodeTap), + accessibilityIdentifier: "replyButton") + } + + private func setupMenuNode() { + setup(buttonNode: menuNode, + with: input.menuImage, + action: #selector(onMenuNodeTap), + accessibilityIdentifier: "messageMenuButton") + } + + private func setup(buttonNode node: ASButtonNode, + with image: UIImage?, + action: Selector, + accessibilityIdentifier: String) { + node.setImage(image, for: .normal) + node.imageNode.imageModificationBlock = ASImageNodeTintColorModificationBlock(input.buttonColor) + node.addTarget(self, action: action, forControlEvents: .touchUpInside) + node.accessibilityIdentifier = accessibilityIdentifier } private func setupExpandNode() { expandNode.image = input.expandImage expandNode.imageModificationBlock = ASImageNodeTintColorModificationBlock(input.buttonColor) - expandNode.contentMode = .right + expandNode.contentMode = .center } @objc private func onReplyNodeTap() { onReplyTap?(self) } + @objc private func onMenuNodeTap() { + onMenuTap?(self) + } + public override func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec { replyNode.style.preferredSize = CGSize(width: 44, height: 44) - expandNode.style.preferredSize = CGSize(width: 18, height: 44) + menuNode.style.preferredSize = CGSize(width: 36, height: 44) + expandNode.style.preferredSize = CGSize(width: 36, height: 44) let infoNode = ASStackLayoutSpec( direction: .vertical, @@ -115,32 +136,26 @@ public final class ThreadMessageSenderCellNode: CellNode { infoNode.style.flexGrow = 1 infoNode.style.flexShrink = 1 - let senderSpec = ASStackLayoutSpec( - direction: .horizontal, - spacing: 4, - justifyContent: .spaceBetween, - alignItems: .start, - children: [infoNode, replyNode, expandNode] - ) - let contentSpec: ASStackLayoutSpec + if input.isExpanded { + let senderSpec = ASStackLayoutSpec( + direction: .horizontal, + spacing: 2, + justifyContent: .spaceBetween, + alignItems: .start, + children: [infoNode, replyNode, menuNode] + ) + let spacer = ASLayoutSpec() spacer.style.flexGrow = 1.0 - var children: [ASLayoutElement] = [] - children.append(encryptionNode) - if let signatureNode = signatureNode { - children.append(signatureNode) - } - children.append(spacer) - let signatureSpec = ASStackLayoutSpec( direction: .horizontal, spacing: 4, justifyContent: .spaceBetween, alignItems: .start, - children: children + children: [encryptionNode, signatureNode, spacer].compactMap { $0 } ) contentSpec = ASStackLayoutSpec( @@ -151,7 +166,13 @@ public final class ThreadMessageSenderCellNode: CellNode { children: [senderSpec, signatureSpec] ) } else { - contentSpec = senderSpec + contentSpec = ASStackLayoutSpec( + direction: .horizontal, + spacing: 4, + justifyContent: .spaceBetween, + alignItems: .start, + children: [infoNode, expandNode] + ) } return ASInsetLayoutSpec( diff --git a/appium/tests/screenobjects/email.screen.ts b/appium/tests/screenobjects/email.screen.ts index ca9044daa..44dc622f3 100644 --- a/appium/tests/screenobjects/email.screen.ts +++ b/appium/tests/screenobjects/email.screen.ts @@ -9,6 +9,8 @@ const SELECTORS = { WRONG_PASS_PHRASE_MESSAGE: '-ios class chain:**/XCUIElementTypeStaticText[`label == "Wrong pass phrase, please try again"`]', DOWNLOAD_ATTACHMENT_BUTTON: '~downloadButton', REPLY_BUTTON: '~replyButton', + MENU_BUTTON: '~messageMenuButton', + FORWARD_BUTTON: '~Forward', DELETE_BUTTON: '~Delete', CONFIRM_DELETING: '~OK', SENDER_EMAIL: '~senderEmail' @@ -44,6 +46,14 @@ class EmailScreen extends BaseScreen { return $(SELECTORS.REPLY_BUTTON); } + get menuButton() { + return $(SELECTORS.MENU_BUTTON); + } + + get forwardButton() { + return $(SELECTORS.FORWARD_BUTTON); + } + get deleteButton() { return $(SELECTORS.DELETE_BUTTON) } @@ -113,6 +123,14 @@ class EmailScreen extends BaseScreen { await ElementHelper.waitAndClick(await this.replyButton); } + clickMenuButton = async () => { + await ElementHelper.waitAndClick(await this.menuButton); + } + + clickForwardButton = async () => { + await ElementHelper.waitAndClick(await this.forwardButton); + } + clickDeleteButton = async () => { await ElementHelper.waitAndClick(await this.deleteButton); } diff --git a/appium/tests/screenobjects/new-message.screen.ts b/appium/tests/screenobjects/new-message.screen.ts index d207e96f8..f232e6831 100644 --- a/appium/tests/screenobjects/new-message.screen.ts +++ b/appium/tests/screenobjects/new-message.screen.ts @@ -5,6 +5,7 @@ const SELECTORS = { ADD_RECIPIENT_FIELD: '-ios class chain:**/XCUIElementTypeTextField[`value == "Add Recipient"`]', SUBJECT_FIELD: '-ios class chain:**/XCUIElementTypeTextField[`value == "Subject"`]', COMPOSE_SECURITY_MESSAGE: '-ios predicate string:type == "XCUIElementTypeTextView"', + RECIPIENTS_LIST: '~recipientsList', ADDED_RECIPIENT: '-ios class chain:**/XCUIElementTypeWindow[1]/XCUIElementTypeOther/XCUIElementTypeOther' + '/XCUIElementTypeOther/XCUIElementTypeOther[1]/XCUIElementTypeOther/XCUIElementTypeTable' + '/XCUIElementTypeCell[1]/XCUIElementTypeOther/XCUIElementTypeCollectionView/XCUIElementTypeCell' + @@ -33,6 +34,10 @@ class NewMessageScreen extends BaseScreen { return $(SELECTORS.COMPOSE_SECURITY_MESSAGE); } + get recipientsList() { + return $(SELECTORS.RECIPIENTS_LIST); + } + get addedRecipientEmail() { return $(SELECTORS.ADDED_RECIPIENT); } @@ -87,9 +92,20 @@ class NewMessageScreen extends BaseScreen { expect(this.composeSecurityMessage).toHaveText(message); const element = await this.filledSubject(subject); await element.waitForDisplayed(); - await this.checkAddedRecipient(recipient); + + if (recipient.length === 0) { + await this.checkEmptyRecipientsList(); + } else { + await this.checkAddedRecipient(recipient); + } }; + checkEmptyRecipientsList = async () => { + const list = await this.recipientsList; + const listText = await list.getText(); + expect(listText.length).toEqual(0); + } + checkAddedRecipient = async (recipient: string) => { const addedRecipientEl = await this.addedRecipientEmail; const value = await addedRecipientEl.getValue(); diff --git a/appium/tests/specs/inbox/CheckReplyForEncryptedEmail.spec.ts b/appium/tests/specs/inbox/CheckReplyAndForwardForEncryptedEmail.spec.ts similarity index 63% rename from appium/tests/specs/inbox/CheckReplyForEncryptedEmail.spec.ts rename to appium/tests/specs/inbox/CheckReplyAndForwardForEncryptedEmail.spec.ts index 4950c81e8..786b51218 100644 --- a/appium/tests/specs/inbox/CheckReplyForEncryptedEmail.spec.ts +++ b/appium/tests/specs/inbox/CheckReplyAndForwardForEncryptedEmail.spec.ts @@ -10,14 +10,15 @@ import { CommonData } from '../../data'; describe('INBOX: ', () => { - it('user is able to reply email and check info from reply email', async () => { + it('user is able to reply or forward email and check info from composed email', async () => { const senderEmail = CommonData.sender.email; const emailSubject = CommonData.encryptedEmail.subject; const emailText = CommonData.encryptedEmail.message; const replySubject = `Re: ${emailSubject}`; - const replyText = `On 10/26/21 at 2:43 PM ${senderEmail} wrote:\n > ${emailText}`; + const forwardSubject = `Fwd: ${emailSubject}`; + const quoteText = `On 10/26/21 at 2:43 PM ${senderEmail} wrote:\n > ${emailText}`; await SplashScreen.login(); await SetupKeyScreen.setPassPhrase(); @@ -27,6 +28,11 @@ describe('INBOX: ', () => { await EmailScreen.checkOpenedEmail(senderEmail, emailSubject, emailText); await EmailScreen.clickReplyButton(); - await NewMessageScreen.checkFilledComposeEmailInfo(senderEmail, replySubject, replyText); + await NewMessageScreen.checkFilledComposeEmailInfo(senderEmail, replySubject, quoteText); + + await NewMessageScreen.clickBackButton(); + await EmailScreen.clickMenuButton(); + await EmailScreen.clickForwardButton(); + await NewMessageScreen.checkFilledComposeEmailInfo("", forwardSubject, quoteText); }); });