From a3f63d8e379871bff42f4152beebd494713f61ca Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 25 Jan 2026 23:58:00 -0800 Subject: [PATCH 1/7] fix: 3 dot menu action button not appear issue --- .../webmail/gmail/gmail-element-replacer.ts | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts index 36c01f418d5..95dc9986536 100644 --- a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts +++ b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts @@ -298,12 +298,18 @@ export class GmailElementReplacer extends WebmailElementReplacer { }; private addMenuButton = (replyOption: ReplyOption, gmailContextMenuBtn: Element | null) => { - if (gmailContextMenuBtn && $(gmailContextMenuBtn).is(':visible') && !document.querySelector(`.action_${replyOption.replace('a_', '')}_message_button`)) { - const button = $(this.factory.btnSecureMenuBtn(replyOption)).insertAfter(gmailContextMenuBtn); // xss-safe-factory - button.on( - 'click', - Ui.event.handle((el, ev: JQuery.Event) => this.actionActivateSecureReplyHandler(el, ev)) - ); + if (gmailContextMenuBtn && $(gmailContextMenuBtn).is(':visible')) { + const btnClass = `action_${replyOption.replace('a_', '')}_message_button`; + // Check if button already exists in this specific menu (sibling of the target button) + const alreadyExists = $(gmailContextMenuBtn).parent().find(`.${btnClass}`).length > 0; + + if (!alreadyExists) { + const button = $(this.factory.btnSecureMenuBtn(replyOption)).insertAfter(gmailContextMenuBtn); // xss-safe-factory + button.on( + 'click', + Ui.event.handle((el, ev: JQuery.Event) => this.actionActivateSecureReplyHandler(el, ev)) + ); + } } }; @@ -970,7 +976,25 @@ export class GmailElementReplacer extends WebmailElementReplacer { } // Find the message container from the menu's position or context - const messageContainer = $('div.h7:visible').last(); // Get the last visible message container + let messageContainer: JQuery; + + // Try to find the trigger button that opened this menu (it should have aria-expanded="true") + // This provides a more reliable way to identify the correct message than selecting the last visible one + const menuTrigger = document.querySelector('div[aria-expanded="true"][role="button"], div[aria-expanded="true"][role="menuitem"], .T-I[aria-expanded="true"]'); + + if (menuTrigger) { + if (this.debug) { + console.debug('addSecureActionsToMessageMenu found menu trigger:', menuTrigger); + } + messageContainer = $(menuTrigger).closest(this.sel.msgOuter); + if (!messageContainer.length) { + // Fallback + messageContainer = $('div.h7:visible').last(); + } + } else { + messageContainer = $('div.h7:visible').last(); // Get the last visible message container + } + const msgIdElement = messageContainer.find('[data-legacy-message-id], [data-message-id]'); const msgId = msgIdElement.attr('data-legacy-message-id') || msgIdElement.attr('data-message-id'); From 6928d4cb407998e3dade846c1e0b1a6388aee0cc Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Mon, 26 Jan 2026 05:40:08 -0800 Subject: [PATCH 2/7] feat: added test --- .../webmail/gmail/gmail-element-replacer.ts | 2 +- test/source/tests/gmail.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts index 95dc9986536..b3804329d6b 100644 --- a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts +++ b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts @@ -976,7 +976,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { } // Find the message container from the menu's position or context - let messageContainer: JQuery; + let messageContainer; // Try to find the trigger button that opened this menu (it should have aria-expanded="true") // This provides a more reliable way to identify the correct message than selecting the last visible one diff --git a/test/source/tests/gmail.ts b/test/source/tests/gmail.ts index 44064561b2f..c29f435e7fb 100644 --- a/test/source/tests/gmail.ts +++ b/test/source/tests/gmail.ts @@ -719,6 +719,44 @@ export const defineGmailTests = (testVariant: TestVariant, testWithBrowser: Test // expect(content).to.contain('Fingerprint: 7A2E 4FFD 34BC 4AED 0F54 4199 D652 7AD6 65C3 B0DD'); // })); + test( + 'mail.google.com - verify secure reply buttons on expanded older message', + testWithBrowser(async (t, browser) => { + await BrowserRecipe.setUpCommonAcct(t, browser, 'ci.tests.gmail'); + const gmailPage = await openGmailPage(t, browser); + // Use a known thread with multiple messages. + await gotoGmailPage(gmailPage, '/FMfcgzGqRGfPBbNLWvfPvDbxnHBwkdGf'); + + // 1. Verify buttons on the newest message (usually expanded by default) + await Util.sleep(3); + let messages = await gmailPage.target.$$('[role="listitem"] .adn.ads'); + const newestMessage = messages[messages.length - 1]; + const newestMenuBtn = await newestMessage.$('[aria-label="More message options"]'); + await newestMenuBtn?.click(); + await Util.sleep(1); + + await gmailPage.waitAll('.action_reply_message_button'); + const collapsedMessage = await gmailPage.target.$('[role="listitem"] .adf.ads'); + if (collapsedMessage) { + await collapsedMessage.click(); + await Util.sleep(2); + } + + // Now find the 3-dot menu on this expanded older message. + // After expansion, it should have .adn.ads class active/visible. + + messages = await gmailPage.target.$$('[role="listitem"] .adn.ads'); + const olderExpandedMessage = messages[0]; + const olderMenuBtn = await olderExpandedMessage.$('[aria-label="More message options"]'); + expect(olderMenuBtn).to.be.ok; + await olderMenuBtn?.click(); + await Util.sleep(1); + + // Verify secure buttons exist in the open menu for the older message + await gmailPage.waitAll('.action_reply_message_button'); + }) + ); + const testMinimumElementHeight = async (page: ControllablePage, selector: string, min: number) => { // testing https://github.com/FlowCrypt/flowcrypt-browser/issues/3519 const elStyle = await page.target.$eval(selector, el => el.getAttribute('style')); // 'height: 289.162px;' From 8535761db64d6152ab2d7a2c8e7c4feb6e8fea26 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Thu, 29 Jan 2026 17:12:14 -0800 Subject: [PATCH 3/7] fix: message select issue --- .../webmail/gmail/gmail-element-replacer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts index b3804329d6b..4fcdfc4381b 100644 --- a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts +++ b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts @@ -57,6 +57,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { msgInnerText: 'table.cf.An', msgInnerContainingPgp: "div.a3s:not(.undefined):contains('" + PgpArmor.headers('null').begin + "')", msgActionsBtn: '.Wsq5Cf', + msgActionsBtnExpanded: '.Wsq5Cf[aria-expanded="true"]', msgActionsMenu: '.tB5Jxf-M-S5Cmsd, ul.aqdrmf-Kf[role="menu"]', attachmentsContainerOuter: 'div.hq.gt', attachmentsContainerInner: 'div.aQH', @@ -408,7 +409,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { this.insertEncryptedReplyBox(messageContainer, replyOption); } if (secureReplyInvokedFromMenu) { - $(this.sel.msgActionsBtn).trigger('click'); + $(this.sel.msgActionsBtnExpanded).trigger('click'); } }; @@ -979,20 +980,20 @@ export class GmailElementReplacer extends WebmailElementReplacer { let messageContainer; // Try to find the trigger button that opened this menu (it should have aria-expanded="true") - // This provides a more reliable way to identify the correct message than selecting the last visible one - const menuTrigger = document.querySelector('div[aria-expanded="true"][role="button"], div[aria-expanded="true"][role="menuitem"], .T-I[aria-expanded="true"]'); + const messageContainerSelector = 'div.h7:visible'; + const menuTrigger = document.querySelector(this.sel.msgActionsBtnExpanded); if (menuTrigger) { if (this.debug) { console.debug('addSecureActionsToMessageMenu found menu trigger:', menuTrigger); } - messageContainer = $(menuTrigger).closest(this.sel.msgOuter); + messageContainer = $(menuTrigger).closest(this.sel.msgOuter).closest(messageContainerSelector); if (!messageContainer.length) { // Fallback - messageContainer = $('div.h7:visible').last(); + messageContainer = $(messageContainerSelector).last(); } } else { - messageContainer = $('div.h7:visible').last(); // Get the last visible message container + messageContainer = $(messageContainerSelector).last(); // Get the last visible message container } const msgIdElement = messageContainer.find('[data-legacy-message-id], [data-message-id]'); From e150449e6dc989cbfd329507ee1af47386a945a3 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 1 Feb 2026 21:37:45 -0800 Subject: [PATCH 4/7] feat: added ui test for exporting all public keys --- test/source/tests/settings.ts | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index 18f75dc610c..8ed5e02c2bb 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -225,6 +225,49 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await SettingsPageRecipe.toggleScreen(settingsPage, 'basic'); }) ); + test( + 'settings - export all public keys from contacts', + testWithBrowser(async (t, browser) => { + const acct = 'flowcrypt.compatibility@gmail.com'; + await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'compatibility', { + google: { acctAliases: flowcryptCompatibilityAliasList }, + }); + const settingsPage = await browser.newExtensionSettingsPage(t, acct); + await SettingsPageRecipe.toggleScreen(settingsPage, 'additional'); + const contactsFrame = await SettingsPageRecipe.awaitNewPageFrame(settingsPage, '@action-open-contacts-page', ['contacts.htm', 'placement=settings']); + await contactsFrame.waitAll('@page-contacts'); + await Util.sleep(1); + + // Trigger the export and capture the downloaded file + const downloadedFiles = await contactsFrame.awaitDownloadTriggeredByClicking(async () => { + await contactsFrame.waitAndClick('.action_export_all'); + }); + + // Verify the file was downloaded + expect(Object.keys(downloadedFiles)).to.have.lengthOf(1); + expect(Object.keys(downloadedFiles)[0]).to.equal('public-keys-export.asc'); + + // Verify the file content is not empty + const fileContent = downloadedFiles['public-keys-export.asc'].toString(); + expect(fileContent).to.not.be.empty; + + // Verify the file contains PGP public key blocks + expect(fileContent).to.contain('-----BEGIN PGP PUBLIC KEY BLOCK-----'); + expect(fileContent).to.contain('-----END PGP PUBLIC KEY BLOCK-----'); + + // Verify it contains the expected public keys (the account's own keys) + const { keys } = await KeyUtil.readMany(Buf.fromUtfStr(fileContent)); + expect(keys.length).to.be.greaterThan(0); + + // Verify the keys can be parsed (they're valid PGP keys) + for (const key of keys) { + expect(key.id).to.not.be.empty; + } + + await SettingsPageRecipe.closeDialog(settingsPage); + await SettingsPageRecipe.toggleScreen(settingsPage, 'basic'); + }) + ); test( 'settings - update contact public key', testWithBrowser(async (t, browser) => { From 931ffcebed9fb03935d7c646eb96315c91ed6285 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 1 Feb 2026 23:07:07 -0800 Subject: [PATCH 5/7] fix: code issue --- test/source/tests/settings.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index 8ed5e02c2bb..cc7596cfc9d 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -244,11 +244,13 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T }); // Verify the file was downloaded - expect(Object.keys(downloadedFiles)).to.have.lengthOf(1); - expect(Object.keys(downloadedFiles)[0]).to.equal('public-keys-export.asc'); + const entries = Object.entries(downloadedFiles); + expect(entries.length).to.equal(1); + const [filename, data] = entries[0]; + expect(filename).to.equal('public-keys-export.asc'); // Verify the file content is not empty - const fileContent = downloadedFiles['public-keys-export.asc'].toString(); + const fileContent = data.toString(); expect(fileContent).to.not.be.empty; // Verify the file contains PGP public key blocks From a85919d8ccf82d45f312cc47b4fb3cbda83819df Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 1 Feb 2026 23:34:22 -0800 Subject: [PATCH 6/7] fix: eslint --- test/source/tests/settings.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index cc7596cfc9d..9b4d83eebb2 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -244,13 +244,11 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T }); // Verify the file was downloaded - const entries = Object.entries(downloadedFiles); - expect(entries.length).to.equal(1); - const [filename, data] = entries[0]; - expect(filename).to.equal('public-keys-export.asc'); + expect(Object.keys(downloadedFiles).length).to.equal(1); + expect(downloadedFiles['public-keys-export.asc']).to.exist; // Verify the file content is not empty - const fileContent = data.toString(); + const fileContent = downloadedFiles['public-keys-export.asc'].toString(); expect(fileContent).to.not.be.empty; // Verify the file contains PGP public key blocks From 7c20e18dfd8dd8c364f2cfd90d3c513979a33de9 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 1 Feb 2026 23:51:08 -0800 Subject: [PATCH 7/7] fix: eslint --- extension/chrome/settings/modules/contacts.ts | 2 +- test/source/tests/settings.ts | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/extension/chrome/settings/modules/contacts.ts b/extension/chrome/settings/modules/contacts.ts index 87cea54ed52..39620574349 100644 --- a/extension/chrome/settings/modules/contacts.ts +++ b/extension/chrome/settings/modules/contacts.ts @@ -72,7 +72,7 @@ View.run( substring: String($('.input-search-contacts').val()), }); let lineActionsHtml = - '  Export all  ' + + '  Export all  ' + '  Import public keys  '; if (this.clientConfiguration.getCustomSksPubkeyServer()) { lineActionsHtml += diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index 9b4d83eebb2..530d9d61196 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -239,17 +239,11 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await Util.sleep(1); // Trigger the export and capture the downloaded file - const downloadedFiles = await contactsFrame.awaitDownloadTriggeredByClicking(async () => { - await contactsFrame.waitAndClick('.action_export_all'); - }); - - // Verify the file was downloaded - expect(Object.keys(downloadedFiles).length).to.equal(1); + const downloadedFiles = await contactsFrame.awaitDownloadTriggeredByClicking('@action-export-all-public-keys'); expect(downloadedFiles['public-keys-export.asc']).to.exist; // Verify the file content is not empty const fileContent = downloadedFiles['public-keys-export.asc'].toString(); - expect(fileContent).to.not.be.empty; // Verify the file contains PGP public key blocks expect(fileContent).to.contain('-----BEGIN PGP PUBLIC KEY BLOCK-----'); @@ -257,7 +251,6 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T // Verify it contains the expected public keys (the account's own keys) const { keys } = await KeyUtil.readMany(Buf.fromUtfStr(fileContent)); - expect(keys.length).to.be.greaterThan(0); // Verify the keys can be parsed (they're valid PGP keys) for (const key of keys) {