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",
19 .getElementById("BrowserLanguagesDialog")
20 .addEventListener("dialoghelp", window.top.openPrefsHelp);
22 /* This dialog provides an interface for managing what language the browser is
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
) {
36 source
: "about:preferences",
38 let install
= await AddonManager
.getInstallForURL(url
, {
43 callback(install
.installId
.toString());
45 await install
.install();
49 async
function dictionaryIdsForLocale(locale
) {
50 let entries
= await
RemoteSettings("language-dictionaries").get({
51 filters
: { id
: locale
},
54 return entries
[0].dictionaries
;
59 class OrderedListBox
{
68 this.richlistbox
= richlistbox
;
69 this.upButton
= upButton
;
70 this.downButton
= downButton
;
71 this.removeButton
= removeButton
;
72 this.onRemove
= onRemove
;
73 this.onReorder
= onReorder
;
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());
84 return this.items
[this.richlistbox
.selectedIndex
];
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
;
96 let { selectedIndex
} = this.richlistbox
;
97 if (selectedIndex
== 0) {
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();
115 let { selectedIndex
} = this.richlistbox
;
116 if (selectedIndex
== this.items
.length
- 1) {
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();
134 let { selectedIndex
} = this.richlistbox
;
136 if (selectedIndex
== -1) {
140 let [item
] = this.items
.splice(selectedIndex
, 1);
141 this.richlistbox
.selectedItem
.remove();
142 this.richlistbox
.selectedIndex
= Math
.min(
144 this.richlistbox
.itemCount
- 1
146 this.richlistbox
.ensureElementIsVisible(this.richlistbox
.selectedItem
);
153 this.setButtonState();
157 * Add an item to the top of the ordered list.
159 * @param {object} item The item to insert.
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
);
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");
187 listitem
.setAttribute("value", value
);
189 let labelEl
= document
.createXULElement("label");
190 labelEl
.textContent
= label
;
191 listitem
.appendChild(labelEl
);
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>} */
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
) {
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;
241 * @param {Array<LocaleDisplayInfo>} items
244 this.items
= items
.sort(this.compareFn
);
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.
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
);
287 item
.classList
.add(className
);
290 item
.setAttribute("disabled", "true");
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(
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
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(
340 { preferNative
: true }
342 return localeCodes
.map((code
, i
) => {
344 id
: "locale-" + code
,
345 label
: localeNames
[i
],
347 canRemove
: code
!= Services
.locale
.defaultLocale
,
348 installed
: availableLocales
.has(code
),
354 * @param {LocaleDisplayInfo} a
355 * @param {LocaleDisplayInfo} b
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") {
366 } else if (b
.value
== "search") {
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
) {
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>}
393 * @type {string | null} An ID used for telemetry pings. It is unique to the current
394 * opening of the browser language.
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
);
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
429 /** @type {Options} */
430 let { telemetryId
, selectedLocalesForRestart
, search
} =
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.
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
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
),
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.
512 return this.loadLocalesFromAMO();
518 async
loadLocalesFromAMO() {
519 if (!this.downloadEnabled
) {
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
;
531 availableLangpacks
= await AddonRepository
.getAvailableLangpacks();
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",
560 // Remove the search option and add the remote locales.
561 let items
= this._availableLocalesUI
.items
;
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
) {
577 if (available
.length
) {
578 items
= await
getLocaleDisplayInfo(available
);
579 items
.push(await
this.createInstalledLabel());
583 if (this.downloadEnabled
) {
585 label
: await document
.l10n
.formatValue("browser-languages-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
);
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
);
637 addon
= await
installFromUrl(url
, hash
, installId
=>
638 this.recordTelemetry("add", { installId
})
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
) {
667 let ids
= await
dictionaryIdsForLocale(locale
);
668 let addonInfos
= await AddonRepository
.getAddonsByIDs(ids
);
670 addonInfos
.map(info
=> installFromUrl(info
.sourceURI
.spec
))
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
);
688 dialogs
[index
].resizeDialog();
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() {
713 label
: await document
.l10n
.formatValue(
714 "browser-languages-installed-label"
716 className
: "label-item",
723 window
.addEventListener("load", () => gBrowserLanguagesDialog
.onLoad());