Bug 1944416: Restore individual tabs from closed groups in closed windows r=dao,sessi...
[gecko.git] / browser / components / preferences / dialogs / browserLanguages.js
blobabf32db00cb2a5ba982290e2b1e23afd7968221c
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /* import-globals-from /toolkit/content/preferencesBindings.js */
7 // This is exported by preferences.js but we can't import that in a subdialog.
8 let { LangPackMatcher } = window.top;
10 ChromeUtils.defineESModuleGetters(this, {
11 AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
12 AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
13 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
14 SelectionChangedMenulist:
15 "resource:///modules/SelectionChangedMenulist.sys.mjs",
16 });
18 document
19 .getElementById("BrowserLanguagesDialog")
20 .addEventListener("dialoghelp", window.top.openPrefsHelp);
22 /* This dialog provides an interface for managing what language the browser is
23 * displayed in.
25 * There is a list of "requested" locales and a list of "available" locales. The
26 * requested locales must be installed and enabled. Available locales could be
27 * installed and enabled, or fetched from the AMO language tools API.
29 * If a langpack is disabled, there is no way to determine what locale it is for and
30 * it will only be listed as available if that locale is also available on AMO and
31 * the user has opted to search for more languages.
34 async function installFromUrl(url, hash, callback) {
35 let telemetryInfo = {
36 source: "about:preferences",
38 let install = await AddonManager.getInstallForURL(url, {
39 hash,
40 telemetryInfo,
41 });
42 if (callback) {
43 callback(install.installId.toString());
45 await install.install();
46 return install.addon;
49 async function dictionaryIdsForLocale(locale) {
50 let entries = await RemoteSettings("language-dictionaries").get({
51 filters: { id: locale },
52 });
53 if (entries.length) {
54 return entries[0].dictionaries;
56 return [];
59 class OrderedListBox {
60 constructor({
61 richlistbox,
62 upButton,
63 downButton,
64 removeButton,
65 onRemove,
66 onReorder,
67 }) {
68 this.richlistbox = richlistbox;
69 this.upButton = upButton;
70 this.downButton = downButton;
71 this.removeButton = removeButton;
72 this.onRemove = onRemove;
73 this.onReorder = onReorder;
75 this.items = [];
77 this.richlistbox.addEventListener("select", () => this.setButtonState());
78 this.upButton.addEventListener("command", () => this.moveUp());
79 this.downButton.addEventListener("command", () => this.moveDown());
80 this.removeButton.addEventListener("command", () => this.removeItem());
83 get selectedItem() {
84 return this.items[this.richlistbox.selectedIndex];
87 setButtonState() {
88 let { upButton, downButton, removeButton } = this;
89 let { selectedIndex, itemCount } = this.richlistbox;
90 upButton.disabled = selectedIndex <= 0;
91 downButton.disabled = selectedIndex == itemCount - 1;
92 removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove;
95 moveUp() {
96 let { selectedIndex } = this.richlistbox;
97 if (selectedIndex == 0) {
98 return;
100 let { items } = this;
101 let selectedItem = items[selectedIndex];
102 let prevItem = items[selectedIndex - 1];
103 items[selectedIndex - 1] = items[selectedIndex];
104 items[selectedIndex] = prevItem;
105 let prevEl = document.getElementById(prevItem.id);
106 let selectedEl = document.getElementById(selectedItem.id);
107 this.richlistbox.insertBefore(selectedEl, prevEl);
108 this.richlistbox.ensureElementIsVisible(selectedEl);
109 this.setButtonState();
111 this.onReorder();
114 moveDown() {
115 let { selectedIndex } = this.richlistbox;
116 if (selectedIndex == this.items.length - 1) {
117 return;
119 let { items } = this;
120 let selectedItem = items[selectedIndex];
121 let nextItem = items[selectedIndex + 1];
122 items[selectedIndex + 1] = items[selectedIndex];
123 items[selectedIndex] = nextItem;
124 let nextEl = document.getElementById(nextItem.id);
125 let selectedEl = document.getElementById(selectedItem.id);
126 this.richlistbox.insertBefore(nextEl, selectedEl);
127 this.richlistbox.ensureElementIsVisible(selectedEl);
128 this.setButtonState();
130 this.onReorder();
133 removeItem() {
134 let { selectedIndex } = this.richlistbox;
136 if (selectedIndex == -1) {
137 return;
140 let [item] = this.items.splice(selectedIndex, 1);
141 this.richlistbox.selectedItem.remove();
142 this.richlistbox.selectedIndex = Math.min(
143 selectedIndex,
144 this.richlistbox.itemCount - 1
146 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
147 this.onRemove(item);
150 setItems(items) {
151 this.items = items;
152 this.populate();
153 this.setButtonState();
157 * Add an item to the top of the ordered list.
159 * @param {object} item The item to insert.
161 addItem(item) {
162 this.items.unshift(item);
163 this.richlistbox.insertBefore(
164 this.createItem(item),
165 this.richlistbox.firstElementChild
167 this.richlistbox.selectedIndex = 0;
168 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
171 populate() {
172 this.richlistbox.textContent = "";
174 let frag = document.createDocumentFragment();
175 for (let item of this.items) {
176 frag.appendChild(this.createItem(item));
178 this.richlistbox.appendChild(frag);
180 this.richlistbox.selectedIndex = 0;
181 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
184 createItem({ id, label, value }) {
185 let listitem = document.createXULElement("richlistitem");
186 listitem.id = id;
187 listitem.setAttribute("value", value);
189 let labelEl = document.createXULElement("label");
190 labelEl.textContent = label;
191 listitem.appendChild(labelEl);
193 return listitem;
198 * The sorted select list of Locales available for the app.
200 class SortedItemSelectList {
201 constructor({ menulist, button, onSelect, onChange, compareFn }) {
202 /** @type {XULElement} */
203 this.menulist = menulist;
205 /** @type {XULElement} */
206 this.popup = menulist.menupopup;
208 /** @type {XULElement} */
209 this.button = button;
211 /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */
212 this.compareFn = compareFn;
214 /** @type {Array<LocaleDisplayInfo>} */
215 this.items = [];
217 // This will register the "command" listener.
218 new SelectionChangedMenulist(this.menulist, () => {
219 button.disabled = !menulist.selectedItem;
220 if (menulist.selectedItem) {
221 onChange(this.items[menulist.selectedIndex]);
224 button.addEventListener("command", () => {
225 if (!menulist.selectedItem) {
226 return;
229 let [item] = this.items.splice(menulist.selectedIndex, 1);
230 menulist.selectedItem.remove();
231 menulist.setAttribute("label", menulist.getAttribute("placeholder"));
232 button.disabled = true;
233 menulist.disabled = menulist.itemCount == 0;
234 menulist.selectedIndex = -1;
236 onSelect(item);
241 * @param {Array<LocaleDisplayInfo>} items
243 setItems(items) {
244 this.items = items.sort(this.compareFn);
245 this.populate();
248 populate() {
249 let { button, items, menulist, popup } = this;
250 popup.textContent = "";
252 let frag = document.createDocumentFragment();
253 for (let item of items) {
254 frag.appendChild(this.createItem(item));
256 popup.appendChild(frag);
258 menulist.setAttribute("label", menulist.getAttribute("placeholder"));
259 menulist.disabled = menulist.itemCount == 0;
260 menulist.selectedIndex = -1;
261 button.disabled = true;
265 * Add an item to the list sorted by the label.
267 * @param {object} item The item to insert.
269 addItem(item) {
270 let { compareFn, items, menulist, popup } = this;
272 // Find the index of the item to insert before.
273 let i = items.findIndex(el => compareFn(el, item) >= 0);
274 items.splice(i, 0, item);
275 popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i));
277 menulist.disabled = menulist.itemCount == 0;
280 createItem({ label, value, className, disabled }) {
281 let item = document.createXULElement("menuitem");
282 item.setAttribute("label", label);
283 if (value) {
284 item.value = value;
286 if (className) {
287 item.classList.add(className);
289 if (disabled) {
290 item.setAttribute("disabled", "true");
292 return item;
296 * Disable the inputs and set a data-l10n-id on the menulist. This can be
297 * reverted with `enableWithMessageId()`.
299 disableWithMessageId(messageId) {
300 document.l10n.setAttributes(this.menulist, messageId);
301 this.menulist.setAttribute(
302 "image",
303 "chrome://global/skin/icons/loading.svg"
305 this.menulist.disabled = true;
306 this.button.disabled = true;
310 * Enable the inputs and set a data-l10n-id on the menulist. This can be
311 * reverted with `disableWithMessageId()`.
313 enableWithMessageId(messageId) {
314 document.l10n.setAttributes(this.menulist, messageId);
315 this.menulist.removeAttribute("image");
316 this.menulist.disabled = this.menulist.itemCount == 0;
317 this.button.disabled = !this.menulist.selectedItem;
322 * @typedef LocaleDisplayInfo
323 * @type {object}
324 * @prop {string} id - A unique ID.
325 * @prop {string} label - The localized display name.
326 * @prop {string} value - The BCP 47 locale identifier or the word "search".
327 * @prop {boolean} canRemove - The default locale cannot be removed.
328 * @prop {boolean} installed - Whether or not the locale is installed.
332 * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers.
333 * @returns {Array<LocaleDisplayInfo>}
335 async function getLocaleDisplayInfo(localeCodes) {
336 let availableLocales = new Set(await LangPackMatcher.getAvailableLocales());
337 let localeNames = Services.intl.getLocaleDisplayNames(
338 undefined,
339 localeCodes,
340 { preferNative: true }
342 return localeCodes.map((code, i) => {
343 return {
344 id: "locale-" + code,
345 label: localeNames[i],
346 value: code,
347 canRemove: code != Services.locale.defaultLocale,
348 installed: availableLocales.has(code),
354 * @param {LocaleDisplayInfo} a
355 * @param {LocaleDisplayInfo} b
356 * @returns {number}
358 function compareItems(a, b) {
359 // Sort by installed.
360 if (a.installed != b.installed) {
361 return a.installed ? -1 : 1;
363 // The search label is always last.
364 } else if (a.value == "search") {
365 return 1;
366 } else if (b.value == "search") {
367 return -1;
369 // If both items are locales, sort by label.
370 } else if (a.value && b.value) {
371 return a.label.localeCompare(b.label);
373 // One of them is a label, put it first.
374 } else if (a.value) {
375 return 1;
377 return -1;
380 var gBrowserLanguagesDialog = {
382 * The publicly readable list of selected locales. It is only set when the dialog is
383 * accepted, and can be retrieved elsewhere by directly reading the property
384 * on gBrowserLanguagesDialog.
386 * let { selected } = gBrowserLanguagesDialog;
388 * @type {null | Array<string>}
390 selected: null,
393 * @type {string | null} An ID used for telemetry pings. It is unique to the current
394 * opening of the browser language.
396 _telemetryId: null,
399 * @type {SortedItemSelectList}
401 _availableLocalesUI: null,
404 * @type {OrderedListBox}
406 _selectedLocalesUI: null,
408 get downloadEnabled() {
409 // Downloading langpacks isn't always supported, check the pref.
410 return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled");
413 recordTelemetry(method, extra = {}) {
414 extra.value = this._telemetryId;
415 Glean.intlUiBrowserLanguage[method + "Dialog"].record(extra);
418 async onLoad() {
420 * @typedef {Object} Options - Options passed in to configure the subdialog.
421 * @property {string} telemetryId,
422 * @property {Array<string>} [selectedLocalesForRestart] The optional list of
423 * previously selected locales for when a restart is required. This list is
424 * preserved between openings of the dialog.
425 * @property {boolean} search Whether the user opened this from "Search for more
426 * languages" option.
429 /** @type {Options} */
430 let { telemetryId, selectedLocalesForRestart, search } =
431 window.arguments[0];
433 this._telemetryId = telemetryId;
435 // This is a list of available locales that the user selected. It's more
436 // restricted than the Intl notion of `requested` as it only contains
437 // locale codes for which we have matching locales available.
438 // The first time this dialog is opened, populate with appLocalesAsBCP47.
439 let selectedLocales =
440 selectedLocalesForRestart || Services.locale.appLocalesAsBCP47;
441 let selectedLocaleSet = new Set(selectedLocales);
442 let available = await LangPackMatcher.getAvailableLocales();
443 let availableSet = new Set(available);
445 // Filter selectedLocales since the user may select a locale when it is
446 // available and then disable it.
447 selectedLocales = selectedLocales.filter(locale =>
448 availableSet.has(locale)
450 // Nothing in available should be in selectedSet.
451 available = available.filter(locale => !selectedLocaleSet.has(locale));
453 await this.initSelectedLocales(selectedLocales);
454 await this.initAvailableLocales(available, search);
456 this.initialized = true;
458 // Now the component is initialized, it's safe to accept the results.
459 document
460 .getElementById("BrowserLanguagesDialog")
461 .addEventListener("beforeaccept", () => {
462 this.selected = this._selectedLocalesUI.items.map(item => item.value);
467 * @param {string[]} selectedLocales - BCP 47 locale identifiers
469 async initSelectedLocales(selectedLocales) {
470 this._selectedLocalesUI = new OrderedListBox({
471 richlistbox: document.getElementById("selectedLocales"),
472 upButton: document.getElementById("up"),
473 downButton: document.getElementById("down"),
474 removeButton: document.getElementById("remove"),
475 onRemove: item => this.selectedLocaleRemoved(item),
476 onReorder: () => this.recordTelemetry("reorder"),
478 this._selectedLocalesUI.setItems(
479 await getLocaleDisplayInfo(selectedLocales)
484 * @param {Set<string>} available - The set of available BCP 47 locale identifiers.
485 * @param {boolean} search - Whether the user opened this from "Search for more
486 * languages" option.
488 async initAvailableLocales(available, search) {
489 this._availableLocalesUI = new SortedItemSelectList({
490 menulist: document.getElementById("availableLocales"),
491 button: document.getElementById("add"),
492 compareFn: compareItems,
493 onSelect: item => this.availableLanguageSelected(item),
494 onChange: item => {
495 this.hideError();
496 if (item.value == "search") {
497 // Record the search event here so we don't track the search from
498 // the main preferences pane twice.
499 this.recordTelemetry("search");
500 this.loadLocalesFromAMO();
505 // Populate the list with the installed locales even if the user is
506 // searching in case the download fails.
507 await this.loadLocalesFromInstalled(available);
509 // If the user opened this from the "Search for more languages" option,
510 // search AMO for available locales.
511 if (search) {
512 return this.loadLocalesFromAMO();
515 return undefined;
518 async loadLocalesFromAMO() {
519 if (!this.downloadEnabled) {
520 return;
523 // Disable the dropdown while we hit the network.
524 this._availableLocalesUI.disableWithMessageId(
525 "browser-languages-searching"
528 // Fetch the available langpacks from AMO.
529 let availableLangpacks;
530 try {
531 availableLangpacks = await AddonRepository.getAvailableLangpacks();
532 } catch (e) {
533 this.showError();
534 return;
537 // Store the available langpack info for later use.
538 this.availableLangpacks = new Map();
539 for (let { target_locale, url, hash } of availableLangpacks) {
540 this.availableLangpacks.set(target_locale, { url, hash });
543 // Remove the installed locales from the available ones.
544 let installedLocales = new Set(await LangPackMatcher.getAvailableLocales());
545 let notInstalledLocales = availableLangpacks
546 .filter(({ target_locale }) => !installedLocales.has(target_locale))
547 .map(lang => lang.target_locale);
549 // Create the rows for the remote locales.
550 let availableItems = await getLocaleDisplayInfo(notInstalledLocales);
551 availableItems.push({
552 label: await document.l10n.formatValue(
553 "browser-languages-available-label"
555 className: "label-item",
556 disabled: true,
557 installed: false,
560 // Remove the search option and add the remote locales.
561 let items = this._availableLocalesUI.items;
562 items.pop();
563 items = items.concat(availableItems);
565 // Update the dropdown and enable it again.
566 this._availableLocalesUI.setItems(items);
567 this._availableLocalesUI.enableWithMessageId(
568 "browser-languages-select-language"
573 * @param {Set<string>} available - The set of available (BCP 47) locales.
575 async loadLocalesFromInstalled(available) {
576 let items;
577 if (available.length) {
578 items = await getLocaleDisplayInfo(available);
579 items.push(await this.createInstalledLabel());
580 } else {
581 items = [];
583 if (this.downloadEnabled) {
584 items.push({
585 label: await document.l10n.formatValue("browser-languages-search"),
586 value: "search",
589 this._availableLocalesUI.setItems(items);
593 * @param {LocaleDisplayInfo} item
595 async availableLanguageSelected(item) {
596 if ((await LangPackMatcher.getAvailableLocales()).includes(item.value)) {
597 this.recordTelemetry("add");
598 await this.requestLocalLanguage(item);
599 } else if (this.availableLangpacks.has(item.value)) {
600 // Telemetry is tracked in requestRemoteLanguage.
601 await this.requestRemoteLanguage(item);
602 } else {
603 this.showError();
608 * @param {LocaleDisplayInfo} item
610 async requestLocalLanguage(item) {
611 this._selectedLocalesUI.addItem(item);
612 let selectedCount = this._selectedLocalesUI.items.length;
613 let availableCount = (await LangPackMatcher.getAvailableLocales()).length;
614 if (selectedCount == availableCount) {
615 // Remove the installed label, they're all installed.
616 this._availableLocalesUI.items.shift();
617 this._availableLocalesUI.setItems(this._availableLocalesUI.items);
619 // The label isn't always reset when the selected item is removed, so set it again.
620 this._availableLocalesUI.enableWithMessageId(
621 "browser-languages-select-language"
626 * @param {LocaleDisplayInfo} item
628 async requestRemoteLanguage(item) {
629 this._availableLocalesUI.disableWithMessageId(
630 "browser-languages-downloading"
633 let { url, hash } = this.availableLangpacks.get(item.value);
634 let addon;
636 try {
637 addon = await installFromUrl(url, hash, installId =>
638 this.recordTelemetry("add", { installId })
640 } catch (e) {
641 this.showError();
642 return;
645 // If the add-on was previously installed, it might be disabled still.
646 if (addon.userDisabled) {
647 await addon.enable();
650 item.installed = true;
651 this._selectedLocalesUI.addItem(item);
652 this._availableLocalesUI.enableWithMessageId(
653 "browser-languages-select-language"
656 // This is an async task that will install the recommended dictionaries for
657 // this locale. This will fail silently at least until a management UI is
658 // added in bug 1493705.
659 this.installDictionariesForLanguage(item.value);
663 * @param {string} locale The BCP 47 locale identifier
665 async installDictionariesForLanguage(locale) {
666 try {
667 let ids = await dictionaryIdsForLocale(locale);
668 let addonInfos = await AddonRepository.getAddonsByIDs(ids);
669 await Promise.all(
670 addonInfos.map(info => installFromUrl(info.sourceURI.spec))
672 } catch (e) {
673 console.error(e);
677 showError() {
678 document.getElementById("warning-message").hidden = false;
679 this._availableLocalesUI.enableWithMessageId(
680 "browser-languages-select-language"
683 // The height has likely changed, find our SubDialog and tell it to resize.
684 requestAnimationFrame(() => {
685 let dialogs = window.opener.gSubDialog._dialogs;
686 let index = dialogs.findIndex(d => d._frame.contentDocument == document);
687 if (index != -1) {
688 dialogs[index].resizeDialog();
693 hideError() {
694 document.getElementById("warning-message").hidden = true;
698 * @param {LocaleDisplayInfo} item
700 async selectedLocaleRemoved(item) {
701 this.recordTelemetry("remove");
703 this._availableLocalesUI.addItem(item);
705 // If the item we added is at the top of the list, it needs the label.
706 if (this._availableLocalesUI.items[0] == item) {
707 this._availableLocalesUI.addItem(await this.createInstalledLabel());
711 async createInstalledLabel() {
712 return {
713 label: await document.l10n.formatValue(
714 "browser-languages-installed-label"
716 className: "label-item",
717 disabled: true,
718 installed: true,
723 window.addEventListener("load", () => gBrowserLanguagesDialog.onLoad());