From 7890042033f6405c4bb79e645f195c1344c51b4a Mon Sep 17 00:00:00 2001 From: Greg Tatum Date: Wed, 2 Mar 2022 15:52:43 +0000 Subject: [PATCH] Bug 1755519 - Add language switching to about:welcome; r=pdahiya,platform-i18n-reviewers,flod,dminor This patch ended up adding some complexity to about:welcome, as the language switching needs to eagerly perform fallible asynchronous actions. Specifically it needs to get the list of addons and pre-emptively install the langpack, which can take time, and can fail. This necessitated building a custom React components and custom hooks to be able to deal with these requirements. The following command will allow for the testing of this feature. ./mach run \ --temp-profile \ --setpref "extensions.getAddons.langpacks.url=https://mock-amo-language-tools.glitch.me/?app=firefox&type=language&appversion=%VERSION%" \ --setpref "intl.multilingual.aboutWelcome.languageMismatchEnabled=true" \ --setpref "intl.multilingual.aboutWelcome.systemLocaleOverride=es-ES" `#(optional)` \ -- --new-tab about:welcome Differential Revision: https://phabricator.services.mozilla.com/D138831 --- browser/app/profile/firefox.js | 2 + .../newtab/aboutwelcome/AboutWelcomeChild.jsm | 56 ++ .../aboutwelcome/AboutWelcomeParent.jsm | 9 + .../content/aboutwelcome.bundle.js | 318 +++++++++- .../aboutwelcome/content/aboutwelcome.css | 14 + .../aboutwelcome/lib/AboutWelcomeDefaults.jsm | 51 +- .../content-src/aboutwelcome/aboutwelcome.jsx | 1 + .../aboutwelcome/aboutwelcome.scss | 16 + .../components/LanguageSwitcher.jsx | 277 +++++++++ .../components/MultiStageAboutWelcome.jsx | 38 +- .../components/MultiStageProtonScreen.jsx | 19 + browser/components/newtab/karma.mc.config.js | 7 + .../newtab/test/browser/browser.ini | 2 + ...boutwelcome_multistage_languageSwitcher.js | 572 ++++++++++++++++++ .../aboutwelcome/MultiStageAWProton.test.jsx | 8 +- .../en-US/browser/newtab/onboarding.ftl | 21 + .../components/nimbus/FeatureManifest.yaml | 6 + 17 files changed, 1388 insertions(+), 29 deletions(-) create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 2b611eb48e0c..af9c34967156 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -2174,6 +2174,8 @@ pref("app.normandy.onsync_skew_sec", 600); // live reloading when switching between LTR and RTL languages. pref("intl.multilingual.liveReload", false); pref("intl.multilingual.liveReloadBidirectional", false); +// Suggest to change the language on about:welcome when there is a mismatch with the OS. +pref("intl.multilingual.aboutWelcome.languageMismatchEnabled", false); // Simulate conditions that will happen when the browser diff --git a/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm b/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm index f6e22ca6162b..2a71a282cdc2 100644 --- a/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm +++ b/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm @@ -162,6 +162,22 @@ class AboutWelcomeChild extends JSWindowActorChild { Cu.exportFunction(this.AWFinish.bind(this), window, { defineAs: "AWFinish", }); + + Cu.exportFunction(this.AWEnsureLangPackInstalled.bind(this), window, { + defineAs: "AWEnsureLangPackInstalled", + }); + + Cu.exportFunction( + this.AWNegotiateLangPackForLanguageMismatch.bind(this), + window, + { + defineAs: "AWNegotiateLangPackForLanguageMismatch", + } + ); + + Cu.exportFunction(this.AWSetRequestedLocales.bind(this), window, { + defineAs: "AWSetRequestedLocales", + }); } /** @@ -173,6 +189,20 @@ class AboutWelcomeChild extends JSWindowActorChild { ); } + /** + * Clones the result of the query into the content window. + */ + sendQueryAndCloneForContent(...sendQueryArgs) { + return this.wrapPromise( + (async () => { + return Cu.cloneInto( + await this.sendQuery(...sendQueryArgs), + this.contentWindow + ); + })() + ); + } + AWSelectTheme(data) { return this.wrapPromise( this.sendQuery("AWPage:SELECT_THEME", data.toUpperCase()) @@ -206,6 +236,11 @@ class AboutWelcomeChild extends JSWindowActorChild { let featureConfig = NimbusFeatures.aboutwelcome.getAllVariables(); featureConfig.needDefault = await this.sendQuery("AWPage:NEED_DEFAULT"); featureConfig.needPin = await this.sendQuery("AWPage:DOES_APP_NEED_PIN"); + if (featureConfig.languageMismatchEnabled) { + featureConfig.appAndSystemLocaleInfo = await this.sendQuery( + "AWPage:GET_APP_AND_SYSTEM_LOCALE_INFO" + ); + } let defaults = AboutWelcomeDefaults.getDefaults(); // FeatureConfig (from prefs or experiments) has higher precendence // to defaults. But the `screens` property isn't defined we shouldn't @@ -277,6 +312,27 @@ class AboutWelcomeChild extends JSWindowActorChild { this.contentWindow.location.href = "about:home"; } + AWEnsureLangPackInstalled(langPack) { + return this.sendQueryAndCloneForContent( + "AWPage:ENSURE_LANG_PACK_INSTALLED", + langPack + ); + } + + AWSetRequestedLocales(requestSystemLocales) { + return this.sendQueryAndCloneForContent( + "AWPage:SET_REQUESTED_LOCALES", + requestSystemLocales + ); + } + + AWNegotiateLangPackForLanguageMismatch(appAndSystemLocaleInfo) { + return this.sendQueryAndCloneForContent( + "AWPage:NEGOTIATE_LANGPACK", + appAndSystemLocaleInfo + ); + } + /** * @param {{type: string, detail?: any}} event * @override diff --git a/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm b/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm index 8a5ddfbf7c28..1f4fb31053ed 100644 --- a/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm +++ b/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm @@ -25,6 +25,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", Region: "resource://gre/modules/Region.jsm", ShellService: "resource:///modules/ShellService.jsm", + LangPackMatcher: "resource://gre/modules/LangPackMatcher.jsm", }); XPCOMUtils.defineLazyGetter(this, "log", () => { @@ -304,6 +305,14 @@ class AboutWelcomeParent extends JSWindowActorParent { } }) ); + case "AWPage:GET_APP_AND_SYSTEM_LOCALE_INFO": + return LangPackMatcher.getAppAndSystemLocaleInfo(); + case "AWPage:NEGOTIATE_LANGPACK": + return LangPackMatcher.negotiateLangPackForLanguageMismatch(data); + case "AWPage:ENSURE_LANG_PACK_INSTALLED": + return LangPackMatcher.ensureLangPackInstalled(data); + case "AWPage:SET_REQUESTED_LOCALES": + return LangPackMatcher.setRequestedAppLocales(data); default: log.debug(`Unexpected event ${type} was not handled.`); } diff --git a/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js index 621c2bf92bbb..70d735ac293e 100644 --- a/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js +++ b/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js @@ -101,7 +101,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2); /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var _components_MultiStageAboutWelcome__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3); -/* harmony import */ var _components_ReturnToAMO__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(10); +/* harmony import */ var _components_ReturnToAMO__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(11); function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } /* This Source Code Form is subject to the terms of the Mozilla Public @@ -191,7 +191,8 @@ class AboutWelcome extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComp metricsFlowUri: this.state.metricsFlowUri, utm_term: props.UTMTerm, transitions: props.transitions, - backdrop: props.backdrop + backdrop: props.backdrop, + appAndSystemLocaleInfo: props.appAndSystemLocaleInfo }); } @@ -276,7 +277,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); /* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5); /* harmony import */ var _MultiStageProtonScreen__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6); -/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9); +/* harmony import */ var _LanguageSwitcher__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9); +/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(10); /* 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/. */ @@ -284,14 +286,18 @@ __webpack_require__.r(__webpack_exports__); + // Amount of milliseconds for all transitions to complete (including delays). const TRANSITION_OUT_TIME = 1000; const MultiStageAboutWelcome = props => { + let { + screens + } = props; const [index, setScreenIndex] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(0); Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { // Send impression ping when respective screen first renders - props.screens.forEach((screen, order) => { + screens.forEach((screen, order) => { if (index === order) { _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_2__["AboutWelcomeUtils"].sendImpressionTelemetry(`${props.message_id}_${order}_${screen.id}`); } @@ -307,7 +313,7 @@ const MultiStageAboutWelcome = props => { // button from about:home const handler = ({ state - }) => setScreenIndex(Math.min(state, props.screens.length - 1)); // Handle page load, e.g., going back to about:welcome from about:home + }) => setScreenIndex(Math.min(state, screens.length - 1)); // Handle page load, e.g., going back to about:welcome from about:home handler(window.history); // Watch for browser back/forward button navigation events @@ -345,7 +351,7 @@ const MultiStageAboutWelcome = props => { setTransition(props.transitions ? "out" : ""); // Actually move forwards after all transitions finish. setTimeout(() => { - if (index < props.screens.length - 1) { + if (index < screens.length - 1) { setTransition(props.transitions ? "in" : ""); setScreenIndex(prevState => prevState + 1); } else { @@ -400,18 +406,24 @@ const MultiStageAboutWelcome = props => { })(); }, [useImportable, region]); const centeredScreens = props.screens.filter(s => s.content.position !== "corner"); + const { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens + } = Object(_LanguageSwitcher__WEBPACK_IMPORTED_MODULE_4__["useLanguageSwitcher"])(props.appAndSystemLocaleInfo, screens, index, setScreenIndex); + screens = languageFilteredScreens; return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_0___default.a.Fragment, null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { className: `outer-wrapper onboardingContainer proton transition-${transition}`, style: props.backdrop ? { background: props.backdrop } : {} - }, props.screens.map((screen, order) => { + }, screens.map((screen, order) => { const isFirstCenteredScreen = screen.content.position !== "corner" && screen.order === centeredScreens[0].order; const isLastCenteredScreen = screen.content.position !== "corner" && screen.order === centeredScreens[centeredScreens.length - 1].order; return index === order ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(WelcomeScreen, { key: screen.id + order, id: screen.id, - totalNumberOfScreens: props.screens.length, + totalNumberOfScreens: screens.length, isFirstCenteredScreen: isFirstCenteredScreen, isLastCenteredScreen: isLastCenteredScreen, order: order, @@ -424,7 +436,9 @@ const MultiStageAboutWelcome = props => { activeTheme: activeTheme, initialTheme: initialTheme, setActiveTheme: setActiveTheme, - autoAdvance: screen.auto_advance + autoAdvance: screen.auto_advance, + negotiatedLanguage: negotiatedLanguage, + langPackInstallPhase: langPackInstallPhase }) : null; }))); }; @@ -468,7 +482,7 @@ class WelcomeScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureCom } = action; if (type === "SHOW_FIREFOX_ACCOUNTS") { - let params = { ..._asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__["BASE_PARAMS"], + let params = { ..._asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_5__["BASE_PARAMS"], utm_term: `aboutwelcome-${UTMTerm}-screen` }; @@ -483,7 +497,7 @@ class WelcomeScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureCom }; } else if (type === "OPEN_URL") { let url = new URL(data.args); - Object(_asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__["addUtmParams"])(url, `aboutwelcome-${UTMTerm}-screen`); + Object(_asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_5__["addUtmParams"])(url, `aboutwelcome-${UTMTerm}-screen`); if (action.addFlowParams && flowParams) { url.searchParams.append("device_id", flowParams.deviceId); @@ -509,7 +523,7 @@ class WelcomeScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureCom let { value } = event.currentTarget; - let targetContent = props.content[value] || props.content.tiles; + let targetContent = props.content[value] || props.content.tiles || props.content.languageSwitcher; if (!(targetContent && targetContent.action)) { return; @@ -551,7 +565,11 @@ class WelcomeScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureCom order: this.props.order, activeTheme: this.props.activeTheme, totalNumberOfScreens: this.props.totalNumberOfScreens - 1, + appAndSystemLocaleInfo: this.props.appAndSystemLocaleInfo, + negotiatedLanguage: this.props.negotiatedLanguage, + langPackInstallPhase: this.props.langPackInstallPhase, handleAction: this.handleAction, + messageId: this.props.messageId, isFirstCenteredScreen: this.props.isFirstCenteredScreen, isLastCenteredScreen: this.props.isLastCenteredScreen, autoAdvance: this.props.autoAdvance @@ -772,6 +790,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _Colorways__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(7); /* harmony import */ var _Themes__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8); /* harmony import */ var _MultiStageAboutWelcome__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(3); +/* harmony import */ var _LanguageSwitcher__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9); /* 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/. */ @@ -780,6 +799,7 @@ __webpack_require__.r(__webpack_exports__); + const MultiStageProtonScreen = props => { const { autoAdvance, @@ -812,7 +832,10 @@ const MultiStageProtonScreen = props => { autoAdvance: props.autoAdvance, isRtamo: props.isRtamo, isTheme: props.isTheme, - iconURL: props.iconURL + iconURL: props.iconURL, + messageId: props.messageId, + negotiatedLanguage: props.negotiatedLanguage, + langPackInstallPhase: props.langPackInstallPhase }); }; class ProtonScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { @@ -869,7 +892,19 @@ class ProtonScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComp }) : null); } + renderLanguageSwitcher() { + return this.props.content.languageSwitcher ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_LanguageSwitcher__WEBPACK_IMPORTED_MODULE_5__["LanguageSwitcher"], { + content: this.props.content, + handleAction: this.props.handleAction, + negotiatedLanguage: this.props.negotiatedLanguage, + langPackInstallPhase: this.props.langPackInstallPhase, + messageId: this.props.messageId + }) : null; + } + render() { + var _this$props$appAndSys, _content$primary_butt; + const { autoAdvance, content, @@ -940,13 +975,15 @@ class ProtonScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComp text: content.subtitle }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", { "data-l10n-args": JSON.stringify({ - "addon-name": this.props.addonName + "addon-name": this.props.addonName, + ...((_this$props$appAndSys = this.props.appAndSystemLocaleInfo) === null || _this$props$appAndSys === void 0 ? void 0 : _this$props$appAndSys.displayNames) }) - })) : null), this.renderContentTiles(), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + })) : null), this.renderContentTiles(), this.renderLanguageSwitcher(), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { text: content.primary_button ? content.primary_button.label : null }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { className: "primary", value: "primary_button", + disabled: ((_content$primary_butt = content.primary_button) === null || _content$primary_butt === void 0 ? void 0 : _content$primary_butt.disabled) === true, onClick: this.props.handleAction })), content.secondary_button ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MultiStageAboutWelcome__WEBPACK_IMPORTED_MODULE_4__["SecondaryCTA"], { content: content, @@ -1214,6 +1251,253 @@ const Themes = props => { /* 9 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "useLanguageSwitcher", function() { return useLanguageSwitcher; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LanguageSwitcher", function() { return LanguageSwitcher; }); +/* 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 _MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* harmony import */ var _lib_aboutwelcome_utils__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/. */ + + + +/** + * The language switcher implements a hook that should be placed at a higher level + * than the actual language switcher component, as it needs to preemptively fetch + * and install langpacks for the user if there is a language mismatch screen. + */ + +function useLanguageSwitcher(appAndSystemLocaleInfo, screens, screenIndex, setScreenIndex) { + const languageMismatchScreenIndex = screens.findIndex(({ + id + }) => id === "AW_LANGUAGE_MISMATCH"); + const screen = screens[languageMismatchScreenIndex]; // If there is a mismatch, then Firefox can negotiate a better langpack to offer + // the user. + + const [negotiatedLanguage, setNegotiatedLanguage] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(null); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(function getNegotiatedLanguage() { + if (!appAndSystemLocaleInfo) { + return; + } + + if (appAndSystemLocaleInfo.matchType !== "language-mismatch") { + // There is no language mismatch, so there is no need to negotiate a langpack. + return; + } + + (async () => { + const langPack = await window.AWNegotiateLangPackForLanguageMismatch(appAndSystemLocaleInfo); + + if (langPack) { + // Convert the BCP 47 identifiers into the proper display names. + // e.g. "fr-CA" -> "Canadian French". + const displayNames = new Intl.DisplayNames(appAndSystemLocaleInfo.appLocaleRaw, { + type: "language" + }); + setNegotiatedLanguage({ + displayName: displayNames.of(langPack.target_locale), + langPack, + requestSystemLocales: [langPack.target_locale, appAndSystemLocaleInfo.appLocaleRaw] + }); + } else { + setNegotiatedLanguage({ + displayName: null, + langPack: null, + requestSystemLocales: null + }); + } + })(); + }, [appAndSystemLocaleInfo]); + /** + * @type { + * "before-installation" + * | "installing" + * | "installed" + * | "installation-error" + * | "none-available" + * } + */ + + const [langPackInstallPhase, setLangPackInstallPhase] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])("before-installation"); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(function ensureLangPackInstalled() { + if (!negotiatedLanguage) { + // There are no negotiated languages to download yet. + return; + } + + setLangPackInstallPhase("installing"); + window.AWEnsureLangPackInstalled(negotiatedLanguage.langPack).then(() => { + setLangPackInstallPhase("installed"); + }, error => { + console.error(error); + setLangPackInstallPhase("installation-error"); + }); + }, [negotiatedLanguage]); + const shouldHideLanguageSwitcher = screen && (appAndSystemLocaleInfo === null || appAndSystemLocaleInfo === void 0 ? void 0 : appAndSystemLocaleInfo.matchType) !== "language-mismatch"; + const [languageFilteredScreens, setLanguageFilteredScreens] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(screens); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(function filterScreen() { + if (shouldHideLanguageSwitcher || (negotiatedLanguage === null || negotiatedLanguage === void 0 ? void 0 : negotiatedLanguage.langPack) === null) { + if (screenIndex > languageMismatchScreenIndex) { + setScreenIndex(screenIndex - 1); + } + + setLanguageFilteredScreens(screens.filter(s => s.id !== "AW_LANGUAGE_MISMATCH")); + } else { + setLanguageFilteredScreens(screens); + } + }, [screens, negotiatedLanguage]); + return { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens + }; +} +/** + * The language switcher is a separate component as it needs to perform some asynchronous + * network actions such as retrieving the list of langpacks available, and downloading + * a new langpack. On a fast connection, this won't be noticeable, but on slow or unreliable + * internet this may fail for a user. + */ + +function LanguageSwitcher(props) { + const { + content, + handleAction, + negotiatedLanguage, + langPackInstallPhase, + messageId + } = props; + const [isAwaitingLangpack, setIsAwaitingLangpack] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(false); // Determine the status of the langpack installation. + + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + if (isAwaitingLangpack && langPackInstallPhase !== "installing") { + window.AWSetRequestedLocales(negotiatedLanguage.requestSystemLocales); + requestAnimationFrame(() => { + handleAction( // Simulate the click event. + { + currentTarget: { + value: "download_complete" + } + }); + }); + } + }, [isAwaitingLangpack, langPackInstallPhase]); // The message args are the localized language names. + + const withMessageArgs = obj => { + const displayName = negotiatedLanguage === null || negotiatedLanguage === void 0 ? void 0 : negotiatedLanguage.displayName; + + if (displayName) { + return { ...obj, + args: { ...obj.args, + negotiatedLanguage: displayName + } + }; + } + + return obj; + }; + + let showWaitingScreen = false; + let showPreloadingScreen = false; + let showReadyScreen = false; + + if (isAwaitingLangpack && langPackInstallPhase !== "installed") { + showWaitingScreen = true; + } else if (langPackInstallPhase === "before-installation") { + showPreloadingScreen = true; + } else { + showReadyScreen = true; + } // Use {display: "none"} rather than if statements to prevent layout thrashing with + // the localized text elements rendering as blank, then filling in the text. + + + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_0___default.a.Fragment, null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + style: { + display: showPreloadingScreen ? "block" : "none" + } + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "primary", + value: "primary_button", + disabled: true, + type: "button" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", { + className: "language-loader", + src: "chrome://browser/skin/tabbrowser/tab-connecting.png", + alt: "" + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.languageSwitcher.waiting + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "secondary-cta" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.languageSwitcher.skip + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + value: "decline_waiting", + type: "button", + className: "secondary text-link", + onClick: handleAction + })))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + style: { + display: showWaitingScreen ? "block" : "none" + } + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "primary", + value: "primary_button", + disabled: true, + type: "button" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", { + className: "language-loader", + src: "chrome://browser/skin/tabbrowser/tab-connecting.png", + alt: "" + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: withMessageArgs(content.languageSwitcher.downloading) + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "secondary-cta" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.languageSwitcher.cancel + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + type: "button", + className: "secondary text-link", + onClick: () => { + setIsAwaitingLangpack(false); + handleAction({ + currentTarget: { + value: "cancel_waiting" + } + }); + } + })))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + style: { + display: showReadyScreen ? "block" : "none" + } + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: withMessageArgs(content.languageSwitcher.switch) + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "primary", + value: "primary_button", + onClick: () => { + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_2__["AboutWelcomeUtils"].sendActionTelemetry(messageId, "download_langpack"); + setIsAwaitingLangpack(true); + } + }))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "secondary-cta" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.languageSwitcher.not_now + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + type: "button", + className: "secondary text-link", + value: "decline", + onClick: handleAction + }))))); +} + +/***/ }), +/* 10 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "BASE_PARAMS", function() { return BASE_PARAMS; }); @@ -1252,7 +1536,7 @@ function addUtmParams(url, utmTerm) { } /***/ }), -/* 10 */ +/* 11 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -1262,7 +1546,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5); /* harmony import */ var _MultiStageProtonScreen__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6); -/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9); +/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(10); /* 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/. */ diff --git a/browser/components/newtab/aboutwelcome/content/aboutwelcome.css b/browser/components/newtab/aboutwelcome/content/aboutwelcome.css index 32081d30a154..643cce7c7e96 100644 --- a/browser/components/newtab/aboutwelcome/content/aboutwelcome.css +++ b/browser/components/newtab/aboutwelcome/content/aboutwelcome.css @@ -211,6 +211,20 @@ body[lwt-newtab-brighttext] { .onboardingContainer .welcomeZap .zap.long::after { background-image: url("chrome://activity-stream/content/data/content/assets/long-zap.svg"); } +.onboardingContainer .language-loader { + filter: invert(1); + margin-inline-end: 10px; + position: relative; + top: 3px; + width: 16px; + height: 16px; + margin-top: -6px; +} +@media (prefers-color-scheme: dark) { + .onboardingContainer .language-loader { + filter: invert(0); + } +} .onboardingContainer .tiles-theme-container { display: flex; flex-direction: column; diff --git a/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm b/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm index bd0ac6cbb270..3c0f0b78160c 100644 --- a/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm +++ b/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm @@ -102,8 +102,36 @@ const DEFAULT_WELCOME_CONTENT = { }, }, { - id: "AW_IMPORT_SETTINGS", + id: "AW_LANGUAGE_MISMATCH", order: 2, + content: { + title: { string_id: "onboarding-live-language-header" }, + subtitle: { string_id: "onboarding-live-language-subtitle" }, + has_noodles: true, + languageSwitcher: { + switch: { + string_id: "onboarding-live-language-switch-button-label", + }, + downloading: { + string_id: "onboarding-live-language-button-label-downloading", + }, + cancel: { + string_id: "onboarding-live-language-secondary-cancel-download", + }, + not_now: { + string_id: "onboarding-live-language-not-now-button-label", + }, + waiting: { string_id: "onboarding-live-language-waiting-button" }, + skip: { string_id: "onboarding-live-language-skip-button-label" }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "AW_IMPORT_SETTINGS", + order: 3, content: { title: { string_id: "mr1-onboarding-import-header", @@ -135,7 +163,7 @@ const DEFAULT_WELCOME_CONTENT = { }, { id: "AW_CHOOSE_THEME", - order: 3, + order: 4, content: { title: { string_id: "mr1-onboarding-theme-header", @@ -411,6 +439,25 @@ async function prepareContentForReact(content) { )?.content.help_text.text; } + if (content.languageMismatchEnabled) { + const screen = content?.screens?.find(s => s.id === "AW_LANGUAGE_MISMATCH"); + if (screen) { + // Add the display names for the OS and Firefox languages, like "American English". + const { appAndSystemLocaleInfo } = content; + function addMessageArgs(obj) { + for (const value of Object.values(obj)) { + if (value?.string_id) { + value.args = appAndSystemLocaleInfo.displayNames; + } + } + } + addMessageArgs(screen.content.languageSwitcher); + addMessageArgs(screen.content); + } + } else { + removeScreens(screen => screen.id === "AW_LANGUAGE_MISMATCH"); + } + return content; } diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx index 4084548a1c94..c6875a5499a4 100644 --- a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx @@ -79,6 +79,7 @@ class AboutWelcome extends React.PureComponent { utm_term={props.UTMTerm} transitions={props.transitions} backdrop={props.backdrop} + appAndSystemLocaleInfo={props.appAndSystemLocaleInfo} /> ); } diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss index 4425b19df152..b3b235c192e4 100644 --- a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss @@ -230,6 +230,22 @@ body { } } + .language-loader { + filter: invert(1); + margin-inline-end: 10px; + position: relative; + top: 3px; + width: 16px; + height: 16px; + margin-top: -6px; + } + + @media (prefers-color-scheme: dark) { + .language-loader { + filter: invert(0); + } + } + .tiles-theme-container { display: flex; flex-direction: column; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx b/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx new file mode 100644 index 000000000000..1dc7f868d906 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx @@ -0,0 +1,277 @@ +/* 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/. */ + +import React, { useState, useEffect } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; + +/** + * The language switcher implements a hook that should be placed at a higher level + * than the actual language switcher component, as it needs to preemptively fetch + * and install langpacks for the user if there is a language mismatch screen. + */ +export function useLanguageSwitcher( + appAndSystemLocaleInfo, + screens, + screenIndex, + setScreenIndex +) { + const languageMismatchScreenIndex = screens.findIndex( + ({ id }) => id === "AW_LANGUAGE_MISMATCH" + ); + const screen = screens[languageMismatchScreenIndex]; + + // If there is a mismatch, then Firefox can negotiate a better langpack to offer + // the user. + const [negotiatedLanguage, setNegotiatedLanguage] = useState(null); + useEffect( + function getNegotiatedLanguage() { + if (!appAndSystemLocaleInfo) { + return; + } + if (appAndSystemLocaleInfo.matchType !== "language-mismatch") { + // There is no language mismatch, so there is no need to negotiate a langpack. + return; + } + + (async () => { + const langPack = await window.AWNegotiateLangPackForLanguageMismatch( + appAndSystemLocaleInfo + ); + if (langPack) { + // Convert the BCP 47 identifiers into the proper display names. + // e.g. "fr-CA" -> "Canadian French". + const displayNames = new Intl.DisplayNames( + appAndSystemLocaleInfo.appLocaleRaw, + { type: "language" } + ); + + setNegotiatedLanguage({ + displayName: displayNames.of(langPack.target_locale), + langPack, + requestSystemLocales: [ + langPack.target_locale, + appAndSystemLocaleInfo.appLocaleRaw, + ], + }); + } else { + setNegotiatedLanguage({ + displayName: null, + langPack: null, + requestSystemLocales: null, + }); + } + })(); + }, + [appAndSystemLocaleInfo] + ); + + /** + * @type { + * "before-installation" + * | "installing" + * | "installed" + * | "installation-error" + * | "none-available" + * } + */ + const [langPackInstallPhase, setLangPackInstallPhase] = useState( + "before-installation" + ); + useEffect( + function ensureLangPackInstalled() { + if (!negotiatedLanguage) { + // There are no negotiated languages to download yet. + return; + } + setLangPackInstallPhase("installing"); + window.AWEnsureLangPackInstalled(negotiatedLanguage.langPack).then( + () => { + setLangPackInstallPhase("installed"); + }, + error => { + console.error(error); + setLangPackInstallPhase("installation-error"); + } + ); + }, + [negotiatedLanguage] + ); + + const shouldHideLanguageSwitcher = + screen && appAndSystemLocaleInfo?.matchType !== "language-mismatch"; + + const [languageFilteredScreens, setLanguageFilteredScreens] = useState( + screens + ); + useEffect( + function filterScreen() { + if (shouldHideLanguageSwitcher || negotiatedLanguage?.langPack === null) { + if (screenIndex > languageMismatchScreenIndex) { + setScreenIndex(screenIndex - 1); + } + setLanguageFilteredScreens( + screens.filter(s => s.id !== "AW_LANGUAGE_MISMATCH") + ); + } else { + setLanguageFilteredScreens(screens); + } + }, + [screens, negotiatedLanguage] + ); + + return { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens, + }; +} + +/** + * The language switcher is a separate component as it needs to perform some asynchronous + * network actions such as retrieving the list of langpacks available, and downloading + * a new langpack. On a fast connection, this won't be noticeable, but on slow or unreliable + * internet this may fail for a user. + */ +export function LanguageSwitcher(props) { + const { + content, + handleAction, + negotiatedLanguage, + langPackInstallPhase, + messageId, + } = props; + + const [isAwaitingLangpack, setIsAwaitingLangpack] = useState(false); + + // Determine the status of the langpack installation. + useEffect(() => { + if (isAwaitingLangpack && langPackInstallPhase !== "installing") { + window.AWSetRequestedLocales(negotiatedLanguage.requestSystemLocales); + requestAnimationFrame(() => { + handleAction( + // Simulate the click event. + { currentTarget: { value: "download_complete" } } + ); + }); + } + }, [isAwaitingLangpack, langPackInstallPhase]); + + // The message args are the localized language names. + const withMessageArgs = obj => { + const displayName = negotiatedLanguage?.displayName; + if (displayName) { + return { + ...obj, + args: { + ...obj.args, + negotiatedLanguage: displayName, + }, + }; + } + return obj; + }; + + let showWaitingScreen = false; + let showPreloadingScreen = false; + let showReadyScreen = false; + + if (isAwaitingLangpack && langPackInstallPhase !== "installed") { + showWaitingScreen = true; + } else if (langPackInstallPhase === "before-installation") { + showPreloadingScreen = true; + } else { + showReadyScreen = true; + } + + // Use {display: "none"} rather than if statements to prevent layout thrashing with + // the localized text elements rendering as blank, then filling in the text. + return ( + <> +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+
+ + ); +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx index 8afba1a15cef..825fc50726e8 100644 --- a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx @@ -6,6 +6,7 @@ import React, { useState, useEffect, useRef } from "react"; import { Localized } from "./MSLocalized"; import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { useLanguageSwitcher } from "./LanguageSwitcher"; import { BASE_PARAMS, addUtmParams, @@ -15,10 +16,12 @@ import { const TRANSITION_OUT_TIME = 1000; export const MultiStageAboutWelcome = props => { + let { screens } = props; + const [index, setScreenIndex] = useState(0); useEffect(() => { // Send impression ping when respective screen first renders - props.screens.forEach((screen, order) => { + screens.forEach((screen, order) => { if (index === order) { AboutWelcomeUtils.sendImpressionTelemetry( `${props.message_id}_${order}_${screen.id}` @@ -37,7 +40,7 @@ export const MultiStageAboutWelcome = props => { // or last screen index if a user navigates by pressing back // button from about:home const handler = ({ state }) => - setScreenIndex(Math.min(state, props.screens.length - 1)); + setScreenIndex(Math.min(state, screens.length - 1)); // Handle page load, e.g., going back to about:welcome from about:home handler(window.history); @@ -81,7 +84,7 @@ export const MultiStageAboutWelcome = props => { // Actually move forwards after all transitions finish. setTimeout( () => { - if (index < props.screens.length - 1) { + if (index < screens.length - 1) { setTransition(props.transitions ? "in" : ""); setScreenIndex(prevState => prevState + 1); } else { @@ -140,13 +143,26 @@ export const MultiStageAboutWelcome = props => { s => s.content.position !== "corner" ); + const { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens, + } = useLanguageSwitcher( + props.appAndSystemLocaleInfo, + screens, + index, + setScreenIndex + ); + + screens = languageFilteredScreens; + return (
- {props.screens.map((screen, order) => { + {screens.map((screen, order) => { const isFirstCenteredScreen = screen.content.position !== "corner" && screen.order === centeredScreens[0].order; @@ -157,7 +173,7 @@ export const MultiStageAboutWelcome = props => { { initialTheme={initialTheme} setActiveTheme={setActiveTheme} autoAdvance={screen.auto_advance} + negotiatedLanguage={negotiatedLanguage} + langPackInstallPhase={langPackInstallPhase} /> ) : null; })} @@ -248,7 +266,11 @@ export class WelcomeScreen extends React.PureComponent { async handleAction(event) { let { props } = this; let { value } = event.currentTarget; - let targetContent = props.content[value] || props.content.tiles; + let targetContent = + props.content[value] || + props.content.tiles || + props.content.languageSwitcher; + if (!(targetContent && targetContent.action)) { return; } @@ -295,7 +317,11 @@ export class WelcomeScreen extends React.PureComponent { order={this.props.order} activeTheme={this.props.activeTheme} totalNumberOfScreens={this.props.totalNumberOfScreens - 1} + appAndSystemLocaleInfo={this.props.appAndSystemLocaleInfo} + negotiatedLanguage={this.props.negotiatedLanguage} + langPackInstallPhase={this.props.langPackInstallPhase} handleAction={this.handleAction} + messageId={this.props.messageId} isFirstCenteredScreen={this.props.isFirstCenteredScreen} isLastCenteredScreen={this.props.isLastCenteredScreen} autoAdvance={this.props.autoAdvance} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx index 0862b1dc6e28..972bb958b62c 100644 --- a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx @@ -7,6 +7,7 @@ import { Localized } from "./MSLocalized"; import { Colorways } from "./Colorways"; import { Themes } from "./Themes"; import { SecondaryCTA, StepsIndicator } from "./MultiStageAboutWelcome"; +import { LanguageSwitcher } from "./LanguageSwitcher"; export const MultiStageProtonScreen = props => { const { autoAdvance, handleAction, order } = props; @@ -38,6 +39,9 @@ export const MultiStageProtonScreen = props => { isRtamo={props.isRtamo} isTheme={props.isTheme} iconURL={props.iconURL} + messageId={props.messageId} + negotiatedLanguage={props.negotiatedLanguage} + langPackInstallPhase={props.langPackInstallPhase} /> ); }; @@ -114,6 +118,18 @@ export class ProtonScreen extends React.PureComponent { ); } + renderLanguageSwitcher() { + return this.props.content.languageSwitcher ? ( + + ) : null; + } + render() { const { autoAdvance, @@ -201,12 +217,14 @@ export class ProtonScreen extends React.PureComponent {

) : null}

{this.renderContentTiles()} + {this.renderLanguageSwitcher()}
diff --git a/browser/components/newtab/karma.mc.config.js b/browser/components/newtab/karma.mc.config.js index 5a811c4cc7cb..3d09c8ac7ac1 100644 --- a/browser/components/newtab/karma.mc.config.js +++ b/browser/components/newtab/karma.mc.config.js @@ -188,6 +188,13 @@ module.exports = function(config) { functions: 96, branches: 70, }, + "content-src/aboutwelcome/components/LanguageSwitcher.jsx": { + // This file is covered by the mochitest: browser_aboutwelcome_multistage_languageSwitcher.js + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, "content-src/aboutwelcome/**/*.jsx": { statements: 62, lines: 60, diff --git a/browser/components/newtab/test/browser/browser.ini b/browser/components/newtab/test/browser/browser.ini index 4a2a79e92102..9e587bd4a601 100644 --- a/browser/components/newtab/test/browser/browser.ini +++ b/browser/components/newtab/test/browser/browser.ini @@ -16,12 +16,14 @@ prefs = browser.newtabpage.activity-stream.feeds.section.topstories=true browser.newtabpage.activity-stream.feeds.section.topstories.options={"provider_name":""} messaging-system.log=all + intl.multilingual.aboutWelcome.languageMismatchEnabled=false [browser_aboutwelcome_configurable_ui.js] [browser_aboutwelcome_focus.js] [browser_aboutwelcome_multistage_default.js] [browser_aboutwelcome_multistage_primary.js] [browser_aboutwelcome_multistage_experimentAPI.js] +[browser_aboutwelcome_multistage_languageSwitcher.js] [browser_aboutwelcome_rtamo.js] skip-if = (os == "linux") # Test setup only implemented for OSX and Windows [browser_aboutwelcome_attribution.js] diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js new file mode 100644 index 000000000000..87f958d5a932 --- /dev/null +++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js @@ -0,0 +1,572 @@ +"use strict"; + +const { getAddonAndLocalAPIsMocker } = ChromeUtils.import( + "resource://testing-common/LangPackMatcherTestUtils.jsm" +); + +const sandbox = sinon.createSandbox(); +const mockAddonAndLocaleAPIs = getAddonAndLocalAPIsMocker(this, sandbox); +add_task(function initSandbox() { + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +/** + * Spy specifically on the button click telemetry. + * + * The returned function flushes the spy of all of the matching button click events, and + * returns the events. + * @returns {() => TelemetryEvents[]} + */ +async function spyOnTelemetryButtonClicks(browser) { + let aboutWelcomeActor = await getAboutWelcomeParent(browser); + sandbox.spy(aboutWelcomeActor, "onContentMessage"); + return () => { + const result = aboutWelcomeActor.onContentMessage + .getCalls() + .filter( + call => + call.args[0] === "AWPage:TELEMETRY_EVENT" && + call.args[1]?.event === "CLICK_BUTTON" + ) + // The second argument is the telemetry event. + .map(call => call.args[1]); + + aboutWelcomeActor.onContentMessage.resetHistory(); + return result; + }; +} + +async function openAboutWelcome() { + await pushPrefs([ + "intl.multilingual.aboutWelcome.languageMismatchEnabled", + true, + ]); + await setAboutWelcomePref(true); + + // Stub out the doesAppNeedPin to false so the about:welcome pages do not attempt + // to pin the app. + const { ShellService } = ChromeUtils.import( + "resource:///modules/ShellService.jsm" + ); + sandbox.stub(ShellService, "doesAppNeedPin").returns(false); + + info("Opening about:welcome"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:welcome", + true + ); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + }); + + return { + browser: tab.linkedBrowser, + flushClickTelemetry: await spyOnTelemetryButtonClicks(tab.linkedBrowser), + }; +} + +async function clickVisibleButton(browser, selector) { + // eslint-disable-next-line no-shadow + await ContentTask.spawn(browser, { selector }, async ({ selector }) => { + function getVisibleElement() { + for (const el of content.document.querySelectorAll(selector)) { + if (el.offsetParent !== null) { + return el; + } + } + return null; + } + + await ContentTaskUtils.waitForCondition(getVisibleElement, selector); + getVisibleElement().click(); + }); +} + +/** + * Test that selectors are present and visible. + */ +async function testScreenContent( + browser, + name, + expectedSelectors = [], + unexpectedSelectors = [] +) { + await ContentTask.spawn( + browser, + { expectedSelectors, name, unexpectedSelectors }, + async ({ + expectedSelectors: expected, + name: experimentName, + unexpectedSelectors: unexpected, + }) => { + function selectorIsVisible(selector) { + const el = content.document.querySelector(selector); + // The offsetParent will be null if element is hidden through "display: none;" + return el && el.offsetParent !== null; + } + + for (let selector of expected) { + await ContentTaskUtils.waitForCondition( + () => selectorIsVisible(selector), + `Should render ${selector} in ${experimentName}` + ); + } + for (let selector of unexpected) { + ok( + !selectorIsVisible(selector), + `Should not render ${selector} in ${experimentName}` + ); + } + } + ); +} + +/** + * Report telemetry mismatches nicely. + */ +function eventsMatch( + actualEvents, + expectedEvents, + message = "Telemetry events match" +) { + if (actualEvents.length !== expectedEvents.length) { + console.error("Events do not match"); + console.error("Actual: ", JSON.stringify(actualEvents, null, 2)); + console.error("Expected: ", JSON.stringify(expectedEvents, null, 2)); + } + for (let i = 0; i < actualEvents.length; i++) { + const actualEvent = JSON.stringify(actualEvents[i], null, 2); + const expectedEvent = JSON.stringify(expectedEvents[i], null, 2); + if (actualEvent !== expectedEvent) { + console.error("Events do not match"); + dump(`Actual: ${actualEvent}`); + dump("\n"); + dump(`Expected: ${expectedEvent}`); + dump("\n"); + } + ok(actualEvent === expectedEvent, message); + } +} + +const liveLanguageSwitchSelectors = [ + ".screen-1", + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="onboarding-live-language-header"]`, +]; + +/** + * Accept the about:welcome offer to change the Firefox language when + * there is a mismatch between the operating system language and the Firefox + * language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_accept() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + + info("Clicking the primary button to start the onboarding process."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching (waiting for languages)", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-header"]`, + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ], + // Unexpected selectors: + [] + ); + + // Ignore the telemetry of the initial welcome screen. + flushClickTelemetry(); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-switch-button-label"]`, + `[data-l10n-id="onboarding-live-language-not-now-button-label"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ] + ); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching, waiting for langpack to download", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-button-label-downloading"]`, + `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + ] + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "download_langpack", + page: "about:welcome", + }, + message_id: "DEFAULT_ABOUTWELCOME_PROTON_1_AW_LANGUAGE_MISMATCH", + }, + ]); + + await resolveInstaller(); + + await testScreenContent( + browser, + "Language selection declined", + // Expected selectors: + [`.screen-2`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "download_complete", + page: "about:welcome", + }, + message_id: "DEFAULT_ABOUTWELCOME_PROTON_1_AW_LANGUAGE_MISMATCH", + }, + ]); +}); + +/** + * Accept the about:welcome offer to change the Firefox language when + * there is a mismatch between the operating system language and the Firefox + * language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_accept() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + + info("Clicking the primary button to start the onboarding process."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching (waiting for languages)", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-header"]`, + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ], + // Unexpected selectors: + [] + ); + + // Ignore the telemetry of the initial welcome screen. + flushClickTelemetry(); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-switch-button-label"]`, + `[data-l10n-id="onboarding-live-language-not-now-button-label"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ] + ); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching, waiting for langpack to download", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-button-label-downloading"]`, + `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + ] + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "download_langpack", + page: "about:welcome", + }, + message_id: "DEFAULT_ABOUTWELCOME_PROTON_1_AW_LANGUAGE_MISMATCH", + }, + ]); + + await resolveInstaller(); + + await testScreenContent( + browser, + "Language selection declined", + // Expected selectors: + [`.screen-2`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); +}); + +/** + * Test declining the about:welcome offer to change the Firefox language when + * there is a mismatch between the operating system language and the Firefox + * language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_decline() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching (waiting for languages)", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-header"]`, + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ], + // Unexpected selectors: + [] + ); + + // Ignore the telemetry of the initial welcome screen. + flushClickTelemetry(); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + resolveInstaller(); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-switch-button-label"]`, + `[data-l10n-id="onboarding-live-language-not-now-button-label"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + `[data-l10n-id="onboarding-live-language-skip-button-label"]`, + ] + ); + + info("Clicking the secondary button to skip installing the langpack."); + await clickVisibleButton(browser, "button.secondary"); + + await testScreenContent( + browser, + "Language selection declined", + // Expected selectors: + [`.screen-2`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "decline", + page: "about:welcome", + }, + message_id: "DEFAULT_ABOUTWELCOME_PROTON_1_AW_LANGUAGE_MISMATCH", + }, + ]); +}); + +/** + * Ensure the langpack can be installed before the user gets to the language screen. + */ +add_task(async function test_aboutwelcome_languageSwitcher_asyncCalls() { + sandbox.restore(); + const { + resolveLangPacks, + resolveInstaller, + mockable, + } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + await openAboutWelcome(); + + info("Waiting for getAvailableLangpacks to be called."); + await TestUtils.waitForCondition( + () => mockable.getAvailableLangpacks.called, + "getAvailableLangpacks called once" + ); + ok(mockable.installLangPack.notCalled); + + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await TestUtils.waitForCondition( + () => mockable.installLangPack.called, + "installLangPack was called once" + ); + ok(mockable.getAvailableLangpacks.called); + + resolveInstaller(); +}); + +/** + * Test when AMO does not have a matching language. + */ +add_task(async function test_aboutwelcome_languageSwitcher_noMatch() { + sandbox.restore(); + const { resolveLangPacks } = mockAddonAndLocaleAPIs({ + systemLocale: "tlh", // Klingon + appLocale: "en-US", + }); + + const { browser } = await openAboutWelcome(); + + info("Clicking the primary button to start installing the langpack."); + await clickVisibleButton(browser, "button.primary"); + + // Klingon is not supported. + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Language selection skipped", + // Expected selectors: + [`.screen-1`], + // Unexpected selectors: + [ + `[data-l10n-id*="onboarding-live-language"]`, + `[data-l10n-id="onboarding-live-language-header"]`, + ] + ); +}); + +/** + * Test hitting the cancel button when waiting on a langpack. + */ +add_task(async function test_aboutwelcome_languageSwitcher_cancelWaiting() { + sandbox.restore(); + const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({ + systemLocale: "es-ES", + appLocale: "en-US", + }); + + const { browser, flushClickTelemetry } = await openAboutWelcome(); + + info("Clicking the primary button to start the onboarding process."); + await clickVisibleButton(browser, "button.primary"); + resolveLangPacks(["es-MX", "es-ES", "fr-FR"]); + + await testScreenContent( + browser, + "Live language switching, asking for a language", + // Expected selectors: + liveLanguageSwitchSelectors, + // Unexpected selectors: + [] + ); + + info("Clicking the primary button to view language switching page."); + await clickVisibleButton(browser, "button.primary"); + + await testScreenContent( + browser, + "Live language switching, waiting for langpack to download", + // Expected selectors: + [ + ...liveLanguageSwitchSelectors, + `[data-l10n-id="onboarding-live-language-button-label-downloading"]`, + `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`, + ], + // Unexpected selectors: + [ + `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`, + ] + ); + + // Ignore all the telemetry up to this point. + flushClickTelemetry(); + + info("Cancel the request for the language"); + await clickVisibleButton(browser, "button.secondary"); + + await testScreenContent( + browser, + "Language selection declined waiting", + // Expected selectors: + [`.screen-2`], + // Unexpected selectors: + liveLanguageSwitchSelectors + ); + + eventsMatch(flushClickTelemetry(), [ + { + event: "CLICK_BUTTON", + event_context: { + source: "cancel_waiting", + page: "about:welcome", + }, + message_id: "DEFAULT_ABOUTWELCOME_PROTON_1_AW_LANGUAGE_MISMATCH", + }, + ]); + + await resolveInstaller(); + + is(flushClickTelemetry().length, 0); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx index 7a797e913a8c..4dc9c198702b 100644 --- a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx +++ b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx @@ -74,28 +74,28 @@ describe("MultiStageAboutWelcomeProton module", () => { ); assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX"); assert.propertyVal(data.screens[1], "id", "AW_SET_DEFAULT"); - assert.lengthOf(data.screens, getData().screens.length); + assert.lengthOf(data.screens, getData().screens.length - 1); }); it("should keep 'pin' and remove 'default' if already default", async () => { const data = await prepConfig({ needPin: true }); assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX"); assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); - assert.lengthOf(data.screens, getData().screens.length - 1); + assert.lengthOf(data.screens, getData().screens.length - 2); }); it("should switch to 'default' if already pinned", async () => { const data = await prepConfig({ needDefault: true }); assert.propertyVal(data.screens[0], "id", "AW_ONLY_DEFAULT"); assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); - assert.lengthOf(data.screens, getData().screens.length - 1); + assert.lengthOf(data.screens, getData().screens.length - 2); }); it("should switch to 'start' if already pinned and default", async () => { const data = await prepConfig(); assert.propertyVal(data.screens[0], "id", "AW_GET_STARTED"); assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); - assert.lengthOf(data.screens, getData().screens.length - 1); + assert.lengthOf(data.screens, getData().screens.length - 2); }); it("should have a FxA button", async () => { const data = await prepConfig(); diff --git a/browser/locales/en-US/browser/newtab/onboarding.ftl b/browser/locales/en-US/browser/newtab/onboarding.ftl index 015f19522367..265c4c2cacf7 100644 --- a/browser/locales/en-US/browser/newtab/onboarding.ftl +++ b/browser/locales/en-US/browser/newtab/onboarding.ftl @@ -222,3 +222,24 @@ mr2-onboarding-default-theme-label = Explore default themes. mr2-onboarding-thank-you-header = Thank you for choosing us mr2-onboarding-thank-you-text = { -brand-short-name } is an independent browser backed by a non-profit. Together, we’re making the web safer, healthier, and more private. mr2-onboarding-start-browsing-button-label = Start browsing + +## Multistage live language reloading onboarding strings (about:welcome pages) +## +## The following language names are generated by the browser's Intl.DisplayNames API. +## +## Variables: +## $appLanguage (String) - The name of Firefox's language, e.g. "American English" +## $systemLanguage (String) - The name of the OS's language, e.g. "European Spanish" +## $negotiatedLanguage (String) - The name of the langpack's language, e.g. "European Spanish" + +onboarding-live-language-header = Choose Your Language +onboarding-live-language-subtitle = { -brand-short-name } is using { $appLanguage } while your system is using { $systemLanguage }. + +onboarding-live-language-switch-button-label = Switch to { $negotiatedLanguage } +onboarding-live-language-button-label-downloading = Downloading the language pack for { $negotiatedLanguage }… +onboarding-live-language-waiting-subtitle = It looks like your system and { -brand-short-name } are using different languages. +onboarding-live-language-waiting-button = Getting available languages… +onboarding-live-language-installing = Installing the language pack for { $negotiatedLanguage }… +onboarding-live-language-secondary-cancel-download = Cancel +onboarding-live-language-not-now-button-label = Not now +onboarding-live-language-skip-button-label = Skip diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml index 6d042be90af3..b59b72f420e2 100644 --- a/toolkit/components/nimbus/FeatureManifest.yaml +++ b/toolkit/components/nimbus/FeatureManifest.yaml @@ -125,6 +125,12 @@ aboutwelcome: description: >- Should the urlbar should be focused when users first land on about:welcome? + languageMismatchEnabled: + type: boolean + fallbackPref: intl.multilingual.aboutWelcome.languageMismatchEnabled + description: >- + Suggest to change the language on about:welcome when there is a mismatch with + the OS. transitions: type: boolean description: Enable transition effect between screens