diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index d629b643e..fe1e18528 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -420,6 +420,8 @@ D2FD0F692453245E00259FF0 /* Either.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FD0F682453245E00259FF0 /* Either.swift */; }; D2FF6966243115EC007182F0 /* SetupImapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FF6965243115EC007182F0 /* SetupImapViewController.swift */; }; D2FF6968243115F9007182F0 /* SetupImapViewDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FF6967243115F9007182F0 /* SetupImapViewDecorator.swift */; }; + D73F7D9D2CD46AE900955806 /* PgpOnlySwitchNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */; }; + D73F7D9F2CD4922500955806 /* NotificationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73F7D9E2CD4922100955806 /* NotificationExtension.swift */; }; D741F9B22CA5661C00E1CAFF /* SecurityWarningNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D741F9B12CA5661400E1CAFF /* SecurityWarningNode.swift */; }; F191F621272511790053833E /* BlurViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F191F620272511790053833E /* BlurViewController.swift */; }; F8678DCC2722143300BB1710 /* GmailService+draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8678DCB2722143300BB1710 /* GmailService+draft.swift */; }; @@ -887,6 +889,8 @@ D2FD0F682453245E00259FF0 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = ""; }; D2FF6965243115EC007182F0 /* SetupImapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupImapViewController.swift; sourceTree = ""; }; D2FF6967243115F9007182F0 /* SetupImapViewDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupImapViewDecorator.swift; sourceTree = ""; }; + D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PgpOnlySwitchNode.swift; sourceTree = ""; }; + D73F7D9E2CD4922100955806 /* NotificationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtension.swift; sourceTree = ""; }; D741F9B12CA5661400E1CAFF /* SecurityWarningNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityWarningNode.swift; sourceTree = ""; }; E26D5E20275AA417007B8802 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; F191F620272511790053833E /* BlurViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurViewController.swift; sourceTree = ""; }; @@ -2090,6 +2094,7 @@ D254AA5F24092A9E0041CAE0 /* Extensions */ = { isa = PBXGroup; children = ( + D73F7D9E2CD4922100955806 /* NotificationExtension.swift */, 21F836B42652A25D00B2448C /* Data */, E26D5E20275AA417007B8802 /* BundleExtensions.swift */, 21C7DEFB26669A3700C44800 /* CalendarExtensions.swift */, @@ -2256,6 +2261,7 @@ D2A1D3D223FD9AE600D626D6 /* Nodes */ = { isa = PBXGroup; children = ( + D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */, 9F7ECCA6272C3FB4008A1770 /* TextImageNode.swift */, 51EBC56F2746A06600178DE8 /* TextWithIconNode.swift */, 511D07E2276A2DF80050417B /* ButtonWithPaddingNode.swift */, @@ -2891,6 +2897,7 @@ D2A9CA3A2426198600E1D898 /* SignInDescriptionNode.swift in Sources */, 5133B6742716E5EA00C95463 /* LabelCellNode.swift in Sources */, D211CE7123FC35AC00D1CE38 /* TextFieldCellNode.swift in Sources */, + D73F7D9D2CD46AE900955806 /* PgpOnlySwitchNode.swift in Sources */, 9FA19890253C841F008C9CF2 /* TableViewController.swift in Sources */, 51DAD9BD273E7DD20076CBA7 /* BadgeNode.swift in Sources */, 51DE2FEE2714DA0400916222 /* ContactKeyCellNode.swift in Sources */, @@ -2960,6 +2967,7 @@ 9F44971626430710003A9FE9 /* Trace.swift in Sources */, 9F67998D277B3E4D00AFE5BE /* (null) in Sources */, 9F67998C277B3E4000AFE5BE /* BundleExtensions.swift in Sources */, + D73F7D9F2CD4922500955806 /* NotificationExtension.swift in Sources */, 9FD5052B278B2C8600FAA82F /* UIPopoverPresentationControllerExtensions.swift in Sources */, D2CDC3CD2402CCD7002B045F /* UIImageExtensions.swift in Sources */, 95473C1B297E61DE006C8957 /* SequenceExtension.swift in Sources */, diff --git a/FlowCrypt/Controllers/Inbox/InboxViewController.swift b/FlowCrypt/Controllers/Inbox/InboxViewController.swift index c4526242d..9257b89e1 100644 --- a/FlowCrypt/Controllers/Inbox/InboxViewController.swift +++ b/FlowCrypt/Controllers/Inbox/InboxViewController.swift @@ -90,6 +90,16 @@ class InboxViewController: ViewController { setupUI() setupNavigationBar() } + NotificationCenter.default.addObserver( + self, + selector: #selector(reloadThreadList), + name: .reloadThreadList, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) } override func viewWillAppear(_ animated: Bool) { @@ -125,6 +135,11 @@ class InboxViewController: ViewController { // MARK: - UI extension InboxViewController { + @objc func reloadThreadList() { + showSpinner() + refresh() + } + private func setupUI() { title = inboxTitle @@ -243,11 +258,16 @@ extension InboxViewController { // MARK: - Functionality extension InboxViewController { private func getSearchQuery() -> String? { - guard searchedExpression.isNotEmpty else { return nil } - - guard !searchedExpression.hasPrefix("subject:") else { return searchedExpression } + let showOnlyPgp = UserDefaults.standard.bool(forKey: "SHOW_PGP_ONLY_FLAG") + let pgpPattern = """ + ("-----BEGIN PGP MESSAGE-----" AND "-----END PGP MESSAGE-----") OR \ + ("-----BEGIN PGP SIGNED MESSAGE-----") OR \ + filename:({asc pgp gpg key}) + """ + let baseQuery = showOnlyPgp ? "\(pgpPattern) AND \(searchedExpression)" : searchedExpression + guard baseQuery.isNotEmpty else { return nil } - return "\(searchedExpression) OR subject:\(searchedExpression)" + return baseQuery.hasPrefix("subject:") ? baseQuery : "\(baseQuery) OR subject:\(searchedExpression)" } func fetchAndRenderEmails(_ batchContext: ASBatchContext?) { @@ -270,6 +290,10 @@ extension InboxViewController { ) state = .refresh handleEndFetching(with: context, context: batchContext) + // Hide spinner after 0.5 seconds as it takes a while to reload view + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.hideSpinner() + } } catch { handle(error: error) } @@ -291,6 +315,7 @@ extension InboxViewController { using: FetchMessageContext( folderPath: viewModel.path, count: messagesToLoad(), + searchQuery: getSearchQuery(), pagination: pagination ) ) diff --git a/FlowCrypt/Controllers/SideMenu/Menu/MyMenuViewController.swift b/FlowCrypt/Controllers/SideMenu/Menu/MyMenuViewController.swift index 5a61b82c8..4cd801317 100644 --- a/FlowCrypt/Controllers/SideMenu/Menu/MyMenuViewController.swift +++ b/FlowCrypt/Controllers/SideMenu/Menu/MyMenuViewController.swift @@ -21,7 +21,7 @@ final class MyMenuViewController: ViewController { } private enum Sections: Int, CaseIterable { - case header = 0, main, additional + case header = 0, pgpOnlySwitch = 1, main, additional } private enum State { @@ -117,6 +117,7 @@ extension MyMenuViewController: ASTableDataSource, ASTableDelegate { guard let sections = Sections(rawValue: section) else { return 0 } switch (sections, state) { + case (.pgpOnlySwitch, _): return 1 case (.header, _): return 1 case (.main, .accountAdding): return accounts.count case (.main, .folders): return folders.count @@ -138,6 +139,8 @@ extension MyMenuViewController: ASTableDataSource, ASTableDelegate { guard let sections = Sections(rawValue: indexPath.section) else { return } switch (sections, state) { + case (.pgpOnlySwitch, _): + return case (.header, _): guard let header = tableNode.nodeForRow(at: indexPath) as? TextImageNode else { return @@ -258,6 +261,8 @@ extension MyMenuViewController { private func node(for section: Sections, row: Int) -> ASCellNode { switch (section, state) { + case (.pgpOnlySwitch, _): + return PgpOnlySwitchNode() case (.header, _): let headerInput = decorator.header(for: appContext.user, image: state.arrowImage) return TextImageNode(input: headerInput) { [weak self] node in diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index 4a1f4f3e0..114b826fa 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -449,3 +449,5 @@ Be careful - avoid clicking links and downloading attachments, or sharing person "load_from_file" = "Load from a file"; "no_pubkeys_found" = "No public keys found"; "load_from_clipboard" = "Load from clipboard"; + +"show_only_pgp_messages" = "Show only PGP messages"; diff --git a/FlowCryptCommon/Extensions/NotificationExtension.swift b/FlowCryptCommon/Extensions/NotificationExtension.swift new file mode 100644 index 000000000..3f0db43be --- /dev/null +++ b/FlowCryptCommon/Extensions/NotificationExtension.swift @@ -0,0 +1,13 @@ +// +// NotificationExtension.swift +// FlowCrypt +// +// Created by Ioan Moldovan on 10/31/24 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +public extension Notification.Name { + static var reloadThreadList: Notification.Name { + return .init(rawValue: "ThreadList.Reload") + } +} diff --git a/FlowCryptUI/Cell Nodes/SwitchCellNode.swift b/FlowCryptUI/Cell Nodes/SwitchCellNode.swift index fbdaae767..8077517f8 100644 --- a/FlowCryptUI/Cell Nodes/SwitchCellNode.swift +++ b/FlowCryptUI/Cell Nodes/SwitchCellNode.swift @@ -56,6 +56,7 @@ public final class SwitchCellNode: CellNode { super.init() self.textNode.attributedText = input?.attributedText + self.textNode.truncationMode = .byWordWrapping self.automaticallyManagesSubnodes = true if let backgroundColor = input?.backgroundColor { @@ -68,17 +69,16 @@ public final class SwitchCellNode: CellNode { } override public func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { - switchNode.style.preferredSize = CGSize(width: 100, height: 30) + switchNode.style.preferredSize = CGSize(width: 55, height: 30) + textNode.style.flexGrow = 1.0 + textNode.style.flexShrink = 1.0 return ASStackLayoutSpec( direction: .horizontal, spacing: 8, - justifyContent: input?.switchJustifyContent ?? .start, + justifyContent: input?.switchJustifyContent ?? .spaceBetween, alignItems: .center, children: [ - ASInsetLayoutSpec( - insets: UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 24), - child: textNode - ), + textNode, switchNode ] ) diff --git a/FlowCryptUI/Nodes/PgpOnlySwitchNode.swift b/FlowCryptUI/Nodes/PgpOnlySwitchNode.swift new file mode 100644 index 000000000..897c05ddd --- /dev/null +++ b/FlowCryptUI/Nodes/PgpOnlySwitchNode.swift @@ -0,0 +1,56 @@ +// +// PgpOnlySwitchNode.swift +// FlowCrypt +// +// Created by Ioan Moldovan on 10/31/24 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit +import FlowCryptCommon + +public final class PgpOnlySwitchNode: CellNode { + + let SHOW_PGP_ONLY_KEY = "SHOW_PGP_ONLY_FLAG" + + private lazy var imageNode: ASImageNode = { + let node = ASImageNode() + let imageConfiguration = UIImage.SymbolConfiguration(font: .systemFont(ofSize: 24, weight: .light)) + node.image = UIImage(systemName: "lock.shield", withConfiguration: imageConfiguration)?.tinted(.main) + return node + }() + + private lazy var toggleNode: SwitchCellNode = { + let input = SwitchCellNode.Input( + isOn: UserDefaults.standard.bool(forKey: SHOW_PGP_ONLY_KEY), + attributedText: "show_only_pgp_messages" + .localized + .attributed(.medium(16), color: .textColor), + accessibilityIdentifier: "aid-toggle-pgp-only-node", + backgroundColor: .clear, + switchJustifyContent: .center + ) + return SwitchCellNode(input: input) { isOn in + UserDefaults.standard.setValue(isOn, forKey: self.SHOW_PGP_ONLY_KEY) + NotificationCenter.default.post(name: .reloadThreadList, object: nil) + } + }() + + override public init() { + super.init() + } + + override public func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec { + toggleNode.style.flexGrow = 1 + toggleNode.style.flexShrink = 1 + return ASInsetLayoutSpec( + insets: .deviceSpecificTextInsets(top: 16, bottom: 16), + child: ASStackLayoutSpec.horizontal().then { + $0.spacing = 6 + $0.alignItems = .center + $0.justifyContent = .spaceBetween + $0.children = [imageNode, toggleNode] + } + ) + } +} diff --git a/appium/api-mocks/apis/google/google-data.ts b/appium/api-mocks/apis/google/google-data.ts index 1129bf74c..16cead237 100644 --- a/appium/api-mocks/apis/google/google-data.ts +++ b/appium/api-mocks/apis/google/google-data.ts @@ -87,6 +87,7 @@ export class GmailMsg { this.draftId = msg.draftId; this.labelIds = msg.labelIds; this.raw = msg.raw; + this.snippet = msg.mimeMsg.text; this.sizeEstimate = Buffer.byteLength(msg.raw, 'utf-8'); const dateHeader = msg.mimeMsg.headers.get('date')! as Date; @@ -488,6 +489,7 @@ export class GoogleData { public getThreads = (labelIds: string[] = [], query?: string) => { const subject = (query?.match(/subject: '([^"]+)'/) || [])[1]?.trim().toLowerCase(); + const pgpFlag = query?.includes('-----BEGIN PGP MESSAGE-----'); const threads: GmailThread[] = []; const filteredThreads = this.getMessagesAndDrafts() @@ -504,6 +506,15 @@ export class GoogleData { } }) .filter(m => (subject ? GoogleData.msgSubject(m).toLowerCase().includes(subject) : true)) + .filter(m => { + if (pgpFlag) { + return ( + m.snippet?.includes('-----BEGIN PGP MESSAGE-----') || + m.snippet?.includes('-----BEGIN PGP SIGNED MESSAGE-----') + ); + } + return true; + }) .map(m => ({ historyId: m.historyId, id: m.threadId!, snippet: `MOCK SNIPPET: ${GoogleData.msgSubject(m)}` })); for (const thread of filteredThreads) { diff --git a/appium/tests/screenobjects/menu-bar.screen.ts b/appium/tests/screenobjects/menu-bar.screen.ts index 291c27b5e..fdc47b4c7 100644 --- a/appium/tests/screenobjects/menu-bar.screen.ts +++ b/appium/tests/screenobjects/menu-bar.screen.ts @@ -11,6 +11,7 @@ const SELECTORS = { TRASH_BTN: '~aid-menu-bar-item-trash', DRAFTS_BTN: '~aid-menu-bar-item-drafts', ALL_MAIL_BTN: '~aid-menu-bar-item-all-mail', + SHOW_ONLY_ENCRYPTED_EMAILS_TOGGLE: '~aid-toggle-pgp-only-node', ADD_ACCOUNT_BUTTON: '~aid-add-account-btn', }; @@ -51,6 +52,10 @@ class MenuBarScreen extends BaseScreen { return $(SELECTORS.ALL_MAIL_BTN); } + get showOnlyEncryptedEmailsToggle() { + return $(SELECTORS.SHOW_ONLY_ENCRYPTED_EMAILS_TOGGLE); + } + get addAccountButton() { return $(SELECTORS.ADD_ACCOUNT_BUTTON); } @@ -110,6 +115,10 @@ class MenuBarScreen extends BaseScreen { await ElementHelper.waitAndClick(await this.allMailButton); }; + clickShowOnlyEncryptedEmailsToggle = async () => { + await ElementHelper.waitAndClick(await this.showOnlyEncryptedEmailsToggle); + }; + checkMenuBarItem = async (menuItem: string) => { const menuBarItem = await $(`~aid-menu-item-${menuItem}`); await menuBarItem.waitForDisplayed(); diff --git a/appium/tests/specs/mock/inbox/CheckShowOnlyEncryptedEmails.spec.ts b/appium/tests/specs/mock/inbox/CheckShowOnlyEncryptedEmails.spec.ts new file mode 100644 index 000000000..896b81ef4 --- /dev/null +++ b/appium/tests/specs/mock/inbox/CheckShowOnlyEncryptedEmails.spec.ts @@ -0,0 +1,35 @@ +import { MockApi } from 'api-mocks/mock'; +import { MockApiConfig } from 'api-mocks/mock-config'; +import { MailFolderScreen, MenuBarScreen, SetupKeyScreen, SplashScreen } from '../../../screenobjects/all-screens'; + +describe('INBOX: ', () => { + it('user is able to see only encrypted emails when he clicks show only encrypted emails button', async () => { + const mockApi = new MockApi(); + + mockApi.fesConfig = MockApiConfig.defaultEnterpriseFesConfiguration; + mockApi.ekmConfig = MockApiConfig.defaultEnterpriseEkmConfiguration; + const plainEmailSubject = 'Honor reply-to address - plain'; + const encryptedEmailSubject = 'Encrypted email with public key attached'; + mockApi.addGoogleAccount('e2e.enterprise.test@flowcrypt.com', { + messages: [plainEmailSubject, encryptedEmailSubject], + }); + + await mockApi.withMockedApis(async () => { + await SplashScreen.mockLogin(); + await SetupKeyScreen.setPassPhrase(); + await MailFolderScreen.checkInboxScreen(); + + // Check if both encrypted & plain emails are present + await MailFolderScreen.checkEmailIsDisplayed(plainEmailSubject); + await MailFolderScreen.checkEmailIsDisplayed(encryptedEmailSubject); + + await MenuBarScreen.clickMenuBtn(); + await MenuBarScreen.clickShowOnlyEncryptedEmailsToggle(); + await browser.pause(1000); + await MenuBarScreen.clickInboxButton(); + + // Now check if plain email is not present + await MailFolderScreen.checkEmailIsNotDisplayed(plainEmailSubject); + }); + }); +});