diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/preferences/dialogs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/preferences/dialogs')
46 files changed, 7728 insertions, 0 deletions
diff --git a/browser/components/preferences/dialogs/addEngine.css b/browser/components/preferences/dialogs/addEngine.css new file mode 100644 index 0000000000..450e07f65f --- /dev/null +++ b/browser/components/preferences/dialogs/addEngine.css @@ -0,0 +1,24 @@ +/* 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/. */ + +input { + flex: 1; +} + +hbox { + width: 100%; +} + +#engineNameLabel, +#engineUrlLabel, +#engineAliasLabel { + /* Align the labels with the inputs */ + margin-inline-start: 4px; +} + +#engineUrl { + /* Full URLs should always be displayed as LTR */ + direction: ltr; + text-align: match-parent; +} diff --git a/browser/components/preferences/dialogs/addEngine.js b/browser/components/preferences/dialogs/addEngine.js new file mode 100644 index 0000000000..1faf8622b3 --- /dev/null +++ b/browser/components/preferences/dialogs/addEngine.js @@ -0,0 +1,69 @@ +/* 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-globals-from ../main.js */ + +let gAddEngineDialog = { + _form: null, + _name: null, + _alias: null, + + onLoad() { + document.mozSubdialogReady = this.init(); + }, + + async init() { + this._dialog = document.querySelector("dialog"); + this._form = document.getElementById("addEngineForm"); + this._name = document.getElementById("engineName"); + this._alias = document.getElementById("engineAlias"); + + this._name.addEventListener("input", this.onNameInput.bind(this)); + this._alias.addEventListener("input", this.onAliasInput.bind(this)); + this._form.addEventListener("input", this.onFormInput.bind(this)); + + document.addEventListener("dialogaccept", this.onAddEngine.bind(this)); + }, + + async onAddEngine(event) { + let url = document + .getElementById("engineUrl") + .value.replace(/%s/, "{searchTerms}"); + await Services.search.wrappedJSObject.addUserEngine( + this._name.value, + url, + this._alias.value + ); + }, + + async onNameInput() { + if (this._name.value) { + let engine = Services.search.getEngineByName(this._name.value); + let validity = engine + ? document.getElementById("engineNameExists").textContent + : ""; + this._name.setCustomValidity(validity); + } + }, + + async onAliasInput() { + let validity = ""; + if (this._alias.value) { + let engine = await Services.search.getEngineByAlias(this._alias.value); + if (engine) { + engine = document.getElementById("engineAliasExists").textContent; + } + } + this._alias.setCustomValidity(validity); + }, + + async onFormInput() { + this._dialog.setAttribute( + "buttondisabledaccept", + !this._form.checkValidity() + ); + }, +}; + +window.addEventListener("load", () => gAddEngineDialog.onLoad()); diff --git a/browser/components/preferences/dialogs/addEngine.xhtml b/browser/components/preferences/dialogs/addEngine.xhtml new file mode 100644 index 0000000000..d664fd8541 --- /dev/null +++ b/browser/components/preferences/dialogs/addEngine.xhtml @@ -0,0 +1,78 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="add-engine-window2" + data-l10n-attrs="title, style" + persist="width height" +> + <dialog + buttons="accept,cancel" + buttondisabledaccept="true" + data-l10n-id="add-engine-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/content/preferences/dialogs/addEngine.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/preferences/addEngine.ftl" /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/addEngine.js" /> + <script src="chrome://global/content/globalOverlay.js" /> + <script src="chrome://browser/content/utilityOverlay.js" /> + + <separator class="thin" /> + + <html:form id="addEngineForm"> + <html:span + id="engineNameExists" + hidden="hidden" + data-l10n-id="engine-name-exists" + /> + <html:label + id="engineNameLabel" + for="engineName" + data-l10n-id="add-engine-name" + /> + <hbox> + <html:input id="engineName" type="text" required="required" /> + </hbox> + + <html:label + id="engineUrlLabel" + for="engineUrl" + data-l10n-id="add-engine-url" + /> + <hbox> + <html:input id="engineUrl" type="url" required="required" /> + </hbox> + + <html:span + id="engineAliasExists" + hidden="hidden" + data-l10n-id="engine-alias-exists" + /> + <html:label + id="engineAliasLabel" + for="engineAlias" + data-l10n-id="add-engine-alias" + /> + <hbox> + <html:input id="engineAlias" type="text" /> + </hbox> + </html:form> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/applicationManager.js b/browser/components/preferences/dialogs/applicationManager.js new file mode 100644 index 0000000000..0c2105cbe4 --- /dev/null +++ b/browser/components/preferences/dialogs/applicationManager.js @@ -0,0 +1,129 @@ +// 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-globals-from ../main.js */ + +var gAppManagerDialog = { + _removed: [], + + onLoad() { + document.mozSubdialogReady = this.init(); + }, + + async init() { + this.handlerInfo = window.arguments[0]; + + document.addEventListener("dialogaccept", function () { + gAppManagerDialog.onOK(); + }); + + let gMainPane = window.parent.gMainPane; + + const appDescElem = document.getElementById("appDescription"); + if (this.handlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) { + let { typeDescription } = this.handlerInfo; + let typeStr; + if (typeDescription.id) { + MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl"); + typeStr = await document.l10n.formatValue( + typeDescription.id, + typeDescription.args + ); + } else { + typeStr = typeDescription.raw; + } + document.l10n.setAttributes(appDescElem, "app-manager-handle-file", { + type: typeStr, + }); + } else { + document.l10n.setAttributes(appDescElem, "app-manager-handle-protocol", { + type: this.handlerInfo.typeDescription.raw, + }); + } + + let list = document.getElementById("appList"); + let listFragment = document.createDocumentFragment(); + for (let app of this.handlerInfo.possibleApplicationHandlers.enumerate()) { + if (!gMainPane.isValidHandlerApp(app)) { + continue; + } + + let item = document.createXULElement("richlistitem"); + listFragment.append(item); + item.app = app; + + let image = document.createXULElement("image"); + image.setAttribute("src", gMainPane._getIconURLForHandlerApp(app)); + item.appendChild(image); + + let label = document.createXULElement("label"); + label.setAttribute("value", app.name); + item.appendChild(label); + } + list.append(listFragment); + + // Triggers onSelect which populates label + list.selectedIndex = 0; + + // We want to block on those elements being localized because the + // result will impact the size of the subdialog. + await document.l10n.translateElements([ + appDescElem, + document.getElementById("appType"), + ]); + }, + + onOK: function appManager_onOK() { + if (this._removed.length) { + for (var i = 0; i < this._removed.length; ++i) { + this.handlerInfo.removePossibleApplicationHandler(this._removed[i]); + } + + this.handlerInfo.store(); + } + }, + + remove: function appManager_remove() { + var list = document.getElementById("appList"); + this._removed.push(list.selectedItem.app); + var index = list.selectedIndex; + var element = list.selectedItem; + list.removeItemFromSelection(element); + element.remove(); + if (list.itemCount == 0) { + // The list is now empty, make the bottom part disappear + document.getElementById("appDetails").hidden = true; + } else { + // Select the item at the same index, if we removed the last + // item of the list, select the previous item + if (index == list.itemCount) { + --index; + } + list.selectedIndex = index; + } + }, + + onSelect: function appManager_onSelect() { + var list = document.getElementById("appList"); + if (!list.selectedItem) { + document.getElementById("remove").disabled = true; + return; + } + document.getElementById("remove").disabled = false; + var app = list.selectedItem.app; + var address = ""; + if (app instanceof Ci.nsILocalHandlerApp) { + address = app.executable.path; + } else if (app instanceof Ci.nsIWebHandlerApp) { + address = app.uriTemplate; + } + document.getElementById("appLocation").value = address; + const l10nId = + app instanceof Ci.nsILocalHandlerApp + ? "app-manager-local-app-info" + : "app-manager-web-app-info"; + const appTypeElem = document.getElementById("appType"); + document.l10n.setAttributes(appTypeElem, l10nId); + }, +}; diff --git a/browser/components/preferences/dialogs/applicationManager.xhtml b/browser/components/preferences/dialogs/applicationManager.xhtml new file mode 100644 index 0000000000..f618543530 --- /dev/null +++ b/browser/components/preferences/dialogs/applicationManager.xhtml @@ -0,0 +1,71 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="gAppManagerDialog.onLoad();" + data-l10n-id="app-manager-window2" + data-l10n-attrs="title, style" +> + <dialog id="appManager" buttons="accept,cancel"> + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/applications.css" + /> + + <html:link + rel="localization" + href="browser/preferences/applicationManager.ftl" + /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + <script src="chrome://browser/content/preferences/dialogs/applicationManager.js" /> + + <commandset id="appManagerCommandSet"> + <command + id="cmd_remove" + oncommand="gAppManagerDialog.remove();" + disabled="true" + /> + </commandset> + + <keyset id="appManagerKeyset"> + <key id="delete" keycode="VK_DELETE" command="cmd_remove" /> + </keyset> + + <description id="appDescription" /> + <separator class="thin" /> + <hbox flex="1"> + <richlistbox + id="appList" + onselect="gAppManagerDialog.onSelect();" + flex="1" + /> + <vbox> + <button + id="remove" + data-l10n-id="app-manager-remove" + command="cmd_remove" + /> + <spacer flex="1" /> + </vbox> + </hbox> + <vbox id="appDetails"> + <separator class="thin" /> + <label id="appType" /> + <html:input + type="text" + id="appLocation" + readonly="readonly" + style="margin-inline: 0" + /> + </vbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/blocklists.js b/browser/components/preferences/dialogs/blocklists.js new file mode 100644 index 0000000000..c28ee09f96 --- /dev/null +++ b/browser/components/preferences/dialogs/blocklists.js @@ -0,0 +1,175 @@ +/* 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/. */ + +const BASE_LIST_ID = "base"; +const CONTENT_LIST_ID = "content"; +const TRACK_SUFFIX = "-track-digest256"; +const TRACKING_TABLE_PREF = "urlclassifier.trackingTable"; +const LISTS_PREF_BRANCH = "browser.safebrowsing.provider.mozilla.lists."; + +var gBlocklistManager = { + _type: "", + _blockLists: [], + _tree: null, + + _view: { + _rowCount: 0, + get rowCount() { + return this._rowCount; + }, + getCellText(row, column) { + if (column.id == "listCol") { + let list = gBlocklistManager._blockLists[row]; + return list.name; + } + return ""; + }, + + isSeparator(index) { + return false; + }, + isSorted() { + return false; + }, + isContainer(index) { + return false; + }, + setTree(tree) {}, + getImageSrc(row, column) {}, + getCellValue(row, column) { + if (column.id == "selectionCol") { + return gBlocklistManager._blockLists[row].selected; + } + return undefined; + }, + cycleHeader(column) {}, + getRowProperties(row) { + return ""; + }, + getColumnProperties(column) { + return ""; + }, + getCellProperties(row, column) { + if (column.id == "selectionCol") { + return "checkmark"; + } + + return ""; + }, + }, + + onLoad() { + this.init(); + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + }, + + init() { + if (this._type) { + // reusing an open dialog, clear the old observer + this.uninit(); + } + + this._type = "tracking"; + + this._loadBlockLists(); + }, + + uninit() {}, + + onListSelected() { + for (let list of this._blockLists) { + list.selected = false; + } + this._blockLists[this._tree.currentIndex].selected = true; + + this._updateTree(); + }, + + onApplyChanges() { + let activeList = this._getActiveList(); + let selected = null; + for (let list of this._blockLists) { + if (list.selected) { + selected = list; + break; + } + } + + if (activeList !== selected.id) { + let trackingTable = Services.prefs.getCharPref(TRACKING_TABLE_PREF); + if (selected.id != CONTENT_LIST_ID) { + trackingTable = trackingTable.replace( + "," + CONTENT_LIST_ID + TRACK_SUFFIX, + "" + ); + } else { + trackingTable += "," + CONTENT_LIST_ID + TRACK_SUFFIX; + } + Services.prefs.setCharPref(TRACKING_TABLE_PREF, trackingTable); + + // Force an update after changing the tracking protection table. + let listmanager = Cc[ + "@mozilla.org/url-classifier/listmanager;1" + ].getService(Ci.nsIUrlListManager); + if (listmanager) { + listmanager.forceUpdates(trackingTable); + } + } + }, + + async _loadBlockLists() { + this._blockLists = []; + + // Load blocklists into a table. + let branch = Services.prefs.getBranch(LISTS_PREF_BRANCH); + let itemArray = branch.getChildList(""); + for (let itemName of itemArray) { + try { + let list = await this._createBlockList(itemName); + this._blockLists.push(list); + } catch (e) { + // Ignore bogus or missing list name. + continue; + } + } + + this._updateTree(); + }, + + async _createBlockList(id) { + let branch = Services.prefs.getBranch(LISTS_PREF_BRANCH); + let l10nKey = branch.getCharPref(id); + + // eslint-disable-next-line mozilla/prefer-formatValues + let [listName, description] = await document.l10n.formatValues([ + { id: `blocklist-item-${l10nKey}-listName` }, + { id: `blocklist-item-${l10nKey}-description` }, + ]); + + // eslint-disable-next-line mozilla/prefer-formatValues + let name = await document.l10n.formatValue("blocklist-item-list-template", { + listName, + description, + }); + + return { + id, + name, + selected: this._getActiveList() === id, + }; + }, + + _updateTree() { + this._tree = document.getElementById("blocklistsTree"); + this._view._rowCount = this._blockLists.length; + this._tree.view = this._view; + }, + + _getActiveList() { + let trackingTable = Services.prefs.getCharPref(TRACKING_TABLE_PREF); + return trackingTable.includes(CONTENT_LIST_ID) + ? CONTENT_LIST_ID + : BASE_LIST_ID; + }, +}; diff --git a/browser/components/preferences/dialogs/blocklists.xhtml b/browser/components/preferences/dialogs/blocklists.xhtml new file mode 100644 index 0000000000..8471847df2 --- /dev/null +++ b/browser/components/preferences/dialogs/blocklists.xhtml @@ -0,0 +1,83 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="BlocklistsDialog" + data-l10n-id="blocklist-window2" + data-l10n-attrs="title, style" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="gBlocklistManager.onLoad();" + onunload="gBlocklistManager.uninit();" + persist="width height" +> + <dialog + buttons="accept,cancel" + data-l10n-id="blocklist-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="browser/preferences/blocklists.ftl" /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/blocklists.js" /> + + <keyset> + <key + data-l10n-id="blocklist-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <description + id="blocklistsText" + data-l10n-id="blocklist-description" + control="url" + > + <html:a + target="_blank" + class="text-link" + data-l10n-name="disconnect-link" + href="https://disconnect.me/" + /> + </description> + <separator class="thin" /> + <tree + id="blocklistsTree" + flex="1" + style="height: 18em" + hidecolumnpicker="true" + onselect="gBlocklistManager.onListSelected();" + > + <treecols> + <treecol + id="selectionCol" + label="" + style="flex: 1 auto" + sortable="false" + type="checkbox" + /> + <treecol + id="listCol" + data-l10n-id="blocklist-treehead-list" + style="flex: 80 80 auto" + sortable="false" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/browserLanguages.js b/browser/components/preferences/dialogs/browserLanguages.js new file mode 100644 index 0000000000..a54d9f299b --- /dev/null +++ b/browser/components/preferences/dialogs/browserLanguages.js @@ -0,0 +1,728 @@ +/* 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-globals-from /toolkit/content/preferencesBindings.js */ + +// This is exported by preferences.js but we can't import that in a subdialog. +let { LangPackMatcher } = window.top; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SelectionChangedMenulist: + "resource:///modules/SelectionChangedMenulist.sys.mjs", +}); + +document + .getElementById("BrowserLanguagesDialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); + +/* This dialog provides an interface for managing what language the browser is + * displayed in. + * + * There is a list of "requested" locales and a list of "available" locales. The + * requested locales must be installed and enabled. Available locales could be + * installed and enabled, or fetched from the AMO language tools API. + * + * If a langpack is disabled, there is no way to determine what locale it is for and + * it will only be listed as available if that locale is also available on AMO and + * the user has opted to search for more languages. + */ + +async function installFromUrl(url, hash, callback) { + let telemetryInfo = { + source: "about:preferences", + }; + let install = await AddonManager.getInstallForURL(url, { + hash, + telemetryInfo, + }); + if (callback) { + callback(install.installId.toString()); + } + await install.install(); + return install.addon; +} + +async function dictionaryIdsForLocale(locale) { + let entries = await RemoteSettings("language-dictionaries").get({ + filters: { id: locale }, + }); + if (entries.length) { + return entries[0].dictionaries; + } + return []; +} + +class OrderedListBox { + constructor({ + richlistbox, + upButton, + downButton, + removeButton, + onRemove, + onReorder, + }) { + this.richlistbox = richlistbox; + this.upButton = upButton; + this.downButton = downButton; + this.removeButton = removeButton; + this.onRemove = onRemove; + this.onReorder = onReorder; + + this.items = []; + + this.richlistbox.addEventListener("select", () => this.setButtonState()); + this.upButton.addEventListener("command", () => this.moveUp()); + this.downButton.addEventListener("command", () => this.moveDown()); + this.removeButton.addEventListener("command", () => this.removeItem()); + } + + get selectedItem() { + return this.items[this.richlistbox.selectedIndex]; + } + + setButtonState() { + let { upButton, downButton, removeButton } = this; + let { selectedIndex, itemCount } = this.richlistbox; + upButton.disabled = selectedIndex <= 0; + downButton.disabled = selectedIndex == itemCount - 1; + removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove; + } + + moveUp() { + let { selectedIndex } = this.richlistbox; + if (selectedIndex == 0) { + return; + } + let { items } = this; + let selectedItem = items[selectedIndex]; + let prevItem = items[selectedIndex - 1]; + items[selectedIndex - 1] = items[selectedIndex]; + items[selectedIndex] = prevItem; + let prevEl = document.getElementById(prevItem.id); + let selectedEl = document.getElementById(selectedItem.id); + this.richlistbox.insertBefore(selectedEl, prevEl); + this.richlistbox.ensureElementIsVisible(selectedEl); + this.setButtonState(); + + this.onReorder(); + } + + moveDown() { + let { selectedIndex } = this.richlistbox; + if (selectedIndex == this.items.length - 1) { + return; + } + let { items } = this; + let selectedItem = items[selectedIndex]; + let nextItem = items[selectedIndex + 1]; + items[selectedIndex + 1] = items[selectedIndex]; + items[selectedIndex] = nextItem; + let nextEl = document.getElementById(nextItem.id); + let selectedEl = document.getElementById(selectedItem.id); + this.richlistbox.insertBefore(nextEl, selectedEl); + this.richlistbox.ensureElementIsVisible(selectedEl); + this.setButtonState(); + + this.onReorder(); + } + + removeItem() { + let { selectedIndex } = this.richlistbox; + + if (selectedIndex == -1) { + return; + } + + let [item] = this.items.splice(selectedIndex, 1); + this.richlistbox.selectedItem.remove(); + this.richlistbox.selectedIndex = Math.min( + selectedIndex, + this.richlistbox.itemCount - 1 + ); + this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); + this.onRemove(item); + } + + setItems(items) { + this.items = items; + this.populate(); + this.setButtonState(); + } + + /** + * Add an item to the top of the ordered list. + * + * @param {object} item The item to insert. + */ + addItem(item) { + this.items.unshift(item); + this.richlistbox.insertBefore( + this.createItem(item), + this.richlistbox.firstElementChild + ); + this.richlistbox.selectedIndex = 0; + this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); + } + + populate() { + this.richlistbox.textContent = ""; + + let frag = document.createDocumentFragment(); + for (let item of this.items) { + frag.appendChild(this.createItem(item)); + } + this.richlistbox.appendChild(frag); + + this.richlistbox.selectedIndex = 0; + this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem); + } + + createItem({ id, label, value }) { + let listitem = document.createXULElement("richlistitem"); + listitem.id = id; + listitem.setAttribute("value", value); + + let labelEl = document.createXULElement("label"); + labelEl.textContent = label; + listitem.appendChild(labelEl); + + return listitem; + } +} + +/** + * The sorted select list of Locales available for the app. + */ +class SortedItemSelectList { + constructor({ menulist, button, onSelect, onChange, compareFn }) { + /** @type {XULElement} */ + this.menulist = menulist; + + /** @type {XULElement} */ + this.popup = menulist.menupopup; + + /** @type {XULElement} */ + this.button = button; + + /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */ + this.compareFn = compareFn; + + /** @type {Array<LocaleDisplayInfo>} */ + this.items = []; + + // This will register the "command" listener. + new SelectionChangedMenulist(this.menulist, () => { + button.disabled = !menulist.selectedItem; + if (menulist.selectedItem) { + onChange(this.items[menulist.selectedIndex]); + } + }); + button.addEventListener("command", () => { + if (!menulist.selectedItem) { + return; + } + + let [item] = this.items.splice(menulist.selectedIndex, 1); + menulist.selectedItem.remove(); + menulist.setAttribute("label", menulist.getAttribute("placeholder")); + button.disabled = true; + menulist.disabled = menulist.itemCount == 0; + menulist.selectedIndex = -1; + + onSelect(item); + }); + } + + /** + * @param {Array<LocaleDisplayInfo>} items + */ + setItems(items) { + this.items = items.sort(this.compareFn); + this.populate(); + } + + populate() { + let { button, items, menulist, popup } = this; + popup.textContent = ""; + + let frag = document.createDocumentFragment(); + for (let item of items) { + frag.appendChild(this.createItem(item)); + } + popup.appendChild(frag); + + menulist.setAttribute("label", menulist.getAttribute("placeholder")); + menulist.disabled = menulist.itemCount == 0; + menulist.selectedIndex = -1; + button.disabled = true; + } + + /** + * Add an item to the list sorted by the label. + * + * @param {object} item The item to insert. + */ + addItem(item) { + let { compareFn, items, menulist, popup } = this; + + // Find the index of the item to insert before. + let i = items.findIndex(el => compareFn(el, item) >= 0); + items.splice(i, 0, item); + popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i)); + + menulist.disabled = menulist.itemCount == 0; + } + + createItem({ label, value, className, disabled }) { + let item = document.createXULElement("menuitem"); + item.setAttribute("label", label); + if (value) { + item.value = value; + } + if (className) { + item.classList.add(className); + } + if (disabled) { + item.setAttribute("disabled", "true"); + } + return item; + } + + /** + * Disable the inputs and set a data-l10n-id on the menulist. This can be + * reverted with `enableWithMessageId()`. + */ + disableWithMessageId(messageId) { + document.l10n.setAttributes(this.menulist, messageId); + this.menulist.setAttribute( + "image", + "chrome://browser/skin/tabbrowser/tab-connecting.png" + ); + this.menulist.disabled = true; + this.button.disabled = true; + } + + /** + * Enable the inputs and set a data-l10n-id on the menulist. This can be + * reverted with `disableWithMessageId()`. + */ + enableWithMessageId(messageId) { + document.l10n.setAttributes(this.menulist, messageId); + this.menulist.removeAttribute("image"); + this.menulist.disabled = this.menulist.itemCount == 0; + this.button.disabled = !this.menulist.selectedItem; + } +} + +/** + * @typedef LocaleDisplayInfo + * @type {object} + * @prop {string} id - A unique ID. + * @prop {string} label - The localized display name. + * @prop {string} value - The BCP 47 locale identifier or the word "search". + * @prop {boolean} canRemove - Locales that are part of the packaged locales cannot be + * removed. + * @prop {boolean} installed - Whether or not the locale is installed. + */ + +/** + * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers. + * @returns {Array<LocaleDisplayInfo>} + */ +async function getLocaleDisplayInfo(localeCodes) { + let availableLocales = new Set(await LangPackMatcher.getAvailableLocales()); + let packagedLocales = new Set(Services.locale.packagedLocales); + let localeNames = Services.intl.getLocaleDisplayNames( + undefined, + localeCodes, + { preferNative: true } + ); + return localeCodes.map((code, i) => { + return { + id: "locale-" + code, + label: localeNames[i], + value: code, + canRemove: !packagedLocales.has(code), + installed: availableLocales.has(code), + }; + }); +} + +/** + * @param {LocaleDisplayInfo} a + * @param {LocaleDisplayInfo} b + * @returns {number} + */ +function compareItems(a, b) { + // Sort by installed. + if (a.installed != b.installed) { + return a.installed ? -1 : 1; + + // The search label is always last. + } else if (a.value == "search") { + return 1; + } else if (b.value == "search") { + return -1; + + // If both items are locales, sort by label. + } else if (a.value && b.value) { + return a.label.localeCompare(b.label); + + // One of them is a label, put it first. + } else if (a.value) { + return 1; + } + return -1; +} + +var gBrowserLanguagesDialog = { + /** + * The publicly readable list of selected locales. It is only set when the dialog is + * accepted, and can be retrieved elsewhere by directly reading the property + * on gBrowserLanguagesDialog. + * + * let { selected } = gBrowserLanguagesDialog; + * + * @type {null | Array<string>} + */ + selected: null, + + /** + * @type {string | null} An ID used for telemetry pings. It is unique to the current + * opening of the browser language. + */ + _telemetryId: null, + + /** + * @type {SortedItemSelectList} + */ + _availableLocalesUI: null, + + /** + * @type {OrderedListBox} + */ + _selectedLocalesUI: null, + + get downloadEnabled() { + // Downloading langpacks isn't always supported, check the pref. + return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled"); + }, + + recordTelemetry(method, extra = null) { + Services.telemetry.recordEvent( + "intl.ui.browserLanguage", + method, + "dialog", + this._telemetryId, + extra + ); + }, + + async onLoad() { + /** + * @typedef {Object} Options - Options passed in to configure the subdialog. + * @property {string} telemetryId, + * @property {Array<string>} [selectedLocalesForRestart] The optional list of + * previously selected locales for when a restart is required. This list is + * preserved between openings of the dialog. + * @property {boolean} search Whether the user opened this from "Search for more + * languages" option. + */ + + /** @type {Options} */ + let { telemetryId, selectedLocalesForRestart, search } = + window.arguments[0]; + + this._telemetryId = telemetryId; + + // This is a list of available locales that the user selected. It's more + // restricted than the Intl notion of `requested` as it only contains + // locale codes for which we have matching locales available. + // The first time this dialog is opened, populate with appLocalesAsBCP47. + let selectedLocales = + selectedLocalesForRestart || Services.locale.appLocalesAsBCP47; + let selectedLocaleSet = new Set(selectedLocales); + let available = await LangPackMatcher.getAvailableLocales(); + let availableSet = new Set(available); + + // Filter selectedLocales since the user may select a locale when it is + // available and then disable it. + selectedLocales = selectedLocales.filter(locale => + availableSet.has(locale) + ); + // Nothing in available should be in selectedSet. + available = available.filter(locale => !selectedLocaleSet.has(locale)); + + await this.initSelectedLocales(selectedLocales); + await this.initAvailableLocales(available, search); + + this.initialized = true; + + // Now the component is initialized, it's safe to accept the results. + document + .getElementById("BrowserLanguagesDialog") + .addEventListener("beforeaccept", () => { + this.selected = this._selectedLocalesUI.items.map(item => item.value); + }); + }, + + /** + * @param {string[]} selectedLocales - BCP 47 locale identifiers + */ + async initSelectedLocales(selectedLocales) { + this._selectedLocalesUI = new OrderedListBox({ + richlistbox: document.getElementById("selectedLocales"), + upButton: document.getElementById("up"), + downButton: document.getElementById("down"), + removeButton: document.getElementById("remove"), + onRemove: item => this.selectedLocaleRemoved(item), + onReorder: () => this.recordTelemetry("reorder"), + }); + this._selectedLocalesUI.setItems( + await getLocaleDisplayInfo(selectedLocales) + ); + }, + + /** + * @param {Set<string>} available - The set of available BCP 47 locale identifiers. + * @param {boolean} search - Whether the user opened this from "Search for more + * languages" option. + */ + async initAvailableLocales(available, search) { + this._availableLocalesUI = new SortedItemSelectList({ + menulist: document.getElementById("availableLocales"), + button: document.getElementById("add"), + compareFn: compareItems, + onSelect: item => this.availableLanguageSelected(item), + onChange: item => { + this.hideError(); + if (item.value == "search") { + // Record the search event here so we don't track the search from + // the main preferences pane twice. + this.recordTelemetry("search"); + this.loadLocalesFromAMO(); + } + }, + }); + + // Populate the list with the installed locales even if the user is + // searching in case the download fails. + await this.loadLocalesFromInstalled(available); + + // If the user opened this from the "Search for more languages" option, + // search AMO for available locales. + if (search) { + return this.loadLocalesFromAMO(); + } + + return undefined; + }, + + async loadLocalesFromAMO() { + if (!this.downloadEnabled) { + return; + } + + // Disable the dropdown while we hit the network. + this._availableLocalesUI.disableWithMessageId( + "browser-languages-searching" + ); + + // Fetch the available langpacks from AMO. + let availableLangpacks; + try { + availableLangpacks = await AddonRepository.getAvailableLangpacks(); + } catch (e) { + this.showError(); + return; + } + + // Store the available langpack info for later use. + this.availableLangpacks = new Map(); + for (let { target_locale, url, hash } of availableLangpacks) { + this.availableLangpacks.set(target_locale, { url, hash }); + } + + // Remove the installed locales from the available ones. + let installedLocales = new Set(await LangPackMatcher.getAvailableLocales()); + let notInstalledLocales = availableLangpacks + .filter(({ target_locale }) => !installedLocales.has(target_locale)) + .map(lang => lang.target_locale); + + // Create the rows for the remote locales. + let availableItems = await getLocaleDisplayInfo(notInstalledLocales); + availableItems.push({ + label: await document.l10n.formatValue( + "browser-languages-available-label" + ), + className: "label-item", + disabled: true, + installed: false, + }); + + // Remove the search option and add the remote locales. + let items = this._availableLocalesUI.items; + items.pop(); + items = items.concat(availableItems); + + // Update the dropdown and enable it again. + this._availableLocalesUI.setItems(items); + this._availableLocalesUI.enableWithMessageId( + "browser-languages-select-language" + ); + }, + + /** + * @param {Set<string>} available - The set of available (BCP 47) locales. + */ + async loadLocalesFromInstalled(available) { + let items; + if (available.length) { + items = await getLocaleDisplayInfo(available); + items.push(await this.createInstalledLabel()); + } else { + items = []; + } + if (this.downloadEnabled) { + items.push({ + label: await document.l10n.formatValue("browser-languages-search"), + value: "search", + }); + } + this._availableLocalesUI.setItems(items); + }, + + /** + * @param {LocaleDisplayInfo} item + */ + async availableLanguageSelected(item) { + if ((await LangPackMatcher.getAvailableLocales()).includes(item.value)) { + this.recordTelemetry("add"); + await this.requestLocalLanguage(item); + } else if (this.availableLangpacks.has(item.value)) { + // Telemetry is tracked in requestRemoteLanguage. + await this.requestRemoteLanguage(item); + } else { + this.showError(); + } + }, + + /** + * @param {LocaleDisplayInfo} item + */ + async requestLocalLanguage(item) { + this._selectedLocalesUI.addItem(item); + let selectedCount = this._selectedLocalesUI.items.length; + let availableCount = (await LangPackMatcher.getAvailableLocales()).length; + if (selectedCount == availableCount) { + // Remove the installed label, they're all installed. + this._availableLocalesUI.items.shift(); + this._availableLocalesUI.setItems(this._availableLocalesUI.items); + } + // The label isn't always reset when the selected item is removed, so set it again. + this._availableLocalesUI.enableWithMessageId( + "browser-languages-select-language" + ); + }, + + /** + * @param {LocaleDisplayInfo} item + */ + async requestRemoteLanguage(item) { + this._availableLocalesUI.disableWithMessageId( + "browser-languages-downloading" + ); + + let { url, hash } = this.availableLangpacks.get(item.value); + let addon; + + try { + addon = await installFromUrl(url, hash, installId => + this.recordTelemetry("add", { installId }) + ); + } catch (e) { + this.showError(); + return; + } + + // If the add-on was previously installed, it might be disabled still. + if (addon.userDisabled) { + await addon.enable(); + } + + item.installed = true; + this._selectedLocalesUI.addItem(item); + this._availableLocalesUI.enableWithMessageId( + "browser-languages-select-language" + ); + + // This is an async task that will install the recommended dictionaries for + // this locale. This will fail silently at least until a management UI is + // added in bug 1493705. + this.installDictionariesForLanguage(item.value); + }, + + /** + * @param {string} locale The BCP 47 locale identifier + */ + async installDictionariesForLanguage(locale) { + try { + let ids = await dictionaryIdsForLocale(locale); + let addonInfos = await AddonRepository.getAddonsByIDs(ids); + await Promise.all( + addonInfos.map(info => installFromUrl(info.sourceURI.spec)) + ); + } catch (e) { + console.error(e); + } + }, + + showError() { + document.getElementById("warning-message").hidden = false; + this._availableLocalesUI.enableWithMessageId( + "browser-languages-select-language" + ); + + // The height has likely changed, find our SubDialog and tell it to resize. + requestAnimationFrame(() => { + let dialogs = window.opener.gSubDialog._dialogs; + let index = dialogs.findIndex(d => d._frame.contentDocument == document); + if (index != -1) { + dialogs[index].resizeDialog(); + } + }); + }, + + hideError() { + document.getElementById("warning-message").hidden = true; + }, + + /** + * @param {LocaleDisplayInfo} item + */ + async selectedLocaleRemoved(item) { + this.recordTelemetry("remove"); + + this._availableLocalesUI.addItem(item); + + // If the item we added is at the top of the list, it needs the label. + if (this._availableLocalesUI.items[0] == item) { + this._availableLocalesUI.addItem(await this.createInstalledLabel()); + } + }, + + async createInstalledLabel() { + return { + label: await document.l10n.formatValue( + "browser-languages-installed-label" + ), + className: "label-item", + disabled: true, + installed: true, + }; + }, +}; diff --git a/browser/components/preferences/dialogs/browserLanguages.xhtml b/browser/components/preferences/dialogs/browserLanguages.xhtml new file mode 100644 index 0000000000..2a404f4aa8 --- /dev/null +++ b/browser/components/preferences/dialogs/browserLanguages.xhtml @@ -0,0 +1,87 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="browser-languages-window2" + data-l10n-attrs="title, style" + onload="gBrowserLanguagesDialog.onLoad();" +> + <dialog + id="BrowserLanguagesDialog" + buttons="accept,cancel,help" + helpTopic="change-language" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="browser/preferences/languages.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + <script src="chrome://browser/content/preferences/dialogs/browserLanguages.js" /> + + <description data-l10n-id="browser-languages-description" /> + + <box class="languages-grid"> + <richlistbox id="selectedLocales" /> + <vbox> + <button + id="up" + class="action-button" + disabled="true" + data-l10n-id="languages-customize-moveup" + /> + <button + id="down" + class="action-button" + disabled="true" + data-l10n-id="languages-customize-movedown" + /> + <button + id="remove" + class="action-button" + disabled="true" + data-l10n-id="languages-customize-remove" + /> + </vbox> + + <menulist + id="availableLocales" + class="available-locales-list" + data-l10n-id="browser-languages-select-language" + data-l10n-attrs="placeholder,label" + > + <menupopup /> + </menulist> + <button + id="add" + class="add-browser-language action-button" + data-l10n-id="languages-customize-add" + disabled="true" + /> + </box> + <hbox + id="warning-message" + class="message-bar message-bar-warning" + hidden="true" + > + <html:img class="message-bar-icon" /> + <description + class="message-bar-description" + data-l10n-id="browser-languages-error" + /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/clearSiteData.css b/browser/components/preferences/dialogs/clearSiteData.css new file mode 100644 index 0000000000..5b1f5bbe2d --- /dev/null +++ b/browser/components/preferences/dialogs/clearSiteData.css @@ -0,0 +1,20 @@ +/* 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/. */ + +.options-container { + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-box-border-color); + border-radius: 2px; + color: var(--in-content-text-color); + padding: 0.5em; +} + +.option { + padding-bottom: 8px; +} + +.option-description { + color: var(--text-color-deemphasized); + margin-top: -0.5em !important; +} diff --git a/browser/components/preferences/dialogs/clearSiteData.js b/browser/components/preferences/dialogs/clearSiteData.js new file mode 100644 index 0000000000..061534b52a --- /dev/null +++ b/browser/components/preferences/dialogs/clearSiteData.js @@ -0,0 +1,96 @@ +/* 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/. */ + +const { SiteDataManager } = ChromeUtils.importESModule( + "resource:///modules/SiteDataManager.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", +}); + +var gClearSiteDataDialog = { + _clearSiteDataCheckbox: null, + _clearCacheCheckbox: null, + + onLoad() { + document.mozSubdialogReady = this.init(); + }, + + async init() { + this._dialog = document.querySelector("dialog"); + this._clearSiteDataCheckbox = document.getElementById("clearSiteData"); + this._clearCacheCheckbox = document.getElementById("clearCache"); + + // We'll block init() on this because the result values may impact + // subdialog sizing. + await Promise.all([ + SiteDataManager.getTotalUsage().then(bytes => { + let [amount, unit] = DownloadUtils.convertByteUnits(bytes); + document.l10n.setAttributes( + this._clearSiteDataCheckbox, + "clear-site-data-cookies-with-data", + { amount, unit } + ); + }), + SiteDataManager.getCacheSize().then(bytes => { + let [amount, unit] = DownloadUtils.convertByteUnits(bytes); + document.l10n.setAttributes( + this._clearCacheCheckbox, + "clear-site-data-cache-with-data", + { amount, unit } + ); + }), + ]); + await document.l10n.translateElements([ + this._clearCacheCheckbox, + this._clearSiteDataCheckbox, + ]); + + document.addEventListener("dialogaccept", event => this.onClear(event)); + + this._clearSiteDataCheckbox.addEventListener("command", e => + this.onCheckboxCommand(e) + ); + this._clearCacheCheckbox.addEventListener("command", e => + this.onCheckboxCommand(e) + ); + }, + + onCheckboxCommand(event) { + this._dialog.setAttribute( + "buttondisabledaccept", + !(this._clearSiteDataCheckbox.checked || this._clearCacheCheckbox.checked) + ); + }, + + onClear(event) { + let clearSiteData = this._clearSiteDataCheckbox.checked; + let clearCache = this._clearCacheCheckbox.checked; + + if (clearSiteData) { + // Ask for confirmation before clearing site data + if (!SiteDataManager.promptSiteDataRemoval(window)) { + clearSiteData = false; + // Prevent closing the dialog when the data removal wasn't allowed. + event.preventDefault(); + } + } + + if (clearSiteData) { + SiteDataManager.removeSiteData(); + } + if (clearCache) { + SiteDataManager.removeCache(); + + // If we're not clearing site data, we need to tell the + // SiteDataManager to signal that it's updating. + if (!clearSiteData) { + SiteDataManager.updateSites(); + } + } + }, +}; + +window.addEventListener("load", () => gClearSiteDataDialog.onLoad()); diff --git a/browser/components/preferences/dialogs/clearSiteData.xhtml b/browser/components/preferences/dialogs/clearSiteData.xhtml new file mode 100644 index 0000000000..784ffc8167 --- /dev/null +++ b/browser/components/preferences/dialogs/clearSiteData.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="ClearSiteDataDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="clear-site-data-window2" + data-l10n-attrs="title, style" + persist="width height" +> + <dialog + buttons="accept,cancel" + data-l10n-id="clear-site-data-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/content/preferences/dialogs/clearSiteData.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link + rel="localization" + href="browser/preferences/clearSiteData.ftl" + /> + </linkset> + <script src="chrome://browser/content/preferences/dialogs/clearSiteData.js" /> + + <keyset> + <key + data-l10n-id="clear-site-data-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <description control="url" data-l10n-id="clear-site-data-description" /> + <separator class="thin" /> + <vbox class="options-container"> + <vbox class="option"> + <checkbox + data-l10n-id="clear-site-data-cookies-empty" + id="clearSiteData" + checked="true" + /> + <description + class="option-description indent" + data-l10n-id="clear-site-data-cookies-info" + /> + </vbox> + <vbox class="option"> + <checkbox + data-l10n-id="clear-site-data-cache-empty" + id="clearCache" + checked="true" + /> + <description + class="option-description indent" + data-l10n-id="clear-site-data-cache-info" + /> + </vbox> + </vbox> + </vbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/colors.js b/browser/components/preferences/dialogs/colors.js new file mode 100644 index 0000000000..30b48b2ad0 --- /dev/null +++ b/browser/components/preferences/dialogs/colors.js @@ -0,0 +1,18 @@ +/* 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-globals-from /toolkit/content/preferencesBindings.js */ + +document + .getElementById("ColorsDialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); + +Preferences.addAll([ + { id: "browser.display.document_color_use", type: "int" }, + { id: "browser.anchor_color", type: "string" }, + { id: "browser.visited_color", type: "string" }, + { id: "browser.display.foreground_color", type: "string" }, + { id: "browser.display.background_color", type: "string" }, + { id: "browser.display.use_system_colors", type: "bool" }, +]); diff --git a/browser/components/preferences/dialogs/colors.xhtml b/browser/components/preferences/dialogs/colors.xhtml new file mode 100644 index 0000000000..1720cf3498 --- /dev/null +++ b/browser/components/preferences/dialogs/colors.xhtml @@ -0,0 +1,135 @@ +<?xml version="1.0"?> + +<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- --> +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="colors-dialog2" + data-l10n-attrs="title, style" + persist="lastSelected" +> + <dialog + id="ColorsDialog" + buttons="accept,cancel,help" + helpTopic="prefs-fonts-and-colors" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/preferences/colors.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + + <keyset> + <key + data-l10n-id="colors-close-key" + modifiers="accel" + oncommand="Preferences.close(event)" + /> + </keyset> + + <hbox> + <groupbox flex="1"> + <label><html:h2 data-l10n-id="colors-text-and-background" /></label> + <hbox align="center"> + <label + data-l10n-id="colors-text-header" + control="foregroundtextmenu" + /> + <spacer flex="1" /> + <html:input + type="color" + id="foregroundtextmenu" + preference="browser.display.foreground_color" + /> + </hbox> + <hbox align="center" style="margin-top: 5px"> + <label data-l10n-id="colors-background" control="backgroundmenu" /> + <spacer flex="1" /> + <html:input + type="color" + id="backgroundmenu" + preference="browser.display.background_color" + /> + </hbox> + <separator class="thin" /> + <hbox align="center"> + <checkbox + id="browserUseSystemColors" + data-l10n-id="colors-use-system" + preference="browser.display.use_system_colors" + /> + </hbox> + </groupbox> + + <groupbox flex="1"> + <label><html:h2 data-l10n-id="colors-links-header" /></label> + <hbox align="center"> + <label + data-l10n-id="colors-unvisited-links" + control="unvisitedlinkmenu" + /> + <spacer flex="1" /> + <html:input + type="color" + id="unvisitedlinkmenu" + preference="browser.anchor_color" + /> + </hbox> + <hbox align="center" style="margin-top: 5px"> + <label + data-l10n-id="colors-visited-links" + control="visitedlinkmenu" + /> + <spacer flex="1" /> + <html:input + type="color" + id="visitedlinkmenu" + preference="browser.visited_color" + /> + </hbox> + </groupbox> + </hbox> + + <label data-l10n-id="colors-page-override" control="useDocumentColors" /> + <hbox> + <menulist + id="useDocumentColors" + preference="browser.display.document_color_use" + flex="1" + > + <menupopup> + <menuitem + data-l10n-id="colors-page-override-option-always" + value="2" + id="documentColorAlways" + /> + <menuitem + data-l10n-id="colors-page-override-option-auto" + value="0" + id="documentColorAutomatic" + /> + <menuitem + data-l10n-id="colors-page-override-option-never" + value="1" + id="documentColorNever" + /> + </menupopup> + </menulist> + </hbox> + + <!-- Load the script after the elements for layout issues (bug 1501755). --> + <script src="chrome://browser/content/preferences/dialogs/colors.js" /> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/connection.js b/browser/components/preferences/dialogs/connection.js new file mode 100644 index 0000000000..0b21b1b5a5 --- /dev/null +++ b/browser/components/preferences/dialogs/connection.js @@ -0,0 +1,388 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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-globals-from /browser/base/content/utilityOverlay.js */ +/* import-globals-from /toolkit/content/preferencesBindings.js */ +/* import-globals-from ../extensionControlled.js */ + +document + .getElementById("ConnectionsDialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); + +Preferences.addAll([ + // Add network.proxy.autoconfig_url before network.proxy.type so they're + // both initialized when network.proxy.type initialization triggers a call to + // gConnectionsDialog.updateReloadButton(). + { id: "network.proxy.autoconfig_url", type: "string" }, + { id: "network.proxy.type", type: "int" }, + { id: "network.proxy.http", type: "string" }, + { id: "network.proxy.http_port", type: "int" }, + { id: "network.proxy.ssl", type: "string" }, + { id: "network.proxy.ssl_port", type: "int" }, + { id: "network.proxy.socks", type: "string" }, + { id: "network.proxy.socks_port", type: "int" }, + { id: "network.proxy.socks_version", type: "int" }, + { id: "network.proxy.socks_remote_dns", type: "bool" }, + { id: "network.proxy.no_proxies_on", type: "string" }, + { id: "network.proxy.share_proxy_settings", type: "bool" }, + { id: "signon.autologin.proxy", type: "bool" }, + { id: "pref.advanced.proxies.disable_button.reload", type: "bool" }, + { id: "network.proxy.backup.ssl", type: "string" }, + { id: "network.proxy.backup.ssl_port", type: "int" }, +]); + +window.addEventListener( + "DOMContentLoaded", + () => { + Preferences.get("network.proxy.type").on( + "change", + gConnectionsDialog.proxyTypeChanged.bind(gConnectionsDialog) + ); + Preferences.get("network.proxy.socks_version").on( + "change", + gConnectionsDialog.updateDNSPref.bind(gConnectionsDialog) + ); + + document + .getElementById("disableProxyExtension") + .addEventListener( + "command", + makeDisableControllingExtension(PREF_SETTING_TYPE, PROXY_KEY).bind( + gConnectionsDialog + ) + ); + gConnectionsDialog.updateProxySettingsUI(); + initializeProxyUI(gConnectionsDialog); + gConnectionsDialog.registerSyncPrefListeners(); + document + .getElementById("ConnectionsDialog") + .addEventListener("beforeaccept", e => + gConnectionsDialog.beforeAccept(e) + ); + }, + { once: true, capture: true } +); + +var gConnectionsDialog = { + beforeAccept(event) { + var proxyTypePref = Preferences.get("network.proxy.type"); + if (proxyTypePref.value == 2) { + this.doAutoconfigURLFixup(); + return; + } + + if (proxyTypePref.value != 1) { + return; + } + + var httpProxyURLPref = Preferences.get("network.proxy.http"); + var httpProxyPortPref = Preferences.get("network.proxy.http_port"); + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + + // If the proxy server (when specified) is invalid or the port is set to 0 then cancel submission. + for (let prefName of ["http", "ssl", "socks"]) { + let proxyPortPref = Preferences.get( + "network.proxy." + prefName + "_port" + ); + let proxyPref = Preferences.get("network.proxy." + prefName); + // Only worry about ports which are currently active. If the share option is on, then ignore + // all ports except the HTTP and SOCKS port + if ( + proxyPref.value != "" && + (prefName == "http" || prefName == "socks" || !shareProxiesPref.value) + ) { + if (proxyPortPref.value == 0) { + document + .getElementById("networkProxy" + prefName.toUpperCase() + "_Port") + .focus(); + event.preventDefault(); + return; + } else if (!Services.io.isValidHostname(proxyPref.value)) { + document + .getElementById("networkProxy" + prefName.toUpperCase()) + .focus(); + event.preventDefault(); + return; + } + } + } + + // In the case of a shared proxy preference, backup the current values and update with the HTTP value + if (shareProxiesPref.value) { + var proxyServerURLPref = Preferences.get("network.proxy.ssl"); + var proxyPortPref = Preferences.get("network.proxy.ssl_port"); + var backupServerURLPref = Preferences.get("network.proxy.backup.ssl"); + var backupPortPref = Preferences.get("network.proxy.backup.ssl_port"); + backupServerURLPref.value = + backupServerURLPref.value || proxyServerURLPref.value; + backupPortPref.value = backupPortPref.value || proxyPortPref.value; + proxyServerURLPref.value = httpProxyURLPref.value; + proxyPortPref.value = httpProxyPortPref.value; + } + + this.sanitizeNoProxiesPref(); + }, + + checkForSystemProxy() { + if ("@mozilla.org/system-proxy-settings;1" in Cc) { + document.getElementById("systemPref").removeAttribute("hidden"); + } + }, + + proxyTypeChanged() { + var proxyTypePref = Preferences.get("network.proxy.type"); + + // Update http + var httpProxyURLPref = Preferences.get("network.proxy.http"); + httpProxyURLPref.updateControlDisabledState(proxyTypePref.value != 1); + var httpProxyPortPref = Preferences.get("network.proxy.http_port"); + httpProxyPortPref.updateControlDisabledState(proxyTypePref.value != 1); + + // Now update the other protocols + this.updateProtocolPrefs(); + + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + shareProxiesPref.updateControlDisabledState(proxyTypePref.value != 1); + var autologinProxyPref = Preferences.get("signon.autologin.proxy"); + autologinProxyPref.updateControlDisabledState(proxyTypePref.value == 0); + var noProxiesPref = Preferences.get("network.proxy.no_proxies_on"); + noProxiesPref.updateControlDisabledState(proxyTypePref.value == 0); + + var autoconfigURLPref = Preferences.get("network.proxy.autoconfig_url"); + autoconfigURLPref.updateControlDisabledState(proxyTypePref.value != 2); + + this.updateReloadButton(); + + document.getElementById("networkProxyNoneLocalhost").hidden = + Services.prefs.getBoolPref( + "network.proxy.allow_hijacking_localhost", + false + ); + }, + + updateDNSPref() { + var socksVersionPref = Preferences.get("network.proxy.socks_version"); + var socksDNSPref = Preferences.get("network.proxy.socks_remote_dns"); + var proxyTypePref = Preferences.get("network.proxy.type"); + var isDefinitelySocks4 = + proxyTypePref.value == 1 && socksVersionPref.value == 4; + socksDNSPref.updateControlDisabledState( + isDefinitelySocks4 || proxyTypePref.value == 0 + ); + return undefined; + }, + + updateReloadButton() { + // Disable the "Reload PAC" button if the selected proxy type is not PAC or + // if the current value of the PAC input does not match the value stored + // in prefs. Likewise, disable the reload button if PAC is not configured + // in prefs. + + var typedURL = document.getElementById("networkProxyAutoconfigURL").value; + var proxyTypeCur = Preferences.get("network.proxy.type").value; + + var pacURL = Services.prefs.getCharPref("network.proxy.autoconfig_url"); + var proxyType = Services.prefs.getIntPref("network.proxy.type"); + + var disableReloadPref = Preferences.get( + "pref.advanced.proxies.disable_button.reload" + ); + disableReloadPref.updateControlDisabledState( + proxyTypeCur != 2 || proxyType != 2 || typedURL != pacURL + ); + }, + + readProxyType() { + this.proxyTypeChanged(); + return undefined; + }, + + updateProtocolPrefs() { + var proxyTypePref = Preferences.get("network.proxy.type"); + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + var proxyPrefs = ["ssl", "socks"]; + for (var i = 0; i < proxyPrefs.length; ++i) { + var proxyServerURLPref = Preferences.get( + "network.proxy." + proxyPrefs[i] + ); + var proxyPortPref = Preferences.get( + "network.proxy." + proxyPrefs[i] + "_port" + ); + + // Restore previous per-proxy custom settings, if present. + if (proxyPrefs[i] != "socks" && !shareProxiesPref.value) { + var backupServerURLPref = Preferences.get( + "network.proxy.backup." + proxyPrefs[i] + ); + var backupPortPref = Preferences.get( + "network.proxy.backup." + proxyPrefs[i] + "_port" + ); + if (backupServerURLPref.hasUserValue) { + proxyServerURLPref.value = backupServerURLPref.value; + backupServerURLPref.reset(); + } + if (backupPortPref.hasUserValue) { + proxyPortPref.value = backupPortPref.value; + backupPortPref.reset(); + } + } + + proxyServerURLPref.updateElements(); + proxyPortPref.updateElements(); + let prefIsShared = proxyPrefs[i] != "socks" && shareProxiesPref.value; + proxyServerURLPref.updateControlDisabledState( + proxyTypePref.value != 1 || prefIsShared + ); + proxyPortPref.updateControlDisabledState( + proxyTypePref.value != 1 || prefIsShared + ); + } + var socksVersionPref = Preferences.get("network.proxy.socks_version"); + socksVersionPref.updateControlDisabledState(proxyTypePref.value != 1); + this.updateDNSPref(); + return undefined; + }, + + readProxyProtocolPref(aProtocol, aIsPort) { + if (aProtocol != "socks") { + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + if (shareProxiesPref.value) { + var pref = Preferences.get( + "network.proxy.http" + (aIsPort ? "_port" : "") + ); + return pref.value; + } + + var backupPref = Preferences.get( + "network.proxy.backup." + aProtocol + (aIsPort ? "_port" : "") + ); + return backupPref.hasUserValue ? backupPref.value : undefined; + } + return undefined; + }, + + reloadPAC() { + Cc["@mozilla.org/network/protocol-proxy-service;1"] + .getService() + .reloadPAC(); + }, + + doAutoconfigURLFixup() { + var autoURL = document.getElementById("networkProxyAutoconfigURL"); + var autoURLPref = Preferences.get("network.proxy.autoconfig_url"); + try { + autoURLPref.value = autoURL.value = Services.uriFixup.getFixupURIInfo( + autoURL.value + ).preferredURI.spec; + } catch (ex) {} + }, + + sanitizeNoProxiesPref() { + var noProxiesPref = Preferences.get("network.proxy.no_proxies_on"); + // replace substrings of ; and \n with commas if they're neither immediately + // preceded nor followed by a valid separator character + noProxiesPref.value = noProxiesPref.value.replace( + /([^, \n;])[;\n]+(?![,\n;])/g, + "$1," + ); + // replace any remaining ; and \n since some may follow commas, etc. + noProxiesPref.value = noProxiesPref.value.replace(/[;\n]/g, ""); + }, + + readHTTPProxyServer() { + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + if (shareProxiesPref.value) { + this.updateProtocolPrefs(); + } + return undefined; + }, + + readHTTPProxyPort() { + var shareProxiesPref = Preferences.get( + "network.proxy.share_proxy_settings" + ); + if (shareProxiesPref.value) { + this.updateProtocolPrefs(); + } + return undefined; + }, + + getProxyControls() { + let controlGroup = document.getElementById("networkProxyType"); + return [ + ...controlGroup.querySelectorAll(":scope > radio"), + ...controlGroup.querySelectorAll("label"), + ...controlGroup.querySelectorAll("input"), + ...controlGroup.querySelectorAll("checkbox"), + ...document.querySelectorAll("#networkProxySOCKSVersion > radio"), + ...document.querySelectorAll("#ConnectionsDialogPane > checkbox"), + ]; + }, + + // Update the UI to show/hide the extension controlled message for + // proxy settings. + async updateProxySettingsUI() { + let isLocked = API_PROXY_PREFS.some(pref => + Services.prefs.prefIsLocked(pref) + ); + + function setInputsDisabledState(isControlled) { + for (let element of gConnectionsDialog.getProxyControls()) { + element.disabled = isControlled; + } + gConnectionsDialog.proxyTypeChanged(); + } + + if (isLocked) { + // An extension can't control this setting if any pref is locked. + hideControllingExtension(PROXY_KEY); + } else { + handleControllingExtension(PREF_SETTING_TYPE, PROXY_KEY).then( + setInputsDisabledState + ); + } + }, + + registerSyncPrefListeners() { + function setSyncFromPrefListener(element_id, callback) { + Preferences.addSyncFromPrefListener( + document.getElementById(element_id), + callback + ); + } + setSyncFromPrefListener("networkProxyType", () => this.readProxyType()); + setSyncFromPrefListener("networkProxyHTTP", () => + this.readHTTPProxyServer() + ); + setSyncFromPrefListener("networkProxyHTTP_Port", () => + this.readHTTPProxyPort() + ); + setSyncFromPrefListener("shareAllProxies", () => + this.updateProtocolPrefs() + ); + setSyncFromPrefListener("networkProxySSL", () => + this.readProxyProtocolPref("ssl", false) + ); + setSyncFromPrefListener("networkProxySSL_Port", () => + this.readProxyProtocolPref("ssl", true) + ); + setSyncFromPrefListener("networkProxySOCKS", () => + this.readProxyProtocolPref("socks", false) + ); + setSyncFromPrefListener("networkProxySOCKS_Port", () => + this.readProxyProtocolPref("socks", true) + ); + }, +}; diff --git a/browser/components/preferences/dialogs/connection.xhtml b/browser/components/preferences/dialogs/connection.xhtml new file mode 100644 index 0000000000..2a1a3c0115 --- /dev/null +++ b/browser/components/preferences/dialogs/connection.xhtml @@ -0,0 +1,247 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="connection-window2" + data-l10n-attrs="title, style" + persist="lastSelected" + onload="gConnectionsDialog.checkForSystemProxy();" +> + <dialog + id="ConnectionsDialog" + buttons="accept,cancel,help" + helpTopic="prefs-connection-settings" + > + <!-- Used for extension-controlled lockdown message --> + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/preferences/connection.ftl" /> + <html:link + rel="localization" + href="browser/preferences/preferences.ftl" + /> + <html:link rel="localization" href="branding/brand.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + <script src="chrome://browser/content/preferences/extensionControlled.js" /> + + <keyset> + <key + data-l10n-id="connection-close-key" + modifiers="accel" + oncommand="Preferences.close(event)" + /> + </keyset> + + <script src="chrome://browser/content/preferences/dialogs/connection.js" /> + + <hbox + id="proxyExtensionContent" + align="start" + hidden="true" + class="extension-controlled" + > + <description control="disableProxyExtension" flex="1" /> + <button + id="disableProxyExtension" + class="extension-controlled-button accessory-button" + data-l10n-id="connection-disable-extension" + /> + </hbox> + + <groupbox> + <label><html:h2 data-l10n-id="connection-proxy-configure" /></label> + + <radiogroup id="networkProxyType" preference="network.proxy.type"> + <radio value="0" data-l10n-id="connection-proxy-option-no" /> + <radio value="4" data-l10n-id="connection-proxy-option-auto" /> + <radio + value="5" + data-l10n-id="connection-proxy-option-system" + id="systemPref" + hidden="true" + /> + <radio value="1" data-l10n-id="connection-proxy-option-manual" /> + <box id="proxy-grid" class="indent" flex="1"> + <html:div class="proxy-grid-row"> + <hbox pack="end"> + <label + data-l10n-id="connection-proxy-http" + control="networkProxyHTTP" + /> + </hbox> + <hbox align="center"> + <html:input + id="networkProxyHTTP" + type="text" + style="flex: 1" + preference="network.proxy.http" + /> + <label + data-l10n-id="connection-proxy-http-port" + control="networkProxyHTTP_Port" + /> + <html:input + id="networkProxyHTTP_Port" + class="proxy-port-input" + hidespinbuttons="true" + type="number" + min="0" + max="65535" + preference="network.proxy.http_port" + /> + </hbox> + </html:div> + <html:div class="proxy-grid-row"> + <hbox /> + <hbox> + <checkbox + id="shareAllProxies" + data-l10n-id="connection-proxy-https-sharing" + preference="network.proxy.share_proxy_settings" + /> + </hbox> + </html:div> + <html:div class="proxy-grid-row"> + <hbox pack="end"> + <label + data-l10n-id="connection-proxy-https" + control="networkProxySSL" + /> + </hbox> + <hbox align="center"> + <html:input + id="networkProxySSL" + type="text" + style="flex: 1" + preference="network.proxy.ssl" + /> + <label + data-l10n-id="connection-proxy-ssl-port" + control="networkProxySSL_Port" + /> + <html:input + id="networkProxySSL_Port" + class="proxy-port-input" + hidespinbuttons="true" + type="number" + min="0" + max="65535" + size="5" + preference="network.proxy.ssl_port" + /> + </hbox> + </html:div> + <separator class="thin" /> + <html:div class="proxy-grid-row"> + <hbox pack="end"> + <label + data-l10n-id="connection-proxy-socks" + control="networkProxySOCKS" + /> + </hbox> + <hbox align="center"> + <html:input + id="networkProxySOCKS" + type="text" + style="flex: 1" + preference="network.proxy.socks" + /> + <label + data-l10n-id="connection-proxy-socks-port" + control="networkProxySOCKS_Port" + /> + <html:input + id="networkProxySOCKS_Port" + class="proxy-port-input" + hidespinbuttons="true" + type="number" + min="0" + max="65535" + size="5" + preference="network.proxy.socks_port" + /> + </hbox> + </html:div> + <html:div class="proxy-grid-row"> + <spacer /> + <box pack="start"> + <radiogroup + id="networkProxySOCKSVersion" + orient="horizontal" + preference="network.proxy.socks_version" + > + <radio + id="networkProxySOCKSVersion4" + value="4" + data-l10n-id="connection-proxy-socks4" + /> + <radio + id="networkProxySOCKSVersion5" + value="5" + data-l10n-id="connection-proxy-socks5" + /> + </radiogroup> + </box> + </html:div> + </box> + <radio value="2" data-l10n-id="connection-proxy-autotype" /> + <hbox class="indent" flex="1" align="center"> + <html:input + id="networkProxyAutoconfigURL" + type="text" + style="flex: 1" + preference="network.proxy.autoconfig_url" + oninput="gConnectionsDialog.updateReloadButton();" + /> + <button + id="autoReload" + data-l10n-id="connection-proxy-reload" + oncommand="gConnectionsDialog.reloadPAC();" + preference="pref.advanced.proxies.disable_button.reload" + /> + </hbox> + </radiogroup> + </groupbox> + <separator class="thin" /> + <label data-l10n-id="connection-proxy-noproxy" control="networkProxyNone" /> + <html:textarea + id="networkProxyNone" + preference="network.proxy.no_proxies_on" + rows="2" + /> + <label + control="networkProxyNone" + data-l10n-id="connection-proxy-noproxy-desc" + /> + <label + id="networkProxyNoneLocalhost" + control="networkProxyNone" + data-l10n-id="connection-proxy-noproxy-localhost-desc-2" + /> + <separator class="thin" /> + <checkbox + id="autologinProxy" + data-l10n-id="connection-proxy-autologin-checkbox" + preference="signon.autologin.proxy" + /> + <checkbox + id="networkProxySOCKSRemoteDNS" + preference="network.proxy.socks_remote_dns" + data-l10n-id="connection-proxy-socks-remote-dns" + /> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/containers.js b/browser/components/preferences/dialogs/containers.js new file mode 100644 index 0000000000..14526545b6 --- /dev/null +++ b/browser/components/preferences/dialogs/containers.js @@ -0,0 +1,167 @@ +/* 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/. */ + +const { ContextualIdentityService } = ChromeUtils.importESModule( + "resource://gre/modules/ContextualIdentityService.sys.mjs" +); + +/** + * We want to set the window title immediately to prevent flickers. + */ +function setTitle() { + let params = window.arguments[0] || {}; + + let winElem = document.documentElement; + if (params.userContextId) { + document.l10n.setAttributes(winElem, "containers-window-update-settings2", { + name: params.identity.name, + }); + } else { + document.l10n.setAttributes(winElem, "containers-window-new2"); + } +} +setTitle(); + +let gContainersManager = { + icons: [ + "fingerprint", + "briefcase", + "dollar", + "cart", + "vacation", + "gift", + "food", + "fruit", + "pet", + "tree", + "chill", + "circle", + "fence", + ], + + colors: [ + "blue", + "turquoise", + "green", + "yellow", + "orange", + "red", + "pink", + "purple", + "toolbar", + ], + + onLoad() { + let params = window.arguments[0] || {}; + this.init(params); + }, + + init(aParams) { + this._dialog = document.querySelector("dialog"); + this.userContextId = aParams.userContextId || null; + this.identity = aParams.identity; + + const iconWrapper = document.getElementById("iconWrapper"); + iconWrapper.appendChild(this.createIconButtons()); + + const colorWrapper = document.getElementById("colorWrapper"); + colorWrapper.appendChild(this.createColorSwatches()); + + if (this.identity.name) { + const name = document.getElementById("name"); + name.value = this.identity.name; + this.checkForm(); + } + + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + + // This is to prevent layout jank caused by the svgs and outlines rendering at different times + document.getElementById("containers-content").removeAttribute("hidden"); + }, + + uninit() {}, + + // Check if name is provided to determine if the form can be submitted + checkForm() { + const name = document.getElementById("name"); + this._dialog.setAttribute("buttondisabledaccept", !name.value.trim()); + }, + + createIconButtons(defaultIcon) { + let radiogroup = document.createXULElement("radiogroup"); + radiogroup.setAttribute("id", "icon"); + radiogroup.className = "icon-buttons radio-buttons"; + + for (let icon of this.icons) { + let iconSwatch = document.createXULElement("radio"); + iconSwatch.id = "iconbutton-" + icon; + iconSwatch.name = "icon"; + iconSwatch.type = "radio"; + iconSwatch.value = icon; + + if (this.identity.icon && this.identity.icon == icon) { + iconSwatch.setAttribute("selected", true); + } + + document.l10n.setAttributes(iconSwatch, `containers-icon-${icon}`); + let iconElement = document.createXULElement("hbox"); + iconElement.className = "userContext-icon"; + iconElement.classList.add("identity-icon-" + icon); + + iconSwatch.appendChild(iconElement); + radiogroup.appendChild(iconSwatch); + } + + return radiogroup; + }, + + createColorSwatches(defaultColor) { + let radiogroup = document.createXULElement("radiogroup"); + radiogroup.setAttribute("id", "color"); + radiogroup.className = "radio-buttons"; + + for (let color of this.colors) { + let colorSwatch = document.createXULElement("radio"); + colorSwatch.id = "colorswatch-" + color; + colorSwatch.name = "color"; + colorSwatch.type = "radio"; + colorSwatch.value = color; + + if (this.identity.color && this.identity.color == color) { + colorSwatch.setAttribute("selected", true); + } + + document.l10n.setAttributes(colorSwatch, `containers-color-${color}`); + let iconElement = document.createXULElement("hbox"); + iconElement.className = "userContext-icon"; + iconElement.classList.add("identity-icon-circle"); + iconElement.classList.add("identity-color-" + color); + + colorSwatch.appendChild(iconElement); + radiogroup.appendChild(colorSwatch); + } + return radiogroup; + }, + + onApplyChanges() { + let icon = document.getElementById("icon").value; + let color = document.getElementById("color").value; + let name = document.getElementById("name").value; + + if (!this.icons.includes(icon)) { + throw new Error("Internal error. The icon value doesn't match."); + } + + if (!this.colors.includes(color)) { + throw new Error("Internal error. The color value doesn't match."); + } + + if (this.userContextId) { + ContextualIdentityService.update(this.userContextId, name, icon, color); + } else { + ContextualIdentityService.create(name, icon, color); + } + window.parent.location.reload(); + }, +}; diff --git a/browser/components/preferences/dialogs/containers.xhtml b/browser/components/preferences/dialogs/containers.xhtml new file mode 100644 index 0000000000..867463ee3a --- /dev/null +++ b/browser/components/preferences/dialogs/containers.xhtml @@ -0,0 +1,75 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="ContainersDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-attrs="title, style" + onload="gContainersManager.onLoad();" + onunload="gContainersManager.uninit();" + persist="width height" +> + <dialog + buttons="accept" + buttondisabledaccept="true" + data-l10n-id="containers-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/containers-dialog.css" + /> + + <html:link rel="localization" href="browser/preferences/containers.ftl" /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/containers.js" /> + + <keyset> + <key + data-l10n-id="containers-window-close" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane" hidden="true" id="containers-content"> + <hbox align="start"> + <label + id="nameLabel" + control="name" + data-l10n-id="containers-name-label" + data-l10n-attrs="style" + /> + <html:input + id="name" + type="text" + data-l10n-id="containers-name-text" + oninput="gContainersManager.checkForm();" + /> + </hbox> + <hbox align="center" id="colorWrapper"> + <label + id="colorLabel" + control="color" + data-l10n-id="containers-color-label" + data-l10n-attrs="style" + /> + </hbox> + <hbox align="center" id="iconWrapper"> + <label + id="iconLabel" + control="icon" + data-l10n-id="containers-icon-label" + data-l10n-attrs="style" + /> + </hbox> + </vbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/dohExceptions.js b/browser/components/preferences/dialogs/dohExceptions.js new file mode 100644 index 0000000000..f4c1d4d80d --- /dev/null +++ b/browser/components/preferences/dialogs/dohExceptions.js @@ -0,0 +1,287 @@ +/* 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/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var gDoHExceptionsManager = { + _exceptions: new Set(), + _list: null, + _prefLocked: false, + + init() { + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + + this._btnAddException = document.getElementById("btnAddException"); + this._removeButton = document.getElementById("removeException"); + this._removeAllButton = document.getElementById("removeAllExceptions"); + this._list = document.getElementById("permissionsBox"); + + this._urlField = document.getElementById("url"); + this.onExceptionInput(); + + this._loadExceptions(); + this.buildExceptionList(); + + this._urlField.focus(); + + this._prefLocked = Services.prefs.prefIsLocked( + "network.trr.excluded-domains" + ); + + this._btnAddException.disabled = this._prefLocked; + document.getElementById("exceptionDialog").getButton("accept").disabled = + this._prefLocked; + this._urlField.disabled = this._prefLocked; + }, + + _loadExceptions() { + let exceptionsFromPref = Services.prefs.getStringPref( + "network.trr.excluded-domains" + ); + + if (!exceptionsFromPref?.trim()) { + return; + } + + let exceptions = exceptionsFromPref.trim().split(","); + for (let exception of exceptions) { + let trimmed = exception.trim(); + if (trimmed) { + this._exceptions.add(trimmed); + } + } + }, + + addException() { + if (this._prefLocked) { + return; + } + + let textbox = document.getElementById("url"); + let inputValue = textbox.value.trim(); // trim any leading and trailing space + if (!inputValue.startsWith("http:") && !inputValue.startsWith("https:")) { + inputValue = `http://${inputValue}`; + } + let domain = ""; + try { + let uri = Services.io.newURI(inputValue); + domain = uri.host; + } catch (ex) { + document.l10n + .formatValues([ + { id: "permissions-invalid-uri-title" }, + { id: "permissions-invalid-uri-label" }, + ]) + .then(([title, message]) => { + Services.prompt.alert(window, title, message); + }); + return; + } + + if (!this._exceptions.has(domain)) { + this._exceptions.add(domain); + this.buildExceptionList(); + } + + textbox.value = ""; + textbox.focus(); + + // covers a case where the site exists already, so the buttons don't disable + this.onExceptionInput(); + + // enable "remove all" button as needed + this._setRemoveButtonState(); + }, + + onExceptionInput() { + this._btnAddException.disabled = !this._urlField.value; + }, + + onExceptionKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._btnAddException.click(); + if (document.activeElement == this._urlField) { + event.preventDefault(); + } + } + }, + + onListBoxKeyPress(event) { + if (!this._list.selectedItem) { + return; + } + + if (this._prefLocked) { + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE) + ) { + this.onExceptionDelete(); + event.preventDefault(); + } + }, + + onListBoxSelect() { + this._setRemoveButtonState(); + }, + + _removeExceptionFromList(exception) { + this._exceptions.delete(exception); + let exceptionlistitem = document.getElementsByAttribute( + "domain", + exception + )[0]; + if (exceptionlistitem) { + exceptionlistitem.remove(); + } + }, + + onExceptionDelete() { + let richlistitem = this._list.selectedItem; + let exception = richlistitem.getAttribute("domain"); + + this._removeExceptionFromList(exception); + + this._setRemoveButtonState(); + }, + + onAllExceptionsDelete() { + for (let exception of this._exceptions.values()) { + this._removeExceptionFromList(exception); + } + + this._setRemoveButtonState(); + }, + + _createExceptionListItem(exception) { + let richlistitem = document.createXULElement("richlistitem"); + richlistitem.setAttribute("domain", exception); + let row = document.createXULElement("hbox"); + row.setAttribute("style", "flex: 1"); + + let hbox = document.createXULElement("hbox"); + let website = document.createXULElement("label"); + website.setAttribute("class", "website-name-value"); + website.setAttribute("value", exception); + hbox.setAttribute("class", "website-name"); + hbox.setAttribute("style", "flex: 3 3; width: 0"); + hbox.appendChild(website); + row.appendChild(hbox); + + richlistitem.appendChild(row); + return richlistitem; + }, + + _sortExceptions(list, frag, column) { + let sortDirection; + + if (!column) { + column = document.querySelector("treecol[data-isCurrentSortCol=true]"); + sortDirection = + column.getAttribute("data-last-sortDirection") || "ascending"; + } else { + sortDirection = column.getAttribute("data-last-sortDirection"); + sortDirection = + sortDirection === "ascending" ? "descending" : "ascending"; + } + + let sortFunc = (a, b) => { + return comp.compare(a.getAttribute("domain"), b.getAttribute("domain")); + }; + + let comp = new Services.intl.Collator(undefined, { + usage: "sort", + }); + + let items = Array.from(frag.querySelectorAll("richlistitem")); + + if (sortDirection === "descending") { + items.sort((a, b) => sortFunc(b, a)); + } else { + items.sort(sortFunc); + } + + // Re-append items in the correct order: + items.forEach(item => frag.appendChild(item)); + + let cols = list.previousElementSibling.querySelectorAll("treecol"); + cols.forEach(c => { + c.removeAttribute("data-isCurrentSortCol"); + c.removeAttribute("sortDirection"); + }); + column.setAttribute("data-isCurrentSortCol", "true"); + column.setAttribute("sortDirection", sortDirection); + column.setAttribute("data-last-sortDirection", sortDirection); + }, + + _setRemoveButtonState() { + if (!this._list) { + return; + } + + if (this._prefLocked) { + this._removeAllButton.disabled = true; + this._removeButton.disabled = true; + return; + } + + let hasSelection = this._list.selectedIndex >= 0; + + this._removeButton.disabled = !hasSelection; + let disabledItems = this._list.querySelectorAll( + "label.website-name-value[disabled='true']" + ); + + this._removeAllButton.disabled = + this._list.itemCount == disabledItems.length; + }, + + onApplyChanges() { + if (this._exceptions.size == 0) { + Services.prefs.setStringPref("network.trr.excluded-domains", ""); + return; + } + + let exceptions = Array.from(this._exceptions); + let exceptionPrefString = exceptions.join(","); + + Services.prefs.setStringPref( + "network.trr.excluded-domains", + exceptionPrefString + ); + }, + + buildExceptionList(sortCol) { + // Clear old entries. + let oldItems = this._list.querySelectorAll("richlistitem"); + for (let item of oldItems) { + item.remove(); + } + let frag = document.createDocumentFragment(); + + let exceptions = Array.from(this._exceptions.values()); + + for (let exception of exceptions) { + let richlistitem = this._createExceptionListItem(exception); + frag.appendChild(richlistitem); + } + + // Sort exceptions. + this._sortExceptions(this._list, frag, sortCol); + + this._list.appendChild(frag); + + this._setRemoveButtonState(); + }, +}; + +document.addEventListener("DOMContentLoaded", () => { + gDoHExceptionsManager.init(); +}); diff --git a/browser/components/preferences/dialogs/dohExceptions.xhtml b/browser/components/preferences/dialogs/dohExceptions.xhtml new file mode 100644 index 0000000000..03a9c87992 --- /dev/null +++ b/browser/components/preferences/dialogs/dohExceptions.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="DoHExceptionsDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="permissions-exceptions-doh-window" + data-l10n-attrs="title, style" + persist="width height" +> + <dialog + id="exceptionDialog" + buttons="accept,cancel" + data-l10n-id="permission-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/content/preferences/dialogs/sitePermissions.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link + rel="localization" + href="browser/preferences/permissions.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/dohExceptions.js" /> + + <keyset> + <key + data-l10n-id="permissions-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <description + id="dohExceptionText" + control="url" + data-l10n-id="permissions-exceptions-manage-doh-desc" + /> + <separator class="thin" /> + <label + id="urlLabel" + control="url" + data-l10n-id="permissions-doh-entry-field" + /> + <hbox align="start"> + <html:input + id="url" + type="text" + style="flex: 1" + oninput="gDoHExceptionsManager.onExceptionInput();" + onkeypress="gDoHExceptionsManager.onExceptionKeyPress(event);" + /> + </hbox> + <hbox pack="end"> + <button + id="btnAddException" + disabled="true" + data-l10n-id="permissions-doh-add-exception" + oncommand="gDoHExceptionsManager.addException();" + /> + </hbox> + <separator class="thin" /> + <listheader> + <treecol + id="siteCol" + data-l10n-id="permissions-doh-col" + style="flex: 3 3 auto; width: 0" + data-isCurrentSortCol="true" + onclick="gDoHExceptionsManager.buildExceptionList(event.target)" + /> + </listheader> + <richlistbox + id="permissionsBox" + selected="false" + onkeypress="gDoHExceptionsManager.onListBoxKeyPress(event);" + onselect="gDoHExceptionsManager.onListBoxSelect();" + /> + </vbox> + + <hbox class="actionButtons"> + <button + id="removeException" + disabled="true" + data-l10n-id="permissions-doh-remove" + oncommand="gDoHExceptionsManager.onExceptionDelete();" + /> + <button + id="removeAllExceptions" + data-l10n-id="permissions-doh-remove-all" + oncommand="gDoHExceptionsManager.onAllExceptionsDelete();" + /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/fonts.js b/browser/components/preferences/dialogs/fonts.js new file mode 100644 index 0000000000..ccc2f8faca --- /dev/null +++ b/browser/components/preferences/dialogs/fonts.js @@ -0,0 +1,173 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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-globals-from /browser/base/content/utilityOverlay.js */ +/* import-globals-from /toolkit/mozapps/preferences/fontbuilder.js */ + +// browser.display.languageList LOCK ALL when LOCKED + +const kDefaultFontType = "font.default.%LANG%"; +const kFontNameFmtSerif = "font.name.serif.%LANG%"; +const kFontNameFmtSansSerif = "font.name.sans-serif.%LANG%"; +const kFontNameFmtMonospace = "font.name.monospace.%LANG%"; +const kFontNameListFmtSerif = "font.name-list.serif.%LANG%"; +const kFontNameListFmtSansSerif = "font.name-list.sans-serif.%LANG%"; +const kFontNameListFmtMonospace = "font.name-list.monospace.%LANG%"; +const kFontSizeFmtVariable = "font.size.variable.%LANG%"; +const kFontSizeFmtFixed = "font.size.monospace.%LANG%"; +const kFontMinSizeFmt = "font.minimum-size.%LANG%"; + +document + .getElementById("FontsDialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); +window.addEventListener("load", () => gFontsDialog.onLoad()); + +Preferences.addAll([ + { id: "font.language.group", type: "wstring" }, + { id: "browser.display.use_document_fonts", type: "int" }, +]); + +var gFontsDialog = { + _selectLanguageGroupPromise: Promise.resolve(), + + onLoad() { + Preferences.addSyncFromPrefListener( + document.getElementById("selectLangs"), + () => this.readFontLanguageGroup() + ); + Preferences.addSyncFromPrefListener( + document.getElementById("useDocumentFonts"), + () => this.readUseDocumentFonts() + ); + Preferences.addSyncToPrefListener( + document.getElementById("useDocumentFonts"), + () => this.writeUseDocumentFonts() + ); + for (let id of ["serif", "sans-serif", "monospace"]) { + let el = document.getElementById(id); + Preferences.addSyncFromPrefListener(el, () => + FontBuilder.readFontSelection(el) + ); + } + }, + + _selectLanguageGroup(aLanguageGroup) { + this._selectLanguageGroupPromise = (async () => { + // Avoid overlapping language group selections by awaiting the resolution + // of the previous one. We do this because this function is re-entrant, + // as inserting <preference> elements into the DOM sometimes triggers a call + // back into this function. And since this function is also asynchronous, + // that call can enter this function before the previous run has completed, + // which would corrupt the font menulists. Awaiting the previous call's + // resolution avoids that fate. + await this._selectLanguageGroupPromise; + + var prefs = [ + { + format: kDefaultFontType, + type: "string", + element: "defaultFontType", + fonttype: null, + }, + { + format: kFontNameFmtSerif, + type: "fontname", + element: "serif", + fonttype: "serif", + }, + { + format: kFontNameFmtSansSerif, + type: "fontname", + element: "sans-serif", + fonttype: "sans-serif", + }, + { + format: kFontNameFmtMonospace, + type: "fontname", + element: "monospace", + fonttype: "monospace", + }, + { + format: kFontNameListFmtSerif, + type: "unichar", + element: null, + fonttype: "serif", + }, + { + format: kFontNameListFmtSansSerif, + type: "unichar", + element: null, + fonttype: "sans-serif", + }, + { + format: kFontNameListFmtMonospace, + type: "unichar", + element: null, + fonttype: "monospace", + }, + { + format: kFontSizeFmtVariable, + type: "int", + element: "sizeVar", + fonttype: null, + }, + { + format: kFontSizeFmtFixed, + type: "int", + element: "sizeMono", + fonttype: null, + }, + { + format: kFontMinSizeFmt, + type: "int", + element: "minSize", + fonttype: null, + }, + ]; + for (var i = 0; i < prefs.length; ++i) { + var name = prefs[i].format.replace(/%LANG%/, aLanguageGroup); + var preference = Preferences.get(name); + if (!preference) { + preference = Preferences.add({ id: name, type: prefs[i].type }); + } + + if (!prefs[i].element) { + continue; + } + + var element = document.getElementById(prefs[i].element); + if (element) { + element.setAttribute("preference", preference.id); + + if (prefs[i].fonttype) { + await FontBuilder.buildFontList( + aLanguageGroup, + prefs[i].fonttype, + element + ); + } + + preference.setElementValue(element); + } + } + })().catch(console.error); + }, + + readFontLanguageGroup() { + var languagePref = Preferences.get("font.language.group"); + this._selectLanguageGroup(languagePref.value); + return undefined; + }, + + readUseDocumentFonts() { + var preference = Preferences.get("browser.display.use_document_fonts"); + return preference.value == 1; + }, + + writeUseDocumentFonts() { + var useDocumentFonts = document.getElementById("useDocumentFonts"); + return useDocumentFonts.checked ? 1 : 0; + }, +}; diff --git a/browser/components/preferences/dialogs/fonts.xhtml b/browser/components/preferences/dialogs/fonts.xhtml new file mode 100644 index 0000000000..44dc473ccb --- /dev/null +++ b/browser/components/preferences/dialogs/fonts.xhtml @@ -0,0 +1,254 @@ +<?xml version="1.0"?> + +<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- --> +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="fonts-window" + data-l10n-attrs="title" + persist="lastSelected" +> + <dialog + id="FontsDialog" + buttons="accept,cancel,help" + helpTopic="prefs-fonts-and-colors" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/preferences/fonts.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + + <keyset> + <key + data-l10n-id="fonts-window-close" + modifiers="accel" + oncommand="Preferences.close(event)" + /> + </keyset> + + <!-- Fonts for: [ Language ] --> + <groupbox> + <hbox align="center"> + <label control="selectLangs" + ><html:h2 data-l10n-id="fonts-langgroup-header" + /></label> + </hbox> + <menulist id="selectLangs" preference="font.language.group"> + <menupopup> + <menuitem value="ar" data-l10n-id="fonts-langgroup-arabic" /> + <menuitem value="x-armn" data-l10n-id="fonts-langgroup-armenian" /> + <menuitem value="x-beng" data-l10n-id="fonts-langgroup-bengali" /> + <menuitem + value="zh-CN" + data-l10n-id="fonts-langgroup-simpl-chinese" + /> + <menuitem + value="zh-HK" + data-l10n-id="fonts-langgroup-trad-chinese-hk" + /> + <menuitem value="zh-TW" data-l10n-id="fonts-langgroup-trad-chinese" /> + <menuitem + value="x-cyrillic" + data-l10n-id="fonts-langgroup-cyrillic" + /> + <menuitem + value="x-devanagari" + data-l10n-id="fonts-langgroup-devanagari" + /> + <menuitem value="x-ethi" data-l10n-id="fonts-langgroup-ethiopic" /> + <menuitem value="x-geor" data-l10n-id="fonts-langgroup-georgian" /> + <menuitem value="el" data-l10n-id="fonts-langgroup-el" /> + <menuitem value="x-gujr" data-l10n-id="fonts-langgroup-gujarati" /> + <menuitem value="x-guru" data-l10n-id="fonts-langgroup-gurmukhi" /> + <menuitem value="he" data-l10n-id="fonts-langgroup-hebrew" /> + <menuitem value="ja" data-l10n-id="fonts-langgroup-japanese" /> + <menuitem value="x-knda" data-l10n-id="fonts-langgroup-kannada" /> + <menuitem value="x-khmr" data-l10n-id="fonts-langgroup-khmer" /> + <menuitem value="ko" data-l10n-id="fonts-langgroup-korean" /> + <menuitem value="x-western" data-l10n-id="fonts-langgroup-latin" /> + <menuitem value="x-mlym" data-l10n-id="fonts-langgroup-malayalam" /> + <menuitem value="x-math" data-l10n-id="fonts-langgroup-math" /> + <menuitem value="x-orya" data-l10n-id="fonts-langgroup-odia" /> + <menuitem value="x-sinh" data-l10n-id="fonts-langgroup-sinhala" /> + <menuitem value="x-tamil" data-l10n-id="fonts-langgroup-tamil" /> + <menuitem value="x-telu" data-l10n-id="fonts-langgroup-telugu" /> + <menuitem value="th" data-l10n-id="fonts-langgroup-thai" /> + <menuitem value="x-tibt" data-l10n-id="fonts-langgroup-tibetan" /> + <menuitem value="x-cans" data-l10n-id="fonts-langgroup-canadian" /> + <menuitem value="x-unicode" data-l10n-id="fonts-langgroup-other" /> + </menupopup> + </menulist> + + <separator class="thin" /> + + <box id="font-chooser-group"> + <!-- proportional row --> + <hbox align="center" pack="end"> + <label + data-l10n-id="fonts-proportional-header" + control="defaultFontType" + /> + </hbox> + <menulist id="defaultFontType"> + <menupopup> + <menuitem value="serif" data-l10n-id="fonts-default-serif" /> + <menuitem + value="sans-serif" + data-l10n-id="fonts-default-sans-serif" + /> + </menupopup> + </menulist> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-proportional-size" control="sizeVar" /> + </hbox> + <menulist id="sizeVar" delayprefsave="true"> + <menupopup> + <menuitem value="9" label="9" /> + <menuitem value="10" label="10" /> + <menuitem value="11" label="11" /> + <menuitem value="12" label="12" /> + <menuitem value="13" label="13" /> + <menuitem value="14" label="14" /> + <menuitem value="15" label="15" /> + <menuitem value="16" label="16" /> + <menuitem value="17" label="17" /> + <menuitem value="18" label="18" /> + <menuitem value="20" label="20" /> + <menuitem value="22" label="22" /> + <menuitem value="24" label="24" /> + <menuitem value="26" label="26" /> + <menuitem value="28" label="28" /> + <menuitem value="30" label="30" /> + <menuitem value="32" label="32" /> + <menuitem value="34" label="34" /> + <menuitem value="36" label="36" /> + <menuitem value="40" label="40" /> + <menuitem value="44" label="44" /> + <menuitem value="48" label="48" /> + <menuitem value="56" label="56" /> + <menuitem value="64" label="64" /> + <menuitem value="72" label="72" /> + </menupopup> + </menulist> + + <!-- serif row --> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-serif" control="serif" /> + </hbox> + <menulist id="serif" delayprefsave="true" /> + <spacer /> + <spacer /> + + <!-- sans-serif row --> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-sans-serif" control="sans-serif" /> + </hbox> + <menulist id="sans-serif" delayprefsave="true" /> + <spacer /> + <spacer /> + + <!-- monospace row --> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-monospace" control="monospace" /> + </hbox> + <!-- + FIXME(emilio): Why is this the only menulist here with crop="end"? + This goes back to the beginning of time... + --> + <menulist id="monospace" crop="end" delayprefsave="true" /> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-monospace-size" control="sizeMono" /> + </hbox> + <menulist id="sizeMono" delayprefsave="true"> + <menupopup> + <menuitem value="9" label="9" /> + <menuitem value="10" label="10" /> + <menuitem value="11" label="11" /> + <menuitem value="12" label="12" /> + <menuitem value="13" label="13" /> + <menuitem value="14" label="14" /> + <menuitem value="15" label="15" /> + <menuitem value="16" label="16" /> + <menuitem value="17" label="17" /> + <menuitem value="18" label="18" /> + <menuitem value="20" label="20" /> + <menuitem value="22" label="22" /> + <menuitem value="24" label="24" /> + <menuitem value="26" label="26" /> + <menuitem value="28" label="28" /> + <menuitem value="30" label="30" /> + <menuitem value="32" label="32" /> + <menuitem value="34" label="34" /> + <menuitem value="36" label="36" /> + <menuitem value="40" label="40" /> + <menuitem value="44" label="44" /> + <menuitem value="48" label="48" /> + <menuitem value="56" label="56" /> + <menuitem value="64" label="64" /> + <menuitem value="72" label="72" /> + </menupopup> + </menulist> + </box> + <separator class="thin" /> + <hbox align="center" pack="end"> + <label data-l10n-id="fonts-minsize" control="minSize" /> + <menulist id="minSize"> + <menupopup> + <menuitem value="0" data-l10n-id="fonts-minsize-none" /> + <menuitem value="9" label="9" /> + <menuitem value="10" label="10" /> + <menuitem value="11" label="11" /> + <menuitem value="12" label="12" /> + <menuitem value="13" label="13" /> + <menuitem value="14" label="14" /> + <menuitem value="15" label="15" /> + <menuitem value="16" label="16" /> + <menuitem value="17" label="17" /> + <menuitem value="18" label="18" /> + <menuitem value="20" label="20" /> + <menuitem value="22" label="22" /> + <menuitem value="24" label="24" /> + <menuitem value="26" label="26" /> + <menuitem value="28" label="28" /> + <menuitem value="30" label="30" /> + <menuitem value="32" label="32" /> + <menuitem value="34" label="34" /> + <menuitem value="36" label="36" /> + <menuitem value="40" label="40" /> + <menuitem value="44" label="44" /> + <menuitem value="48" label="48" /> + <menuitem value="56" label="56" /> + <menuitem value="64" label="64" /> + <menuitem value="72" label="72" /> + </menupopup> + </menulist> + </hbox> + <separator /> + <separator class="groove" /> + <hbox> + <checkbox + id="useDocumentFonts" + data-l10n-id="fonts-allow-own" + preference="browser.display.use_document_fonts" + /> + </hbox> + </groupbox> + + <!-- Load the script after the elements for layout issues (bug 1501755). --> + <script src="chrome://mozapps/content/preferences/fontbuilder.js" /> + <script src="chrome://browser/content/preferences/dialogs/fonts.js" /> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/handlers.css b/browser/components/preferences/dialogs/handlers.css new file mode 100644 index 0000000000..b53963fa4d --- /dev/null +++ b/browser/components/preferences/dialogs/handlers.css @@ -0,0 +1,21 @@ +/* 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/. */ + +/** + * Make the icons appear. + * Note: we display the icon box for every item whether or not it has an icon + * so the labels of all the items align vertically. + */ +.actionsMenu > menupopup > menuitem > .menu-iconic-left { + display: flex; + min-width: 16px; +} + +/* Apply crisp rendering for favicons at exactly 2dppx resolution */ +@media (resolution: 2dppx) { + #handlersView > richlistitem, + .actionsMenu > menupopup > menuitem > .menu-iconic-left { + image-rendering: -moz-crisp-edges; + } +} diff --git a/browser/components/preferences/dialogs/jar.mn b/browser/components/preferences/dialogs/jar.mn new file mode 100644 index 0000000000..a9f3dd1da2 --- /dev/null +++ b/browser/components/preferences/dialogs/jar.mn @@ -0,0 +1,49 @@ +# 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/. + +browser.jar: + content/browser/preferences/dialogs/addEngine.xhtml + content/browser/preferences/dialogs/addEngine.js + content/browser/preferences/dialogs/addEngine.css + content/browser/preferences/dialogs/applicationManager.xhtml + content/browser/preferences/dialogs/applicationManager.js + content/browser/preferences/dialogs/blocklists.xhtml + content/browser/preferences/dialogs/blocklists.js + content/browser/preferences/dialogs/browserLanguages.xhtml + content/browser/preferences/dialogs/browserLanguages.js + content/browser/preferences/dialogs/clearSiteData.css + content/browser/preferences/dialogs/clearSiteData.js + content/browser/preferences/dialogs/clearSiteData.xhtml + content/browser/preferences/dialogs/colors.xhtml + content/browser/preferences/dialogs/colors.js + content/browser/preferences/dialogs/connection.xhtml + content/browser/preferences/dialogs/connection.js + content/browser/preferences/dialogs/dohExceptions.xhtml + content/browser/preferences/dialogs/dohExceptions.js + content/browser/preferences/dialogs/fonts.xhtml + content/browser/preferences/dialogs/fonts.js + content/browser/preferences/dialogs/handlers.css + content/browser/preferences/dialogs/languages.xhtml + content/browser/preferences/dialogs/languages.js + content/browser/preferences/dialogs/permissions.xhtml + content/browser/preferences/dialogs/sitePermissions.xhtml + content/browser/preferences/dialogs/sitePermissions.js + content/browser/preferences/dialogs/sitePermissions.css + content/browser/preferences/dialogs/containers.xhtml + content/browser/preferences/dialogs/containers.js + content/browser/preferences/dialogs/permissions.js + content/browser/preferences/dialogs/sanitize.xhtml + content/browser/preferences/dialogs/sanitize.js + content/browser/preferences/dialogs/selectBookmark.xhtml + content/browser/preferences/dialogs/selectBookmark.js + content/browser/preferences/dialogs/siteDataSettings.xhtml + content/browser/preferences/dialogs/siteDataSettings.js +* content/browser/preferences/dialogs/siteDataRemoveSelected.xhtml + content/browser/preferences/dialogs/siteDataRemoveSelected.js + content/browser/preferences/dialogs/syncChooseWhatToSync.xhtml + content/browser/preferences/dialogs/syncChooseWhatToSync.js + content/browser/preferences/dialogs/translationExceptions.xhtml + content/browser/preferences/dialogs/translationExceptions.js + content/browser/preferences/dialogs/translations.xhtml + content/browser/preferences/dialogs/translations.js diff --git a/browser/components/preferences/dialogs/languages.js b/browser/components/preferences/dialogs/languages.js new file mode 100644 index 0000000000..5e32e1c261 --- /dev/null +++ b/browser/components/preferences/dialogs/languages.js @@ -0,0 +1,386 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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-globals-from /toolkit/content/preferencesBindings.js */ + +document + .getElementById("LanguagesDialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); + +Preferences.addAll([ + { id: "intl.accept_languages", type: "wstring" }, + { id: "pref.browser.language.disable_button.up", type: "bool" }, + { id: "pref.browser.language.disable_button.down", type: "bool" }, + { id: "pref.browser.language.disable_button.remove", type: "bool" }, + { id: "privacy.spoof_english", type: "int" }, +]); + +var gLanguagesDialog = { + _availableLanguagesList: [], + _acceptLanguages: {}, + + _selectedItemID: null, + + onLoad() { + let spoofEnglishElement = document.getElementById("spoofEnglish"); + Preferences.addSyncFromPrefListener(spoofEnglishElement, () => + gLanguagesDialog.readSpoofEnglish() + ); + Preferences.addSyncToPrefListener(spoofEnglishElement, () => + gLanguagesDialog.writeSpoofEnglish() + ); + + Preferences.get("intl.accept_languages").on("change", () => + this._readAcceptLanguages().catch(console.error) + ); + + if (!this._availableLanguagesList.length) { + document.mozSubdialogReady = this._loadAvailableLanguages(); + } + }, + + get _activeLanguages() { + return document.getElementById("activeLanguages"); + }, + + get _availableLanguages() { + return document.getElementById("availableLanguages"); + }, + + async _loadAvailableLanguages() { + // This is a parser for: resource://gre/res/language.properties + // The file is formatted like so: + // ab[-cd].accept=true|false + // ab = language + // cd = region + var bundleAccepted = document.getElementById("bundleAccepted"); + + function LocaleInfo(aLocaleName, aLocaleCode, aIsVisible) { + this.name = aLocaleName; + this.code = aLocaleCode; + this.isVisible = aIsVisible; + } + + // 1) Read the available languages out of language.properties + + let localeCodes = []; + let localeValues = []; + for (let currString of bundleAccepted.strings) { + var property = currString.key.split("."); // ab[-cd].accept + if (property[1] == "accept") { + localeCodes.push(property[0]); + localeValues.push(currString.value); + } + } + + let localeNames = Services.intl.getLocaleDisplayNames( + undefined, + localeCodes + ); + + for (let i in localeCodes) { + let isVisible = + localeValues[i] == "true" && + (!(localeCodes[i] in this._acceptLanguages) || + !this._acceptLanguages[localeCodes[i]]); + + let li = new LocaleInfo(localeNames[i], localeCodes[i], isVisible); + this._availableLanguagesList.push(li); + } + + await this._buildAvailableLanguageList(); + await this._readAcceptLanguages(); + }, + + async _buildAvailableLanguageList() { + var availableLanguagesPopup = document.getElementById( + "availableLanguagesPopup" + ); + while (availableLanguagesPopup.hasChildNodes()) { + availableLanguagesPopup.firstChild.remove(); + } + + let frag = document.createDocumentFragment(); + + // Load the UI with the data + for (var i = 0; i < this._availableLanguagesList.length; ++i) { + let locale = this._availableLanguagesList[i]; + let localeCode = locale.code; + if ( + locale.isVisible && + (!(localeCode in this._acceptLanguages) || + !this._acceptLanguages[localeCode]) + ) { + var menuitem = document.createXULElement("menuitem"); + menuitem.id = localeCode; + document.l10n.setAttributes(menuitem, "languages-code-format", { + locale: locale.name, + code: localeCode, + }); + frag.appendChild(menuitem); + } + } + + await document.l10n.translateFragment(frag); + + // Sort the list of languages by name + let comp = new Services.intl.Collator(undefined, { + usage: "sort", + }); + + let items = Array.from(frag.children); + + items.sort((a, b) => { + return comp.compare(a.getAttribute("label"), b.getAttribute("label")); + }); + + // Re-append items in the correct order: + items.forEach(item => frag.appendChild(item)); + + availableLanguagesPopup.appendChild(frag); + + this._availableLanguages.setAttribute( + "label", + this._availableLanguages.getAttribute("placeholder") + ); + }, + + async _readAcceptLanguages() { + while (this._activeLanguages.hasChildNodes()) { + this._activeLanguages.firstChild.remove(); + } + + var selectedIndex = 0; + var preference = Preferences.get("intl.accept_languages"); + if (preference.value == "") { + this._activeLanguages.selectedIndex = -1; + this.onLanguageSelect(); + return; + } + var languages = preference.value.toLowerCase().split(/\s*,\s*/); + for (var i = 0; i < languages.length; ++i) { + var listitem = document.createXULElement("richlistitem"); + var label = document.createXULElement("label"); + listitem.appendChild(label); + listitem.id = languages[i]; + if (languages[i] == this._selectedItemID) { + selectedIndex = i; + } + this._activeLanguages.appendChild(listitem); + var localeName = this._getLocaleName(languages[i]); + document.l10n.setAttributes(label, "languages-active-code-format", { + locale: localeName, + code: languages[i], + }); + + // Hash this language as an "Active" language so we don't + // show it in the list that can be added. + this._acceptLanguages[languages[i]] = true; + } + + // We're forcing an early localization here because otherwise + // the initial sizing of the dialog will happen before it and + // result in overflow. + await document.l10n.translateFragment(this._activeLanguages); + + if (this._activeLanguages.childNodes.length) { + this._activeLanguages.ensureIndexIsVisible(selectedIndex); + this._activeLanguages.selectedIndex = selectedIndex; + } + + // Update states of accept-language list and buttons according to + // privacy.resistFingerprinting and privacy.spoof_english. + this.readSpoofEnglish(); + }, + + onAvailableLanguageSelect() { + var availableLanguages = this._availableLanguages; + var addButton = document.getElementById("addButton"); + addButton.disabled = + availableLanguages.disabled || availableLanguages.selectedIndex < 0; + + this._availableLanguages.removeAttribute("accesskey"); + }, + + addLanguage() { + var selectedID = this._availableLanguages.selectedItem.id; + var preference = Preferences.get("intl.accept_languages"); + var arrayOfPrefs = preference.value.toLowerCase().split(/\s*,\s*/); + for (var i = 0; i < arrayOfPrefs.length; ++i) { + if (arrayOfPrefs[i] == selectedID) { + return; + } + } + + this._selectedItemID = selectedID; + + if (preference.value == "") { + preference.value = selectedID; + } else { + arrayOfPrefs.unshift(selectedID); + preference.value = arrayOfPrefs.join(","); + } + + this._acceptLanguages[selectedID] = true; + this._availableLanguages.selectedItem = null; + + // Rebuild the available list with the added item removed... + this._buildAvailableLanguageList().catch(console.error); + }, + + removeLanguage() { + // Build the new preference value string. + var languagesArray = []; + for (var i = 0; i < this._activeLanguages.childNodes.length; ++i) { + var item = this._activeLanguages.childNodes[i]; + if (!item.selected) { + languagesArray.push(item.id); + } else { + this._acceptLanguages[item.id] = false; + } + } + var string = languagesArray.join(","); + + // Get the item to select after the remove operation completes. + var selection = this._activeLanguages.selectedItems; + var lastSelected = selection[selection.length - 1]; + var selectItem = lastSelected.nextSibling || lastSelected.previousSibling; + selectItem = selectItem ? selectItem.id : null; + + this._selectedItemID = selectItem; + + // Update the preference and force a UI rebuild + var preference = Preferences.get("intl.accept_languages"); + preference.value = string; + + this._buildAvailableLanguageList().catch(console.error); + }, + + _getLocaleName(localeCode) { + if (!this._availableLanguagesList.length) { + this._loadAvailableLanguages(); + } + let languageName = ""; + for (var i = 0; i < this._availableLanguagesList.length; ++i) { + if (localeCode == this._availableLanguagesList[i].code) { + return this._availableLanguagesList[i].name; + } + // Try resolving the locale code without region code. Can't return + // directly because there might be a perfect match later. + if (localeCode.split("-")[0] == this._availableLanguagesList[i].code) { + languageName = this._availableLanguagesList[i].name; + } + } + + return languageName; + }, + + moveUp() { + var selectedItem = this._activeLanguages.selectedItems[0]; + var previousItem = selectedItem.previousSibling; + + var string = ""; + for (var i = 0; i < this._activeLanguages.childNodes.length; ++i) { + var item = this._activeLanguages.childNodes[i]; + string += i == 0 ? "" : ","; + if (item.id == previousItem.id) { + string += selectedItem.id; + } else if (item.id == selectedItem.id) { + string += previousItem.id; + } else { + string += item.id; + } + } + + this._selectedItemID = selectedItem.id; + + // Update the preference and force a UI rebuild + var preference = Preferences.get("intl.accept_languages"); + preference.value = string; + }, + + moveDown() { + var selectedItem = this._activeLanguages.selectedItems[0]; + var nextItem = selectedItem.nextSibling; + + var string = ""; + for (var i = 0; i < this._activeLanguages.childNodes.length; ++i) { + var item = this._activeLanguages.childNodes[i]; + string += i == 0 ? "" : ","; + if (item.id == nextItem.id) { + string += selectedItem.id; + } else if (item.id == selectedItem.id) { + string += nextItem.id; + } else { + string += item.id; + } + } + + this._selectedItemID = selectedItem.id; + + // Update the preference and force a UI rebuild + var preference = Preferences.get("intl.accept_languages"); + preference.value = string; + }, + + onLanguageSelect() { + var upButton = document.getElementById("up"); + var downButton = document.getElementById("down"); + var removeButton = document.getElementById("remove"); + switch (this._activeLanguages.selectedCount) { + case 0: + upButton.disabled = downButton.disabled = removeButton.disabled = true; + break; + case 1: + upButton.disabled = this._activeLanguages.selectedIndex == 0; + downButton.disabled = + this._activeLanguages.selectedIndex == + this._activeLanguages.childNodes.length - 1; + removeButton.disabled = false; + break; + default: + upButton.disabled = true; + downButton.disabled = true; + removeButton.disabled = false; + } + }, + + readSpoofEnglish() { + var checkbox = document.getElementById("spoofEnglish"); + var resistFingerprinting = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + if (!resistFingerprinting) { + checkbox.hidden = true; + return false; + } + + var spoofEnglish = Preferences.get("privacy.spoof_english").value; + var activeLanguages = this._activeLanguages; + var availableLanguages = this._availableLanguages; + checkbox.hidden = false; + switch (spoofEnglish) { + case 1: // don't spoof intl.accept_languages + activeLanguages.disabled = false; + activeLanguages.selectItem(activeLanguages.firstChild); + availableLanguages.disabled = false; + this.onAvailableLanguageSelect(); + return false; + case 2: // spoof intl.accept_languages + activeLanguages.clearSelection(); + activeLanguages.disabled = true; + availableLanguages.disabled = true; + this.onAvailableLanguageSelect(); + return true; + default: + // will prompt for spoofing intl.accept_languages if resisting fingerprinting + return false; + } + }, + + writeSpoofEnglish() { + return document.getElementById("spoofEnglish").checked ? 2 : 1; + }, +}; diff --git a/browser/components/preferences/dialogs/languages.xhtml b/browser/components/preferences/dialogs/languages.xhtml new file mode 100644 index 0000000000..67717253a7 --- /dev/null +++ b/browser/components/preferences/dialogs/languages.xhtml @@ -0,0 +1,107 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="webpage-languages-window2" + data-l10n-attrs="title, style" + persist="lastSelected" + onload="gLanguagesDialog.onLoad();" +> + <dialog + id="LanguagesDialog" + buttons="accept,cancel,help" + helpTopic="prefs-languages" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/preferences/languages.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + <script src="chrome://browser/content/preferences/dialogs/languages.js" /> + + <keyset> + <key + data-l10n-id="languages-close-key" + modifiers="accel" + oncommand="Preferences.close(event)" + /> + </keyset> + + <stringbundleset id="languageSet"> + <stringbundle + id="bundleAccepted" + src="resource://gre/res/language.properties" + /> + </stringbundleset> + + <description data-l10n-id="languages-description" /> + <checkbox + id="spoofEnglish" + data-l10n-id="languages-customize-spoof-english" + preference="privacy.spoof_english" + /> + <box class="languages-grid"> + <richlistbox + id="activeLanguages" + seltype="multiple" + onselect="gLanguagesDialog.onLanguageSelect();" + /> + <vbox> + <button + id="up" + class="up" + oncommand="gLanguagesDialog.moveUp();" + disabled="true" + data-l10n-id="languages-customize-moveup" + preference="pref.browser.language.disable_button.up" + /> + <button + id="down" + class="down" + oncommand="gLanguagesDialog.moveDown();" + disabled="true" + data-l10n-id="languages-customize-movedown" + preference="pref.browser.language.disable_button.down" + /> + <button + id="remove" + oncommand="gLanguagesDialog.removeLanguage();" + disabled="true" + data-l10n-id="languages-customize-remove" + preference="pref.browser.language.disable_button.remove" + /> + </vbox> + <!-- This <vbox> is needed to position search tooltips correctly. --> + <vbox> + <menulist + id="availableLanguages" + oncommand="gLanguagesDialog.onAvailableLanguageSelect();" + data-l10n-id="languages-customize-select-language" + data-l10n-attrs="placeholder" + > + <menupopup id="availableLanguagesPopup" /> + </menulist> + </vbox> + <button + id="addButton" + class="add-web-language" + oncommand="gLanguagesDialog.addLanguage();" + disabled="true" + data-l10n-id="languages-customize-add" + /> + </box> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/moz.build b/browser/components/preferences/dialogs/moz.build new file mode 100644 index 0000000000..603c560505 --- /dev/null +++ b/browser/components/preferences/dialogs/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +for var in ("MOZ_APP_NAME", "MOZ_MACBUNDLE_NAME"): + DEFINES[var] = CONFIG[var] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"): + DEFINES["HAVE_SHELL_SERVICE"] = 1 + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/components/preferences/dialogs/permissions.js b/browser/components/preferences/dialogs/permissions.js new file mode 100644 index 0000000000..32ebeae128 --- /dev/null +++ b/browser/components/preferences/dialogs/permissions.js @@ -0,0 +1,673 @@ +/* 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/. */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "contentBlockingAllowList", + "@mozilla.org/content-blocking-allow-list;1", + "nsIContentBlockingAllowList" +); + +const permissionExceptionsL10n = { + trackingprotection: { + window: "permissions-exceptions-etp-window2", + description: "permissions-exceptions-manage-etp-desc", + }, + cookie: { + window: "permissions-exceptions-cookie-window2", + description: "permissions-exceptions-cookie-desc", + }, + popup: { + window: "permissions-exceptions-popup-window2", + description: "permissions-exceptions-popup-desc", + }, + "login-saving": { + window: "permissions-exceptions-saved-passwords-window", + description: "permissions-exceptions-saved-passwords-desc", + }, + "https-only-load-insecure": { + window: "permissions-exceptions-https-only-window2", + description: "permissions-exceptions-https-only-desc2", + }, + install: { + window: "permissions-exceptions-addons-window2", + description: "permissions-exceptions-addons-desc", + }, +}; + +function Permission(principal, type, capability) { + this.principal = principal; + this.origin = principal.origin; + this.type = type; + this.capability = capability; +} + +var gPermissionManager = { + _type: "", + _isObserving: false, + _permissions: new Map(), + _permissionsToAdd: new Map(), + _permissionsToDelete: new Map(), + _bundle: null, + _list: null, + _removeButton: null, + _removeAllButton: null, + _forcedHTTP: null, + + onLoad() { + let params = window.arguments[0]; + document.mozSubdialogReady = this.init(params); + }, + + /** + * @param {Object} params + * @param {string} params.permissionType Permission type for which the dialog should be shown + * @param {string} params.prefilledHost The value which the URL field should initially contain + * @param {boolean} params.blockVisible Display the "Block" button in the dialog + * @param {boolean} params.sessionVisible Display the "Allow for Session" button in the dialog (Only for Cookie & HTTPS-Only permissions) + * @param {boolean} params.allowVisible Display the "Allow" button in the dialog + * @param {boolean} params.disableETPVisible Display the "Add Exception" button in the dialog (Only for ETP permissions) + * @param {boolean} params.hideStatusColumn Hide the "Status" column in the dialog + * @param {boolean} params.forcedHTTP Save inputs whose URI has a HTTPS scheme with a HTTP scheme (Used by HTTPS-Only) + */ + async init(params) { + if (!this._isObserving) { + Services.obs.addObserver(this, "perm-changed"); + this._isObserving = true; + } + + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + + this._type = params.permissionType; + this._list = document.getElementById("permissionsBox"); + this._removeButton = document.getElementById("removePermission"); + this._removeAllButton = document.getElementById("removeAllPermissions"); + + this._btnCookieSession = document.getElementById("btnCookieSession"); + this._btnBlock = document.getElementById("btnBlock"); + this._btnDisableETP = document.getElementById("btnDisableETP"); + this._btnAllow = document.getElementById("btnAllow"); + this._btnHttpsOnlyOff = document.getElementById("btnHttpsOnlyOff"); + this._btnHttpsOnlyOffTmp = document.getElementById("btnHttpsOnlyOffTmp"); + + let permissionsText = document.getElementById("permissionsText"); + + let l10n = permissionExceptionsL10n[this._type]; + document.l10n.setAttributes(permissionsText, l10n.description); + document.l10n.setAttributes(document.documentElement, l10n.window); + + let urlFieldVisible = + params.blockVisible || + params.sessionVisible || + params.allowVisible || + params.disableETPVisible; + + this._urlField = document.getElementById("url"); + this._urlField.value = params.prefilledHost; + this._urlField.hidden = !urlFieldVisible; + + this._forcedHTTP = params.forcedHTTP; + + await document.l10n.translateElements([ + permissionsText, + document.documentElement, + ]); + + document.getElementById("btnDisableETP").hidden = !params.disableETPVisible; + document.getElementById("btnBlock").hidden = !params.blockVisible; + document.getElementById("btnCookieSession").hidden = !( + params.sessionVisible && this._type == "cookie" + ); + document.getElementById("btnHttpsOnlyOff").hidden = !( + this._type == "https-only-load-insecure" + ); + document.getElementById("btnHttpsOnlyOffTmp").hidden = !( + params.sessionVisible && this._type == "https-only-load-insecure" + ); + document.getElementById("btnAllow").hidden = !params.allowVisible; + + this.onHostInput(this._urlField); + + let urlLabel = document.getElementById("urlLabel"); + urlLabel.hidden = !urlFieldVisible; + + this._hideStatusColumn = params.hideStatusColumn; + let statusCol = document.getElementById("statusCol"); + statusCol.hidden = this._hideStatusColumn; + if (this._hideStatusColumn) { + statusCol.removeAttribute("data-isCurrentSortCol"); + document + .getElementById("siteCol") + .setAttribute("data-isCurrentSortCol", "true"); + } + + Services.obs.notifyObservers(null, "flush-pending-permissions", this._type); + + this._loadPermissions(); + this.buildPermissionsList(); + + this._urlField.focus(); + }, + + uninit() { + if (this._isObserving) { + Services.obs.removeObserver(this, "perm-changed"); + this._isObserving = false; + } + }, + + observe(subject, topic, data) { + if (topic !== "perm-changed") { + return; + } + + let permission = subject.QueryInterface(Ci.nsIPermission); + + // Ignore unrelated permission types. + if (permission.type !== this._type) { + return; + } + + if (data == "added") { + this._addPermissionToList(permission); + this.buildPermissionsList(); + } else if (data == "changed") { + let p = this._permissions.get(permission.principal.origin); + // Maybe this item has been excluded before because it had an invalid capability. + if (p) { + p.capability = permission.capability; + this._handleCapabilityChange(p); + } else { + this._addPermissionToList(permission); + } + this.buildPermissionsList(); + } else if (data == "deleted") { + this._removePermissionFromList(permission.principal.origin); + } + }, + + _handleCapabilityChange(perm) { + let permissionlistitem = document.getElementsByAttribute( + "origin", + perm.origin + )[0]; + document.l10n.setAttributes( + permissionlistitem.querySelector(".website-capability-value"), + this._getCapabilityL10nId(perm.capability) + ); + }, + + _isCapabilitySupported(capability) { + return ( + capability == Ci.nsIPermissionManager.ALLOW_ACTION || + capability == Ci.nsIPermissionManager.DENY_ACTION || + capability == Ci.nsICookiePermission.ACCESS_SESSION || + // Bug 1753600 there are still a few legacy cookies around that have the capability 9, + // _getCapabilityL10nId will throw if it receives a capability of 9 + // that is not in combination with the type https-only-load-insecure + (capability == + Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION && + this._type == "https-only-load-insecure") + ); + }, + + _getCapabilityL10nId(capability) { + // HTTPS-Only Mode phrases exceptions as turning it off + if (this._type == "https-only-load-insecure") { + return this._getHttpsOnlyCapabilityL10nId(capability); + } + + switch (capability) { + case Ci.nsIPermissionManager.ALLOW_ACTION: + return "permissions-capabilities-listitem-allow"; + case Ci.nsIPermissionManager.DENY_ACTION: + return "permissions-capabilities-listitem-block"; + case Ci.nsICookiePermission.ACCESS_SESSION: + return "permissions-capabilities-listitem-allow-session"; + default: + throw new Error(`Unknown capability: ${capability}`); + } + }, + + _getHttpsOnlyCapabilityL10nId(capability) { + switch (capability) { + case Ci.nsIPermissionManager.ALLOW_ACTION: + return "permissions-capabilities-listitem-off"; + case Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION: + return "permissions-capabilities-listitem-off-temporarily"; + default: + throw new Error(`Unknown HTTPS-Only Mode capability: ${capability}`); + } + }, + + _addPermissionToList(perm) { + if (perm.type !== this._type) { + return; + } + if (!this._isCapabilitySupported(perm.capability)) { + return; + } + + // Skip private browsing session permissions. + if ( + perm.principal.privateBrowsingId !== + Services.scriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID && + perm.expireType === Services.perms.EXPIRE_SESSION + ) { + return; + } + + let p = new Permission(perm.principal, perm.type, perm.capability); + this._permissions.set(p.origin, p); + }, + + _addOrModifyPermission(principal, capability) { + // check whether the permission already exists, if not, add it + let permissionParams = { principal, type: this._type, capability }; + let existingPermission = this._permissions.get(principal.origin); + if (!existingPermission) { + this._permissionsToAdd.set(principal.origin, permissionParams); + this._addPermissionToList(permissionParams); + this.buildPermissionsList(); + } else if (existingPermission.capability != capability) { + existingPermission.capability = capability; + this._permissionsToAdd.set(principal.origin, permissionParams); + this._handleCapabilityChange(existingPermission); + } + }, + + _addNewPrincipalToList(list, uri) { + list.push(Services.scriptSecurityManager.createContentPrincipal(uri, {})); + // If we have ended up with an unknown scheme, the following will throw. + list[list.length - 1].origin; + }, + + addPermission(capability) { + let textbox = document.getElementById("url"); + let input_url = textbox.value.trim(); // trim any leading and trailing space + let principals = []; + try { + // The origin accessor on the principal object will throw if the + // principal doesn't have a canonical origin representation. This will + // help catch cases where the URI parser parsed something like + // `localhost:8080` as having the scheme `localhost`, rather than being + // an invalid URI. A canonical origin representation is required by the + // permission manager for storage, so this won't prevent any valid + // permissions from being entered by the user. + try { + let uri = Services.io.newURI(input_url); + if (this._forcedHTTP && uri.schemeIs("https")) { + uri = uri.mutate().setScheme("http").finalize(); + } + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + if (principal.origin.startsWith("moz-nullprincipal:")) { + throw new Error("Null principal"); + } + principals.push(principal); + } catch (ex) { + // If the `input_url` already starts with http:// or https://, it is + // definetely invalid here and can't be fixed by prefixing it with + // http:// or https://. + if ( + input_url.startsWith("http://") || + input_url.startsWith("https://") + ) { + throw ex; + } + this._addNewPrincipalToList( + principals, + Services.io.newURI("http://" + input_url) + ); + if (!this._forcedHTTP) { + this._addNewPrincipalToList( + principals, + Services.io.newURI("https://" + input_url) + ); + } + } + } catch (ex) { + document.l10n + .formatValues([ + { id: "permissions-invalid-uri-title" }, + { id: "permissions-invalid-uri-label" }, + ]) + .then(([title, message]) => { + Services.prompt.alert(window, title, message); + }); + return; + } + // In case of an ETP exception we compute the contentBlockingAllowList principal + // to align with the allow list behavior triggered by the protections panel + if (this._type == "trackingprotection") { + principals = principals.map( + lazy.contentBlockingAllowList.computeContentBlockingAllowListPrincipal + ); + } + for (let principal of principals) { + this._addOrModifyPermission(principal, capability); + } + + textbox.value = ""; + textbox.focus(); + + // covers a case where the site exists already, so the buttons don't disable + this.onHostInput(textbox); + + // enable "remove all" button as needed + this._setRemoveButtonState(); + }, + + _removePermission(permission) { + this._removePermissionFromList(permission.origin); + + // If this permission was added during this session, let's remove + // it from the pending adds list to prevent calls to the + // permission manager. + let isNewPermission = this._permissionsToAdd.delete(permission.origin); + if (!isNewPermission) { + this._permissionsToDelete.set(permission.origin, permission); + } + }, + + _removePermissionFromList(origin) { + this._permissions.delete(origin); + let permissionlistitem = document.getElementsByAttribute( + "origin", + origin + )[0]; + if (permissionlistitem) { + permissionlistitem.remove(); + } + }, + + _loadPermissions() { + // load permissions into a table. + for (let nextPermission of Services.perms.all) { + this._addPermissionToList(nextPermission); + } + }, + + _createPermissionListItem(permission) { + let disabledByPolicy = this._permissionDisabledByPolicy(permission); + let richlistitem = document.createXULElement("richlistitem"); + richlistitem.setAttribute("origin", permission.origin); + let row = document.createXULElement("hbox"); + row.setAttribute("style", "flex: 1"); + + let hbox = document.createXULElement("hbox"); + let website = document.createXULElement("label"); + website.setAttribute("disabled", disabledByPolicy); + website.setAttribute("class", "website-name-value"); + website.setAttribute("value", permission.origin); + hbox.setAttribute("class", "website-name"); + hbox.setAttribute("style", "flex: 3 3; width: 0"); + hbox.appendChild(website); + row.appendChild(hbox); + + if (!this._hideStatusColumn) { + hbox = document.createXULElement("hbox"); + let capability = document.createXULElement("label"); + capability.setAttribute("disabled", disabledByPolicy); + capability.setAttribute("class", "website-capability-value"); + document.l10n.setAttributes( + capability, + this._getCapabilityL10nId(permission.capability) + ); + hbox.setAttribute("class", "website-name"); + hbox.setAttribute("style", "flex: 1; width: 0"); + hbox.appendChild(capability); + row.appendChild(hbox); + } + + richlistitem.appendChild(row); + return richlistitem; + }, + + onWindowKeyPress(event) { + // Prevent dialog.js from closing the dialog when the user submits the input + // field via the return key. + if ( + event.keyCode == KeyEvent.DOM_VK_RETURN && + document.activeElement == this._urlField + ) { + event.preventDefault(); + } + }, + + onPermissionKeyPress(event) { + if (!this._list.selectedItem) { + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE) + ) { + this.onPermissionDelete(); + event.preventDefault(); + } + }, + + onHostKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + if (!document.getElementById("btnAllow").hidden) { + document.getElementById("btnAllow").click(); + } else if (!document.getElementById("btnBlock").hidden) { + document.getElementById("btnBlock").click(); + } else if (!document.getElementById("btnHttpsOnlyOff").hidden) { + document.getElementById("btnHttpsOnlyOff").click(); + } else if (!document.getElementById("btnDisableETP").hidden) { + document.getElementById("btnDisableETP").click(); + } + } + }, + + onHostInput(siteField) { + this._btnCookieSession.disabled = + this._btnCookieSession.hidden || !siteField.value; + this._btnHttpsOnlyOff.disabled = + this._btnHttpsOnlyOff.hidden || !siteField.value; + this._btnHttpsOnlyOffTmp.disabled = + this._btnHttpsOnlyOffTmp.hidden || !siteField.value; + this._btnBlock.disabled = this._btnBlock.hidden || !siteField.value; + this._btnDisableETP.disabled = + this._btnDisableETP.hidden || !siteField.value; + this._btnAllow.disabled = this._btnAllow.hidden || !siteField.value; + }, + + _setRemoveButtonState() { + if (!this._list) { + return; + } + + let hasSelection = this._list.selectedIndex >= 0; + + let disabledByPolicy = false; + if (Services.policies.status === Services.policies.ACTIVE && hasSelection) { + let origin = this._list.selectedItem.getAttribute("origin"); + disabledByPolicy = this._permissionDisabledByPolicy( + this._permissions.get(origin) + ); + } + + this._removeButton.disabled = !hasSelection || disabledByPolicy; + let disabledItems = this._list.querySelectorAll( + "label.website-name-value[disabled='true']" + ); + + this._removeAllButton.disabled = + this._list.itemCount == disabledItems.length; + }, + + onPermissionDelete() { + let richlistitem = this._list.selectedItem; + let origin = richlistitem.getAttribute("origin"); + let permission = this._permissions.get(origin); + if (this._permissionDisabledByPolicy(permission)) { + return; + } + + this._removePermission(permission); + + this._setRemoveButtonState(); + }, + + onAllPermissionsDelete() { + for (let permission of this._permissions.values()) { + if (this._permissionDisabledByPolicy(permission)) { + continue; + } + this._removePermission(permission); + } + + this._setRemoveButtonState(); + }, + + onPermissionSelect() { + this._setRemoveButtonState(); + }, + + onApplyChanges() { + // Stop observing permission changes since we are about + // to write out the pending adds/deletes and don't need + // to update the UI + this.uninit(); + + for (let p of this._permissionsToDelete.values()) { + Services.perms.removeFromPrincipal(p.principal, p.type); + } + + for (let p of this._permissionsToAdd.values()) { + // If this sets the HTTPS-Only exemption only for this + // session, then the expire-type has to be set. + if ( + p.capability == + Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION + ) { + Services.perms.addFromPrincipal( + p.principal, + p.type, + p.capability, + Ci.nsIPermissionManager.EXPIRE_SESSION + ); + } else { + Services.perms.addFromPrincipal(p.principal, p.type, p.capability); + } + } + }, + + buildPermissionsList(sortCol) { + // Clear old entries. + let oldItems = this._list.querySelectorAll("richlistitem"); + for (let item of oldItems) { + item.remove(); + } + let frag = document.createDocumentFragment(); + + let permissions = Array.from(this._permissions.values()); + + for (let permission of permissions) { + let richlistitem = this._createPermissionListItem(permission); + frag.appendChild(richlistitem); + } + + // Sort permissions. + this._sortPermissions(this._list, frag, sortCol); + + this._list.appendChild(frag); + + this._setRemoveButtonState(); + }, + + _permissionDisabledByPolicy(permission) { + let permissionObject = Services.perms.getPermissionObject( + permission.principal, + this._type, + false + ); + return ( + permissionObject?.expireType == Ci.nsIPermissionManager.EXPIRE_POLICY + ); + }, + + _sortPermissions(list, frag, column) { + let sortDirection; + + if (!column) { + column = document.querySelector("treecol[data-isCurrentSortCol=true]"); + sortDirection = + column.getAttribute("data-last-sortDirection") || "ascending"; + } else { + sortDirection = column.getAttribute("data-last-sortDirection"); + sortDirection = + sortDirection === "ascending" ? "descending" : "ascending"; + } + + let sortFunc = null; + switch (column.id) { + case "siteCol": + sortFunc = (a, b) => { + return comp.compare( + a.getAttribute("origin"), + b.getAttribute("origin") + ); + }; + break; + + case "statusCol": + sortFunc = (a, b) => { + // The capabilities values ("Allow" and "Block") are localized asynchronously. + // Sort based on the guaranteed-present localization ID instead, note that the + // ascending/descending arrow may be pointing the wrong way. + return ( + a + .querySelector(".website-capability-value") + .getAttribute("data-l10n-id") > + b + .querySelector(".website-capability-value") + .getAttribute("data-l10n-id") + ); + }; + break; + } + + let comp = new Services.intl.Collator(undefined, { + usage: "sort", + }); + + let items = Array.from(frag.querySelectorAll("richlistitem")); + + if (sortDirection === "descending") { + items.sort((a, b) => sortFunc(b, a)); + } else { + items.sort(sortFunc); + } + + // Re-append items in the correct order: + items.forEach(item => frag.appendChild(item)); + + let cols = list.previousElementSibling.querySelectorAll("treecol"); + cols.forEach(c => { + c.removeAttribute("data-isCurrentSortCol"); + c.removeAttribute("sortDirection"); + }); + column.setAttribute("data-isCurrentSortCol", "true"); + column.setAttribute("sortDirection", sortDirection); + column.setAttribute("data-last-sortDirection", sortDirection); + }, +}; diff --git a/browser/components/preferences/dialogs/permissions.xhtml b/browser/components/preferences/dialogs/permissions.xhtml new file mode 100644 index 0000000000..78c2461e2d --- /dev/null +++ b/browser/components/preferences/dialogs/permissions.xhtml @@ -0,0 +1,140 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="PermissionsDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="permissions-window2" + data-l10n-attrs="title, style" + onload="gPermissionManager.onLoad();" + onunload="gPermissionManager.uninit();" + persist="width height" + onkeypress="gPermissionManager.onWindowKeyPress(event);" +> + <dialog + buttons="accept,cancel" + data-l10n-id="permission-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/content/preferences/dialogs/sitePermissions.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link + rel="localization" + href="browser/preferences/permissions.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/permissions.js" /> + + <keyset> + <key + data-l10n-id="permissions-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <description id="permissionsText" control="url" /> + <separator class="thin" /> + <label id="urlLabel" control="url" data-l10n-id="permissions-address" /> + <hbox align="start"> + <html:input + id="url" + type="text" + style="flex: 1" + oninput="gPermissionManager.onHostInput(event.target);" + onkeypress="gPermissionManager.onHostKeyPress(event);" + /> + </hbox> + <hbox pack="end"> + <button + id="btnDisableETP" + disabled="true" + data-l10n-id="permissions-disable-etp" + oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.ALLOW_ACTION);" + /> + <button + id="btnBlock" + disabled="true" + data-l10n-id="permissions-block" + oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.DENY_ACTION);" + /> + <button + id="btnCookieSession" + disabled="true" + data-l10n-id="permissions-session" + oncommand="gPermissionManager.addPermission(Ci.nsICookiePermission.ACCESS_SESSION);" + /> + <button + id="btnAllow" + disabled="true" + data-l10n-id="permissions-allow" + oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.ALLOW_ACTION);" + /> + <button + id="btnHttpsOnlyOff" + disabled="true" + data-l10n-id="permissions-button-off" + oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.ALLOW_ACTION);" + /> + <button + id="btnHttpsOnlyOffTmp" + disabled="true" + data-l10n-id="permissions-button-off-temporarily" + oncommand="gPermissionManager.addPermission(Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION);" + /> + </hbox> + <separator class="thin" /> + <listheader> + <treecol + id="siteCol" + data-l10n-id="permissions-site-name" + style="flex: 3 3 auto; width: 0" + onclick="gPermissionManager.buildPermissionsList(event.target)" + /> + <treecol + id="statusCol" + data-l10n-id="permissions-status" + style="flex: 1 1 auto; width: 0" + data-isCurrentSortCol="true" + onclick="gPermissionManager.buildPermissionsList(event.target);" + /> + </listheader> + <richlistbox + id="permissionsBox" + selected="false" + onkeypress="gPermissionManager.onPermissionKeyPress(event);" + onselect="gPermissionManager.onPermissionSelect();" + /> + </vbox> + + <hbox class="actionButtons"> + <button + id="removePermission" + disabled="true" + data-l10n-id="permissions-remove" + oncommand="gPermissionManager.onPermissionDelete();" + /> + <button + id="removeAllPermissions" + data-l10n-id="permissions-remove-all" + oncommand="gPermissionManager.onAllPermissionsDelete();" + /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/sanitize.js b/browser/components/preferences/dialogs/sanitize.js new file mode 100644 index 0000000000..37f818e011 --- /dev/null +++ b/browser/components/preferences/dialogs/sanitize.js @@ -0,0 +1,38 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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-globals-from /toolkit/content/preferencesBindings.js */ + +document + .querySelector("dialog") + .addEventListener("dialoghelp", window.top.openPrefsHelp); + +Preferences.addAll([ + { id: "privacy.clearOnShutdown.history", type: "bool" }, + { id: "privacy.clearOnShutdown.formdata", type: "bool" }, + { id: "privacy.clearOnShutdown.downloads", type: "bool" }, + { id: "privacy.clearOnShutdown.cookies", type: "bool" }, + { id: "privacy.clearOnShutdown.cache", type: "bool" }, + { id: "privacy.clearOnShutdown.offlineApps", type: "bool" }, + { id: "privacy.clearOnShutdown.sessions", type: "bool" }, + { id: "privacy.clearOnShutdown.siteSettings", type: "bool" }, +]); + +var gSanitizeDialog = Object.freeze({ + init() { + this.onClearHistoryChanged(); + + Preferences.get("privacy.clearOnShutdown.history").on( + "change", + this.onClearHistoryChanged.bind(this) + ); + }, + + onClearHistoryChanged() { + let downloadsPref = Preferences.get("privacy.clearOnShutdown.downloads"); + let historyPref = Preferences.get("privacy.clearOnShutdown.history"); + downloadsPref.value = historyPref.value; + }, +}); diff --git a/browser/components/preferences/dialogs/sanitize.xhtml b/browser/components/preferences/dialogs/sanitize.xhtml new file mode 100644 index 0000000000..cd2bea0649 --- /dev/null +++ b/browser/components/preferences/dialogs/sanitize.xhtml @@ -0,0 +1,94 @@ +<?xml version="1.0"?> + +<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- --> +<!-- 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/. --> + +<!DOCTYPE window> + +<window + id="SanitizeDialog" + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + persist="lastSelected" + data-l10n-id="sanitize-prefs2" + data-l10n-attrs="style" + onload="gSanitizeDialog.init();" +> + <dialog buttons="accept,cancel,help" helpTopic="prefs-clear-private-data"> + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/sanitize.ftl" /> + <html:link rel="localization" href="branding/brand.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/preferencesBindings.js" /> + + <keyset> + <key + data-l10n-id="window-close" + modifiers="accel" + oncommand="Preferences.close(event)" + /> + </keyset> + + <script src="chrome://browser/content/preferences/dialogs/sanitize.js" /> + + <description data-l10n-id="clear-data-settings-label"></description> + + <groupbox> + <label><html:h2 data-l10n-id="history-section-label" /></label> + <hbox> + <vbox data-l10n-id="sanitize-prefs-style" data-l10n-attrs="style"> + <checkbox + data-l10n-id="item-history-and-downloads" + preference="privacy.clearOnShutdown.history" + /> + <checkbox + data-l10n-id="item-active-logins" + preference="privacy.clearOnShutdown.sessions" + /> + <checkbox + data-l10n-id="item-form-search-history" + preference="privacy.clearOnShutdown.formdata" + /> + </vbox> + <vbox> + <checkbox + data-l10n-id="item-cookies" + preference="privacy.clearOnShutdown.cookies" + /> + <checkbox + data-l10n-id="item-cache" + preference="privacy.clearOnShutdown.cache" + /> + </vbox> + </hbox> + </groupbox> + <groupbox> + <label><html:h2 data-l10n-id="data-section-label" /></label> + <hbox> + <vbox data-l10n-id="sanitize-prefs-style" data-l10n-attrs="style"> + <checkbox + data-l10n-id="item-site-settings" + preference="privacy.clearOnShutdown.siteSettings" + /> + </vbox> + <vbox flex="1"> + <checkbox + data-l10n-id="item-offline-apps" + preference="privacy.clearOnShutdown.offlineApps" + /> + </vbox> + </hbox> + </groupbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/selectBookmark.js b/browser/components/preferences/dialogs/selectBookmark.js new file mode 100644 index 0000000000..5fe4c645e7 --- /dev/null +++ b/browser/components/preferences/dialogs/selectBookmark.js @@ -0,0 +1,119 @@ +//* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +/** + * SelectBookmarkDialog controls the user interface for the "Use Bookmark for + * Home Page" dialog. + * + * The caller (gMainPane.setHomePageToBookmark in main.js) invokes this dialog + * with a single argument - a reference to an object with a .urls property and + * a .names property. This dialog is responsible for updating the contents of + * the .urls property with an array of URLs to use as home pages and for + * updating the .names property with an array of names for those URLs before it + * closes. + */ +var SelectBookmarkDialog = { + init: function SBD_init() { + document.getElementById("bookmarks").place = + "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; + + // Initial update of the OK button. + this.selectionChanged(); + document.addEventListener("dialogaccept", function () { + SelectBookmarkDialog.accept(); + }); + }, + + /** + * Update the disabled state of the OK button as the user changes the + * selection within the view. + */ + selectionChanged: function SBD_selectionChanged() { + var accept = document + .getElementById("selectBookmarkDialog") + .getButton("accept"); + var bookmarks = document.getElementById("bookmarks"); + var disableAcceptButton = true; + if (bookmarks.hasSelection) { + if (!PlacesUtils.nodeIsSeparator(bookmarks.selectedNode)) { + disableAcceptButton = false; + } + } + accept.disabled = disableAcceptButton; + }, + + onItemDblClick: function SBD_onItemDblClick() { + var bookmarks = document.getElementById("bookmarks"); + var selectedNode = bookmarks.selectedNode; + if (selectedNode && PlacesUtils.nodeIsURI(selectedNode)) { + /** + * The user has double clicked on a tree row that is a link. Take this to + * mean that they want that link to be their homepage, and close the dialog. + */ + document + .getElementById("selectBookmarkDialog") + .getButton("accept") + .click(); + } + }, + + /** + * User accepts their selection. Set all the selected URLs or the contents + * of the selected folder as the list of homepages. + */ + accept: function SBD_accept() { + var bookmarks = document.getElementById("bookmarks"); + if (!bookmarks.hasSelection) { + throw new Error( + "Should not be able to accept dialog if there is no selected URL!" + ); + } + var urls = []; + var names = []; + var selectedNode = bookmarks.selectedNode; + if (PlacesUtils.nodeIsFolder(selectedNode)) { + let concreteGuid = PlacesUtils.getConcreteItemGuid(selectedNode); + var contents = PlacesUtils.getFolderContents(concreteGuid).root; + var cc = contents.childCount; + for (var i = 0; i < cc; ++i) { + var node = contents.getChild(i); + if (PlacesUtils.nodeIsURI(node)) { + urls.push(node.uri); + names.push(node.title); + } + } + contents.containerOpen = false; + } else { + urls.push(selectedNode.uri); + names.push(selectedNode.title); + } + window.arguments[0].urls = urls; + window.arguments[0].names = names; + }, +}; diff --git a/browser/components/preferences/dialogs/selectBookmark.xhtml b/browser/components/preferences/dialogs/selectBookmark.xhtml new file mode 100644 index 0000000000..34317467bf --- /dev/null +++ b/browser/components/preferences/dialogs/selectBookmark.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="select-bookmark-window2" + data-l10n-attrs="title, style" + persist="width height" + onload="SelectBookmarkDialog.init();" +> + <dialog id="selectBookmarkDialog"> + <linkset> + <html:link + rel="stylesheet" + href="chrome://browser/content/places/places.css" + /> + + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/tree-icons.css" + /> + + <html:link + rel="localization" + href="browser/preferences/selectBookmark.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/selectBookmark.js" /> + <script src="chrome://global/content/globalOverlay.js" /> + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://browser/content/places/places-tree.js" /> + + <description data-l10n-id="select-bookmark-desc" /> + + <separator class="thin" /> + + <tree + id="bookmarks" + flex="1" + is="places-tree" + style="height: 15em" + hidecolumnpicker="true" + seltype="single" + ondblclick="SelectBookmarkDialog.onItemDblClick();" + onselect="SelectBookmarkDialog.selectionChanged();" + disableUserActions="true" + > + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true" /> + </treecols> + <treechildren id="bookmarksChildren" flex="1" /> + </tree> + + <separator class="thin" /> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/siteDataRemoveSelected.js b/browser/components/preferences/dialogs/siteDataRemoveSelected.js new file mode 100644 index 0000000000..e722a2d826 --- /dev/null +++ b/browser/components/preferences/dialogs/siteDataRemoveSelected.js @@ -0,0 +1,56 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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"; + +/** + * This dialog will ask the user to confirm that they really want to delete all + * site data for a number of hosts. + **/ +let gSiteDataRemoveSelected = { + init() { + document.addEventListener("dialogaccept", function () { + window.arguments[0].allowed = true; + }); + document.addEventListener("dialogcancel", function () { + window.arguments[0].allowed = false; + }); + + let list = document.getElementById("removalList"); + + let hosts = window.arguments[0].hosts; + + if (!hosts) { + throw new Error("Must specify hosts option in arguments."); + } + let dialog = document.getElementById("SiteDataRemoveSelectedDialog"); + if (hosts.length == 1) { + dialog.classList.add("single-entry"); + document.l10n.setAttributes( + document.getElementById("removing-description"), + "site-data-removing-single-desc", + { + baseDomain: hosts[0], + } + ); + return; + } + dialog.classList.add("multi-entry"); + hosts.sort(); + let fragment = document.createDocumentFragment(); + for (let host of hosts) { + let listItem = document.createXULElement("richlistitem"); + let label = document.createXULElement("label"); + if (host) { + label.setAttribute("value", host); + } else { + document.l10n.setAttributes(label, "site-data-local-file-host"); + } + listItem.appendChild(label); + fragment.appendChild(listItem); + } + list.appendChild(fragment); + }, +}; diff --git a/browser/components/preferences/dialogs/siteDataRemoveSelected.xhtml b/browser/components/preferences/dialogs/siteDataRemoveSelected.xhtml new file mode 100644 index 0000000000..7ab8391ce7 --- /dev/null +++ b/browser/components/preferences/dialogs/siteDataRemoveSelected.xhtml @@ -0,0 +1,51 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window id="SiteDataRemoveSelectedDialog" + width="500" + data-l10n-id="site-data-removing-dialog" + data-l10n-attrs="title" + onload="gSiteDataRemoveSelected.init();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> +<dialog data-l10n-id="site-data-removing-dialog" + data-l10n-attrs="buttonlabelaccept"> + + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/siteDataSettings.css" + /> + + <html:link rel="localization" href="browser/preferences/siteDataSettings.ftl"/> + </linkset> + + <hbox> + <vbox> + <image class="question-icon"/> + </vbox> + <vbox flex="1"> + <!-- Only show this label on OS X because of no dialog title --> + <label id="removing-label" + data-l10n-id="site-data-removing-header" +#ifndef XP_MACOSX + hidden="true" +#endif + /> + <separator class="thin"/> + <description id="removing-description" data-l10n-id="site-data-removing-desc"/> + </vbox> + </hbox> + <separator class="multi-site"/> + + <label data-l10n-id="site-data-removing-table" class="multi-site"/> + <separator class="thin multi-site"/> + <richlistbox id="removalList" class="theme-listbox multi-site" flex="1"/> + <!-- Load the script after the elements for layout issues (bug 1501755). --> + <script src="chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.js"/> +</dialog> +</window> diff --git a/browser/components/preferences/dialogs/siteDataSettings.js b/browser/components/preferences/dialogs/siteDataSettings.js new file mode 100644 index 0000000000..5323a3cf3b --- /dev/null +++ b/browser/components/preferences/dialogs/siteDataSettings.js @@ -0,0 +1,331 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + SiteDataManager: "resource:///modules/SiteDataManager.sys.mjs", +}); + +let gSiteDataSettings = { + // Array of metadata of sites. Each array element is object holding: + // - uri: uri of site; instance of nsIURI + // - baseDomain: base domain of the site + // - cookies: array of cookies of that site + // - usage: disk usage which site uses + // - userAction: "remove" or "update-permission"; the action user wants to take. + _sites: null, + + _list: null, + _searchBox: null, + + _createSiteListItem(site) { + let item = document.createXULElement("richlistitem"); + item.setAttribute("host", site.baseDomain); + let container = document.createXULElement("hbox"); + + // Creates a new column item with the specified relative width. + function addColumnItem(l10n, flexWidth, tooltipText) { + let box = document.createXULElement("hbox"); + box.className = "item-box"; + box.setAttribute("style", `flex: ${flexWidth} ${flexWidth};`); + let label = document.createXULElement("label"); + label.setAttribute("crop", "end"); + if (l10n) { + if (l10n.hasOwnProperty("raw")) { + box.setAttribute("tooltiptext", l10n.raw); + label.setAttribute("value", l10n.raw); + } else { + document.l10n.setAttributes(label, l10n.id, l10n.args); + } + } + if (tooltipText) { + box.setAttribute("tooltiptext", tooltipText); + } + box.appendChild(label); + container.appendChild(box); + } + + // Add "Host" column. + let hostData = site.baseDomain + ? { raw: site.baseDomain } + : { id: "site-data-local-file-host" }; + addColumnItem(hostData, "4"); + + // Add "Cookies" column. + addColumnItem({ raw: site.cookies.length }, "1"); + + // Add "Storage" column + if (site.usage > 0 || site.persisted) { + let [value, unit] = DownloadUtils.convertByteUnits(site.usage); + let strName = site.persisted + ? "site-storage-persistent" + : "site-storage-usage"; + addColumnItem( + { + id: strName, + args: { value, unit }, + }, + "2" + ); + } else { + // Pass null to avoid showing "0KB" when there is no site data stored. + addColumnItem(null, "2"); + } + + // Add "Last Used" column. + let formattedLastAccessed = + site.lastAccessed > 0 + ? this._relativeTimeFormat.formatBestUnit(site.lastAccessed) + : null; + let formattedFullDate = + site.lastAccessed > 0 + ? this._absoluteTimeFormat.format(site.lastAccessed) + : null; + addColumnItem( + site.lastAccessed > 0 ? { raw: formattedLastAccessed } : null, + "2", + formattedFullDate + ); + + item.appendChild(container); + return item; + }, + + init() { + function setEventListener(id, eventType, callback) { + document + .getElementById(id) + .addEventListener(eventType, callback.bind(gSiteDataSettings)); + } + + this._absoluteTimeFormat = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "short", + }); + + this._relativeTimeFormat = new Services.intl.RelativeTimeFormat( + undefined, + {} + ); + + this._list = document.getElementById("sitesList"); + this._searchBox = document.getElementById("searchBox"); + SiteDataManager.getSites().then(sites => { + this._sites = sites; + let sortCol = document.querySelector( + "treecol[data-isCurrentSortCol=true]" + ); + this._sortSites(this._sites, sortCol); + this._buildSitesList(this._sites); + Services.obs.notifyObservers(null, "sitedata-settings-init"); + }); + + setEventListener("sitesList", "select", this.onSelect); + setEventListener("hostCol", "click", this.onClickTreeCol); + setEventListener("usageCol", "click", this.onClickTreeCol); + setEventListener("lastAccessedCol", "click", this.onClickTreeCol); + setEventListener("cookiesCol", "click", this.onClickTreeCol); + setEventListener("searchBox", "command", this.onCommandSearch); + setEventListener("removeAll", "command", this.onClickRemoveAll); + setEventListener("removeSelected", "command", this.removeSelected); + + document.addEventListener("dialogaccept", e => this.saveChanges(e)); + }, + + _updateButtonsState() { + let items = this._list.getElementsByTagName("richlistitem"); + let removeSelectedBtn = document.getElementById("removeSelected"); + let removeAllBtn = document.getElementById("removeAll"); + removeSelectedBtn.disabled = !this._list.selectedItems.length; + removeAllBtn.disabled = !items.length; + + let l10nId = this._searchBox.value + ? "site-data-remove-shown" + : "site-data-remove-all"; + document.l10n.setAttributes(removeAllBtn, l10nId); + }, + + /** + * @param sites {Array} + * @param col {XULElement} the <treecol> being sorted on + */ + _sortSites(sites, col) { + let isCurrentSortCol = col.getAttribute("data-isCurrentSortCol"); + let sortDirection = + col.getAttribute("data-last-sortDirection") || "ascending"; + if (isCurrentSortCol) { + // Sort on the current column, flip the sorting direction + sortDirection = + sortDirection === "ascending" ? "descending" : "ascending"; + } + + let sortFunc = null; + switch (col.id) { + case "hostCol": + sortFunc = (a, b) => { + let aHost = a.baseDomain.toLowerCase(); + let bHost = b.baseDomain.toLowerCase(); + return aHost.localeCompare(bHost); + }; + break; + + case "cookiesCol": + sortFunc = (a, b) => a.cookies.length - b.cookies.length; + break; + + case "usageCol": + sortFunc = (a, b) => a.usage - b.usage; + break; + + case "lastAccessedCol": + sortFunc = (a, b) => a.lastAccessed - b.lastAccessed; + break; + } + if (sortDirection === "descending") { + sites.sort((a, b) => sortFunc(b, a)); + } else { + sites.sort(sortFunc); + } + + let cols = this._list.previousElementSibling.querySelectorAll("treecol"); + cols.forEach(c => { + c.removeAttribute("sortDirection"); + c.removeAttribute("data-isCurrentSortCol"); + }); + col.setAttribute("data-isCurrentSortCol", true); + col.setAttribute("sortDirection", sortDirection); + col.setAttribute("data-last-sortDirection", sortDirection); + }, + + /** + * @param sites {Array} array of metadata of sites + */ + _buildSitesList(sites) { + // Clear old entries. + let oldItems = this._list.querySelectorAll("richlistitem"); + for (let item of oldItems) { + item.remove(); + } + + let keyword = this._searchBox.value.toLowerCase().trim(); + let fragment = document.createDocumentFragment(); + for (let site of sites) { + if (keyword && !site.baseDomain.includes(keyword)) { + continue; + } + + if (site.userAction === "remove") { + continue; + } + + let item = this._createSiteListItem(site); + fragment.appendChild(item); + } + this._list.appendChild(fragment); + this._updateButtonsState(); + }, + + _removeSiteItems(items) { + for (let i = items.length - 1; i >= 0; --i) { + let item = items[i]; + let baseDomain = item.getAttribute("host"); + let siteForBaseDomain = this._sites.find( + site => site.baseDomain == baseDomain + ); + if (siteForBaseDomain) { + siteForBaseDomain.userAction = "remove"; + } + item.remove(); + } + this._updateButtonsState(); + }, + + async saveChanges(event) { + let removals = this._sites + .filter(site => site.userAction == "remove") + .map(site => site.baseDomain); + + if (removals.length) { + let removeAll = removals.length == this._sites.length; + let promptArg = removeAll ? undefined : removals; + if (!SiteDataManager.promptSiteDataRemoval(window, promptArg)) { + // If the user cancelled the confirm dialog keep the site data window open, + // they can still press cancel again to exit. + event.preventDefault(); + return; + } + try { + if (removeAll) { + await SiteDataManager.removeAll(); + } else { + await SiteDataManager.remove(removals); + } + } catch (e) { + console.error(e); + } + } + }, + + removeSelected() { + let lastIndex = this._list.selectedItems.length - 1; + let lastSelectedItem = this._list.selectedItems[lastIndex]; + let lastSelectedItemPosition = this._list.getIndexOfItem(lastSelectedItem); + let nextSelectedItem = this._list.getItemAtIndex( + lastSelectedItemPosition + 1 + ); + + this._removeSiteItems(this._list.selectedItems); + this._list.clearSelection(); + + if (nextSelectedItem) { + this._list.selectedItem = nextSelectedItem; + } else { + this._list.selectedIndex = this._list.itemCount - 1; + } + }, + + onClickTreeCol(e) { + this._sortSites(this._sites, e.target); + this._buildSitesList(this._sites); + this._list.clearSelection(); + }, + + onCommandSearch() { + this._buildSitesList(this._sites); + this._list.clearSelection(); + }, + + onClickRemoveAll() { + let siteItems = this._list.getElementsByTagName("richlistitem"); + if (siteItems.length) { + this._removeSiteItems(siteItems); + } + }, + + onKeyPress(e) { + if ( + e.keyCode == KeyEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + e.keyCode == KeyEvent.DOM_VK_BACK_SPACE) + ) { + if (!e.target.closest("#sitesList")) { + // The user is typing or has not selected an item from the list to remove + return; + } + // The users intention is to delete site data + this.removeSelected(); + } + }, + + onSelect() { + this._updateButtonsState(); + }, +}; diff --git a/browser/components/preferences/dialogs/siteDataSettings.xhtml b/browser/components/preferences/dialogs/siteDataSettings.xhtml new file mode 100644 index 0000000000..56c8238864 --- /dev/null +++ b/browser/components/preferences/dialogs/siteDataSettings.xhtml @@ -0,0 +1,92 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="SiteDataSettingsDialog" + data-l10n-id="site-data-settings-window" + data-l10n-attrs="title" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + style="min-width: 45em" + onload="gSiteDataSettings.init();" + onkeypress="gSiteDataSettings.onKeyPress(event);" + persist="width height" +> + <dialog + buttons="accept,cancel" + data-l10n-id="site-data-settings-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/siteDataSettings.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link + rel="localization" + href="browser/preferences/siteDataSettings.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/siteDataSettings.js" /> + + <vbox flex="1" class="contentPane"> + <description + id="settingsDescription" + data-l10n-id="site-data-settings-description" + /> + <separator class="thin" /> + + <hbox id="searchBoxContainer"> + <search-textbox + id="searchBox" + flex="1" + data-l10n-id="site-data-search-textbox" + data-l10n-attrs="placeholder" + /> + </hbox> + <separator class="thin" /> + + <listheader> + <treecol + style="flex: 4 4 auto; width: 50px" + data-l10n-id="site-data-column-host" + id="hostCol" + /> + <treecol + style="flex: 1 auto; width: 50px" + data-l10n-id="site-data-column-cookies" + id="cookiesCol" + /> + <!-- Sorted by usage so the user can quickly see which sites use the most data. --> + <treecol + style="flex: 2 2 auto; width: 50px" + data-l10n-id="site-data-column-storage" + id="usageCol" + data-isCurrentSortCol="true" + /> + <treecol + style="flex: 2 2 auto; width: 50px" + data-l10n-id="site-data-column-last-used" + id="lastAccessedCol" + /> + </listheader> + <richlistbox seltype="multiple" id="sitesList" orient="vertical" /> + </vbox> + + <hbox align="start"> + <button id="removeSelected" data-l10n-id="site-data-remove-selected" /> + <button id="removeAll" /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/sitePermissions.css b/browser/components/preferences/dialogs/sitePermissions.css new file mode 100644 index 0000000000..f9b7b447c7 --- /dev/null +++ b/browser/components/preferences/dialogs/sitePermissions.css @@ -0,0 +1,58 @@ +/* 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/. */ + +.website-name, +label.website-status { + overflow: hidden; + text-overflow: ellipsis; + padding-inline-start: 7px; +} + +#permissionsBox { + flex: 1 auto; + height: 18em; + min-height: 70px; /* 2 * 35px, which is the min row height specified below */ +} + +#siteCol, +#statusCol, +#permissionsBox > richlistitem { + min-height: 35px; +} + +#permissionsBox > richlistitem > hbox { + flex: 1; + align-items: center; +} + +#siteCol, +.website-name { + flex: 1; + width: 0; /* Don't make our intrinsic size affect our final size */ +} + +#statusCol, +.website-status { + width: 35%; +} + +menulist.website-status { + margin-block: 1px; + margin-inline: 0 5px; + width: calc(35% - 5px); +} + +#browserNotificationsPermissionExtensionContent, +#permissionsDisableDescription { + margin-inline-start: 32px; +} + +#permissionsDisableDescription { + color: var(--text-color-deemphasized); +} + +#permissionsDisableCheckbox { + margin-inline-start: 4px; + padding-top: 10px; +} diff --git a/browser/components/preferences/dialogs/sitePermissions.js b/browser/components/preferences/dialogs/sitePermissions.js new file mode 100644 index 0000000000..e23706a01d --- /dev/null +++ b/browser/components/preferences/dialogs/sitePermissions.js @@ -0,0 +1,679 @@ +/* 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-globals-from ../extensionControlled.js */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { SitePermissions } = ChromeUtils.importESModule( + "resource:///modules/SitePermissions.sys.mjs" +); + +const sitePermissionsL10n = { + "desktop-notification": { + window: "permissions-site-notification-window2", + description: "permissions-site-notification-desc", + disableLabel: "permissions-site-notification-disable-label", + disableDescription: "permissions-site-notification-disable-desc", + }, + geo: { + window: "permissions-site-location-window2", + description: "permissions-site-location-desc", + disableLabel: "permissions-site-location-disable-label", + disableDescription: "permissions-site-location-disable-desc", + }, + xr: { + window: "permissions-site-xr-window2", + description: "permissions-site-xr-desc", + disableLabel: "permissions-site-xr-disable-label", + disableDescription: "permissions-site-xr-disable-desc", + }, + camera: { + window: "permissions-site-camera-window2", + description: "permissions-site-camera-desc", + disableLabel: "permissions-site-camera-disable-label", + disableDescription: "permissions-site-camera-disable-desc", + }, + microphone: { + window: "permissions-site-microphone-window2", + description: "permissions-site-microphone-desc", + disableLabel: "permissions-site-microphone-disable-label", + disableDescription: "permissions-site-microphone-disable-desc", + }, + speaker: { + window: "permissions-site-speaker-window", + description: "permissions-site-speaker-desc", + }, + "autoplay-media": { + window: "permissions-site-autoplay-window2", + description: "permissions-site-autoplay-desc", + }, +}; + +const sitePermissionsConfig = { + "autoplay-media": { + _getCapabilityString(capability) { + switch (capability) { + case SitePermissions.ALLOW: + return "permissions-capabilities-autoplay-allow"; + case SitePermissions.BLOCK: + return "permissions-capabilities-autoplay-block"; + case SitePermissions.AUTOPLAY_BLOCKED_ALL: + return "permissions-capabilities-autoplay-blockall"; + } + throw new Error(`Unknown capability: ${capability}`); + }, + }, +}; + +// A set of permissions for a single origin. One PermissionGroup instance +// corresponds to one row in the gSitePermissionsManager._list richlistbox. +// Permissions may be single or double keyed, but the primary key of all +// permissions matches the permission type of the dialog. +class PermissionGroup { + #changedCapability; + + constructor(perm) { + this.principal = perm.principal; + this.origin = perm.principal.origin; + this.perms = [perm]; + } + addPermission(perm) { + this.perms.push(perm); + } + removePermission(perm) { + this.perms = this.perms.filter(p => p.type != perm.type); + } + set capability(cap) { + this.#changedCapability = cap; + } + get capability() { + if (this.#changedCapability) { + return this.#changedCapability; + } + return this.savedCapability; + } + revert() { + this.#changedCapability = null; + } + get savedCapability() { + // This logic to present a single capability for permissions of different + // keys and capabilities caters for speaker-selection, where a block + // permission may be set for all devices with no second key, which would + // override any device-specific double-keyed allow permissions. + let cap; + for (let perm of this.perms) { + let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); + if (type == perm.type) { + // No second key. This overrides double-keyed perms. + return perm.capability; + } + // Double-keyed perms are not expected to have different capabilities. + cap = perm.capability; + } + return cap; + } +} + +const PERMISSION_STATES = [ + SitePermissions.ALLOW, + SitePermissions.BLOCK, + SitePermissions.PROMPT, + SitePermissions.AUTOPLAY_BLOCKED_ALL, +]; + +const NOTIFICATIONS_PERMISSION_OVERRIDE_KEY = "webNotificationsDisabled"; +const NOTIFICATIONS_PERMISSION_PREF = + "permissions.default.desktop-notification"; + +const AUTOPLAY_PREF = "media.autoplay.default"; + +var gSitePermissionsManager = { + _type: "", + _isObserving: false, + _permissionGroups: new Map(), + _permissionsToChange: new Map(), + _permissionsToDelete: new Map(), + _list: null, + _removeButton: null, + _removeAllButton: null, + _searchBox: null, + _checkbox: null, + _currentDefaultPermissionsState: null, + _defaultPermissionStatePrefName: null, + + onLoad() { + let params = window.arguments[0]; + document.mozSubdialogReady = this.init(params); + }, + + async init(params) { + if (!this._isObserving) { + Services.obs.addObserver(this, "perm-changed"); + this._isObserving = true; + } + + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + + this._type = params.permissionType; + this._list = document.getElementById("permissionsBox"); + this._removeButton = document.getElementById("removePermission"); + this._removeAllButton = document.getElementById("removeAllPermissions"); + this._searchBox = document.getElementById("searchBox"); + this._checkbox = document.getElementById("permissionsDisableCheckbox"); + this._disableExtensionButton = document.getElementById( + "disableNotificationsPermissionExtension" + ); + this._permissionsDisableDescription = document.getElementById( + "permissionsDisableDescription" + ); + this._setAutoplayPref = document.getElementById("setAutoplayPref"); + + let permissionsText = document.getElementById("permissionsText"); + + document.l10n.pauseObserving(); + let l10n = sitePermissionsL10n[this._type]; + document.l10n.setAttributes(permissionsText, l10n.description); + if (l10n.disableLabel) { + document.l10n.setAttributes(this._checkbox, l10n.disableLabel); + } + if (l10n.disableDescription) { + document.l10n.setAttributes( + this._permissionsDisableDescription, + l10n.disableDescription + ); + } + document.l10n.setAttributes(document.documentElement, l10n.window); + + await document.l10n.translateElements([ + permissionsText, + this._checkbox, + this._permissionsDisableDescription, + document.documentElement, + ]); + document.l10n.resumeObserving(); + + // Initialize the checkbox state and handle showing notification permission UI + // when it is disabled by an extension. + this._defaultPermissionStatePrefName = "permissions.default." + this._type; + this._watchPermissionPrefChange(); + + this._loadPermissions(); + this.buildPermissionsList(); + + if (params.permissionType == "autoplay-media") { + await this.buildAutoplayMenulist(); + this._setAutoplayPref.hidden = false; + } + + this._searchBox.focus(); + }, + + uninit() { + if (this._isObserving) { + Services.obs.removeObserver(this, "perm-changed"); + this._isObserving = false; + } + if (this._setAutoplayPref) { + this._setAutoplayPref.hidden = true; + } + }, + + observe(subject, topic, data) { + if (topic !== "perm-changed") { + return; + } + + let permission = subject.QueryInterface(Ci.nsIPermission); + let [type] = permission.type.split(SitePermissions.PERM_KEY_DELIMITER); + + // Ignore unrelated permission types and permissions with unknown states. + if ( + type !== this._type || + !PERMISSION_STATES.includes(permission.capability) + ) { + return; + } + + if (data == "added") { + this._addPermissionToList(permission); + } else { + let group = this._permissionGroups.get(permission.principal.origin); + if (!group) { + // already moved to _permissionsToDelete + // or private browsing session permission + return; + } + if (data == "changed") { + group.removePermission(permission); + group.addPermission(permission); + } else if (data == "deleted") { + group.removePermission(permission); + if (!group.perms.length) { + this._removePermissionFromList(permission.principal.origin); + return; + } + } + } + this.buildPermissionsList(); + }, + + _handleCheckboxUIUpdates() { + let pref = Services.prefs.getPrefType(this._defaultPermissionStatePrefName); + if (pref != Services.prefs.PREF_INVALID) { + this._currentDefaultPermissionsState = Services.prefs.getIntPref( + this._defaultPermissionStatePrefName + ); + } + + if (this._currentDefaultPermissionsState === null) { + this._checkbox.hidden = true; + this._permissionsDisableDescription.hidden = true; + } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { + this._checkbox.checked = true; + } else { + this._checkbox.checked = false; + } + + if (Services.prefs.prefIsLocked(this._defaultPermissionStatePrefName)) { + this._checkbox.disabled = true; + } + }, + + /** + * Listen for changes to the permissions.default.* pref and make + * necessary changes to the UI. + */ + _watchPermissionPrefChange() { + this._handleCheckboxUIUpdates(); + + if (this._type == "desktop-notification") { + this._handleWebNotificationsDisable(); + + this._disableExtensionButton.addEventListener( + "command", + makeDisableControllingExtension( + PREF_SETTING_TYPE, + NOTIFICATIONS_PERMISSION_OVERRIDE_KEY + ) + ); + } + + let observer = () => { + this._handleCheckboxUIUpdates(); + if (this._type == "desktop-notification") { + this._handleWebNotificationsDisable(); + } + }; + Services.prefs.addObserver(this._defaultPermissionStatePrefName, observer); + window.addEventListener("unload", () => { + Services.prefs.removeObserver( + this._defaultPermissionStatePrefName, + observer + ); + }); + }, + + /** + * Handles the UI update for web notifications disable by extensions. + */ + async _handleWebNotificationsDisable() { + let prefLocked = Services.prefs.prefIsLocked(NOTIFICATIONS_PERMISSION_PREF); + if (prefLocked) { + // An extension can't control these settings if they're locked. + hideControllingExtension(NOTIFICATIONS_PERMISSION_OVERRIDE_KEY); + } else { + let isControlled = await handleControllingExtension( + PREF_SETTING_TYPE, + NOTIFICATIONS_PERMISSION_OVERRIDE_KEY + ); + this._checkbox.disabled = isControlled; + } + }, + + _getCapabilityL10nId(element, type, capability) { + if ( + type in sitePermissionsConfig && + sitePermissionsConfig[type]._getCapabilityString + ) { + return sitePermissionsConfig[type]._getCapabilityString(capability); + } + switch (element.tagName) { + case "menuitem": + switch (capability) { + case Services.perms.ALLOW_ACTION: + return "permissions-capabilities-allow"; + case Services.perms.DENY_ACTION: + return "permissions-capabilities-block"; + case Services.perms.PROMPT_ACTION: + return "permissions-capabilities-prompt"; + default: + throw new Error(`Unknown capability: ${capability}`); + } + case "label": + switch (capability) { + case Services.perms.ALLOW_ACTION: + return "permissions-capabilities-listitem-allow"; + case Services.perms.DENY_ACTION: + return "permissions-capabilities-listitem-block"; + default: + throw new Error(`Unexpected capability: ${capability}`); + } + default: + throw new Error(`Unexpected tag: ${element.tagName}`); + } + }, + + _addPermissionToList(perm) { + let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); + // Ignore unrelated permission types and permissions with unknown states. + if ( + type !== this._type || + !PERMISSION_STATES.includes(perm.capability) || + // Skip private browsing session permissions + (perm.principal.privateBrowsingId !== + Services.scriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID && + perm.expireType === Services.perms.EXPIRE_SESSION) + ) { + return; + } + let group = this._permissionGroups.get(perm.principal.origin); + if (group) { + group.addPermission(perm); + } else { + group = new PermissionGroup(perm); + this._permissionGroups.set(group.origin, group); + } + }, + + _removePermissionFromList(origin) { + this._permissionGroups.delete(origin); + this._permissionsToChange.delete(origin); + let permissionlistitem = document.getElementsByAttribute( + "origin", + origin + )[0]; + if (permissionlistitem) { + permissionlistitem.remove(); + } + }, + + _loadPermissions() { + // load permissions into a table. + for (let nextPermission of Services.perms.all) { + this._addPermissionToList(nextPermission); + } + }, + + _createPermissionListItem(permissionGroup) { + let richlistitem = document.createXULElement("richlistitem"); + richlistitem.setAttribute("origin", permissionGroup.origin); + let row = document.createXULElement("hbox"); + + let website = document.createXULElement("label"); + website.textContent = permissionGroup.origin; + website.className = "website-name"; + + let states = SitePermissions.getAvailableStates(this._type).filter( + state => state != SitePermissions.UNKNOWN + ); + // Handle the cases of a double-keyed ALLOW permission or a PROMPT + // permission after the default has been changed back to UNKNOWN. + if (!states.includes(permissionGroup.savedCapability)) { + states.unshift(permissionGroup.savedCapability); + } + let siteStatus; + if (states.length == 1) { + // Only a single state is available. Show a label. + siteStatus = document.createXULElement("label"); + document.l10n.setAttributes( + siteStatus, + this._getCapabilityL10nId( + siteStatus, + this._type, + permissionGroup.capability + ) + ); + } else { + // Multiple states are available. Show a menulist. + siteStatus = document.createXULElement("menulist"); + for (let state of states) { + let m = siteStatus.appendItem(undefined, state); + document.l10n.setAttributes( + m, + this._getCapabilityL10nId(m, this._type, state) + ); + } + siteStatus.addEventListener("select", () => { + this.onPermissionChange(permissionGroup, Number(siteStatus.value)); + }); + } + siteStatus.className = "website-status"; + siteStatus.value = permissionGroup.capability; + + row.appendChild(website); + row.appendChild(siteStatus); + richlistitem.appendChild(row); + return richlistitem; + }, + + onPermissionKeyPress(event) { + if (!this._list.selectedItem) { + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE) + ) { + this.onPermissionDelete(); + event.preventDefault(); + } + }, + + _setRemoveButtonState() { + if (!this._list) { + return; + } + + let hasSelection = this._list.selectedIndex >= 0; + let hasRows = this._list.itemCount > 0; + this._removeButton.disabled = !hasSelection; + this._removeAllButton.disabled = !hasRows; + }, + + onPermissionDelete() { + let richlistitem = this._list.selectedItem; + let origin = richlistitem.getAttribute("origin"); + let permissionGroup = this._permissionGroups.get(origin); + + this._removePermissionFromList(origin); + this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); + + this._setRemoveButtonState(); + }, + + onAllPermissionsDelete() { + for (let permissionGroup of this._permissionGroups.values()) { + this._removePermissionFromList(permissionGroup.origin); + this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); + } + + this._setRemoveButtonState(); + }, + + onPermissionSelect() { + this._setRemoveButtonState(); + }, + + onPermissionChange(perm, capability) { + let group = this._permissionGroups.get(perm.origin); + if (group.capability == capability) { + return; + } + if (capability == group.savedCapability) { + group.revert(); + this._permissionsToChange.delete(group.origin); + } else { + group.capability = capability; + this._permissionsToChange.set(group.origin, group); + } + + // enable "remove all" button as needed + this._setRemoveButtonState(); + }, + + onApplyChanges() { + // Stop observing permission changes since we are about + // to write out the pending adds/deletes and don't need + // to update the UI + this.uninit(); + + // Delete even _permissionsToChange to clear out double-keyed permissions + for (let group of [ + ...this._permissionsToDelete.values(), + ...this._permissionsToChange.values(), + ]) { + for (let perm of group.perms) { + SitePermissions.removeFromPrincipal(perm.principal, perm.type); + } + } + + for (let group of this._permissionsToChange.values()) { + SitePermissions.setForPrincipal( + group.principal, + this._type, + group.capability + ); + } + + if (this._checkbox.checked) { + Services.prefs.setIntPref( + this._defaultPermissionStatePrefName, + SitePermissions.BLOCK + ); + } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { + Services.prefs.setIntPref( + this._defaultPermissionStatePrefName, + SitePermissions.UNKNOWN + ); + } + }, + + buildPermissionsList(sortCol) { + // Clear old entries. + let oldItems = this._list.querySelectorAll("richlistitem"); + for (let item of oldItems) { + item.remove(); + } + let frag = document.createDocumentFragment(); + + let permissionGroups = Array.from(this._permissionGroups.values()); + + let keyword = this._searchBox.value.toLowerCase().trim(); + for (let permissionGroup of permissionGroups) { + if (keyword && !permissionGroup.origin.includes(keyword)) { + continue; + } + + let richlistitem = this._createPermissionListItem(permissionGroup); + frag.appendChild(richlistitem); + } + + // Sort permissions. + this._sortPermissions(this._list, frag, sortCol); + + this._list.appendChild(frag); + + this._setRemoveButtonState(); + }, + + async buildAutoplayMenulist() { + let menulist = document.createXULElement("menulist"); + let states = SitePermissions.getAvailableStates("autoplay-media"); + document.l10n.pauseObserving(); + for (let state of states) { + let m = menulist.appendItem(undefined, state); + document.l10n.setAttributes( + m, + this._getCapabilityL10nId(m, "autoplay-media", state) + ); + } + + menulist.value = SitePermissions.getDefault("autoplay-media"); + + menulist.addEventListener("select", () => { + SitePermissions.setDefault("autoplay-media", Number(menulist.value)); + }); + + menulist.menupopup.setAttribute("incontentshell", "false"); + + menulist.disabled = Services.prefs.prefIsLocked(AUTOPLAY_PREF); + + document.getElementById("setAutoplayPref").appendChild(menulist); + await document.l10n.translateFragment(menulist); + document.l10n.resumeObserving(); + }, + + _sortPermissions(list, frag, column) { + let sortDirection; + + if (!column) { + column = document.querySelector("treecol[data-isCurrentSortCol=true]"); + sortDirection = + column.getAttribute("data-last-sortDirection") || "ascending"; + } else { + sortDirection = column.getAttribute("data-last-sortDirection"); + sortDirection = + sortDirection === "ascending" ? "descending" : "ascending"; + } + + let sortFunc = null; + switch (column.id) { + case "siteCol": + sortFunc = (a, b) => { + return comp.compare( + a.getAttribute("origin"), + b.getAttribute("origin") + ); + }; + break; + + case "statusCol": + sortFunc = (a, b) => { + return ( + parseInt(a.querySelector(".website-status").value) > + parseInt(b.querySelector(".website-status").value) + ); + }; + break; + } + + let comp = new Services.intl.Collator(undefined, { + usage: "sort", + }); + + let items = Array.from(frag.querySelectorAll("richlistitem")); + + if (sortDirection === "descending") { + items.sort((a, b) => sortFunc(b, a)); + } else { + items.sort(sortFunc); + } + + // Re-append items in the correct order: + items.forEach(item => frag.appendChild(item)); + + let cols = list.previousElementSibling.querySelectorAll("treecol"); + cols.forEach(c => { + c.removeAttribute("data-isCurrentSortCol"); + c.removeAttribute("sortDirection"); + }); + column.setAttribute("data-isCurrentSortCol", "true"); + column.setAttribute("sortDirection", sortDirection); + column.setAttribute("data-last-sortDirection", sortDirection); + }, +}; diff --git a/browser/components/preferences/dialogs/sitePermissions.xhtml b/browser/components/preferences/dialogs/sitePermissions.xhtml new file mode 100644 index 0000000000..e17d701dc6 --- /dev/null +++ b/browser/components/preferences/dialogs/sitePermissions.xhtml @@ -0,0 +1,121 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="SitePermissionsDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="permissions-window2" + data-l10n-attrs="title, style" + onload="gSitePermissionsManager.onLoad();" + onunload="gSitePermissionsManager.uninit();" + persist="width height" +> + <dialog + buttons="accept,cancel" + data-l10n-id="permission-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/content/preferences/dialogs/sitePermissions.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link + rel="localization" + href="browser/preferences/preferences.ftl" + /> + <html:link + rel="localization" + href="browser/preferences/permissions.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/sitePermissions.js" /> + <script src="chrome://browser/content/preferences/extensionControlled.js" /> + + <keyset> + <key + data-l10n-id="permissions-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <hbox align="center" id="setAutoplayPref" hidden="true"> + <label data-l10n-id="permissions-autoplay-menu" /> + </hbox> + <description id="permissionsText" control="url" /> + <separator class="thin" /> + <hbox align="start"> + <search-textbox + id="searchBox" + flex="1" + data-l10n-id="permissions-searchbox" + data-l10n-attrs="placeholder" + oncommand="gSitePermissionsManager.buildPermissionsList();" + /> + </hbox> + <separator class="thin" /> + <listheader> + <treecol + id="siteCol" + data-l10n-id="permissions-site-name" + onclick="gSitePermissionsManager.buildPermissionsList(event.target)" + /> + <treecol + id="statusCol" + data-l10n-id="permissions-status" + data-isCurrentSortCol="true" + onclick="gSitePermissionsManager.buildPermissionsList(event.target);" + /> + </listheader> + <richlistbox + id="permissionsBox" + selected="false" + onkeypress="gSitePermissionsManager.onPermissionKeyPress(event);" + onselect="gSitePermissionsManager.onPermissionSelect();" + /> + </vbox> + + <hbox class="actionButtons"> + <button + id="removePermission" + disabled="true" + data-l10n-id="permissions-remove" + oncommand="gSitePermissionsManager.onPermissionDelete();" + /> + <button + id="removeAllPermissions" + data-l10n-id="permissions-remove-all" + oncommand="gSitePermissionsManager.onAllPermissionsDelete();" + /> + </hbox> + + <checkbox id="permissionsDisableCheckbox" /> + <description id="permissionsDisableDescription" /> + <hbox + id="browserNotificationsPermissionExtensionContent" + class="extension-controlled" + align="center" + hidden="true" + > + <description control="disableNotificationsPermissionExtension" flex="1" /> + <button + id="disableNotificationsPermissionExtension" + class="extension-controlled-button accessory-button" + data-l10n-id="disable-extension" + /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/syncChooseWhatToSync.js b/browser/components/preferences/dialogs/syncChooseWhatToSync.js new file mode 100644 index 0000000000..2cc965b4e1 --- /dev/null +++ b/browser/components/preferences/dialogs/syncChooseWhatToSync.js @@ -0,0 +1,60 @@ +/* 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-globals-from /toolkit/content/preferencesBindings.js */ + +Preferences.addAll([ + { id: "services.sync.engine.addons", type: "bool" }, + { id: "services.sync.engine.bookmarks", type: "bool" }, + { id: "services.sync.engine.history", type: "bool" }, + { id: "services.sync.engine.tabs", type: "bool" }, + { id: "services.sync.engine.prefs", type: "bool" }, + { id: "services.sync.engine.passwords", type: "bool" }, + { id: "services.sync.engine.addresses", type: "bool" }, + { id: "services.sync.engine.creditcards", type: "bool" }, +]); + +let gSyncChooseWhatToSync = { + init() { + this._adjustForPrefs(); + let options = window.arguments[0]; + if (options.disconnectFun) { + // We offer 'disconnect' + document.addEventListener("dialogextra2", function () { + options.disconnectFun().then(disconnected => { + if (disconnected) { + window.close(); + } + }); + }); + } else { + // no "disconnect" - hide the button. + document + .getElementById("syncChooseOptions") + .getButton("extra2").hidden = true; + } + }, + + // make whatever tweaks we need based on preferences. + _adjustForPrefs() { + // These 2 engines are unique in that there are prefs that make the + // entire engine unavailable (which is distinct from "disabled"). + let enginePrefs = [ + ["services.sync.engine.addresses", ".sync-engine-addresses"], + ["services.sync.engine.creditcards", ".sync-engine-creditcards"], + ]; + for (let [enabledPref, className] of enginePrefs) { + let availablePref = enabledPref + ".available"; + // If the engine is enabled we force it to be available, otherwise we see + // spooky things happen (like it magically re-appear later) + if (Services.prefs.getBoolPref(enabledPref, false)) { + Services.prefs.setBoolPref(availablePref, true); + } + if (!Services.prefs.getBoolPref(availablePref)) { + let elt = document.querySelector(className); + elt.hidden = true; + } + } + }, +}; diff --git a/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml b/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml new file mode 100644 index 0000000000..2b041c4802 --- /dev/null +++ b/browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml @@ -0,0 +1,88 @@ +<?xml version="1.0"?> + +<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- --> +<!-- 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/. --> + +<window + type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="gSyncChooseWhatToSync.init();" + data-l10n-id="sync-choose-what-to-sync-dialog3" + data-l10n-attrs="title, style" +> + <dialog + id="syncChooseOptions" + buttons="accept,cancel,extra2" + data-l10n-id="sync-choose-what-to-sync-dialog3" + data-l10n-attrs="buttonlabelaccept, buttonlabelextra2" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <html:link + rel="localization" + href="browser/preferences/preferences.ftl" + /> + <html:link rel="localization" href="toolkit/branding/accounts.ftl" /> + </linkset> + <script src="chrome://global/content/preferencesBindings.js" /> + <script src="chrome://browser/content/preferences/dialogs/syncChooseWhatToSync.js" /> + <description + class="sync-choose-dialog-description" + data-l10n-id="sync-choose-dialog-subtitle" + /> + <html:div class="sync-engines-list"> + <html:div class="sync-engine-bookmarks"> + <checkbox + data-l10n-id="sync-engine-bookmarks" + preference="services.sync.engine.bookmarks" + /> + </html:div> + <html:div class="sync-engine-history"> + <checkbox + data-l10n-id="sync-engine-history" + preference="services.sync.engine.history" + /> + </html:div> + <html:div class="sync-engine-tabs"> + <checkbox + data-l10n-id="sync-engine-tabs" + preference="services.sync.engine.tabs" + /> + </html:div> + <html:div class="sync-engine-passwords"> + <checkbox + data-l10n-id="sync-engine-passwords" + preference="services.sync.engine.passwords" + /> + </html:div> + <html:div class="sync-engine-addresses"> + <checkbox + data-l10n-id="sync-engine-addresses" + preference="services.sync.engine.addresses" + /> + </html:div> + <html:div class="sync-engine-creditcards"> + <checkbox + data-l10n-id="sync-engine-payment-methods2" + preference="services.sync.engine.creditcards" + /> + </html:div> + <html:div class="sync-engine-addons"> + <checkbox + data-l10n-id="sync-engine-addons" + preference="services.sync.engine.addons" + /> + </html:div> + <html:div class="sync-engine-prefs"> + <checkbox + data-l10n-id="sync-engine-settings" + preference="services.sync.engine.prefs" + /> + </html:div> + </html:div> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/translationExceptions.js b/browser/components/preferences/dialogs/translationExceptions.js new file mode 100644 index 0000000000..27579594c9 --- /dev/null +++ b/browser/components/preferences/dialogs/translationExceptions.js @@ -0,0 +1,256 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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/. */ + +// TODO (Bug 1817084) Remove this file when we disable the extension + +"use strict"; + +const kPermissionType = "translate"; +const kLanguagesPref = "browser.translation.neverForLanguages"; + +function Tree(aId, aData) { + this._data = aData; + this._tree = document.getElementById(aId); + this._tree.view = this; +} + +Tree.prototype = { + get tree() { + return this._tree; + }, + get isEmpty() { + return !this._data.length; + }, + get hasSelection() { + return this.selection.count > 0; + }, + getSelectedItems() { + let result = []; + + let rc = this.selection.getRangeCount(); + for (let i = 0; i < rc; ++i) { + let min = {}, + max = {}; + this.selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; ++j) { + result.push(this._data[j]); + } + } + + return result; + }, + + // nsITreeView implementation + get rowCount() { + return this._data.length; + }, + getCellText(aRow, aColumn) { + return this._data[aRow]; + }, + isSeparator(aIndex) { + return false; + }, + isSorted() { + return false; + }, + isContainer(aIndex) { + return false; + }, + setTree(aTree) {}, + getImageSrc(aRow, aColumn) {}, + getCellValue(aRow, aColumn) {}, + cycleHeader(column) {}, + getRowProperties(row) { + return ""; + }, + getColumnProperties(column) { + return ""; + }, + getCellProperties(row, column) { + return ""; + }, + QueryInterface: ChromeUtils.generateQI(["nsITreeView"]), +}; + +function Lang(aCode, label) { + this.langCode = aCode; + this._label = label; +} + +Lang.prototype = { + toString() { + return this._label; + }, +}; + +var gTranslationExceptions = { + onLoad() { + if (this._siteTree) { + // Re-using an open dialog, clear the old observers. + this.uninit(); + } + + // Load site permissions into an array. + this._sites = []; + for (let perm of Services.perms.all) { + if ( + perm.type == kPermissionType && + perm.capability == Services.perms.DENY_ACTION + ) { + this._sites.push(perm.principal.origin); + } + } + Services.obs.addObserver(this, "perm-changed"); + this._sites.sort(); + + this._siteTree = new Tree("sitesTree", this._sites); + this.onSiteSelected(); + + this._langs = this.getLanguageExceptions(); + Services.prefs.addObserver(kLanguagesPref, this); + this._langTree = new Tree("languagesTree", this._langs); + this.onLanguageSelected(); + }, + + // Get the list of languages we don't translate as an array. + getLanguageExceptions() { + let langs = Services.prefs.getCharPref(kLanguagesPref); + if (!langs) { + return []; + } + + let langArr = langs.split(","); + let displayNames = Services.intl.getLanguageDisplayNames( + undefined, + langArr + ); + let result = langArr.map((lang, i) => new Lang(lang, displayNames[i])); + result.sort(); + + return result; + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "perm-changed") { + if (aData == "cleared") { + if (!this._sites.length) { + return; + } + let removed = this._sites.splice(0, this._sites.length); + this._siteTree.tree.rowCountChanged(0, -removed.length); + } else { + let perm = aSubject.QueryInterface(Ci.nsIPermission); + if (perm.type != kPermissionType) { + return; + } + + if (aData == "added") { + if (perm.capability != Services.perms.DENY_ACTION) { + return; + } + this._sites.push(perm.principal.origin); + this._sites.sort(); + let tree = this._siteTree.tree; + tree.rowCountChanged(0, 1); + tree.invalidate(); + } else if (aData == "deleted") { + let index = this._sites.indexOf(perm.principal.origin); + if (index == -1) { + return; + } + this._sites.splice(index, 1); + this._siteTree.tree.rowCountChanged(index, -1); + this.onSiteSelected(); + return; + } + } + this.onSiteSelected(); + } else if (aTopic == "nsPref:changed") { + this._langs = this.getLanguageExceptions(); + let change = this._langs.length - this._langTree.rowCount; + this._langTree._data = this._langs; + let tree = this._langTree.tree; + if (change) { + tree.rowCountChanged(0, change); + } + tree.invalidate(); + this.onLanguageSelected(); + } + }, + + _handleButtonDisabling(aTree, aIdPart) { + let empty = aTree.isEmpty; + document.getElementById("removeAll" + aIdPart + "s").disabled = empty; + document.getElementById("remove" + aIdPart).disabled = + empty || !aTree.hasSelection; + }, + + onLanguageSelected() { + this._handleButtonDisabling(this._langTree, "Language"); + }, + + onSiteSelected() { + this._handleButtonDisabling(this._siteTree, "Site"); + }, + + onLanguageDeleted() { + let langs = Services.prefs.getCharPref(kLanguagesPref); + if (!langs) { + return; + } + + let removed = this._langTree.getSelectedItems().map(l => l.langCode); + + langs = langs.split(",").filter(l => !removed.includes(l)); + Services.prefs.setCharPref(kLanguagesPref, langs.join(",")); + }, + + onAllLanguagesDeleted() { + Services.prefs.setCharPref(kLanguagesPref, ""); + }, + + onSiteDeleted() { + let removedSites = this._siteTree.getSelectedItems(); + for (let origin of removedSites) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin); + Services.perms.removeFromPrincipal(principal, kPermissionType); + } + }, + + onAllSitesDeleted() { + if (this._siteTree.isEmpty) { + return; + } + + let removedSites = this._sites.splice(0, this._sites.length); + this._siteTree.tree.rowCountChanged(0, -removedSites.length); + + for (let origin of removedSites) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin); + Services.perms.removeFromPrincipal(principal, kPermissionType); + } + + this.onSiteSelected(); + }, + + onSiteKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_DELETE) { + this.onSiteDeleted(); + } + }, + + onLanguageKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_DELETE) { + this.onLanguageDeleted(); + } + }, + + uninit() { + Services.obs.removeObserver(this, "perm-changed"); + Services.prefs.removeObserver(kLanguagesPref, this); + }, +}; diff --git a/browser/components/preferences/dialogs/translationExceptions.xhtml b/browser/components/preferences/dialogs/translationExceptions.xhtml new file mode 100644 index 0000000000..1417595676 --- /dev/null +++ b/browser/components/preferences/dialogs/translationExceptions.xhtml @@ -0,0 +1,130 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<!-- TODO (Bug 1817084) Remove this file when we disable the extension --> + +<window + id="TranslationDialog" + data-l10n-id="translation-window2" + data-l10n-attrs="title, style" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="gTranslationExceptions.onLoad();" + onunload="gTranslationExceptions.uninit();" + persist="width height" +> + <dialog + buttons="accept" + data-l10n-id="translation-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link + rel="localization" + href="browser/preferences/translation.ftl" + /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/translationExceptions.js" /> + + <keyset> + <key + data-l10n-id="translation-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <vbox flex="1"> + <label + id="languagesLabel" + data-l10n-id="translation-languages-disabled-desc" + control="permissionsTree" + /> + <separator class="thin" /> + <tree + id="languagesTree" + flex="1" + style="height: 12em" + hidecolumnpicker="true" + onkeypress="gTranslationExceptions.onLanguageKeyPress(event)" + onselect="gTranslationExceptions.onLanguageSelected();" + > + <treecols> + <treecol + id="languageCol" + data-l10n-id="translation-languages-column" + flex="1" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + <hbox class="actionButtons" pack="end"> + <button + id="removeLanguage" + disabled="true" + data-l10n-id="translation-languages-button-remove" + oncommand="gTranslationExceptions.onLanguageDeleted();" + /> + <button + id="removeAllLanguages" + data-l10n-id="translation-languages-button-remove-all" + oncommand="gTranslationExceptions.onAllLanguagesDeleted();" + /> + <spacer flex="1" /> + </hbox> + <separator /> + <vbox flex="1"> + <label + id="languagesLabel" + data-l10n-id="translation-sites-disabled-desc" + control="permissionsTree" + /> + <separator class="thin" /> + <tree + id="sitesTree" + flex="1" + style="height: 12em" + hidecolumnpicker="true" + onkeypress="gTranslationExceptions.onSiteKeyPress(event)" + onselect="gTranslationExceptions.onSiteSelected();" + > + <treecols> + <treecol + id="siteCol" + data-l10n-id="translation-sites-column" + flex="1" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + </vbox> + + <hbox class="actionButtons" pack="end"> + <button + id="removeSite" + disabled="true" + data-l10n-id="translation-sites-button-remove" + oncommand="gTranslationExceptions.onSiteDeleted();" + /> + <button + id="removeAllSites" + data-l10n-id="translation-sites-button-remove-all" + oncommand="gTranslationExceptions.onAllSitesDeleted();" + /> + <spacer flex="1" /> + </hbox> + </dialog> +</window> diff --git a/browser/components/preferences/dialogs/translations.js b/browser/components/preferences/dialogs/translations.js new file mode 100644 index 0000000000..826e6efb4b --- /dev/null +++ b/browser/components/preferences/dialogs/translations.js @@ -0,0 +1,454 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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"; + +/** + * The permission type to give to Services.perms for Translations. + */ +const TRANSLATIONS_PERMISSION = "translations"; +/** + * The list of BCP-47 language tags that will trigger auto-translate. + */ +const ALWAYS_TRANSLATE_LANGS_PREF = + "browser.translations.alwaysTranslateLanguages"; +/** + * The list of BCP-47 language tags that will prevent showing Translations UI. + */ +const NEVER_TRANSLATE_LANGS_PREF = + "browser.translations.neverTranslateLanguages"; + +ChromeUtils.defineESModuleGetters(this, { + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + +function Tree(aId, aData) { + this._data = aData; + this._tree = document.getElementById(aId); + this._tree.view = this; +} + +Tree.prototype = { + get tree() { + return this._tree; + }, + get isEmpty() { + return !this._data.length; + }, + get hasSelection() { + return this.selection.count > 0; + }, + getSelectedItems() { + let result = []; + + let rc = this.selection.getRangeCount(); + for (let i = 0; i < rc; ++i) { + let min = {}, + max = {}; + this.selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; ++j) { + result.push(this._data[j]); + } + } + + return result; + }, + + // nsITreeView implementation + get rowCount() { + return this._data.length; + }, + getCellText(aRow, aColumn) { + return this._data[aRow]; + }, + isSeparator(aIndex) { + return false; + }, + isSorted() { + return false; + }, + isContainer(aIndex) { + return false; + }, + setTree(aTree) {}, + getImageSrc(aRow, aColumn) {}, + getCellValue(aRow, aColumn) {}, + cycleHeader(column) {}, + getRowProperties(row) { + return ""; + }, + getColumnProperties(column) { + return ""; + }, + getCellProperties(row, column) { + return ""; + }, + QueryInterface: ChromeUtils.generateQI(["nsITreeView"]), +}; + +function Lang(aCode, label) { + this.langCode = aCode; + this._label = label; +} + +Lang.prototype = { + toString() { + return this._label; + }, +}; + +var gTranslationsSettings = { + onLoad() { + if (this._neverTranslateSiteTree) { + // Re-using an open dialog, clear the old observers. + this.removeObservers(); + } + + // Load site permissions into an array. + this._neverTranslateSites = TranslationsParent.listNeverTranslateSites(); + + // Load language tags into arrays. + this._alwaysTranslateLangs = this.getAlwaysTranslateLanguages(); + this._neverTranslateLangs = this.getNeverTranslateLanguages(); + + // Add observers for relevant prefs and permissions. + Services.obs.addObserver(this, "perm-changed"); + Services.prefs.addObserver(ALWAYS_TRANSLATE_LANGS_PREF, this); + Services.prefs.addObserver(NEVER_TRANSLATE_LANGS_PREF, this); + + // Build trees from the arrays. + this._alwaysTranslateLangsTree = new Tree( + "alwaysTranslateLanguagesTree", + this._alwaysTranslateLangs + ); + this._neverTranslateLangsTree = new Tree( + "neverTranslateLanguagesTree", + this._neverTranslateLangs + ); + this._neverTranslateSiteTree = new Tree( + "neverTranslateSitesTree", + this._neverTranslateSites + ); + + // Ensure the UI for each group is in the correct state. + this.onSelectAlwaysTranslateLanguage(); + this.onSelectNeverTranslateLanguage(); + this.onSelectNeverTranslateSite(); + }, + + /** + * Retrieves the value of a char-pref splits its value into an + * array delimited by commas. + * + * This is used for the translations preferences which are comma- + * separated lists of BCP-47 language tags. + * + * @param {string} pref + * @returns {Array<string>} + */ + getLangsFromPref(pref) { + let rawLangs = Services.prefs.getCharPref(pref); + if (!rawLangs) { + return []; + } + + let langArr = rawLangs.split(","); + let displayNames = Services.intl.getLanguageDisplayNames( + undefined, + langArr + ); + let langs = langArr.map((lang, i) => new Lang(lang, displayNames[i])); + langs.sort(); + + return langs; + }, + + /** + * Retrieves the always-translate language tags as an array. + * @returns {Array<string>} + */ + getAlwaysTranslateLanguages() { + return this.getLangsFromPref(ALWAYS_TRANSLATE_LANGS_PREF); + }, + + /** + * Retrieves the never-translate language tags as an array. + * @returns {Array<string>} + */ + getNeverTranslateLanguages() { + return this.getLangsFromPref(NEVER_TRANSLATE_LANGS_PREF); + }, + + /** + * Handles updating the UI components on pref or permission changes. + */ + observe(aSubject, aTopic, aData) { + if (aTopic === "perm-changed") { + if (aData === "cleared") { + // Permissions have been cleared + if (!this._neverTranslateSites.length) { + // There were no sites with permissions set, nothing to do. + return; + } + // Update the tree based on the amount of permissions removed. + let removed = this._neverTranslateSites.splice( + 0, + this._neverTranslateSites.length + ); + this._neverTranslateSiteTree.tree.rowCountChanged(0, -removed.length); + } else { + let perm = aSubject.QueryInterface(Ci.nsIPermission); + if (perm.type != TRANSLATIONS_PERMISSION) { + // The updated permission was not for Translations, nothing to do. + return; + } + if (aData === "added") { + if (perm.capability != Services.perms.DENY_ACTION) { + // We are only showing data for sites we should never translate. + // If the permission is not DENY_ACTION, we don't care about it here. + return; + } + this._neverTranslateSites.push(perm.principal.origin); + this._neverTranslateSites.sort(); + let tree = this._neverTranslateSiteTree.tree; + tree.rowCountChanged(0, 1); + tree.invalidate(); + } else if (aData == "deleted") { + let index = this._neverTranslateSites.indexOf(perm.principal.origin); + if (index == -1) { + // The deleted permission was not in the tree, nothing to do. + return; + } + this._neverTranslateSites.splice(index, 1); + this._neverTranslateSiteTree.tree.rowCountChanged(index, -1); + } + } + // Ensure the UI updates to the changes. + this.onSelectNeverTranslateSite(); + } else if (aTopic === "nsPref:changed") { + switch (aData) { + case ALWAYS_TRANSLATE_LANGS_PREF: { + this._alwaysTranslateLangs = this.getAlwaysTranslateLanguages(); + + let alwaysTranslateLangsChange = + this._alwaysTranslateLangs.length - + this._alwaysTranslateLangsTree.rowCount; + + this._alwaysTranslateLangsTree._data = this._alwaysTranslateLangs; + let alwaysTranslateLangsTree = this._alwaysTranslateLangsTree.tree; + + if (alwaysTranslateLangsChange) { + alwaysTranslateLangsTree.rowCountChanged( + 0, + alwaysTranslateLangsChange + ); + } + + alwaysTranslateLangsTree.invalidate(); + + // Ensure the UI updates to the changes. + this.onSelectAlwaysTranslateLanguage(); + break; + } + case NEVER_TRANSLATE_LANGS_PREF: { + this._neverTranslateLangs = this.getNeverTranslateLanguages(); + + let neverTranslateLangsChange = + this._neverTranslateLangs.length - + this._neverTranslateLangsTree.rowCount; + + this._neverTranslateLangsTree._data = this._neverTranslateLangs; + let neverTranslateLangsTree = this._neverTranslateLangsTree.tree; + + if (neverTranslateLangsChange) { + neverTranslateLangsTree.rowCountChanged( + 0, + neverTranslateLangsChange + ); + } + + neverTranslateLangsTree.invalidate(); + + // Ensure the UI updates to the changes. + this.onSelectNeverTranslateLanguage(); + break; + } + } + } + }, + + /** + * Ensures that buttons states are enabled/disabled accordingly based on the + * content of the trees. + * + * The remove button should be enabled only if an item is selected. + * The removeAll button should be enabled any time the tree has content. + * + * @param {Tree} aTree + * @param {string} aIdPart + */ + _handleButtonDisabling(aTree, aIdPart) { + let empty = aTree.isEmpty; + document.getElementById("removeAll" + aIdPart + "s").disabled = empty; + document.getElementById("remove" + aIdPart).disabled = + empty || !aTree.hasSelection; + }, + + /** + * Updates the UI state for the always-translate languages section. + */ + onSelectAlwaysTranslateLanguage() { + this._handleButtonDisabling( + this._alwaysTranslateLangsTree, + "AlwaysTranslateLanguage" + ); + }, + + /** + * Updates the UI state for the never-translate languages section. + */ + onSelectNeverTranslateLanguage() { + this._handleButtonDisabling( + this._neverTranslateLangsTree, + "NeverTranslateLanguage" + ); + }, + + /** + * Updates the UI state for the never-translate sites section. + */ + onSelectNeverTranslateSite() { + this._handleButtonDisabling( + this._neverTranslateSiteTree, + "NeverTranslateSite" + ); + }, + + /** + * Updates the value of a language pref to match when a language is removed + * through the UI. + * + * @param {string} pref + * @param {Tree} tree + */ + _onRemoveLanguage(pref, tree) { + let langs = Services.prefs.getCharPref(pref); + if (!langs) { + return; + } + + let removed = tree.getSelectedItems().map(l => l.langCode); + + langs = langs.split(",").filter(l => !removed.includes(l)); + Services.prefs.setCharPref(pref, langs.join(",")); + }, + + /** + * Updates the never-translate language pref when a never-translate language + * is removed via the UI. + */ + onRemoveAlwaysTranslateLanguage() { + this._onRemoveLanguage( + ALWAYS_TRANSLATE_LANGS_PREF, + this._alwaysTranslateLangsTree + ); + }, + + /** + * Updates the always-translate language pref when a always-translate language + * is removed via the UI. + */ + onRemoveNeverTranslateLanguage() { + this._onRemoveLanguage( + NEVER_TRANSLATE_LANGS_PREF, + this._neverTranslateLangsTree + ); + }, + + /** + * Updates the permissions for a never-translate site when it is removed via the UI. + */ + onRemoveNeverTranslateSite() { + let removedNeverTranslateSites = + this._neverTranslateSiteTree.getSelectedItems(); + for (let origin of removedNeverTranslateSites) { + TranslationsParent.setNeverTranslateSiteByOrigin(false, origin); + } + }, + + /** + * Clears the always-translate languages pref when the list is cleared in the UI. + */ + onRemoveAllAlwaysTranslateLanguages() { + Services.prefs.setCharPref(ALWAYS_TRANSLATE_LANGS_PREF, ""); + }, + + /** + * Clears the never-translate languages pref when the list is cleared in the UI. + */ + onRemoveAllNeverTranslateLanguages() { + Services.prefs.setCharPref(NEVER_TRANSLATE_LANGS_PREF, ""); + }, + + /** + * Clears the never-translate sites pref when the list is cleared in the UI. + */ + onRemoveAllNeverTranslateSites() { + if (this._neverTranslateSiteTree.isEmpty) { + return; + } + + let removedNeverTranslateSites = this._neverTranslateSites.splice( + 0, + this._neverTranslateSites.length + ); + this._neverTranslateSiteTree.tree.rowCountChanged( + 0, + -removedNeverTranslateSites.length + ); + + for (let origin of removedNeverTranslateSites) { + TranslationsParent.setNeverTranslateSiteByOrigin(false, origin); + } + + this.onSelectNeverTranslateSite(); + }, + + /** + * Handles removing a selected always-translate language via the keyboard. + */ + onAlwaysTranslateLanguageKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_DELETE) { + this.onRemoveAlwaysTranslateLanguage(); + } + }, + + /** + * Handles removing a selected never-translate language via the keyboard. + */ + onNeverTranslateLanguageKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_DELETE) { + this.onRemoveNeverTranslateLanguage(); + } + }, + + /** + * Handles removing a selected never-translate site via the keyboard. + */ + onNeverTranslateSiteKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_DELETE) { + this.onRemoveNeverTranslateSite(); + } + }, + + /** + * Removes any active preference and permissions observers. + */ + removeObservers() { + Services.obs.removeObserver(this, "perm-changed"); + Services.prefs.removeObserver(ALWAYS_TRANSLATE_LANGS_PREF, this); + Services.prefs.removeObserver(NEVER_TRANSLATE_LANGS_PREF, this); + }, +}; diff --git a/browser/components/preferences/dialogs/translations.xhtml b/browser/components/preferences/dialogs/translations.xhtml new file mode 100644 index 0000000000..1a55631b0c --- /dev/null +++ b/browser/components/preferences/dialogs/translations.xhtml @@ -0,0 +1,161 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="TranslationsDialog" + data-l10n-id="translations-settings-title" + data-l10n-attrs="title, style" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="gTranslationsSettings.onLoad();" + onunload="gTranslationsSettings.removeObservers();" + persist="width height" +> + <dialog + buttons="accept" + data-l10n-id="translations-settings-close-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/preferences/preferences.css" + /> + + <html:link rel="localization" href="browser/translations.ftl" /> + </linkset> + + <script src="chrome://browser/content/preferences/dialogs/translations.js" /> + + <keyset> + <key + data-l10n-id="translations-settings-close-key" + modifiers="accel" + oncommand="window.close();" + /> + </keyset> + + <vbox class="contentPane"> + <vbox flex="1"> + <label + id="alwaysTranslateLanguagesLabel" + data-l10n-id="translations-settings-always-translate-langs-description" + control="permissionsTree" + /> + <separator class="thin" /> + <tree + id="alwaysTranslateLanguagesTree" + flex="1" + style="height: 12em" + hidecolumnpicker="true" + onkeypress="gTranslationsSettings.onAlwaysTranslateLanguageKeyPress(event)" + onselect="gTranslationsSettings.onSelectAlwaysTranslateLanguage();" + > + <treecols> + <treecol + id="languageCol" + data-l10n-id="translations-settings-languages-column" + flex="1" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + <hbox class="actionButtons" pack="start"> + <button + id="removeAlwaysTranslateLanguage" + disabled="true" + data-l10n-id="translations-settings-remove-language-button" + oncommand="gTranslationsSettings.onRemoveAlwaysTranslateLanguage();" + /> + <button + id="removeAllAlwaysTranslateLanguages" + data-l10n-id="translations-settings-remove-all-languages-button" + oncommand="gTranslationsSettings.onRemoveAllAlwaysTranslateLanguages();" + /> + </hbox> + <separator /> + <vbox flex="1"> + <label + id="neverTranslateLanguagesLabel" + data-l10n-id="translations-settings-never-translate-langs-description" + control="permissionsTree" + /> + <separator class="thin" /> + <tree + id="neverTranslateLanguagesTree" + flex="1" + style="height: 12em" + hidecolumnpicker="true" + onkeypress="gTranslationsSettings.onNeverTranslateLanguageKeyPress(event)" + onselect="gTranslationsSettings.onSelectNeverTranslateLanguage();" + > + <treecols> + <treecol + id="languageCol" + data-l10n-id="translations-settings-languages-column" + flex="1" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + <hbox class="actionButtons" pack="start"> + <button + id="removeNeverTranslateLanguage" + disabled="true" + data-l10n-id="translations-settings-remove-language-button" + oncommand="gTranslationsSettings.onRemoveNeverTranslateLanguage();" + /> + <button + id="removeAllNeverTranslateLanguages" + data-l10n-id="translations-settings-remove-all-languages-button" + oncommand="gTranslationsSettings.onRemoveAllNeverTranslateLanguages();" + /> + </hbox> + <separator /> + <vbox flex="1"> + <label + id="neverTranslateSitesLabel" + data-l10n-id="translations-settings-never-translate-sites-description" + control="permissionsTree" + /> + <separator class="thin" /> + <tree + id="neverTranslateSitesTree" + flex="1" + style="height: 12em" + hidecolumnpicker="true" + onkeypress="gTranslationsSettings.onNeverTranslateSiteKeyPress(event)" + onselect="gTranslationsSettings.onSelectNeverTranslateSite();" + > + <treecols> + <treecol + id="siteCol" + data-l10n-id="translations-settings-sites-column" + flex="1" + /> + </treecols> + <treechildren /> + </tree> + </vbox> + <hbox class="actionButtons" pack="start"> + <button + id="removeNeverTranslateSite" + disabled="true" + data-l10n-id="translations-settings-remove-site-button" + oncommand="gTranslationsSettings.onRemoveNeverTranslateSite();" + /> + <button + id="removeAllNeverTranslateSites" + data-l10n-id="translations-settings-remove-all-sites-button" + oncommand="gTranslationsSettings.onRemoveAllNeverTranslateSites();" + /> + </hbox> + </vbox> + </dialog> +</window> |