commit 16c739b7a1679507e51101c2dcb35c1aaf2be553 parent e6a5b1c759c695b57077d410ba3d149f22680fbe Author: kpatenio <kpatenio@mozilla.com> Date: Thu, 13 Nov 2025 00:03:45 +0000 Bug 1997404 — split the panel status card into its own component. r=ip-protection-reviewers,rking,frontend-codestyle-reviewers Differential Revision: https://phabricator.services.mozilla.com/D270913 Diffstat:
14 files changed, 666 insertions(+), 404 deletions(-)
diff --git a/browser/components/ipprotection/content/ipprotection-content.css b/browser/components/ipprotection/content/ipprotection-content.css @@ -4,16 +4,6 @@ @import "chrome://global/skin/global.css"; -:host { - --status-card-connected-background-color: var(--color-violet-90); - --connection-globe-icon-size: 48px; - --connection-rings-extra-space-x: 23px; - --connection-rings-extra-space-y: 23px; - --connection-shield-color: var(--panel-background); - --connection-shield-off-background-color: var(--icon-color-critical); - --connection-shield-on-background-color: var(--color-accent-primary); -} - hr { border-style: solid none none none; border-color: var(--panel-separator-color); @@ -32,87 +22,6 @@ hr { margin-block-start: var(--space-small); } -.vpn-status-group { - position: relative; - margin: var(--space-large); - overflow: hidden; -} - -.is-enabled { - color: #fff; - background-color: var(--status-card-connected-background-color); -} - -#status-card { - --shield-bgcolor: var(--connection-shield-off-background-color); - --box-icon-size: var(--connection-globe-icon-size); - --box-icon-fill: var(--connection-shield-color); - --box-icon-stroke: var(--shield-bgcolor); - --box-label-font-weight: var(--font-weight-semibold); - -moz-context-properties: fill, stroke; - /** - * moz-box-group changes the border of items based on its position in the group. - * Undo it to preserve the border radius even though the animation rings are - * loaded first in the group. - */ - --box-border-radius-start: initial; - fill: var(--connection-shield-color); - stroke: var(--shield-bgcolor); - - &.is-enabled { - --shield-bgcolor: var(--connection-shield-on-background-color); - } -} - -#connection-toggle { - --toggle-width: var(--size-item-xlarge); - --toggle-height: var(--size-item-medium); - --toggle-dot-margin: 2px; - margin-inline-end: var(--space-small); -} - -#status-card-animation { - display: block; - position: absolute; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 1; -} - -#animation-rings { - display: block; - width: 100%; - height: 100%; - contain: strict; - /** - * A hacky attempt at centering the rings with the globe icon. - * It makes some assumptions about the icon size and padding pixels used in moz-box-item. - * The rings are 200px in height and width, so they have a radius of 100px. - * Additional px gap is merely for alignment and not tied to any element. - * TODO: (Bug 1981251) See if we can better calculate the center coords. - */ - background-position-x: calc(-100px + var(--connection-globe-icon-size) + var(--space-large) - var(--connection-rings-extra-space-x)); - background-position-y: calc(-100px + var(--connection-globe-icon-size) + var(--space-large) - var(--connection-rings-extra-space-y)); - background-image: url("chrome://browser/content/ipprotection/assets/rings.svg"); - background-repeat: no-repeat; - - &:dir(rtl) { - --connection-rings-extra-space-x: 4.5px; - /* Shift by approximately 100% of the element's width to move the rings to the opposite side. */ - background-position-x: calc(100% + var(--connection-globe-icon-size) + var(--space-large) + var(--connection-rings-extra-space-x)); - } - - @media (prefers-reduced-motion: reduce) { - display: none; - } -} - -#location-wrapper { - --box-icon-fill: currentColor; - --box-label-font-weight: var(--font-weight-semibold); -} - .vpn-bottom-content { display: flex; flex-direction: column; diff --git a/browser/components/ipprotection/content/ipprotection-content.mjs b/browser/components/ipprotection/content/ipprotection-content.mjs @@ -3,21 +3,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; -import { - html, - classMap, - ifDefined, -} from "chrome://global/content/vendor/lit.all.mjs"; +import { html } from "chrome://global/content/vendor/lit.all.mjs"; import { LINKS, ERRORS, } from "chrome://browser/content/ipprotection/ipprotection-constants.mjs"; -import { - connectionTimer, - defaultTimeValue, -} from "chrome://browser/content/ipprotection/ipprotection-timer.mjs"; - // eslint-disable-next-line import/no-unassigned-import import "chrome://browser/content/ipprotection/ipprotection-header.mjs"; // eslint-disable-next-line import/no-unassigned-import @@ -27,6 +18,8 @@ import "chrome://browser/content/ipprotection/ipprotection-message-bar.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://browser/content/ipprotection/ipprotection-signedout.mjs"; // eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/ipprotection/ipprotection-status-card.mjs"; +// eslint-disable-next-line import/no-unassigned-import import "chrome://global/content/elements/moz-toggle.mjs"; /** @@ -37,10 +30,7 @@ export default class IPProtectionContentElement extends MozLitElement { headerEl: "ipprotection-header", signedOutEl: "ipprotection-signedout", messagebarEl: "ipprotection-message-bar", - statusCardEl: "#status-card", - animationEl: "#status-card-animation", - connectionToggleEl: "#connection-toggle", - locationEl: "#location-wrapper", + statusCardEl: "ipprotection-status-card", upgradeEl: "#upgrade-vpn-content", activeSubscriptionEl: "#active-subscription-vpn-content", supportLinkEl: "#vpn-support-link", @@ -48,10 +38,8 @@ export default class IPProtectionContentElement extends MozLitElement { static properties = { state: { type: Object, attribute: false }, - showAnimation: { type: Boolean, state: true }, _showMessageBar: { type: Boolean, state: true }, _messageDismissed: { type: Boolean, state: true }, - _enabled: { type: Boolean, state: true }, }; constructor() { @@ -61,10 +49,9 @@ export default class IPProtectionContentElement extends MozLitElement { this.keyListener = this.#keyListener.bind(this); this.messageBarListener = this.#messageBarListener.bind(this); + this.toggleListener = this.#toggleEventListener.bind(this); this._showMessageBar = false; this._messageDismissed = false; - this.showAnimation = false; - this._enabled = null; } connectedCallback() { @@ -72,6 +59,14 @@ export default class IPProtectionContentElement extends MozLitElement { this.dispatchEvent(new CustomEvent("IPProtection:Init", { bubbles: true })); this.addEventListener("keydown", this.keyListener, { capture: true }); this.addEventListener( + "ipprotection-status-card:user-toggled-on", + this.#toggleEventListener + ); + this.addEventListener( + "ipprotection-status-card:user-toggled-off", + this.#toggleEventListener + ); + this.addEventListener( "ipprotection-message-bar:user-dismissed", this.#messageBarListener ); @@ -82,6 +77,14 @@ export default class IPProtectionContentElement extends MozLitElement { this.removeEventListener("keydown", this.keyListener, { capture: true }); this.removeEventListener( + "ipprotection-status-card:user-toggled-on", + this.#toggleEventListener + ); + this.removeEventListener( + "ipprotection-status-card:user-toggled-off", + this.#toggleEventListener + ); + this.removeEventListener( "ipprotection-message-bar:user-dismissed", this.#messageBarListener ); @@ -96,6 +99,10 @@ export default class IPProtectionContentElement extends MozLitElement { ); } + get canEnableConnection() { + return this.state && this.state.isProtectionEnabled && !this.state.error; + } + get #hasErrors() { return !this.state || this.state.error !== ""; } @@ -112,22 +119,6 @@ export default class IPProtectionContentElement extends MozLitElement { } } - handleToggleConnect(event) { - let isEnabled = event.target.pressed; - - if (isEnabled) { - this.dispatchEvent( - new CustomEvent("IPProtection:UserEnable", { bubbles: true }) - ); - } else { - this.dispatchEvent( - new CustomEvent("IPProtection:UserDisable", { bubbles: true }) - ); - } - - this._enabled = isEnabled; - } - handleUpgrade(event) { const win = event.target.ownerGlobal; win.openWebLinkIn(LINKS.PRODUCT_URL + "#pricing", "tab"); @@ -143,7 +134,7 @@ export default class IPProtectionContentElement extends MozLitElement { if (this.state.isSignedOut) { this.signedOutEl?.focus(); } else { - this.connectionToggleEl?.focus(); + this.statusCardEl?.focus(); } } @@ -171,6 +162,18 @@ export default class IPProtectionContentElement extends MozLitElement { } } + #toggleEventListener(event) { + if (event.type === "ipprotection-status-card:user-toggled-on") { + this.dispatchEvent( + new CustomEvent("IPProtection:UserEnable", { bubbles: true }) + ); + } else if (event.type === "ipprotection-status-card:user-toggled-off") { + this.dispatchEvent( + new CustomEvent("IPProtection:UserDisable", { bubbles: true }) + ); + } + } + #messageBarListener(event) { if (event.type === "ipprotection-message-bar:user-dismissed") { this._showMessageBar = false; @@ -182,26 +185,9 @@ export default class IPProtectionContentElement extends MozLitElement { updated(changedProperties) { super.updated(changedProperties); - // Set the toggle to the protection enabled state, if it hasn't just changed. - if (!changedProperties.has("_enabled")) { - this._enabled = this.state.isProtectionEnabled; - } - - // Clear hiding messages and disable the toggle when if there is an error. + // Clear messages when there is an error. if (this.state.error) { this._messageDismissed = false; - this._enabled = false; - } - - /** - * Don't show animations until all elements are connected and layout is fully drawn. - * This will allow us to best position our animation component with the globe icon - * based on the most up to date status card dimensions. - */ - if (this.state.isProtectionEnabled) { - this.showAnimation = true; - } else { - this.showAnimation = false; } } @@ -225,59 +211,15 @@ export default class IPProtectionContentElement extends MozLitElement { : null; } - animationRingsTemplate() { - return html` <div id="status-card-animation"> - <div id="animation-rings"></div> - </div>`; - } - statusCardTemplate() { - let protectionEnabled = this.state.isProtectionEnabled; - const statusCardL10nId = protectionEnabled - ? "ipprotection-connection-status-on" - : "ipprotection-connection-status-off"; - const toggleL10nId = protectionEnabled - ? "ipprotection-toggle-active" - : "ipprotection-toggle-inactive"; - const statusIcon = protectionEnabled - ? "chrome://browser/content/ipprotection/assets/ipprotection-connection-on.svg" - : "chrome://browser/content/ipprotection/assets/ipprotection-connection-off.svg"; - - let time = this.canShowConnectionTime - ? connectionTimer(this.state.protectionEnabledSince) - : defaultTimeValue; - - return html` <moz-box-group class="vpn-status-group"> - ${this.showAnimation ? this.animationRingsTemplate() : null} - <moz-box-item - id="status-card" - class=${classMap({ - "is-enabled": this.state.isProtectionEnabled, - })} - layout="large-icon" - iconsrc=${statusIcon} - data-l10n-id=${statusCardL10nId} - data-l10n-args=${time} - > - <moz-toggle - id="connection-toggle" - data-l10n-id=${toggleL10nId} - @click=${this.handleToggleConnect} - ?pressed=${ifDefined(this._enabled)} - slot="actions" - ></moz-toggle> - </moz-box-item> - <moz-box-item - id="location-wrapper" - class=${classMap({ - "is-enabled": this.state.isProtectionEnabled, - })} - iconsrc="chrome://global/skin/icons/info.svg" - data-l10n-id="ipprotection-location-title" - .description=${this.descriptionTemplate()} - > - </moz-box-item> - </moz-box-group>`; + return html` + <ipprotection-status-card + .protectionEnabled=${this.canEnableConnection} + .canShowTime=${this.canShowConnectionTime} + .enabledSince=${this.state.protectionEnabledSince} + .location=${this.state.location} + ></ipprotection-status-card> + `; } beforeUpgradeTemplate() { diff --git a/browser/components/ipprotection/content/ipprotection-status-card.css b/browser/components/ipprotection/content/ipprotection-status-card.css @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +@import "chrome://global/skin/global.css"; + +:host { + --status-card-connected-background-color: var(--color-violet-90); + --status-card-connected-text-color: var(--color-white); + --connection-globe-icon-size: 48px; + --connection-rings-extra-space-x: 23px; + --connection-rings-extra-space-y: 23px; + --connection-shield-color: var(--panel-background); + --connection-shield-off-background-color: var(--icon-color-critical); + --connection-shield-on-background-color: var(--color-accent-primary); +} + +#ipprotection-content-wrapper { + display: flex; + flex-direction: column; + padding-inline: var(--panel-subview-body-padding-inline); + padding-block: var(--panel-subview-body-padding-block); +} + +.vpn-top-content { + margin-inline: var(--space-large); + margin-block-start: var(--space-small); +} + +.vpn-status-group { + display: block; + position: relative; + margin: var(--space-large); + overflow: hidden; +} + +.is-enabled { + color: var(--status-card-connected-text-color); + background-color: var(--status-card-connected-background-color); +} + +#status-card { + --shield-bgcolor: var(--connection-shield-off-background-color); + --box-icon-size: var(--connection-globe-icon-size); + --box-icon-fill: var(--connection-shield-color); + --box-icon-stroke: var(--shield-bgcolor); + --box-label-font-weight: var(--font-weight-semibold); + -moz-context-properties: fill, stroke; + /** + * moz-box-group changes the border of items based on its position in the group. + * Undo it to preserve the border radius even though the animation rings are + * loaded first in the group. + */ + --box-border-radius-start: initial; + fill: var(--connection-shield-color); + stroke: var(--shield-bgcolor); + + &.is-enabled { + --shield-bgcolor: var(--connection-shield-on-background-color); + } +} + +#connection-toggle { + --toggle-width: var(--size-item-xlarge); + --toggle-height: var(--size-item-medium); + --toggle-dot-margin: 2px; + margin-inline-end: var(--space-small); +} + +#status-card-animation { + display: block; + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} + +#animation-rings { + display: block; + width: 100%; + height: 100%; + contain: strict; + /** + * A hacky attempt at centering the rings with the globe icon. + * It makes some assumptions about the icon size and padding pixels used in moz-box-item. + * The rings are 200px in height and width, so they have a radius of 100px. + * Additional px gap is merely for alignment and not tied to any element. + * TODO: (Bug 1981251) See if we can better calculate the center coords. + */ + background-position-x: calc(-100px + var(--connection-globe-icon-size) + var(--space-large) - var(--connection-rings-extra-space-x)); + background-position-y: calc(-100px + var(--connection-globe-icon-size) + var(--space-large) - var(--connection-rings-extra-space-y)); + background-image: url("chrome://browser/content/ipprotection/assets/rings.svg"); + background-repeat: no-repeat; + + &:dir(rtl) { + --connection-rings-extra-space-x: 4.5px; + /* Shift by approximately 100% of the element's width to move the rings to the opposite side. */ + background-position-x: calc(100% + var(--connection-globe-icon-size) + var(--space-large) + var(--connection-rings-extra-space-x)); + } + + @media (prefers-reduced-motion: reduce) { + display: none; + } +} + +#location-wrapper { + --box-icon-fill: currentColor; + --box-label-font-weight: var(--font-weight-semibold); +} diff --git a/browser/components/ipprotection/content/ipprotection-status-card.mjs b/browser/components/ipprotection/content/ipprotection-status-card.mjs @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { html, classMap } from "chrome://global/content/vendor/lit.all.mjs"; +import { + connectionTimer, + defaultTimeValue, +} from "chrome://browser/content/ipprotection/ipprotection-timer.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/ipprotection/ipprotection-header.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/ipprotection/ipprotection-flag.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-toggle.mjs"; + +/** + * Custom element that implements a status card for IP protection. + */ +export default class IPProtectionStatusCard extends MozLitElement { + static queries = { + statusGroupEl: "#status-card", + animationEl: "#status-card-animation", + connectionToggleEl: "#connection-toggle", + locationEl: "#location-wrapper", + }; + + static shadowRootOptions = { + ...MozLitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static properties = { + protectionEnabled: { type: Boolean }, + canShowTime: { type: Boolean }, + enabledSince: { type: Object }, + location: { type: Object }, + _showAnimation: { type: Boolean, state: true }, + // Track toggle state separately so that we can tell when the toggle + // is enabled because of existing protection state or because of user action. + _toggleEnabled: { type: Boolean, state: true }, + }; + + constructor() { + super(); + + this.keyListener = this.#keyListener.bind(this); + this._showAnimation = false; + } + + connectedCallback() { + super.connectedCallback(); + this.dispatchEvent(new CustomEvent("IPProtection:Init", { bubbles: true })); + this.addEventListener("keydown", this.keyListener, { capture: true }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.removeEventListener("keydown", this.keyListener, { capture: true }); + } + + handleToggleConnect(event) { + let isEnabled = event.target.pressed; + + if (isEnabled) { + this.dispatchEvent( + new CustomEvent("ipprotection-status-card:user-toggled-on", { + bubbles: true, + composed: true, + }) + ); + } else { + this.dispatchEvent( + new CustomEvent("ipprotection-status-card:user-toggled-off", { + bubbles: true, + composed: true, + }) + ); + } + + this._toggleEnabled = isEnabled; + } + + focus() { + this.connectionToggleEl?.focus(); + } + + #keyListener(event) { + let keyCode = event.code; + switch (keyCode) { + case "ArrowUp": + // Intentional fall-through + case "ArrowDown": { + event.stopPropagation(); + event.preventDefault(); + + let direction = + keyCode == "ArrowDown" + ? Services.focus.MOVEFOCUS_FORWARD + : Services.focus.MOVEFOCUS_BACKWARD; + Services.focus.moveFocus( + window, + null, + direction, + Services.focus.FLAG_BYKEY + ); + break; + } + } + } + + updated(changedProperties) { + super.updated(changedProperties); + + // If the toggle state isn't set, do so now and let it + // match the protection state. + if (!changedProperties.has("_toggleEnabled")) { + this._toggleEnabled = this.protectionEnabled; + } + + if (!this.protectionEnabled && this._toggleEnabled) { + // After pressing the toggle, if somehow protection was turned off + // (eg. error thrown), unset the toggle. + this._toggleEnabled = false; + } + + /** + * Don't show animations until all elements are connected and layout is fully drawn. + * This will allow us to best position our animation component with the globe icon + * based on the most up to date status card dimensions. + */ + if (this.protectionEnabled) { + this._showAnimation = true; + } else { + this._showAnimation = false; + } + } + + descriptionTemplate() { + return this.location + ? html` + <ipprotection-flag .location=${this.location}></ipprotection-flag> + ` + : null; + } + + animationRingsTemplate() { + return html` <div id="status-card-animation"> + <div id="animation-rings"></div> + </div>`; + } + + alphaCardTemplate() { + const statusCardL10nId = this.protectionEnabled + ? "ipprotection-connection-status-on" + : "ipprotection-connection-status-off"; + const toggleL10nId = this.protectionEnabled + ? "ipprotection-toggle-active" + : "ipprotection-toggle-inactive"; + const statusIcon = this.protectionEnabled + ? "chrome://browser/content/ipprotection/assets/ipprotection-connection-on.svg" + : "chrome://browser/content/ipprotection/assets/ipprotection-connection-off.svg"; + + let time = this.canShowTime + ? connectionTimer(this.enabledSince) + : defaultTimeValue; + + return html` <moz-box-group class="vpn-status-group"> + ${this._showAnimation ? this.animationRingsTemplate() : null} + <moz-box-item + id="status-card" + class=${classMap({ + "is-enabled": this.protectionEnabled, + })} + layout="large-icon" + iconsrc=${statusIcon} + data-l10n-id=${statusCardL10nId} + data-l10n-args=${time} + > + <moz-toggle + id="connection-toggle" + data-l10n-id=${toggleL10nId} + @click=${this.handleToggleConnect} + ?pressed=${this._toggleEnabled} + slot="actions" + ></moz-toggle> + </moz-box-item> + <moz-box-item + id="location-wrapper" + class=${classMap({ + "is-enabled": this.protectionEnabled, + })} + iconsrc="chrome://global/skin/icons/info.svg" + data-l10n-id="ipprotection-location-title" + .description=${this.descriptionTemplate()} + > + </moz-box-item> + </moz-box-group>`; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/ipprotection/ipprotection-status-card.css" + /> + ${this.alphaCardTemplate()} + `; + } +} + +customElements.define("ipprotection-status-card", IPProtectionStatusCard); diff --git a/browser/components/ipprotection/jar.mn b/browser/components/ipprotection/jar.mn @@ -15,4 +15,6 @@ browser.jar: content/browser/ipprotection/ipprotection-flag.mjs (content/ipprotection-flag.mjs) content/browser/ipprotection/ipprotection-message-bar.mjs (content/ipprotection-message-bar.mjs) content/browser/ipprotection/ipprotection-signedout.mjs (content/ipprotection-signedout.mjs) + content/browser/ipprotection/ipprotection-status-card.css (content/ipprotection-status-card.css) + content/browser/ipprotection/ipprotection-status-card.mjs (content/ipprotection-status-card.mjs) content/browser/ipprotection/ipprotection-timer.mjs (content/ipprotection-timer.mjs) diff --git a/browser/components/ipprotection/tests/browser/browser.toml b/browser/components/ipprotection/tests/browser/browser.toml @@ -34,6 +34,8 @@ prefs = [ ["browser_ipprotection_proxy_errors.js"] +["browser_ipprotection_status_card.js"] + ["browser_ipprotection_telemetry.js"] ["browser_ipprotection_toolbar.js"] diff --git a/browser/components/ipprotection/tests/browser/browser_IPProtectionService.js b/browser/components/ipprotection/tests/browser/browser_IPProtectionService.js @@ -158,9 +158,11 @@ add_task( "User should now be enrolled" ); + let statusCard = content.statusCardEl; + // User is already signed in so the toggle should be available. Assert.ok( - content.connectionToggleEl, + statusCard?.connectionToggleEl, "Status card connection toggle should be present" ); @@ -278,9 +280,18 @@ add_task(async function test_IPProtectionService_pass_errors() { () => content.shadowRoot.querySelector("ipprotection-message-bar") ); - content.connectionToggleEl.click(); + let statusCard = content.statusCardEl; + + let toggleChangedPromise = BrowserTestUtils.waitForMutationCondition( + statusCard.shadowRoot, + { childList: true, subtree: true }, + () => !statusCard.toggleEnabled + ); + + statusCard.connectionToggleEl.click(); await messageBarLoadedPromise; + await toggleChangedPromise; Assert.equal( IPPProxyManager.state, @@ -290,7 +301,10 @@ add_task(async function test_IPProtectionService_pass_errors() { let messageBar = content.shadowRoot.querySelector("ipprotection-message-bar"); - Assert.ok(!content.connectionToggleEl.pressed, "Toggle is off"); + Assert.ok( + !statusCard.connectionToggleEl.pressed, + "Toggle is still turned off because of an error" + ); Assert.ok(messageBar, "Message bar should be present"); Assert.equal( content.state.error, @@ -329,6 +343,7 @@ add_task(async function test_IPProtectionService_retry_errors() { IPPProxyManager.updateState(); let content = await openPanel(); + let statusCard = content.statusCardEl; // Mock a failure IPPEnrollAndEntitleManager.resetEntitlement(); @@ -340,7 +355,7 @@ add_task(async function test_IPProtectionService_retry_errors() { false, () => !!IPPProxyManager.activatedAt ); - content.connectionToggleEl.click(); + statusCard.connectionToggleEl.click(); await startedEventPromise; @@ -366,13 +381,14 @@ add_task(async function test_IPProtectionService_stop_on_signout() { IPProtectionService.updateState(); let content = await openPanel(); + let statusCard = content.statusCardEl; Assert.ok( BrowserTestUtils.isVisible(content), "ipprotection content component should be present" ); Assert.ok( - content.connectionToggleEl, + statusCard.connectionToggleEl, "Status card connection toggle should be present" ); @@ -382,7 +398,7 @@ add_task(async function test_IPProtectionService_stop_on_signout() { false, () => !!IPPProxyManager.activatedAt ); - content.connectionToggleEl.click(); + statusCard.connectionToggleEl.click(); await startedEventPromise; @@ -441,6 +457,7 @@ add_task(async function test_IPProtectionService_reload() { }); let content = await openPanel(); + let statusCard = content.statusCardEl; await IPProtectionService.enrolling; Assert.equal( IPProtectionService.state, @@ -453,18 +470,18 @@ add_task(async function test_IPProtectionService_reload() { "ipprotection content component should be present" ); Assert.ok( - content.connectionToggleEl, + statusCard.connectionToggleEl, "Status card connection toggle should be present" ); let tabReloaded = waitForTabReloaded(gBrowser.selectedTab); - content.connectionToggleEl.click(); + statusCard.connectionToggleEl.click(); await tabReloaded; Assert.equal(IPPProxyManager.state, IPPProxyStates.ACTIVE, "Proxy is active"); tabReloaded = waitForTabReloaded(gBrowser.selectedTab); - content.connectionToggleEl.click(); + statusCard.connectionToggleEl.click(); await tabReloaded; Assert.notStrictEqual( diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_content.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_content.js @@ -93,187 +93,6 @@ add_task(async function test_main_content() { await panelHiddenPromise; }); -/** - * Tests UI updates to the status card in the panel after enable/disable. - */ -add_task(async function test_status_card() { - const l10nIdOn = "ipprotection-connection-status-on"; - const l10nIdOff = "ipprotection-connection-status-off"; - const fiveDaysInMS = 5 * 24 * 60 * 60 * 1000; - const enabledSince = Date.now() - fiveDaysInMS; - const mockLocation = { - name: "United States", - code: "us", - }; - - let content = await openPanel({ - isSignedOut: false, - protectionEnabledSince: enabledSince, - location: mockLocation, - }); - - Assert.ok( - BrowserTestUtils.isVisible(content), - "ipprotection content component should be present" - ); - Assert.ok(content.statusCardEl, "Status card should be present"); - Assert.equal( - content.statusCardEl?.getAttribute("data-l10n-id"), - l10nIdOff, - "Status card connection toggle data-l10n-id should be correct by default" - ); - Assert.equal( - content.statusCardEl?.description, - "", - "Time string should be empty" - ); - Assert.ok(content.locationEl, "Location details should be present"); - - let flag = content.locationEl?.shadowRoot.querySelector("ipprotection-flag"); - - Assert.ok(flag, "Flag component should be present"); - - let animationLoadedPromise = BrowserTestUtils.waitForMutationCondition( - content.shadowRoot, - { childList: true, subtree: true }, - () => !content.animationEl - ); - let timerUpdatedPromise = BrowserTestUtils.waitForMutationCondition( - content.shadowRoot, - { childList: true, subtree: true }, - () => JSON.parse(content.statusCardEl.dataset.l10nArgs).time != "" - ); - - // Set state as if protection is enabled - content.state.isProtectionEnabled = true; - content.state.protectionEnabledSince = enabledSince; - content.requestUpdate(); - await Promise.all([ - content.updateComplete, - timerUpdatedPromise, - animationLoadedPromise, - ]); - - Assert.equal( - content.statusCardEl?.getAttribute("data-l10n-id"), - l10nIdOn, - "Status card connection toggle data-l10n-id should be correct when protection is enabled" - ); - Assert.ok(content.animationEl, "Status card animation should be present"); - - let animationUnloadedPromise = BrowserTestUtils.waitForMutationCondition( - content.shadowRoot, - { childList: true, subtree: true }, - () => !content.animationEl - ); - let timerStoppedPromise = BrowserTestUtils.waitForMutationCondition( - content.shadowRoot, - { childList: true, subtree: true }, - () => JSON.parse(content.statusCardEl.dataset.l10nArgs).time === "" - ); - - // Set state as if protection is disabled - content.state.isProtectionEnabled = false; - content.requestUpdate(); - await Promise.all([ - content.updateComplete, - animationUnloadedPromise, - timerStoppedPromise, - ]); - - Assert.equal( - content.statusCardEl?.getAttribute("data-l10n-id"), - l10nIdOff, - "Status card connection toggle data-l10n-id should be correct when protection is disabled" - ); - Assert.ok( - !content.animationEl, - "Status card animation should not be present" - ); - - await closePanel(); -}); - -/** - * Tests that the correct IPProtection events are dispatched on toggle. - */ -add_task(async function test_ipprotection_events_on_toggle() { - const userEnableEventName = "IPProtection:UserEnable"; - const userDisableEventName = "IPProtection:UserDisable"; - - // Reset service state. - cleanupService(); - IPProtectionService.updateState(); - - let button = document.getElementById(lazy.IPProtectionWidget.WIDGET_ID); - let panelView = PanelMultiView.getViewNode( - document, - lazy.IPProtectionWidget.PANEL_ID - ); - - let panelShownPromise = waitForPanelEvent(document, "popupshown"); - // Open the panel - button.click(); - await panelShownPromise; - - let content = panelView.querySelector(lazy.IPProtectionPanel.CONTENT_TAGNAME); - - setupService({ - isSignedIn: true, - isEnrolledAndEntitled: true, - }); - await IPPEnrollAndEntitleManager.refetchEntitlement(); - - Assert.ok( - BrowserTestUtils.isVisible(content), - "ipprotection content component should be present" - ); - Assert.ok(content.statusCardEl, "Status card should be present"); - Assert.ok( - content.connectionToggleEl, - "Status card connection toggle should be present" - ); - - let enableEventPromise = BrowserTestUtils.waitForEvent( - window, - userEnableEventName - ); - - content.connectionToggleEl.click(); - - await enableEventPromise; - Assert.ok("Enable event was found after clicking the toggle"); - let userEnabledPref = Services.prefs.getBoolPref( - "browser.ipProtection.userEnabled", - false - ); - Assert.equal(userEnabledPref, true, "userEnabled pref should be set to true"); - - let disableEventPromise = BrowserTestUtils.waitForEvent( - window, - userDisableEventName - ); - content.connectionToggleEl.click(); - - await disableEventPromise; - Assert.ok("Disable event was found after clicking the toggle"); - userEnabledPref = Services.prefs.getBoolPref( - "browser.ipProtection.userEnabled", - true - ); - Assert.equal( - userEnabledPref, - false, - "userEnabled pref should be set to false" - ); - - // Close the panel - let panelHiddenPromise = waitForPanelEvent(document, "popuphidden"); - EventUtils.synthesizeKey("KEY_Escape"); - await panelHiddenPromise; - cleanupService(); -}); - add_task(async function test_support_link() { let button = document.getElementById(lazy.IPProtectionWidget.WIDGET_ID); let panelView = PanelMultiView.getViewNode( @@ -300,10 +119,7 @@ add_task(async function test_support_link() { let supportLink = content.upgradeEl.querySelector("#vpn-support-link"); Assert.ok(supportLink, "Support link should be present"); - let newTabPromise = BrowserTestUtils.waitForNewTab( - gBrowser, - LINKS.PRODUCT_URL - ); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); let panelHiddenPromise = waitForPanelEvent(document, "popuphidden"); supportLink.click(); let newTab = await newTabPromise; diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_flag.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_flag.js @@ -13,23 +13,26 @@ add_task(async function test_flags_content() { code: "us", }; - let content = await openPanel(); + let content = await openPanel({ isSignedOut: false }); - let flagLoadedPromise = BrowserTestUtils.waitForMutationCondition( + let cardLoadedPromise = BrowserTestUtils.waitForMutationCondition( content.shadowRoot, { childList: true, subtree: true }, - () => content.locationEl?.shadowRoot.querySelector("ipprotection-flag") + () => content.statusCardEl ); await setPanelState({ isSignedOut: false, location: mockLocation, }); - await flagLoadedPromise; + await cardLoadedPromise; - Assert.ok(content.locationEl, "Location details should be present"); + let statusCard = content.statusCardEl; - let flag = content.locationEl?.shadowRoot.querySelector("ipprotection-flag"); + Assert.ok(statusCard.locationEl, "Location details should be present"); + + let flag = + statusCard.locationEl?.shadowRoot.querySelector("ipprotection-flag"); Assert.ok(flag, "Flag component should be present"); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_keyboard_navigation.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_keyboard_navigation.js @@ -34,7 +34,9 @@ add_task(async function test_keyboard_navigation_in_panel() { "ipprotection-content component should be present" ); - await expectFocusAfterKey("Tab", content.connectionToggleEl); + let statusCard = content.statusCardEl; + + await expectFocusAfterKey("Tab", statusCard.connectionToggleEl); await expectFocusAfterKey("Tab", content.upgradeEl.querySelector("a")); await expectFocusAfterKey( "Tab", @@ -42,7 +44,7 @@ add_task(async function test_keyboard_navigation_in_panel() { ); await expectFocusAfterKey("Tab", content.headerEl.helpButtonEl); // Loop back around - await expectFocusAfterKey("Tab", content.connectionToggleEl); + await expectFocusAfterKey("Tab", statusCard.connectionToggleEl); await expectFocusAfterKey("ArrowDown", content.upgradeEl.querySelector("a")); await expectFocusAfterKey( @@ -51,7 +53,7 @@ add_task(async function test_keyboard_navigation_in_panel() { ); await expectFocusAfterKey("ArrowDown", content.headerEl.helpButtonEl); // Loop back around - await expectFocusAfterKey("ArrowDown", content.connectionToggleEl); + await expectFocusAfterKey("ArrowDown", statusCard.connectionToggleEl); // Loop backwards await expectFocusAfterKey("Shift+Tab", content.headerEl.helpButtonEl); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_status_card.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_status_card.js @@ -0,0 +1,236 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LINKS } = ChromeUtils.importESModule( + "chrome://browser/content/ipprotection/ipprotection-constants.mjs" +); +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IPProtectionWidget: "resource:///modules/ipprotection/IPProtection.sys.mjs", + IPProtectionPanel: + "resource:///modules/ipprotection/IPProtectionPanel.sys.mjs", +}); + +/** + * Tests UI updates to the status card in the panel after enable/disable. + */ +add_task(async function test_status_card_in_panel() { + const l10nIdOn = "ipprotection-connection-status-on"; + const l10nIdOff = "ipprotection-connection-status-off"; + const fiveDaysInMS = 5 * 24 * 60 * 60 * 1000; + const enabledSince = Date.now() - fiveDaysInMS; + const mockLocation = { + name: "United States", + code: "us", + }; + + let content = await openPanel({ + isSignedOut: false, + protectionEnabledSince: enabledSince, + location: mockLocation, + }); + + Assert.ok( + BrowserTestUtils.isVisible(content), + "ipprotection content component should be present" + ); + + let statusCard = content.statusCardEl; + Assert.ok(content.statusCardEl, "ipprotection-status-card should be present"); + + Assert.equal( + statusCard?.statusGroupEl.getAttribute("data-l10n-id"), + l10nIdOff, + "Status card connection toggle data-l10n-id should be correct by default" + ); + Assert.equal( + statusCard?.statusGroupEl.description, + "", + "Time string should be empty" + ); + Assert.ok(statusCard.locationEl, "Location details should be present"); + + let flag = + statusCard.locationEl?.shadowRoot.querySelector("ipprotection-flag"); + + Assert.ok(flag, "Flag component should be present"); + + let animationLoadedPromise = BrowserTestUtils.waitForMutationCondition( + statusCard.shadowRoot, + { childList: true, subtree: true }, + () => statusCard.animationEl + ); + let timerUpdatedPromise = BrowserTestUtils.waitForMutationCondition( + statusCard.shadowRoot, + { childList: true, subtree: true }, + () => JSON.parse(statusCard.statusGroupEl.dataset.l10nArgs).time != "" + ); + + // Set state as if protection is enabled + await setPanelState({ + isSignedOut: false, + protectionEnabledSince: enabledSince, + location: mockLocation, + isProtectionEnabled: true, + }); + + content.requestUpdate(); + + await Promise.all([ + content.updateComplete, + timerUpdatedPromise, + animationLoadedPromise, + ]); + + Assert.equal( + statusCard?.statusGroupEl.getAttribute("data-l10n-id"), + l10nIdOn, + "Status card connection toggle data-l10n-id should be correct when protection is enabled" + ); + Assert.ok(statusCard.animationEl, "Status card animation should be present"); + + let animationUnloadedPromise = BrowserTestUtils.waitForMutationCondition( + statusCard.shadowRoot, + { childList: true, subtree: true }, + () => !statusCard.animationEl + ); + let timerStoppedPromise = BrowserTestUtils.waitForMutationCondition( + statusCard.shadowRoot, + { childList: true, subtree: true }, + () => JSON.parse(statusCard?.statusGroupEl.dataset.l10nArgs).time === "" + ); + + // // Set state as if protection is disabled + await setPanelState({ + isSignedOut: false, + protectionEnabledSince: enabledSince, + location: mockLocation, + isProtectionEnabled: false, + }); + + content.requestUpdate(); + + await Promise.all([ + content.updateComplete, + animationUnloadedPromise, + timerStoppedPromise, + ]); + + Assert.equal( + statusCard?.statusGroupEl.getAttribute("data-l10n-id"), + l10nIdOff, + "Status card connection toggle data-l10n-id should be correct when protection is disabled" + ); + Assert.ok( + !statusCard.animationEl, + "Status card animation should not be present" + ); + + await closePanel(); +}); + +/** + * Tests that the correct IPProtection events are dispatched on toggle. + */ +add_task(async function test_ipprotection_events_on_toggle() { + // These events are different from the ones sent by + // ipprotection-status-card. The prefixed "IPProtection:" events + // actually change the connection state in the service when dispatched. + // If the IPProtection events are sent, then we know that the status-card + // events worked. + const userEnableEventName = "IPProtection:UserEnable"; + const userDisableEventName = "IPProtection:UserDisable"; + + // Reset service state. + cleanupService(); + IPProtectionService.updateState(); + + setupService({ + isSignedIn: true, + isEnrolledAndEntitled: true, + canEnroll: true, + proxyPass: { + status: 200, + error: undefined, + pass: makePass(), + }, + }); + await IPPEnrollAndEntitleManager.refetchEntitlement(); + + let button = document.getElementById(lazy.IPProtectionWidget.WIDGET_ID); + let panelView = PanelMultiView.getViewNode( + document, + lazy.IPProtectionWidget.PANEL_ID + ); + + let panelShownPromise = waitForPanelEvent(document, "popupshown"); + // Open the panel + button.click(); + await panelShownPromise; + + let content = panelView.querySelector(lazy.IPProtectionPanel.CONTENT_TAGNAME); + + Assert.ok( + BrowserTestUtils.isVisible(content), + "ipprotection content component should be present" + ); + + let statusCard = content.statusCardEl; + + await BrowserTestUtils.waitForMutationCondition( + content.shadowRoot, + { childList: true, subtree: true }, + () => content.statusCardEl + ); + + Assert.ok(statusCard, "Status card should be present"); + + let toggle = statusCard?.connectionToggleEl; + Assert.ok(toggle, "Status card connection toggle should be present"); + + let enableEventPromise = BrowserTestUtils.waitForEvent( + window, + userEnableEventName + ); + + toggle.click(); + + await enableEventPromise; + Assert.ok("Enable event was found after clicking the toggle"); + + let userEnabledPref = Services.prefs.getBoolPref( + "browser.ipProtection.userEnabled", + false + ); + Assert.equal(userEnabledPref, true, "userEnabled pref should be set to true"); + + let disableEventPromise = BrowserTestUtils.waitForEvent( + window, + userDisableEventName + ); + + toggle.click(); + + await disableEventPromise; + Assert.ok("Disable event was found after clicking the toggle"); + + userEnabledPref = Services.prefs.getBoolPref( + "browser.ipProtection.userEnabled", + true + ); + Assert.equal( + userEnabledPref, + false, + "userEnabled pref should be set to false" + ); + + // Close the panel + let panelHiddenPromise = waitForPanelEvent(document, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await panelHiddenPromise; + cleanupService(); +}); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_telemetry.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_telemetry.js @@ -55,7 +55,9 @@ add_task(async function user_toggle_on_and_off() { IPProtectionService.updateState(); await content.updateComplete; - let toggle = content.connectionToggleEl; + let statusCard = content.shadowRoot.querySelector("ipprotection-status-card"); + + let toggle = statusCard.connectionToggleEl; Assert.ok(toggle, "Status card connection toggle should be present"); Services.fog.testResetFOG(); @@ -141,7 +143,8 @@ add_task(async function toggle_off_on_shutdown() { await content.updateComplete; await putServerInRemoteSettings(); - let toggle = content.connectionToggleEl; + let statusCard = content.statusCardEl; + let toggle = statusCard.connectionToggleEl; Assert.ok(toggle, "Status card connection toggle should be present"); Services.fog.testResetFOG(); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_toolbar.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_toolbar.js @@ -51,7 +51,6 @@ add_task(async function toolbar_added_and_removed() { /** * Tests that the toolbar icon state updates when the connection status changes */ - add_task(async function toolbar_icon_status() { let button = document.getElementById(IPProtectionWidget.WIDGET_ID); Assert.ok( @@ -82,7 +81,9 @@ add_task(async function toolbar_icon_status() { await content.updateComplete; Assert.ok(content, "Panel content should be present"); - let toggle = content.connectionToggleEl; + + let statusCard = content.statusCardEl; + let toggle = statusCard.connectionToggleEl; Assert.ok(toggle, "Status card connection toggle should be present"); let vpnOnPromise = BrowserTestUtils.waitForEvent( @@ -139,7 +140,8 @@ add_task(async function toolbar_icon_status_new_window() { () => !!IPPProxyManager.activatedAt ); // Toggle the VPN on - content.connectionToggleEl.click(); + let statusCard = content.statusCardEl; + statusCard.connectionToggleEl.click(); await vpnOnPromise; let button = document.getElementById(IPProtectionWidget.WIDGET_ID); diff --git a/stylelint-rollouts.config.js b/stylelint-rollouts.config.js @@ -661,6 +661,7 @@ module.exports = [ "browser/components/genai/content/link-preview-card.css", "browser/components/ipprotection/content/ipprotection-content.css", "browser/components/ipprotection/content/ipprotection-header.css", + "browser/components/ipprotection/content/ipprotection-status-card.css", "browser/components/places/metadataViewer/interactionsViewer.css", "browser/components/preferences/dialogs/clearSiteData.css", "browser/components/preferences/dialogs/sitePermissions.css", @@ -994,6 +995,7 @@ module.exports = [ "browser/components/genai/chat.css", "browser/components/genai/content/link-preview-card.css", "browser/components/ipprotection/content/ipprotection-content.css", + "browser/components/ipprotection/content/ipprotection-status-card.css", "browser/components/preferences/widgets/nav-notice/nav-notice.css", "browser/components/profiles/content/avatar.css", "browser/components/profiles/content/profile-avatar-selector.css", @@ -1222,6 +1224,7 @@ module.exports = [ "browser/components/firefoxview/view-syncedtabs.css", "browser/components/genai/content/link-preview-card.css", "browser/components/ipprotection/content/ipprotection-content.css", + "browser/components/ipprotection/content/ipprotection-status-card.css", "browser/components/preferences/widgets/nav-notice/nav-notice.css", "browser/components/profiles/content/edit-profile-card.css", "browser/components/profiles/content/profile-avatar-selector.css",