tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit b63cca551be4eff43597ae95edc0435e376b7e3c
parent 07a1fec17a14c4cd7a429dd3af68c9fd20c370ea
Author: negin <nsauermann@mozilla.com>
Date:   Thu,  2 Oct 2025 14:22:10 +0000

Bug 1986470 - [Fx backup messaging] Disable secondary "Don't Restore" button on Backup Screen when backup in progress and renable if there is an error in backing up r=omc-reviewers,mconley,jprickett

Adds secondary CTA to the Embedded Backup AW screen. Secondary CTA is disabled when there is a backup in progress. If there's an error, CTA should be re-enabled.

[figma](https://www.figma.com/design/vNbX4c0ws0L1qr0mxpKvsW/Fx-Backup?node-id=5886-68187&p=f&m=dev)

Differential Revision: https://phabricator.services.mozilla.com/D263767

Diffstat:
Mbrowser/components/aboutwelcome/content-src/components/ContentTiles.jsx | 1+
Mbrowser/components/aboutwelcome/content-src/components/EmbeddedBackupRestore.jsx | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mbrowser/components/aboutwelcome/content/aboutwelcome.bundle.js | 45++++++++++++++++++++++++++++++++++++++++-----
Mbrowser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx | 197++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mbrowser/components/backup/content/restore-from-backup.css | 4++++
Mbrowser/components/backup/content/restore-from-backup.mjs | 23++++++++++++++++++++---
Mbrowser/components/backup/tests/chrome/test_restore_from_backup.html | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 315 insertions(+), 103 deletions(-)

diff --git a/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx b/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx @@ -302,6 +302,7 @@ export const ContentTiles = props => { <EmbeddedBackupRestore handleAction={props.handleAction} content={{ tiles: tile }} + skipButton={props.content.skip_button} /> )} </div> diff --git a/browser/components/aboutwelcome/content-src/components/EmbeddedBackupRestore.jsx b/browser/components/aboutwelcome/content-src/components/EmbeddedBackupRestore.jsx @@ -2,28 +2,70 @@ * 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/. */ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState, useCallback } from "react"; import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { Localized } from "./MSLocalized"; -export const EmbeddedBackupRestore = () => { - const ref = useRef(); +export const EmbeddedBackupRestore = ({ handleAction, skipButton }) => { + const [recoveryInProgress, setRecoveryInProgress] = useState(false); + const ref = useRef(null); useEffect(() => { // Clear the pref used to target the restore screen so that users will not // automatically see it again the next time they visit about:welcome. AboutWelcomeUtils.handleUserAction({ type: "SET_PREF", - data: { - pref: { name: "showRestoreFromBackup", value: false }, - }, + data: { pref: { name: "showRestoreFromBackup", value: false } }, }); }, []); + const onRecoveryProgressChange = useCallback(e => { + setRecoveryInProgress(e.detail.recoveryInProgress); + }, []); + + useEffect(() => { + const backupRef = ref.current; + + if (backupRef.backupServiceState) { + setRecoveryInProgress(backupRef.backupServiceState.recoveryInProgress); + } + + backupRef.addEventListener( + "BackupUI:RecoveryProgress", + onRecoveryProgressChange + ); + + return () => { + backupRef.removeEventListener( + "BackupUI:RecoveryProgress", + onRecoveryProgressChange + ); + }; + }, [onRecoveryProgressChange]); + return ( - <restore-from-backup - aboutWelcomeEmbedded="true" - labelFontWeight="600" - ref={ref} - ></restore-from-backup> + <div className="embedded-backup-restore-container"> + <restore-from-backup + aboutWelcomeEmbedded="true" + labelFontWeight="600" + ref={ref} + /> + {skipButton ? ( + <div className="action-buttons"> + <div className="secondary-cta"> + <Localized text={skipButton.label}> + <button + id="secondary_button" + className="secondary" + value="skip_button" + disabled={recoveryInProgress} + aria-busy={recoveryInProgress || undefined} + onClick={handleAction} + /> + </Localized> + </div> + </div> + ) : null} + </div> ); }; diff --git a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js @@ -2489,7 +2489,8 @@ const ContentTiles = props => { handleAction: props.handleAction, content: { tiles: tile - } + }, + skipButton: props.content.skip_button })) : null); }; const renderContentTiles = () => { @@ -3452,14 +3453,20 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5); /* 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/. */ -const EmbeddedBackupRestore = () => { - const ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(); + +const EmbeddedBackupRestore = ({ + handleAction, + skipButton +}) => { + const [recoveryInProgress, setRecoveryInProgress] = (0,react__WEBPACK_IMPORTED_MODULE_0__.useState)(false); + const ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(null); (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => { // Clear the pref used to target the restore screen so that users will not // automatically see it again the next time they visit about:welcome. @@ -3473,11 +3480,39 @@ const EmbeddedBackupRestore = () => { } }); }, []); - return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("restore-from-backup", { + const onRecoveryProgressChange = (0,react__WEBPACK_IMPORTED_MODULE_0__.useCallback)(e => { + setRecoveryInProgress(e.detail.recoveryInProgress); + }, []); + (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => { + const backupRef = ref.current; + if (backupRef.backupServiceState) { + setRecoveryInProgress(backupRef.backupServiceState.recoveryInProgress); + } + backupRef.addEventListener("BackupUI:RecoveryProgress", onRecoveryProgressChange); + return () => { + backupRef.removeEventListener("BackupUI:RecoveryProgress", onRecoveryProgressChange); + }; + }, [onRecoveryProgressChange]); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { + className: "embedded-backup-restore-container" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("restore-from-backup", { aboutWelcomeEmbedded: "true", labelFontWeight: "600", ref: ref - }); + }), skipButton ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { + className: "action-buttons" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { + className: "secondary-cta" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__.Localized, { + text: skipButton.label + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", { + id: "secondary_button", + className: "secondary", + value: "skip_button", + disabled: recoveryInProgress, + "aria-busy": recoveryInProgress || undefined, + onClick: handleAction + })))) : null); }; /***/ }), diff --git a/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx b/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx @@ -900,98 +900,129 @@ describe("ContentTiles component", () => { ); wrapper.unmount(); }); -}); -it("restores last tiles focus in Spotlight context and genuine Tab is ignored", async () => { - const TAB_GRACE_WINDOW_MS = 250; - - function nextFrame() { - return new Promise(r => requestAnimationFrame(r)); - } - function delay(ms) { - return new Promise(r => setTimeout(r, ms)); - } - async function waitFor(condition, timeout = TAB_GRACE_WINDOW_MS) { - const start = performance.now(); - while (!condition()) { - await nextFrame(); - if (performance.now() - start > timeout) { - throw new Error("timeout waiting for condition"); + it("restores last tiles focus in Spotlight context and genuine Tab is ignored", async () => { + const TAB_GRACE_WINDOW_MS = 250; + + function nextFrame() { + return new Promise(r => requestAnimationFrame(r)); + } + function delay(ms) { + return new Promise(r => setTimeout(r, ms)); + } + async function waitFor(condition, timeout = TAB_GRACE_WINDOW_MS) { + const start = performance.now(); + while (!condition()) { + await nextFrame(); + if (performance.now() - start > timeout) { + throw new Error("timeout waiting for condition"); + } } } - } - // Pretend we're in a Spotlight dialog so the effect runs - const root = document.body.appendChild(document.createElement("div")); - root.id = "multi-stage-message-root"; - root.className = "onboardingContainer"; - root.dataset.page = "spotlight"; + // Pretend we're in a Spotlight dialog so the effect runs + const root = document.body.appendChild(document.createElement("div")); + root.id = "multi-stage-message-root"; + root.className = "onboardingContainer"; + root.dataset.page = "spotlight"; - const mountNode = document.body.appendChild(document.createElement("div")); + const mountNode = document.body.appendChild(document.createElement("div")); - const content = { - tiles: [{ type: "multiselect", header: { title: "Test" }, data: [] }], - }; + const content = { + tiles: [{ type: "multiselect", header: { title: "Test" }, data: [] }], + }; + + const focusWrapper = mount( + <main role="alertdialog"> + <ContentTiles + content={content} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={() => {}} + /> + <div className="action-buttons"> + <button className="primary">Continue</button> + </div> + </main>, + { attachTo: mountNode } + ); + + // Let the hook attach listeners + await nextFrame(); + + const dialog = focusWrapper.getDOMNode(); + const header = dialog.querySelector(".tile-header"); + const primary = dialog.querySelector(".action-buttons .primary"); + + // Record real DOM focus inside tiles + header.focus(); + header.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + await nextFrame(); + + // Wait past the tab grace window so this isn’t treated as a real Tab + await delay(TAB_GRACE_WINDOW_MS + 1); + + // Simulate programmatic focus “snap” to an outside control + primary.focus(); + primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + + await waitFor(() => document.activeElement === header); + assert.strictEqual( + document.activeElement, + header, + "restored focus to tiles header" + ); + + dialog.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }) + ); + primary.focus(); + primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + await nextFrame(); + assert.strictEqual( + document.activeElement, + primary, + "did not override genuine Tab focus" + ); - const wrapper = mount( - <main role="alertdialog"> + // Cleanup + focusWrapper.unmount(); + mountNode.remove(); + root.remove(); + }); + + it("passes content.skip_button to EmbeddedBackupRestore as skipButton", () => { + const content = { + tiles: [EMBEDDED_BACKUP_RESTORE_TILE], + skip_button: { + label: { raw: "Don't restore" }, + action: { navigate: true }, + }, + }; + + const mountedWrapper = mount( <ContentTiles content={content} - handleAction={() => {}} + handleAction={handleAction} activeMultiSelect={null} - setActiveMultiSelect={() => {}} + setActiveMultiSelect={setActiveMultiSelect} /> - <div className="action-buttons"> - <button className="primary">Continue</button> - </div> - </main>, - { attachTo: mountNode } - ); - - // Let the hook attach listeners - await nextFrame(); - - const dialog = wrapper.getDOMNode(); - const header = dialog.querySelector(".tile-header"); - const primary = dialog.querySelector(".action-buttons .primary"); - - // Record real DOM focus inside tiles - header.focus(); - header.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); - await nextFrame(); - - // Wait past the tab grace window so this isn’t treated as a real Tab - await delay(TAB_GRACE_WINDOW_MS + 1); - - // Simulate programmatic focus “snap” to an outside control - primary.focus(); - primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); - - await waitFor(() => document.activeElement === header); - assert.strictEqual( - document.activeElement, - header, - "restored focus to tiles header" - ); - - dialog.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - bubbles: true, - cancelable: true, - }) - ); - primary.focus(); - primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); - await nextFrame(); - assert.strictEqual( - document.activeElement, - primary, - "did not override genuine Tab focus" - ); - - // Cleanup - wrapper.unmount(); - mountNode.remove(); - root.remove(); + ); + + const embeddedBackupComponent = mountedWrapper.find(EmbeddedBackupRestore); + assert.ok( + embeddedBackupComponent.exists(), + "EmbeddedBackupRestore rendered" + ); + assert.deepEqual( + embeddedBackupComponent.prop("skipButton"), + content.skip_button, + "prop is wired" + ); + mountedWrapper.unmount(); + }); }); diff --git a/browser/components/backup/content/restore-from-backup.css b/browser/components/backup/content/restore-from-backup.css @@ -93,6 +93,10 @@ font-weight: var(--label-font-weight, normal); } + & > #backup-password-error { + text-align: start; + } + & > #backup-password-description { color: var(--text-color-deemphasized); font-size: var(--font-size-small); diff --git a/browser/components/backup/content/restore-from-backup.mjs b/browser/components/backup/content/restore-from-backup.mjs @@ -58,7 +58,7 @@ export default class RestoreFromBackup extends MozLitElement { supportBaseLink: "", backupInProgress: false, recoveryInProgress: false, - recoveryErrorCode: 0, + recoveryErrorCode: ERRORS.NONE, }; } @@ -91,6 +91,23 @@ export default class RestoreFromBackup extends MozLitElement { } } + updated(changedProperties) { + if (changedProperties.has("backupServiceState")) { + // If we got a recovery error, recoveryInProgress should be false + const inProgress = + this.backupServiceState.recoveryInProgress && + !this.backupServiceState.recoveryErrorCode; + + this.dispatchEvent( + new CustomEvent("BackupUI:RecoveryProgress", { + bubbles: true, + composed: true, + detail: { recoveryInProgress: inProgress }, + }) + ); + } + } + async handleChooseBackupFile() { this.dispatchEvent( new CustomEvent("BackupUI:ShowFilepicker", { @@ -236,10 +253,10 @@ export default class RestoreFromBackup extends MozLitElement { <span id="backup-password-error" class="field-error" - data-l10n-id="restore-from-backup-error-incorrect-password" + data-l10n-id="backup-service-error-incorrect-password" > <a - id="restore-from-backup-incorrect-password-support-link" + id="backup-incorrect-password-support-link" slot="support-link" is="moz-support-link" support-page="todo-backup" diff --git a/browser/components/backup/tests/chrome/test_restore_from_backup.html b/browser/components/backup/tests/chrome/test_restore_from_backup.html @@ -146,7 +146,7 @@ add_task(async function test_incorrect_password_error_condition() { let restoreFromBackup = document.getElementById("test-restore-from-backup"); - is(restoreFromBackup.backupServiceState.recoveryErrorCode, 0, "Recovery error code should be 0"); + is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.NONE, "Recovery error code should be 0"); ok(!restoreFromBackup.isIncorrectPassword, "Error message should not be displayed"); ok(!restoreFromBackup.errorMessageEl, "No error message should be displayed"); @@ -180,6 +180,88 @@ is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.CORRUPTED_ARCHIVE, "Recovery error code should be set"); ok(restoreFromBackup.errorMessageEl, "Error message should be displayed"); }); + + /** + * Tests that changes to backupServiceState emits BackupUI:RecoveryProgress, + * with the current progress state and progress is false when an error code is present. + */ + add_task(async function test_recovery_state_updates() { + const content = document.getElementById("content"); + let restoreFromBackup = document.getElementById("test-restore-from-backup"); + content.appendChild(restoreFromBackup); + + // Reset previous state changes + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + recoveryInProgress: false, + recoveryErrorCode: ERRORS.NONE, + }; + await restoreFromBackup.updateComplete; + + // Helper to dispatch the BackupUI:RecoveryProgress event + async function sendState(testState) { + const promise = BrowserTestUtils.waitForEvent( + restoreFromBackup, + "BackupUI:RecoveryProgress" + ); + restoreFromBackup.backupServiceState = { + ...restoreFromBackup.backupServiceState, + ...testState, + }; + await restoreFromBackup.updateComplete; + return promise; + } + + // Initial state + is( + restoreFromBackup.backupServiceState.recoveryInProgress, + false, + "Initial progress state is false" + ); + is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.NONE, "Initial error code should be 0"); + + // Backup in progress with no error + let event = await sendState({ recoveryInProgress: true }); + is(event.detail?.recoveryInProgress, true, "'recoveryInProgress' is true"); + is(restoreFromBackup.backupServiceState.recoveryInProgress, true, "State reflects in-progress"); + + // Backup not in progress + event = await sendState({ recoveryInProgress: false, recoveryErrorCode: 0 }); + is(event.detail?.recoveryInProgress, false, "'recoveryInProgress' is false"); + is( + restoreFromBackup.backupServiceState.recoveryInProgress, + false, + "State reflects not in-progress" + ); + + // Any error should clear progress + for (const code of [ERRORS.CORRUPTED_ARCHIVE, ERRORS.UNAUTHORIZED]) { + info(`Asserting recovery progress clears with error code: ${code}`); + event = await sendState({ + recoveryInProgress: true, + recoveryErrorCode: ERRORS.NONE, + }); + is( + restoreFromBackup.backupServiceState.recoveryInProgress, + true, + "State reflects in-progress" + ); + + // Add an error + event = await sendState({ + recoveryInProgress: true, + recoveryErrorCode: code, + }); + is( + event.detail?.recoveryInProgress, + false, + `Progress cleared for error ${code}` + ); + // Clear state + await sendState({ recoveryInProgress: false, recoveryErrorCode: ERRORS.NONE }); + } + restoreFromBackup.remove(); + }); </script> </head> <body>