browserLanguages.js (21634B)
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/. */ 4 5 /* import-globals-from /toolkit/content/preferencesBindings.js */ 6 7 // This is exported by preferences.js but we can't import that in a subdialog. 8 let { LangPackMatcher } = window.top; 9 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 }); 17 18 /* This dialog provides an interface for managing what language the browser is 19 * displayed in. 20 * 21 * There is a list of "requested" locales and a list of "available" locales. The 22 * requested locales must be installed and enabled. Available locales could be 23 * installed and enabled, or fetched from the AMO language tools API. 24 * 25 * If a langpack is disabled, there is no way to determine what locale it is for and 26 * it will only be listed as available if that locale is also available on AMO and 27 * the user has opted to search for more languages. 28 */ 29 30 async function installFromUrl(url, hash, callback) { 31 let telemetryInfo = { 32 source: "about:preferences", 33 }; 34 let install = await AddonManager.getInstallForURL(url, { 35 hash, 36 telemetryInfo, 37 }); 38 if (callback) { 39 callback(install.installId.toString()); 40 } 41 await install.install(); 42 return install.addon; 43 } 44 45 async function dictionaryIdsForLocale(locale) { 46 let entries = await RemoteSettings("language-dictionaries").get({ 47 filters: { id: locale }, 48 }); 49 if (entries.length) { 50 return entries[0].dictionaries; 51 } 52 return []; 53 } 54 55 class OrderedListBox { 56 constructor({ 57 richlistbox, 58 upButton, 59 downButton, 60 removeButton, 61 onRemove, 62 onReorder, 63 }) { 64 this.richlistbox = richlistbox; 65 this.upButton = upButton; 66 this.downButton = downButton; 67 this.removeButton = removeButton; 68 this.onRemove = onRemove; 69 this.onReorder = onReorder; 70 71 this.items = []; 72 73 this.richlistbox.addEventListener("select", () => this.setButtonState()); 74 this.upButton.addEventListener("command", () => this.moveUp()); 75 this.downButton.addEventListener("command", () => this.moveDown()); 76 this.removeButton.addEventListener("command", () => this.removeItem()); 77 } 78 79 get selectedItem() { 80 return this.items[this.richlistbox.selectedIndex]; 81 } 82 83 setButtonState() { 84 let { upButton, downButton, removeButton } = this; 85 let { selectedIndex, itemCount } = this.richlistbox; 86 upButton.disabled = selectedIndex <= 0; 87 downButton.disabled = selectedIndex == itemCount - 1; 88 removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove; 89 } 90 91 moveUp() { 92 let { selectedIndex } = this.richlistbox; 93 if (selectedIndex == 0) { 94 return; 95 } 96 let { items } = this; 97 let selectedItem = items[selectedIndex]; 98 let prevItem = items[selectedIndex - 1]; 99 items[selectedIndex - 1] = items[selectedIndex]; 100 items[selectedIndex] = prevItem; 101 let prevEl = document.getElementById(prevItem.id); 102 let selectedEl = document.getElementById(selectedItem.id); 103 this.richlistbox.insertBefore(selectedEl, prevEl); 104 this.richlistbox.ensureElementIsVisible(selectedEl); 105 this.setButtonState(); 106 107 this.onReorder(); 108 } 109 110 moveDown() { 111 let { selectedIndex } = this.richlistbox; 112 if (selectedIndex == this.items.length - 1) { 113 return; 114 } 115 let { items } = this; 116 let selectedItem = items[selectedIndex]; 117 let nextItem = items[selectedIndex + 1]; 118 items[selectedIndex + 1] = items[selectedIndex]; 119 items[selectedIndex] = nextItem; 120 let nextEl = document.getElementById(nextItem.id); 121 let selectedEl = document.getElementById(selectedItem.id); 122 this.richlistbox.insertBefore(nextEl, selectedEl); 123 this.richlistbox.ensureElementIsVisible(selectedEl); 124 this.setButtonState(); 125 126 this.onReorder(); 127 } 128 129 removeItem() { 130 let { selectedIndex } = this.richlistbox; 131 132 if (selectedIndex == -1) { 133 return; 134 } 135 136 let [item] = this.items.splice(selectedIndex, 1); 137 this.richlistbox.selectedItem.remove(); 138 this.richlistbox.selectedIndex = Math.min( 139 selectedIndex, 140 this.richlistbox.itemCount - 1 141 ); 142 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); 143 this.onRemove(item); 144 } 145 146 setItems(items) { 147 this.items = items; 148 this.populate(); 149 this.setButtonState(); 150 } 151 152 /** 153 * Add an item to the top of the ordered list. 154 * 155 * @param {object} item The item to insert. 156 */ 157 addItem(item) { 158 this.items.unshift(item); 159 this.richlistbox.insertBefore( 160 this.createItem(item), 161 this.richlistbox.firstElementChild 162 ); 163 this.richlistbox.selectedIndex = 0; 164 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); 165 } 166 167 populate() { 168 this.richlistbox.textContent = ""; 169 170 let frag = document.createDocumentFragment(); 171 for (let item of this.items) { 172 frag.appendChild(this.createItem(item)); 173 } 174 this.richlistbox.appendChild(frag); 175 176 this.richlistbox.selectedIndex = 0; 177 this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); 178 } 179 180 createItem({ id, label, value }) { 181 let listitem = document.createXULElement("richlistitem"); 182 listitem.id = id; 183 listitem.setAttribute("value", value); 184 185 let labelEl = document.createXULElement("label"); 186 labelEl.textContent = label; 187 listitem.appendChild(labelEl); 188 189 return listitem; 190 } 191 } 192 193 /** 194 * The sorted select list of Locales available for the app. 195 */ 196 class SortedItemSelectList { 197 constructor({ menulist, button, onSelect, onChange, compareFn }) { 198 /** @type {XULElement} */ 199 this.menulist = menulist; 200 201 /** @type {XULElement} */ 202 this.popup = menulist.menupopup; 203 204 /** @type {XULElement} */ 205 this.button = button; 206 207 /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */ 208 this.compareFn = compareFn; 209 210 /** @type {Array<LocaleDisplayInfo>} */ 211 this.items = []; 212 213 // This will register the "command" listener. 214 new SelectionChangedMenulist(this.menulist, () => { 215 button.disabled = !menulist.selectedItem; 216 if (menulist.selectedItem) { 217 onChange(this.items[menulist.selectedIndex]); 218 } 219 }); 220 button.addEventListener("command", () => { 221 if (!menulist.selectedItem) { 222 return; 223 } 224 225 let [item] = this.items.splice(menulist.selectedIndex, 1); 226 menulist.selectedItem.remove(); 227 menulist.setAttribute("label", menulist.getAttribute("placeholder")); 228 button.disabled = true; 229 menulist.disabled = menulist.itemCount == 0; 230 menulist.selectedIndex = -1; 231 232 onSelect(item); 233 }); 234 } 235 236 /** 237 * @param {Array<LocaleDisplayInfo>} items 238 */ 239 setItems(items) { 240 this.items = items.sort(this.compareFn); 241 this.populate(); 242 } 243 244 populate() { 245 let { button, items, menulist, popup } = this; 246 popup.textContent = ""; 247 248 let frag = document.createDocumentFragment(); 249 for (let item of items) { 250 frag.appendChild(this.createItem(item)); 251 } 252 popup.appendChild(frag); 253 254 menulist.setAttribute("label", menulist.getAttribute("placeholder")); 255 menulist.disabled = menulist.itemCount == 0; 256 menulist.selectedIndex = -1; 257 button.disabled = true; 258 } 259 260 /** 261 * Add an item to the list sorted by the label. 262 * 263 * @param {object} item The item to insert. 264 */ 265 addItem(item) { 266 let { compareFn, items, menulist, popup } = this; 267 268 // Find the index of the item to insert before. 269 let i = items.findIndex(el => compareFn(el, item) >= 0); 270 items.splice(i, 0, item); 271 popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i)); 272 273 menulist.disabled = menulist.itemCount == 0; 274 } 275 276 createItem({ label, value, className, disabled }) { 277 let item = document.createXULElement("menuitem"); 278 item.setAttribute("label", label); 279 if (value) { 280 item.value = value; 281 } 282 if (className) { 283 item.classList.add(className); 284 } 285 if (disabled) { 286 item.setAttribute("disabled", "true"); 287 } 288 return item; 289 } 290 291 /** 292 * Disable the inputs and set a data-l10n-id on the menulist. This can be 293 * reverted with `enableWithMessageId()`. 294 */ 295 disableWithMessageId(messageId) { 296 document.l10n.setAttributes(this.menulist, messageId); 297 this.menulist.setAttribute( 298 "image", 299 "chrome://global/skin/icons/loading.svg" 300 ); 301 this.menulist.disabled = true; 302 this.button.disabled = true; 303 } 304 305 /** 306 * Enable the inputs and set a data-l10n-id on the menulist. This can be 307 * reverted with `disableWithMessageId()`. 308 */ 309 enableWithMessageId(messageId) { 310 document.l10n.setAttributes(this.menulist, messageId); 311 this.menulist.removeAttribute("image"); 312 this.menulist.disabled = this.menulist.itemCount == 0; 313 this.button.disabled = !this.menulist.selectedItem; 314 } 315 } 316 317 /** 318 * @typedef LocaleDisplayInfo 319 * @type {object} 320 * @property {string} id - A unique ID. 321 * @property {string} label - The localized display name. 322 * @property {string} value - The BCP 47 locale identifier or the word "search". 323 * @property {boolean} canRemove - The default locale cannot be removed. 324 * @property {boolean} installed - Whether or not the locale is installed. 325 */ 326 327 /** 328 * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers. 329 * @returns {Array<LocaleDisplayInfo>} 330 */ 331 async function getLocaleDisplayInfo(localeCodes) { 332 let availableLocales = new Set(await LangPackMatcher.getAvailableLocales()); 333 let localeNames = Services.intl.getLocaleDisplayNames( 334 undefined, 335 localeCodes, 336 { preferNative: true } 337 ); 338 return localeCodes.map((code, i) => { 339 return { 340 id: "locale-" + code, 341 label: localeNames[i], 342 value: code, 343 canRemove: code != Services.locale.defaultLocale, 344 installed: availableLocales.has(code), 345 }; 346 }); 347 } 348 349 /** 350 * @param {LocaleDisplayInfo} a 351 * @param {LocaleDisplayInfo} b 352 * @returns {number} 353 */ 354 function compareItems(a, b) { 355 // Sort by installed. 356 if (a.installed != b.installed) { 357 return a.installed ? -1 : 1; 358 359 // The search label is always last. 360 } else if (a.value == "search") { 361 return 1; 362 } else if (b.value == "search") { 363 return -1; 364 365 // If both items are locales, sort by label. 366 } else if (a.value && b.value) { 367 return a.label.localeCompare(b.label); 368 369 // One of them is a label, put it first. 370 } else if (a.value) { 371 return 1; 372 } 373 return -1; 374 } 375 376 var gBrowserLanguagesDialog = { 377 /** 378 * The publicly readable list of selected locales. It is only set when the dialog is 379 * accepted, and can be retrieved elsewhere by directly reading the property 380 * on gBrowserLanguagesDialog. 381 * 382 * let { selected } = gBrowserLanguagesDialog; 383 * 384 * @type {null | Array<string>} 385 */ 386 selected: null, 387 388 /** 389 * @type {string | null} An ID used for telemetry pings. It is unique to the current 390 * opening of the browser language. 391 */ 392 _telemetryId: null, 393 394 /** 395 * @type {SortedItemSelectList} 396 */ 397 _availableLocalesUI: null, 398 399 /** 400 * @type {OrderedListBox} 401 */ 402 _selectedLocalesUI: null, 403 404 get downloadEnabled() { 405 // Downloading langpacks isn't always supported, check the pref. 406 return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled"); 407 }, 408 409 recordTelemetry(method, extra = {}) { 410 extra.value = this._telemetryId; 411 Glean.intlUiBrowserLanguage[method + "Dialog"].record(extra); 412 }, 413 414 async onLoad() { 415 /** 416 * @typedef {object} Options - Options passed in to configure the subdialog. 417 * @property {string} telemetryId, 418 * @property {Array<string>} [selectedLocalesForRestart] The optional list of 419 * previously selected locales for when a restart is required. This list is 420 * preserved between openings of the dialog. 421 * @property {boolean} search Whether the user opened this from "Search for more 422 * languages" option. 423 */ 424 425 /** @type {Options} */ 426 let { telemetryId, selectedLocalesForRestart, search } = 427 window.arguments[0]; 428 429 this._telemetryId = telemetryId; 430 431 // This is a list of available locales that the user selected. It's more 432 // restricted than the Intl notion of `requested` as it only contains 433 // locale codes for which we have matching locales available. 434 // The first time this dialog is opened, populate with appLocalesAsBCP47. 435 let selectedLocales = 436 selectedLocalesForRestart || Services.locale.appLocalesAsBCP47; 437 let selectedLocaleSet = new Set(selectedLocales); 438 let available = await LangPackMatcher.getAvailableLocales(); 439 let availableSet = new Set(available); 440 441 // Filter selectedLocales since the user may select a locale when it is 442 // available and then disable it. 443 selectedLocales = selectedLocales.filter(locale => 444 availableSet.has(locale) 445 ); 446 // Nothing in available should be in selectedSet. 447 available = available.filter(locale => !selectedLocaleSet.has(locale)); 448 449 await this.initSelectedLocales(selectedLocales); 450 await this.initAvailableLocales(available, search); 451 452 this.initialized = true; 453 454 // Now the component is initialized, it's safe to accept the results. 455 document 456 .getElementById("BrowserLanguagesDialog") 457 .addEventListener("beforeaccept", () => { 458 this.selected = this._selectedLocalesUI.items.map(item => item.value); 459 }); 460 }, 461 462 /** 463 * @param {string[]} selectedLocales - BCP 47 locale identifiers 464 */ 465 async initSelectedLocales(selectedLocales) { 466 this._selectedLocalesUI = new OrderedListBox({ 467 richlistbox: document.getElementById("selectedLocales"), 468 upButton: document.getElementById("up"), 469 downButton: document.getElementById("down"), 470 removeButton: document.getElementById("remove"), 471 onRemove: item => this.selectedLocaleRemoved(item), 472 onReorder: () => this.recordTelemetry("reorder"), 473 }); 474 this._selectedLocalesUI.setItems( 475 await getLocaleDisplayInfo(selectedLocales) 476 ); 477 }, 478 479 /** 480 * @param {Set<string>} available - The set of available BCP 47 locale identifiers. 481 * @param {boolean} search - Whether the user opened this from "Search for more 482 * languages" option. 483 */ 484 async initAvailableLocales(available, search) { 485 this._availableLocalesUI = new SortedItemSelectList({ 486 menulist: document.getElementById("availableLocales"), 487 button: document.getElementById("add"), 488 compareFn: compareItems, 489 onSelect: item => this.availableLanguageSelected(item), 490 onChange: item => { 491 this.hideError(); 492 if (item.value == "search") { 493 // Record the search event here so we don't track the search from 494 // the main preferences pane twice. 495 this.recordTelemetry("search"); 496 this.loadLocalesFromAMO(); 497 } 498 }, 499 }); 500 501 // Populate the list with the installed locales even if the user is 502 // searching in case the download fails. 503 await this.loadLocalesFromInstalled(available); 504 505 // If the user opened this from the "Search for more languages" option, 506 // search AMO for available locales. 507 if (search) { 508 return this.loadLocalesFromAMO(); 509 } 510 511 return undefined; 512 }, 513 514 async loadLocalesFromAMO() { 515 if (!this.downloadEnabled) { 516 return; 517 } 518 519 // Disable the dropdown while we hit the network. 520 this._availableLocalesUI.disableWithMessageId( 521 "browser-languages-searching" 522 ); 523 524 // Fetch the available langpacks from AMO. 525 let availableLangpacks; 526 try { 527 availableLangpacks = await AddonRepository.getAvailableLangpacks(); 528 } catch (e) { 529 this.showError(); 530 return; 531 } 532 533 // Store the available langpack info for later use. 534 this.availableLangpacks = new Map(); 535 for (let { target_locale, url, hash } of availableLangpacks) { 536 this.availableLangpacks.set(target_locale, { url, hash }); 537 } 538 539 // Remove the installed locales from the available ones. 540 let installedLocales = new Set(await LangPackMatcher.getAvailableLocales()); 541 let notInstalledLocales = availableLangpacks 542 .filter(({ target_locale }) => !installedLocales.has(target_locale)) 543 .map(lang => lang.target_locale); 544 545 // Create the rows for the remote locales. 546 let availableItems = await getLocaleDisplayInfo(notInstalledLocales); 547 availableItems.push({ 548 label: await document.l10n.formatValue( 549 "browser-languages-available-label" 550 ), 551 className: "label-item", 552 disabled: true, 553 installed: false, 554 }); 555 556 // Remove the search option and add the remote locales. 557 let items = this._availableLocalesUI.items; 558 items.pop(); 559 items = items.concat(availableItems); 560 561 // Update the dropdown and enable it again. 562 this._availableLocalesUI.setItems(items); 563 this._availableLocalesUI.enableWithMessageId( 564 "browser-languages-select-language" 565 ); 566 }, 567 568 /** 569 * @param {Set<string>} available - The set of available (BCP 47) locales. 570 */ 571 async loadLocalesFromInstalled(available) { 572 let items; 573 if (available.length) { 574 items = await getLocaleDisplayInfo(available); 575 items.push(await this.createInstalledLabel()); 576 } else { 577 items = []; 578 } 579 if (this.downloadEnabled) { 580 items.push({ 581 label: await document.l10n.formatValue("browser-languages-search"), 582 value: "search", 583 }); 584 } 585 this._availableLocalesUI.setItems(items); 586 }, 587 588 /** 589 * @param {LocaleDisplayInfo} item 590 */ 591 async availableLanguageSelected(item) { 592 if ((await LangPackMatcher.getAvailableLocales()).includes(item.value)) { 593 this.recordTelemetry("add"); 594 await this.requestLocalLanguage(item); 595 } else if (this.availableLangpacks.has(item.value)) { 596 // Telemetry is tracked in requestRemoteLanguage. 597 await this.requestRemoteLanguage(item); 598 } else { 599 this.showError(); 600 } 601 }, 602 603 /** 604 * @param {LocaleDisplayInfo} item 605 */ 606 async requestLocalLanguage(item) { 607 this._selectedLocalesUI.addItem(item); 608 let selectedCount = this._selectedLocalesUI.items.length; 609 let availableCount = (await LangPackMatcher.getAvailableLocales()).length; 610 if (selectedCount == availableCount) { 611 // Remove the installed label, they're all installed. 612 this._availableLocalesUI.items.shift(); 613 this._availableLocalesUI.setItems(this._availableLocalesUI.items); 614 } 615 // The label isn't always reset when the selected item is removed, so set it again. 616 this._availableLocalesUI.enableWithMessageId( 617 "browser-languages-select-language" 618 ); 619 }, 620 621 /** 622 * @param {LocaleDisplayInfo} item 623 */ 624 async requestRemoteLanguage(item) { 625 this._availableLocalesUI.disableWithMessageId( 626 "browser-languages-downloading" 627 ); 628 629 let { url, hash } = this.availableLangpacks.get(item.value); 630 let addon; 631 632 try { 633 addon = await installFromUrl(url, hash, installId => 634 this.recordTelemetry("add", { installId }) 635 ); 636 } catch (e) { 637 this.showError(); 638 return; 639 } 640 641 // If the add-on was previously installed, it might be disabled still. 642 if (addon.userDisabled) { 643 await addon.enable(); 644 } 645 646 item.installed = true; 647 this._selectedLocalesUI.addItem(item); 648 this._availableLocalesUI.enableWithMessageId( 649 "browser-languages-select-language" 650 ); 651 652 // This is an async task that will install the recommended dictionaries for 653 // this locale. This will fail silently at least until a management UI is 654 // added in bug 1493705. 655 this.installDictionariesForLanguage(item.value); 656 }, 657 658 /** 659 * @param {string} locale The BCP 47 locale identifier 660 */ 661 async installDictionariesForLanguage(locale) { 662 try { 663 let ids = await dictionaryIdsForLocale(locale); 664 let addonInfos = await AddonRepository.getAddonsByIDs(ids); 665 await Promise.all( 666 addonInfos.map(info => installFromUrl(info.sourceURI.spec)) 667 ); 668 } catch (e) { 669 console.error(e); 670 } 671 }, 672 673 showError() { 674 document.getElementById("warning-message").hidden = false; 675 this._availableLocalesUI.enableWithMessageId( 676 "browser-languages-select-language" 677 ); 678 679 // The height has likely changed, find our SubDialog and tell it to resize. 680 requestAnimationFrame(() => { 681 let dialogs = window.opener.gSubDialog._dialogs; 682 let index = dialogs.findIndex(d => d._frame.contentDocument == document); 683 if (index != -1) { 684 dialogs[index].resizeDialog(); 685 } 686 }); 687 }, 688 689 hideError() { 690 document.getElementById("warning-message").hidden = true; 691 }, 692 693 /** 694 * @param {LocaleDisplayInfo} item 695 */ 696 async selectedLocaleRemoved(item) { 697 this.recordTelemetry("remove"); 698 699 this._availableLocalesUI.addItem(item); 700 701 // If the item we added is at the top of the list, it needs the label. 702 if (this._availableLocalesUI.items[0] == item) { 703 this._availableLocalesUI.addItem(await this.createInstalledLabel()); 704 } 705 }, 706 707 async createInstalledLabel() { 708 return { 709 label: await document.l10n.formatValue( 710 "browser-languages-installed-label" 711 ), 712 className: "label-item", 713 disabled: true, 714 installed: true, 715 }; 716 }, 717 }; 718 719 window.addEventListener("load", () => gBrowserLanguagesDialog.onLoad());