diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/newmailaccount/content/accountProvisioner.js | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/newmailaccount/content/accountProvisioner.js')
-rw-r--r-- | comm/mail/components/newmailaccount/content/accountProvisioner.js | 892 |
1 files changed, 892 insertions, 0 deletions
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.js b/comm/mail/components/newmailaccount/content/accountProvisioner.js new file mode 100644 index 0000000000..14ba69c515 --- /dev/null +++ b/comm/mail/components/newmailaccount/content/accountProvisioner.js @@ -0,0 +1,892 @@ +/* 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"; + +/* globals MsgAccountManager, MozElements */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AccountCreationUtils: + "resource:///modules/accountcreation/AccountCreationUtils.jsm", + + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", +}); + +var { gAccountSetupLogger } = AccountCreationUtils; + +// AbortController to handle timeouts and abort the fetch requests. +var gAbortController; +var RETRY_TIMEOUT = 5000; // 5 seconds +var CONNECTION_TIMEOUT = 15000; // 15 seconds +var MAX_SMALL_ADDRESSES = 2; + +// Keep track of the prefers-reduce-motion media query for JS based animations. +var gReducedMotion; + +// The main 3 Pane Window that we need to define on load in order to properly +// update the UI when a new account is created. +var gMainWindow; + +// Define window event listeners. +window.addEventListener("load", () => { + gAccountProvisioner.onLoad(); +}); +window.addEventListener("unload", () => { + gAccountProvisioner.onUnload(); +}); + +// Object to collect all the extra providers attributes to be used when +// building the URL for the API call to purchase an item. +var storedData = {}; + +/** + * Helper method to split a value based on its first available blank space. + * + * @param {string} str - The string to split. + * @returns {Array} - An array with the generated first and last name. + */ +function splitName(str) { + let i = str.lastIndexOf(" "); + if (i >= 1) { + return [str.substring(0, i), str.substring(i + 1)]; + } + return [str, ""]; +} + +/** + * Quick and simple HTML sanitization. + * + * @param {string} inputID - The ID of the currently used input field. + * @returns {string} - The HTML sanitized input value. + */ +function sanitizeName(inputID) { + let div = document.createElement("div"); + div.textContent = document.getElementById(inputID).value; + return div.innerHTML.trim(); +} + +/** + * Replace occurrences of placeholder with the given node + * + * @param aTextContainer {Node} - DOM node containing the text child + * @param aTextNode {Node} - Text node containing the text, child of the aTextContainer + * @param aPlaceholder {String} - String to look for in aTextNode's textContent + * @param aReplacement {Node} - DOM node to insert instead of the found replacement + */ +function insertHTMLReplacement( + aTextContainer, + aTextNode, + aPlaceholder, + aReplacement +) { + if (aTextNode.textContent.includes(aPlaceholder)) { + let placeIndex = aTextNode.textContent.indexOf(aPlaceholder); + let restNode = aTextNode.splitText(placeIndex + aPlaceholder.length); + aTextContainer.insertBefore(aReplacement, restNode); + let placeholderNode = aTextNode.splitText(placeIndex); + placeholderNode.remove(); + } +} + +/** + * This is our controller for the entire account provisioner setup process. + */ +var gAccountProvisioner = { + // If the setup wizard has already been initialized. + _isInited: false, + // If the data fetching of the providers is currently in progress. + _isLoadingProviders: false, + // If the providers have already been loaded. + _isLoadedProviders: false, + // Store a timeout retry in case fetching the providers fails. + _loadProviderRetryId: null, + // Array containing all fetched providers. + allProviders: [], + // Array containing all fetched provider names that only offer email. + mailProviders: [], + // Array containing all fetched provider names that also offer custom domain. + domainProviders: [], + // Handle a timeout to abort the fetch requests. + timeoutId: null, + + /** + * Returns the URL for retrieving suggested names from the selected providers. + */ + get suggestFromName() { + return Services.prefs.getCharPref("mail.provider.suggestFromName"); + }, + + /** + * Initialize the main notification box for the account setup process. + */ + get notificationBox() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document + .getElementById("accountProvisionerNotifications") + .append(element); + }); + } + return this._notificationBox; + }, + + /** + * Clear currently running async fetches and reset important variables. + */ + onUnload() { + this.clearAbortTimeout(); + gAbortController.abort(); + gAbortController = null; + }, + + async onLoad() { + // We can only init once, so bail out if we've been called again. + if (this._isInited) { + return; + } + + gAccountSetupLogger.debug("Initializing provisioner wizard"); + gReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + // Store the main window. + gMainWindow = Services.wm.getMostRecentWindow("mail:3pane"); + + // Initialize the fetch abort controller. + gAbortController = new AbortController(); + + // If we have a name stored, populate the search field with it. + if ("@mozilla.org/userinfo;1" in Cc) { + let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo); + // Assume that it's a genuine full name if it includes a space. + if (userInfo.fullname.includes(" ")) { + document.getElementById("mailName").value = userInfo.fullname; + document.getElementById("domainName").value = userInfo.fullname; + } + } + + this.setupEventListeners(); + await this.tryToFetchProviderList(); + + gAccountSetupLogger.debug("Provisioner wizard init complete."); + + // Move the focus on the first available field. + document.getElementById("mailName").focus(); + this._isInited = true; + + Services.telemetry.scalarAdd("tb.account.opened_account_provisioner", 1); + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + }, + + /** + * Set up the event listeners for the static elements in the page. + */ + setupEventListeners() { + document.getElementById("cancelButton").onclick = () => { + window.close(); + }; + + document.getElementById("existingButton").onclick = () => { + window.close(); + gMainWindow.postMessage("open-account-setup-tab", "*"); + }; + + document.getElementById("backButton").onclick = () => { + this.backToSetupView(); + }; + }, + + /** + * Return to the initial view without resetting any existing data. + */ + backToSetupView() { + this.clearAbortTimeout(); + this.clearNotifications(); + + // Clear search results. + let mailResultsArea = document.getElementById("mailResultsArea"); + while (mailResultsArea.hasChildNodes()) { + mailResultsArea.lastChild.remove(); + } + let domainResultsArea = document.getElementById("domainResultsArea"); + while (domainResultsArea.hasChildNodes()) { + domainResultsArea.lastChild.remove(); + } + + // Update the UI to show the initial view. + document.getElementById("mailSearch").hidden = false; + document.getElementById("domainSearch").hidden = false; + document.getElementById("mailSearchResults").hidden = true; + document.getElementById("domainSearchResults").hidden = true; + + // Update the buttons visibility. + document.getElementById("backButton").hidden = true; + document.getElementById("cancelButton").hidden = false; + document.getElementById("existingButton").hidden = false; + + // Move the focus back on the first available field. + document.getElementById("mailName").focus(); + }, + + /** + * Show a loading notification. + */ + async startLoadingState(stringName) { + this.clearNotifications(); + + let notificationMessage = await document.l10n.formatValue(stringName); + + gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`); + + let notification = this.notificationBox.appendNotification( + "accountSetupLoading", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_INFO_LOW, + }, + null + ); + notification.setAttribute("align", "center"); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + /** + * Show an error notification in case something went wrong. + * + * @param {string} stringName - The name of the fluent string that needs to + * be attached to the notification. + * @param {boolean} isMsgError - True if the message comes from a server error + * response or try/catch. + */ + async showErrorNotification(stringName, isMsgError) { + gAccountSetupLogger.debug(`Status error: ${stringName}`); + + // Always remove any leftover notification before creating a new one. + this.clearNotifications(); + + // Fetch the fluent string only if this is not an error message coming from + // a previous method. + let notificationMessage = isMsgError + ? stringName + : await document.l10n.formatValue(stringName); + + let notification = this.notificationBox.appendNotification( + "accountProvisionerError", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_WARNING_MEDIUM, + }, + null + ); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + async showSuccessNotification(stringName) { + // Always remove any leftover notification before creating a new one. + this.clearNotifications(); + + let notification = this.notificationBox.appendNotification( + "accountProvisionerSuccess", + { + label: await document.l10n.formatValue(stringName), + priority: this.notificationBox.PRIORITY_WARNING_MEDIUM, + }, + null + ); + notification.setAttribute("type", "success"); + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + /** + * Clear all leftover notifications. + */ + clearNotifications() { + this.notificationBox.removeAllNotifications(); + }, + + /** + * Event handler for when the user selects an address by clicking on the price + * button for that address. This function spawns the content tab for the + * address order form, and then closes the Account Provisioner tab. + * + * @param {string} providerId - The ID of the chosen provider. + * @param {string} email - The chosen email address. + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + */ + onAddressSelected(providerId, email, isDomain = false) { + gAccountSetupLogger.debug("An address was selected by the user."); + let provider = this.allProviders.find(p => p.id == providerId); + + let url = provider.api; + let inputID = isDomain ? "domainName" : "mailName"; + let [firstName, lastName] = splitName(sanitizeName(inputID)); + // Replace the variables in the API url. + url = url.replace("{firstname}", firstName); + url = url.replace("{lastname}", lastName); + url = url.replace("{email}", email); + + // And add the extra data. + let data = storedData[providerId]; + delete data.provider; + for (let name in data) { + url += `${!url.includes("?") ? "?" : "&"}${name}=${encodeURIComponent( + data[name] + )}`; + } + + gAccountSetupLogger.debug("Opening up a contentTab with the order form."); + // Open the checkout content tab. + let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane"); + let tabmail = mail3Pane.document.getElementById("tabmail"); + tabmail.openTab("provisionerCheckoutTab", { + url, + realName: (firstName + " " + lastName).trim(), + email, + }); + + let providerHostname = new URL(url).hostname; + // Collect telemetry on which provider was selected for a new email account. + Services.telemetry.keyedScalarAdd( + "tb.account.selected_account_from_provisioner", + providerHostname, + 1 + ); + + // The user has made a selection. Close the provisioner window and let the + // provider setup process take place in a dedicated tab. + window.close(); + }, + + /** + * Attempt to fetch the provider list from the server. + */ + async tryToFetchProviderList() { + // If we're already in the middle of getting the provider list, or we + // already got it before, bail out. + if (this._isLoadingProviders || this._isLoadedProviders) { + return; + } + + this._isLoadingProviders = true; + + // If there's a timeout ID for waking the account provisioner, clear it. + if (this._loadProviderRetryId) { + window.clearTimeout(this._loadProviderRetryId); + this._loadProviderRetryId = null; + } + + await this.startLoadingState("account-provisioner-fetching-provisioners"); + + let providerListUrl = Services.prefs.getCharPref( + "mail.provider.providerList" + ); + + gAccountSetupLogger.debug( + `Trying to populate provider list from ${providerListUrl}…` + ); + + try { + let res = await fetch(providerListUrl, { + signal: gAbortController.signal, + }); + this.startAbortTimeout(); + let data = await res.json(); + this.populateProvidersLists(data); + } catch (error) { + // Ugh, we couldn't get the JSON file. Maybe we're not online. Or maybe + // the server is down, or the file isn't being served. Regardless, if + // we get here, none of this stuff is going to work. + this._loadProviderRetryId = window.setTimeout( + () => this.tryToFetchProviderList(), + RETRY_TIMEOUT + ); + this._isLoadingProviders = false; + this.showErrorNotification("account-provisioner-connection-issues"); + gAccountSetupLogger.warn(`Failed to populate providers: ${error}`); + } + }, + + /** + * Validate a provider fetched during an API request to be sure we have all + * the necessary fields to complete a setup process. + * + * @param {object} provider - The fetched provider. + * @returns {boolean} - True if all the fields in the provider match the + * required fields. + */ + providerHasCorrectFields(provider) { + let result = true; + + let required = [ + "id", + "label", + "paid", + "languages", + "api", + "tos_url", + "privacy_url", + "sells_domain", + ]; + + for (let field of required) { + let fieldExists = field in provider; + result &= fieldExists; + + if (!fieldExists) { + gAccountSetupLogger.warn( + `A provider did not have the field ${field}, and will be skipped.` + ); + } + } + + return result; + }, + + /** + * Take the fetched providers, create checkboxes, icons and labels, and insert + * them below the corresponding search input. + * + * @param {?object} data - The object containing all fetched providers. + */ + populateProvidersLists(data) { + gAccountSetupLogger.debug("Populating the provider list"); + this.clearAbortTimeout(); + + if (!data || !data.length) { + gAccountSetupLogger.warn( + "The provider list we got back from the server was empty!" + ); + this.showErrorNotification("account-provisioner-connection-issues"); + return; + } + + let mailProviderList = document.getElementById("mailProvidersList"); + let domainProviderList = document.getElementById("domainProvidersList"); + + this.allProviders = data; + this.mailProviders = []; + this.domainProviders = []; + + for (let provider of data) { + if (!this.providerHasCorrectFields(provider)) { + gAccountSetupLogger.warn( + "A provider had incorrect fields, and has been skipped" + ); + continue; + } + + let entry = document.createElement("li"); + entry.setAttribute("id", provider.id); + + if (provider.icon) { + let icon = document.createElement("img"); + icon.setAttribute("src", provider.icon); + icon.setAttribute("alt", ""); + entry.appendChild(icon); + } + + let name = document.createElement("span"); + name.textContent = provider.label; + entry.appendChild(name); + + if (provider.sells_domain) { + domainProviderList.appendChild(entry); + this.domainProviders.push(provider.id); + } else { + mailProviderList.appendChild(entry); + this.mailProviders.push(provider.id); + } + } + + this._isLoadedProviders = true; + this.clearNotifications(); + }, + + /** + * Enable or disable the form fields when a fetch request starts or ends. + * + * @param {boolean} state - True if a fetch request is in progress. + */ + updateSearchingState(state) { + for (let element of document.querySelectorAll(".disable-on-submit")) { + element.disabled = state; + } + }, + + /** + * Search for available email accounts. + * + * @param {DOMEvent} event - The form submit event. + */ + async onMailFormSubmit(event) { + // Always prevent the actual form submission. + event.preventDefault(); + + // Quick HTML sanitization. + let name = sanitizeName("mailName"); + + // Bail out if the user didn't type anything. + if (!name) { + return; + } + + let resultsArea = document.getElementById("mailSearchResults"); + resultsArea.hidden = true; + + this.startLoadingState("account-provisioner-searching-email"); + let data = await this.submitFormRequest(name, this.mailProviders.join(",")); + this.clearAbortTimeout(); + + let count = this.populateSearchResults(data); + if (!count) { + // Bail out if we didn't get any usable data. + gAccountSetupLogger.warn( + "We got nothing back from the server for search results!" + ); + this.showErrorNotification("account-provisioner-searching-error"); + return; + } + + let resultsTitle = document.getElementById("mailResultsTitle"); + let resultsString = await document.l10n.formatValue( + "account-provisioner-results-title", + { count } + ); + // Attach the sanitized search terms to avoid HTML conversion in fluent. + resultsTitle.textContent = `${resultsString} "${name}"`; + + // Hide the domain section. + document.getElementById("domainSearch").hidden = true; + // Show the results area. + resultsArea.hidden = false; + // Update the buttons visibility. + document.getElementById("cancelButton").hidden = true; + document.getElementById("existingButton").hidden = true; + // Show the back button. + document.getElementById("backButton").hidden = false; + }, + + /** + * Search for available domain names. + * + * @param {DOMEvent} event - The form submit event. + */ + async onDomainFormSubmit(event) { + // Always prevent the actual form submission. + event.preventDefault(); + + // Quick HTML sanitization. + let name = sanitizeName("domainName"); + + // Bail out if the user didn't type anything. + if (!name) { + return; + } + + let resultsArea = document.getElementById("domainSearchResults"); + resultsArea.hidden = true; + + this.startLoadingState("account-provisioner-searching-domain"); + let data = await this.submitFormRequest( + name, + this.domainProviders.join(",") + ); + this.clearAbortTimeout(); + + let count = this.populateSearchResults(data, true); + if (!count) { + // Bail out if we didn't get any usable data. + gAccountSetupLogger.warn( + "We got nothing back from the server for search results!" + ); + this.showErrorNotification("account-provisioner-searching-error"); + return; + } + + let resultsTitle = document.getElementById("domainResultsTitle"); + let resultsString = await document.l10n.formatValue( + "account-provisioner-results-title", + { count } + ); + // Attach the sanitized search terms to avoid HTML conversion in fluent. + resultsTitle.textContent = `${resultsString} "${name}"`; + + // Hide the mail section. + document.getElementById("mailSearch").hidden = true; + // Show the results area. + resultsArea.hidden = false; + // Update the buttons visibility. + document.getElementById("cancelButton").hidden = true; + document.getElementById("existingButton").hidden = true; + // Show the back button. + document.getElementById("backButton").hidden = false; + }, + + /** + * Update the UI to show the fetched address data. + * + * @param {object} data - The fetched data from an email or domain search. + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + */ + populateSearchResults(data, isDomain = false) { + if (!data || !data.length) { + return 0; + } + + this.clearNotifications(); + + let resultsArea = isDomain + ? document.getElementById("domainResultsArea") + : document.getElementById("mailResultsArea"); + // Clear previously generated content. + while (resultsArea.hasChildNodes()) { + resultsArea.lastChild.remove(); + } + + // Filter out possible errors or empty lists. + let validData = data.filter( + result => result.succeeded && result.addresses.length + ); + + if (!validData || !validData.length) { + return 0; + } + + let providersList = isDomain ? this.domainProviders : this.mailProviders; + + let count = 0; + for (let provider of validData) { + count += provider.addresses.length; + + // Don't add a provider header if only 1 is currently available. + if (providersList.length > 1) { + let header = document.createElement("h5"); + header.classList.add("result-list-header"); + header.textContent = this.allProviders.find( + p => p.id == provider.provider + ).label; + resultsArea.appendChild(header); + } + + let list = document.createElement("ul"); + + // Only show a chink of addresses if we got a long list. + let isLongList = provider.addresses.length > 5; + let addresses = isLongList + ? provider.addresses.slice(0, 4) + : provider.addresses; + + for (let address of addresses) { + list.appendChild(this.createAddressRow(address, provider, isDomain)); + } + + resultsArea.appendChild(list); + + // If we got more than 5 addresses, create an hidden bug expandable list + // with the rest of the data. + if (isLongList) { + let hiddenList = document.createElement("ul"); + hiddenList.hidden = true; + + for (let address of provider.addresses.slice(5)) { + hiddenList.appendChild( + this.createAddressRow(address, provider, isDomain) + ); + } + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + button.classList.add("btn-link", "self-center"); + document.l10n.setAttributes( + button, + "account-provisioner-all-results-button" + ); + button.onclick = () => { + hiddenList.hidden = false; + button.hidden = true; + }; + + resultsArea.appendChild(button); + resultsArea.appendChild(hiddenList); + } + } + + for (let provider of data) { + delete provider.succeeded; + delete provider.addresses; + delete provider.price; + storedData[provider.provider] = provider; + } + + return count; + }, + + /** + * Create the list item to show the suggested address returned from a search. + * + * @param {object} address - The address returned from the provider search. + * @param {object} provider - The provider from which the address is + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + * available. + * @returns {HTMLLIElement} + */ + createAddressRow(address, provider, isDomain = false) { + let row = document.createElement("li"); + row.classList.add("result-item"); + + let suggestedAddress = address.address || address; + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + button.onclick = () => { + this.onAddressSelected(provider.provider, suggestedAddress, isDomain); + }; + + let leftArea = document.createElement("span"); + leftArea.classList.add("result-data"); + + let name = document.createElement("span"); + name.classList.add("result-name"); + name.textContent = suggestedAddress; + leftArea.appendChild(name); + row.setAttribute("data-label", suggestedAddress); + + let price = document.createElement("span"); + price.classList.add("result-price"); + + // Build the pricing text and handle possible free trials. + if (address.price) { + if (address.price != 0) { + // Some pricing is defined. + document.l10n.setAttributes(price, "account-provision-price-per-year", { + price: address.price, + }); + } else if (address.price == 0) { + // Price is defined by it's zero. + document.l10n.setAttributes(price, "account-provisioner-free-account"); + } + } else if (provider.price && provider.price != 0) { + // We don't have a price for the current result so let's try to use + // the general Provider's price. + document.l10n.setAttributes(price, "account-provision-price-per-year", { + price: provider.price, + }); + } else { + // No price was specified, let's return "Free". + document.l10n.setAttributes(price, "account-provisioner-free-account"); + } + leftArea.appendChild(price); + + button.appendChild(leftArea); + + let img = document.createElement("img"); + document.l10n.setAttributes(img, "account-provisioner-open-in-tab-img"); + img.setAttribute("alt", ""); + img.setAttribute("src", "chrome://global/skin/icons/open-in-new.svg"); + button.appendChild(img); + + row.appendChild(button); + + return row; + }, + + /** + * Fetches a list of suggested email addresses or domain names from a list of + * selected providers. + * + * @param {string} name - The search value typed by the user. + * @param {Array} providers - Array of providers to search for. + * @returns {object} - A list of available emails or domains. + */ + async submitFormRequest(name, providers) { + // If the focused element is disabled by `updateSearchingState`, focus is + // lost. Save the focused element to restore it later. + let activeElement = document.activeElement; + this.updateSearchingState(true); + + let [firstName, lastName] = splitName(name); + let url = `${this.suggestFromName}?first_name=${encodeURIComponent( + firstName + )}&last_name=${encodeURIComponent(lastName)}&providers=${encodeURIComponent( + providers + )}&version=2`; + + let data; + try { + let res = await fetch(url, { signal: gAbortController.signal }); + this.startAbortTimeout(); + data = await res.json(); + } catch (error) { + gAccountSetupLogger.warn(`Failed to fetch address data: ${error}`); + } + + this.updateSearchingState(false); + // Restore focus. + activeElement.focus(); + return data; + }, + + /** + * Start a timeout to abort a fetch request based on a time limit. + */ + startAbortTimeout() { + this.timeoutId = setTimeout(() => { + gAbortController.abort(); + this.showErrorNotification("account-provisioner-connection-timeout"); + gAccountSetupLogger.warn("Connection timed out"); + }, CONNECTION_TIMEOUT); + }, + + /** + * Clear any leftover timeout to prevent an unnecessary fetch abort. + */ + clearAbortTimeout() { + if (this.timeoutId) { + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }, + + /** + * Always ensure the notification area is visible when a new notification is + * created. + */ + ensureVisibleNotification() { + document.getElementById("accountProvisionerNotifications").scrollIntoView({ + behavior: gReducedMotion ? "auto" : "smooth", + block: "start", + inline: "nearest", + }); + }, +}; |