diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mailnews/addrbook/content | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mailnews/addrbook/content')
-rw-r--r-- | comm/mailnews/addrbook/content/abAddressBookNameDialog.js | 103 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abAddressBookNameDialog.xhtml | 51 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abCardDAVDialog.js | 229 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abCardDAVDialog.xhtml | 96 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abCardDAVProperties.js | 140 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abCardDAVProperties.xhtml | 93 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abDragDrop.js | 124 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abMailListDialog.js | 961 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abResultsPane.js | 482 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/abView.js | 539 | ||||
-rw-r--r-- | comm/mailnews/addrbook/content/map-list.js | 217 |
11 files changed, 3035 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/content/abAddressBookNameDialog.js b/comm/mailnews/addrbook/content/abAddressBookNameDialog.js new file mode 100644 index 0000000000..6c921d9285 --- /dev/null +++ b/comm/mailnews/addrbook/content/abAddressBookNameDialog.js @@ -0,0 +1,103 @@ +/* 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 { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var gOkButton; +var gNameInput; +var gDirectory = null; + +var kPersonalAddressbookURI = "jsaddrbook://abook.sqlite"; +var kCollectedAddressbookURI = "jsaddrbook://history.sqlite"; +var kAllDirectoryRoot = "moz-abdirectory://"; + +window.addEventListener("DOMContentLoaded", abNameOnLoad); + +function abNameOnLoad() { + // Get the document elements. + gOkButton = document.querySelector("dialog").getButton("accept"); + gNameInput = document.getElementById("name"); + + // look in arguments[0] for parameters to see if we have a directory or not + if ( + "arguments" in window && + window.arguments[0] && + "selectedDirectory" in window.arguments[0] + ) { + gDirectory = window.arguments[0].selectedDirectory; + gNameInput.value = gDirectory.dirName; + } + + // Work out the window title (if we have a directory specified, then it's a + // rename). + var bundle = document.getElementById("bundle_addressBook"); + + if (gDirectory) { + let oldListName = gDirectory.dirName; + document.title = bundle.getFormattedString("addressBookTitleEdit", [ + oldListName, + ]); + } else { + document.title = bundle.getString("addressBookTitleNew"); + } + + if ( + gDirectory && + (gDirectory.URI == kCollectedAddressbookURI || + gDirectory.URI == kPersonalAddressbookURI || + gDirectory.URI == kAllDirectoryRoot + "?") + ) { + // Address book name is not editable, therefore disable the field and + // only have an ok button that doesn't do anything. + gNameInput.readOnly = true; + document.querySelector("dialog").buttons = "accept"; + } else { + document.addEventListener("dialogaccept", abNameOKButton); + gNameInput.focus(); + abNameDoOkEnabling(); + } +} + +function abNameOKButton(event) { + let newDirName = gNameInput.value.trim(); + + // Do not allow an already existing name. + if ( + MailServices.ab.directoryNameExists(newDirName) && + (!gDirectory || newDirName != gDirectory.dirName) + ) { + const kAlertTitle = document + .getElementById("bundle_addressBook") + .getString("duplicateNameTitle"); + const kAlertText = document + .getElementById("bundle_addressBook") + .getFormattedString("duplicateNameText", [newDirName]); + Services.prompt.alert(window, kAlertTitle, kAlertText); + event.preventDefault(); + return; + } + + // Either create a new directory or update an existing one depending on what + // we were given when we started. + if (gDirectory) { + gDirectory.dirName = newDirName; + } else { + let dirPrefId = MailServices.ab.newAddressBook( + newDirName, + "", + Ci.nsIAbManager.JS_DIRECTORY_TYPE + ); + let directory = MailServices.ab.getDirectoryFromId(dirPrefId); + window.arguments[0].newDirectoryUID = directory.UID; + if ("onNewDirectory" in window.arguments[0]) { + window.arguments[0].onNewDirectory(directory); + } + } +} + +function abNameDoOkEnabling() { + gOkButton.disabled = gNameInput.value.trim() == ""; +} diff --git a/comm/mailnews/addrbook/content/abAddressBookNameDialog.xhtml b/comm/mailnews/addrbook/content/abAddressBookNameDialog.xhtml new file mode 100644 index 0000000000..3fc0d7d9e1 --- /dev/null +++ b/comm/mailnews/addrbook/content/abAddressBookNameDialog.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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/addressbook/abAddressBookNameDialog.dtd"> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + scrolling="false" + style="min-width: 540px; min-height: 210px" +> + <head> + <title><!-- addressBookTitleEdit --></title> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/addressbook/abAddressBookNameDialog.js" + ></script> + </head> + <body> + <xul:dialog> + <xul:stringbundle + id="bundle_addressBook" + src="chrome://messenger/locale/addressbook/addressBook.properties" + /> + + <div class="input-container"> + <label id="nameLabel" for="name">&name.label;</label> + <input + id="name" + type="text" + class="input-inline" + oninput="abNameDoOkEnabling();" + /> + </div> + </xul:dialog> + </body> +</html> diff --git a/comm/mailnews/addrbook/content/abCardDAVDialog.js b/comm/mailnews/addrbook/content/abCardDAVDialog.js new file mode 100644 index 0000000000..8ccce89680 --- /dev/null +++ b/comm/mailnews/addrbook/content/abCardDAVDialog.js @@ -0,0 +1,229 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CardDAVUtils: "resource:///modules/CardDAVUtils.jsm", + MailServices: "resource:///modules/MailServices.jsm", +}); + +var log = console.createInstance({ + prefix: "carddav.setup", + maxLogLevel: "Warn", + maxLogLevelPref: "carddav.setup.loglevel", +}); + +var oAuth = null; +var callbacks = null; +var uiElements = {}; +var userContextId; + +window.addEventListener( + "DOMContentLoaded", + () => { + for (let id of [ + "username", + "location", + "statusArea", + "statusImage", + "statusMessage", + "resultsArea", + "availableBooks", + ]) { + uiElements[id] = document.getElementById("carddav-" + id); + } + }, + { once: true } +); +window.addEventListener( + "DOMContentLoaded", + async () => { + await document.l10n.translateRoots(); + fillLocationPlaceholder(); + setStatus(); + }, + { once: true } +); + +/** + * Update the placeholder text for the network location field. If the username + * is a valid email address use the domain part of the username, otherwise use + * the default placeholder. + */ +function fillLocationPlaceholder() { + let parts = uiElements.username.value.split("@"); + let domain = parts.length == 2 && parts[1] ? parts[1] : null; + + if (domain) { + uiElements.location.setAttribute("placeholder", domain); + } else { + uiElements.location.setAttribute( + "placeholder", + uiElements.location.getAttribute("default-placeholder") + ); + } +} + +function handleCardDAVURLInput(event) { + changeCardDAVURL(); +} + +function changeCardDAVURL() { + uiElements.resultsArea.hidden = true; + setStatus(); +} + +function handleCardDAVURLBlur(event) { + if ( + uiElements.location.validity.typeMismatch && + !uiElements.location.value.match(/^https?:\/\//) + ) { + uiElements.location.value = `https://${uiElements.location.value}`; + } +} + +async function check() { + // We might be accepting the dialog by pressing Enter in the URL input. + handleCardDAVURLBlur(); + + let username = uiElements.username.value; + + if (!uiElements.location.validity.valid && !username.split("@")[1]) { + log.error(`Invalid URL: "${uiElements.location.value}"`); + return; + } + + let url = uiElements.location.value || username.split("@")[1]; + if (!url.match(/^https?:\/\//)) { + url = "https://" + url; + } + + setStatus("loading", "carddav-loading"); + while (uiElements.availableBooks.lastChild) { + uiElements.availableBooks.lastChild.remove(); + } + + let foundBooks; + try { + foundBooks = await CardDAVUtils.detectAddressBooks( + username, + undefined, + url, + true + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_NOT_AVAILABLE) { + setStatus("error", "carddav-known-incompatible", { + url: new URL(url).hostname, + }); + } else { + log.error(ex); + setStatus("error", "carddav-connection-error"); + } + return; + } + + // Create a list of CardDAV directories that already exist. + let existing = []; + for (let d of MailServices.ab.directories) { + if (d.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) { + existing.push(d.getStringValue("carddav.url", "")); + } + } + + // Display a checkbox for each directory that doesn't already exist. + let alreadyAdded = 0; + for (let book of foundBooks) { + if (existing.includes(book.url.href)) { + alreadyAdded++; + continue; + } + let checkbox = uiElements.availableBooks.appendChild( + document.createXULElement("checkbox") + ); + checkbox.setAttribute("label", book.name); + checkbox.checked = true; + checkbox.value = book.url.href; + checkbox._book = book; + } + + if (uiElements.availableBooks.childElementCount == 0) { + if (alreadyAdded > 0) { + setStatus("error", "carddav-already-added"); + } else { + setStatus("error", "carddav-none-found"); + } + } else { + uiElements.resultsArea.hidden = false; + setStatus(); + } +} + +function setStatus(status, message, args) { + uiElements.username.disabled = status == "loading"; + uiElements.location.disabled = status == "loading"; + + switch (status) { + case "loading": + uiElements.statusImage.setAttribute( + "src", + "chrome://global/skin/icons/loading.png" + ); + uiElements.statusImage.setAttribute( + "srcset", + "chrome://global/skin/icons/loading@2x.png 2x" + ); + break; + case "error": + uiElements.statusImage.setAttribute( + "src", + "chrome://global/skin/icons/warning.svg" + ); + uiElements.statusImage.removeAttribute("srcset"); + break; + default: + uiElements.statusImage.removeAttribute("src"); + uiElements.statusImage.removeAttribute("srcset"); + break; + } + + if (status) { + uiElements.statusArea.setAttribute("status", status); + document.l10n.setAttributes(uiElements.statusMessage, message, args); + } else { + uiElements.statusArea.removeAttribute("status"); + uiElements.statusMessage.removeAttribute("data-l10n-id"); + uiElements.statusMessage.textContent = ""; + } + + // Grow to fit the list of books. Uses `resizeBy` because it has special + // handling in SubDialog.jsm that the other resize functions don't have. + window.resizeBy(0, Math.min(250, uiElements.availableBooks.scrollHeight)); + window.dispatchEvent(new CustomEvent("status-changed")); +} + +window.addEventListener("dialogaccept", event => { + if (uiElements.resultsArea.hidden) { + event.preventDefault(); + check(); + return; + } + + if (uiElements.availableBooks.childElementCount == 0) { + return; + } + + for (let checkbox of uiElements.availableBooks.children) { + if (checkbox.checked) { + let book = checkbox._book.create(); + if (window.arguments[0]) { + // Pass the UID of the book back to the opening window. + window.arguments[0].newDirectoryUID = book.UID; + } + } + } +}); diff --git a/comm/mailnews/addrbook/content/abCardDAVDialog.xhtml b/comm/mailnews/addrbook/content/abCardDAVDialog.xhtml new file mode 100644 index 0000000000..9e0fc5e1e4 --- /dev/null +++ b/comm/mailnews/addrbook/content/abCardDAVDialog.xhtml @@ -0,0 +1,96 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/cardDAV.css" type="text/css"?> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + scrolling="false" + style="min-width: 540px; min-height: 210px" +> + <head> + <title data-l10n-id="carddav-window-title"></title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/addressbook/abCardDAVDialog.ftl" /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/addressbook/abCardDAVDialog.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <dialog + id="carddav-dialog" + data-l10n-id="carddav-dialog" + data-l10n-attrs="buttonlabelaccept,buttonaccesskeyaccept" + > + <html:div id="carddav-properties-table"> + <html:div> + <label + control="carddav-username" + data-l10n-id="carddav-username-label" + data-l10n-attrs="value,accesskey" + /> + </html:div> + <html:div class="input-container"> + <html:input + id="carddav-username" + type="text" + oninput="fillLocationPlaceholder();" + /> + </html:div> + <html:div> + <label + control="carddav-location" + data-l10n-id="carddav-location-label" + data-l10n-attrs="value,accesskey" + /> + </html:div> + <html:div class="input-container"> + <html:input + id="carddav-location" + type="url" + data-l10n-id="carddav-location" + data-l10n-attrs="default-placeholder" + required="required" + onblur="handleCardDAVURLBlur(event);" + oninput="handleCardDAVURLInput(event);" + /> + </html:div> + </html:div> + + <html:div id="carddav-statusArea"> + <html:div id="carddav-statusContainer"> + <html:img id="carddav-statusImage" alt="" /> + <html:span id="carddav-statusMessage"> </html:span> + <!-- Include 160 = nbsp, to make the element occupy the + full height, for at least one line. With a normal space, + it does not have sufficient height. --> + </html:div> + </html:div> + + <vbox id="carddav-resultsArea" hidden="true" flex="1"> + <label + id="carddav-availableBooksHeader" + data-l10n-id="carddav-available-books" + /> + <vbox id="carddav-availableBooks" class="indent"></vbox> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/mailnews/addrbook/content/abCardDAVProperties.js b/comm/mailnews/addrbook/content/abCardDAVProperties.js new file mode 100644 index 0000000000..3b2bddf2c4 --- /dev/null +++ b/comm/mailnews/addrbook/content/abCardDAVProperties.js @@ -0,0 +1,140 @@ +/* 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 { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var gDirectory = window.arguments[0].selectedDirectory; +var gStringBundle, + gNameInput, + gURLInput, + gRefreshActiveInput, + gRefreshMenulist, + gReadOnlyInput, + gAcceptButton; + +window.addEventListener( + "DOMContentLoaded", + () => { + gStringBundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + document.title = gStringBundle.formatStringFromName( + "addressBookTitleEdit", + [gDirectory.dirName] + ); + + gNameInput = document.getElementById("carddav-name"); + gNameInput.value = gDirectory.dirName; + gNameInput.addEventListener("input", () => { + gAcceptButton.disabled = gNameInput.value.trim() == ""; + }); + + gURLInput = document.getElementById("carddav-url"); + gURLInput.value = gDirectory.getStringValue("carddav.url", ""); + + gRefreshActiveInput = document.getElementById("carddav-refreshActive"); + gRefreshActiveInput.addEventListener( + "command", + () => (gRefreshMenulist.disabled = !gRefreshActiveInput.checked) + ); + + gRefreshMenulist = document.getElementById("carddav-refreshInterval"); + initRefreshInterval(); + + gReadOnlyInput = document.getElementById("carddav-readOnly"); + gReadOnlyInput.checked = gDirectory.readOnly; + + gAcceptButton = document.querySelector("dialog").getButton("accept"); + }, + { once: true } +); + +window.addEventListener("dialogaccept", event => { + let newDirName = gNameInput.value.trim(); + let newSyncInterval = gRefreshActiveInput.checked + ? gRefreshMenulist.value + : 0; + + if (newDirName != gDirectory.dirName) { + // Do not allow an already existing name. + if (MailServices.ab.directoryNameExists(newDirName)) { + let alertTitle = gStringBundle.GetStringFromName("duplicateNameTitle"); + let alertText = gStringBundle.formatStringFromName("duplicateNameText", [ + newDirName, + ]); + Services.prompt.alert(window, alertTitle, alertText); + event.preventDefault(); + return; + } + + gDirectory.dirName = newDirName; + } + + if (newSyncInterval != gDirectory.getIntValue("carddav.syncinterval", -1)) { + gDirectory.setIntValue("carddav.syncinterval", newSyncInterval); + } + + if (gReadOnlyInput.checked != gDirectory.readOnly) { + gDirectory.setBoolValue("readOnly", gReadOnlyInput.checked); + } +}); + +function initRefreshInterval() { + function createMenuItem(minutes) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("value", minutes); + menuitem.setAttribute("data-l10n-attrs", "label"); + if (minutes < 60) { + document.l10n.setAttributes( + menuitem, + "carddav-refreshinterval-minutes-value", + { + minutes, + } + ); + } else { + document.l10n.setAttributes( + menuitem, + "carddav-refreshinterval-hours-value", + { + hours: minutes / 60, + } + ); + } + + gRefreshMenulist.menupopup.appendChild(menuitem); + if (refreshInterval == minutes) { + gRefreshMenulist.value = minutes; + foundValue = true; + } + + return menuitem; + } + + let refreshInterval = gDirectory.getIntValue("carddav.syncinterval", 30); + if (refreshInterval === null) { + refreshInterval = 30; + } + + let foundValue = false; + + for (let min of [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]) { + createMenuItem(min); + } + + if (refreshInterval == 0) { + gRefreshMenulist.value = 30; // The default. + gRefreshMenulist.disabled = true; + foundValue = true; + } else { + gRefreshActiveInput.checked = true; + } + + if (!foundValue) { + // Special menuitem in case the user changed the value in the config editor. + createMenuItem(refreshInterval); + } +} diff --git a/comm/mailnews/addrbook/content/abCardDAVProperties.xhtml b/comm/mailnews/addrbook/content/abCardDAVProperties.xhtml new file mode 100644 index 0000000000..b611cbbf43 --- /dev/null +++ b/comm/mailnews/addrbook/content/abCardDAVProperties.xhtml @@ -0,0 +1,93 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/cardDAV.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/addressbook/abAddressBookNameDialog.dtd"> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + width="540" + height="210" + scrolling="false" +> + <head> + <title><!-- addressBookTitleEdit --></title> + <link + rel="localization" + href="messenger/addressbook/abCardDAVProperties.ftl" + /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/addressbook/abCardDAVProperties.js" + ></script> + </head> + <body> + <xul:dialog id="carddav-properties-dialog"> + <div id="carddav-properties-table"> + <div> + <xul:label + control="carddav-name" + value="&name.label;" + accesskey="&name.accesskey;" + /> + </div> + <div class="input-container"> + <input id="carddav-name" type="text" class="input-inline" /> + </div> + <div> + <xul:label + data-l10n-id="carddav-url-label" + data-l10n-attrs="value,accesskey" + control="carddav-url" + /> + </div> + <div class="input-container"> + <input + id="carddav-url" + type="url" + class="input-inline" + readonly="readonly" + /> + </div> + <div id="carddav-refreshActive-cell"> + <xul:checkbox + id="carddav-refreshActive" + data-l10n-id="carddav-refreshinterval-label" + data-l10n-attrs="label,accesskey" + control="carddav-refreshInterval" + /> + </div> + <div id="carddav-refreshInterval-cell"> + <xul:menulist id="carddav-refreshInterval"> + <xul:menupopup> + <!-- This will be filled programmatically to reduce the number of needed strings --> + </xul:menupopup> + </xul:menulist> + </div> + <div></div> + <div> + <xul:checkbox + id="carddav-readOnly" + data-l10n-id="carddav-readonly-label" + data-l10n-attrs="label,accesskey" + /> + </div> + </div> + </xul:dialog> + </body> +</html> diff --git a/comm/mailnews/addrbook/content/abDragDrop.js b/comm/mailnews/addrbook/content/abDragDrop.js new file mode 100644 index 0000000000..56cbee0e08 --- /dev/null +++ b/comm/mailnews/addrbook/content/abDragDrop.js @@ -0,0 +1,124 @@ +/* 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 abResultsPane.js */ + +// Returns the load context for the current window +function getLoadContext() { + return window.docShell.QueryInterface(Ci.nsILoadContext); +} + +var abFlavorDataProvider = { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(aTransferable, aFlavor, aData) { + if (aFlavor == "application/x-moz-file-promise") { + var primitive = {}; + aTransferable.getTransferData("text/vcard", primitive); + var vCard = primitive.value.QueryInterface(Ci.nsISupportsString).data; + aTransferable.getTransferData( + "application/x-moz-file-promise-dest-filename", + primitive + ); + var leafName = primitive.value.QueryInterface(Ci.nsISupportsString).data; + aTransferable.getTransferData( + "application/x-moz-file-promise-dir", + primitive + ); + var localFile = primitive.value.QueryInterface(Ci.nsIFile).clone(); + localFile.append(leafName); + + var ofStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + ofStream.init(localFile, -1, -1, 0); + var converter = Cc[ + "@mozilla.org/intl/converter-output-stream;1" + ].createInstance(Ci.nsIConverterOutputStream); + converter.init(ofStream, null); + converter.writeString(vCard); + converter.close(); + + aData.value = localFile; + } + }, +}; + +let abResultsPaneObserver = { + onDragStart(event) { + let selectedRows = GetSelectedRows(); + + if (!selectedRows) { + return; + } + + let selectedAddresses = GetSelectedAddresses(); + + event.dataTransfer.setData("moz/abcard", selectedRows); + event.dataTransfer.setData("moz/abcard", selectedRows); + event.dataTransfer.setData("text/x-moz-address", selectedAddresses); + event.dataTransfer.setData("text/plain", selectedAddresses); + + let card = GetSelectedCard(); + if (card && card.displayName && !card.isMailList) { + try { + // A card implementation may throw NS_ERROR_NOT_IMPLEMENTED. + // Don't break drag-and-drop if that happens. + let vCard = card.translateTo("vcard"); + event.dataTransfer.setData("text/vcard", decodeURIComponent(vCard)); + event.dataTransfer.setData( + "application/x-moz-file-promise-dest-filename", + `${card.displayName}.vcf`.replace(/(.{74}).*(.{10})$/u, "$1...$2") + ); + event.dataTransfer.setData( + "application/x-moz-file-promise-url", + "data:text/vcard," + vCard + ); + event.dataTransfer.setData( + "application/x-moz-file-promise", + abFlavorDataProvider + ); + } catch (ex) { + console.error(ex); + } + } + + event.dataTransfer.effectAllowed = "copyMove"; + // a drag targeted at a tree should instead use the treechildren so that + // the current selection is used as the drag feedback + event.dataTransfer.addElement(event.target); + event.stopPropagation(); + }, +}; + +function DragAddressOverTargetControl(event) { + var dragSession = Cc["@mozilla.org/widget/dragservice;1"] + .getService(Ci.nsIDragService) + .getCurrentSession(); + + if (!dragSession.isDataFlavorSupported("text/x-moz-address")) { + return; + } + + var trans = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + trans.init(getLoadContext()); + trans.addDataFlavor("text/x-moz-address"); + + var canDrop = true; + + for (var i = 0; i < dragSession.numDropItems; ++i) { + dragSession.getData(trans, i); + var dataObj = {}; + var bestFlavor = {}; + try { + trans.getAnyTransferData(bestFlavor, dataObj); + } catch (ex) { + canDrop = false; + break; + } + } + dragSession.canDrop = canDrop; +} diff --git a/comm/mailnews/addrbook/content/abMailListDialog.js b/comm/mailnews/addrbook/content/abMailListDialog.js new file mode 100644 index 0000000000..6d512d3014 --- /dev/null +++ b/comm/mailnews/addrbook/content/abMailListDialog.js @@ -0,0 +1,961 @@ +/* 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 ../../../mail/components/addrbook/content/abCommon.js */ +/* import-globals-from ../../../mail/components/compose/content/addressingWidgetOverlay.js */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +top.MAX_RECIPIENTS = 1; + +var gListCard; +var gEditList; +var gOldListName = ""; + +var gAWContentHeight = 0; +var gAWRowHeight = 0; +var gNumberOfCols = 0; + +var test_addresses_sequence = false; + +if ( + Services.prefs.getPrefType("mail.debug.test_addresses_sequence") == + Ci.nsIPrefBranch.PREF_BOOL +) { + test_addresses_sequence = Services.prefs.getBoolPref( + "mail.debug.test_addresses_sequence" + ); +} + +try { + var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); +} catch (e) {} + +// Returns the load context for the current window +function getLoadContext() { + return window.docShell.QueryInterface(Ci.nsILoadContext); +} + +function mailingListExists(listname) { + if (MailServices.ab.mailListNameExists(listname)) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + Services.prompt.alert( + window, + bundle.GetStringFromName("mailListNameExistsTitle"), + bundle.GetStringFromName("mailListNameExistsMessage") + ); + return true; + } + return false; +} + +/** + * Get the new inputs from the create/edit mailing list dialog and use them to + * update the mailing list that was passed in as an argument. + * + * @param {nsIAbDirectory} mailList - The mailing list object to update. When + * creating a new list it will be newly created and empty. + * @param {boolean} isNewList - Whether we are populating a new list. + * @returns {boolean} - Whether the operation succeeded or not. + */ +function updateMailList(mailList, isNewList) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + let listname = document.getElementById("ListName").value.trim(); + + if (listname.length == 0) { + alert(bundle.GetStringFromName("emptyListName")); + return false; + } + + if (listname.match(" ")) { + alert(bundle.GetStringFromName("badListNameSpaces")); + return false; + } + + for (let char of ',;"<>') { + if (listname.includes(char)) { + alert(bundle.GetStringFromName("badListNameCharacters")); + return false; + } + } + + let canonicalNewListName = listname.toLowerCase(); + let canonicalOldListName = gOldListName.toLowerCase(); + if (isNewList || canonicalOldListName != canonicalNewListName) { + if (mailingListExists(listname)) { + // After showing the "Mailing List Already Exists" error alert, + // focus ListName input field for user to choose a different name. + document.getElementById("ListName").focus(); + return false; + } + } + + mailList.isMailList = true; + mailList.dirName = listname; + mailList.listNickName = document.getElementById("ListNickName").value; + mailList.description = document.getElementById("ListDescription").value; + + return true; +} + +/** + * Updates the members of the mailing list. + * + * @param {nsIAbDirectory} mailList - The mailing list object to + * update. When creating a new list it will be newly created and empty. + * @param {nsIAbDirectory} parentDirectory - The address book containing the + * mailing list. + */ +function updateMailListMembers(mailList, parentDirectory) { + // Gather email address inputs into a single string (comma-separated). + let addresses = Array.from( + document.querySelectorAll(".textbox-addressingWidget"), + element => element.value + ) + .filter(value => value.trim()) + .join(); + + // Convert the addresses string into address objects. + let addressObjects = + MailServices.headerParser.makeFromDisplayAddress(addresses); + let existingCards = mailList.childCards; + + // Work out which addresses need to be added... + let existingCardAddresses = existingCards.map(card => card.primaryEmail); + let addressObjectsToAdd = addressObjects.filter( + aObj => !existingCardAddresses.includes(aObj.email) + ); + + // ... and which need to be removed. + let addressObjectAddresses = addressObjects.map(aObj => aObj.email); + let cardsToRemove = existingCards.filter( + card => !addressObjectAddresses.includes(card.primaryEmail) + ); + + for (let { email, name } of addressObjectsToAdd) { + let card = parentDirectory.cardForEmailAddress(email); + if (!card) { + card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + card.primaryEmail = email; + card.displayName = name || email; + } + mailList.addCard(card); + } + + if (cardsToRemove.length > 0) { + mailList.deleteCards(cardsToRemove); + } +} + +function MailListOKButton(event) { + var popup = document.getElementById("abPopup"); + if (popup) { + var uri = popup.getAttribute("value"); + + // FIX ME - hack to avoid crashing if no ab selected because of blank option bug from template + // should be able to just remove this if we are not seeing blank lines in the ab popup + if (!uri) { + event.preventDefault(); + return; // don't close window + } + // ----- + + // Add mailing list to database + var mailList = + Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(); + mailList = mailList.QueryInterface(Ci.nsIAbDirectory); + + if (updateMailList(mailList, true)) { + var parentDirectory = GetDirectoryFromURI(uri); + mailList = parentDirectory.addMailList(mailList); + updateMailListMembers(mailList, parentDirectory); + window.arguments[0].newListUID = mailList.UID; + window.arguments[0].newListURI = mailList.URI; + } else { + event.preventDefault(); + } + } +} + +function OnLoadNewMailList() { + var selectedAB = null; + + if ("arguments" in window && window.arguments[0]) { + var abURI = window.arguments[0].selectedAB; + if (abURI && abURI != kAllDirectoryRoot + "?") { + var directory = GetDirectoryFromURI(abURI); + if (directory.isMailList) { + var parentURI = GetParentDirectoryFromMailingListURI(abURI); + if (parentURI) { + selectedAB = parentURI; + } + } else if (directory.readOnly) { + selectedAB = kPersonalAddressbookURI; + } else { + selectedAB = abURI; + } + } + + let cards = window.arguments[0].cards; + if (cards && cards.length > 0) { + let listbox = document.getElementById("addressingWidget"); + let newListBoxNode = listbox.cloneNode(false); + let templateNode = listbox.querySelector("richlistitem"); + + top.MAX_RECIPIENTS = 0; + for (let card of cards) { + let address = MailServices.headerParser + .makeMailboxObject(card.displayName, card.primaryEmail) + .toString(); + SetInputValue(address, newListBoxNode, templateNode); + } + listbox.parentNode.replaceChild(newListBoxNode, listbox); + } + } + + if (!selectedAB) { + selectedAB = kPersonalAddressbookURI; + } + + // set popup with address book names + var abPopup = document.getElementById("abPopup"); + abPopup.value = selectedAB; + + AppendNewRowAndSetFocus(); + awFitDummyRows(1); + + if (AppConstants.MOZ_APP_NAME == "seamonkey") { + /* global awDocumentKeyPress */ + document.addEventListener("keypress", awDocumentKeyPress, true); + } + + // focus on first name + var listName = document.getElementById("ListName"); + if (listName) { + setTimeout( + function (firstTextBox) { + firstTextBox.focus(); + }, + 0, + listName + ); + } + + let input = document.getElementById("addressCol1#1"); + input.popup.addEventListener("click", () => { + awReturnHit(input); + }); + + document.addEventListener("dialogaccept", MailListOKButton); +} + +function EditListOKButton(event) { + // edit mailing list in database + if (updateMailList(gEditList, false)) { + let parentURI = GetParentDirectoryFromMailingListURI(gEditList.URI); + let parentDirectory = GetDirectoryFromURI(parentURI); + updateMailListMembers(gEditList, parentDirectory); + if (gListCard) { + // modify the list card (for the results pane) from the mailing list + gListCard.displayName = gEditList.dirName; + gListCard.lastName = gEditList.dirName; + gListCard.setProperty("NickName", gEditList.listNickName); + gListCard.setProperty("Notes", gEditList.description); + } + + gEditList.editMailListToDatabase(gListCard); + + window.arguments[0].refresh = true; + return; // close the window + } + event.preventDefault(); +} + +function OnLoadEditList() { + gListCard = window.arguments[0].abCard; + var listUri = window.arguments[0].listURI; + + gEditList = GetDirectoryFromURI(listUri); + + document.getElementById("ListName").value = gEditList.dirName; + document.getElementById("ListNickName").value = gEditList.listNickName; + document.getElementById("ListDescription").value = gEditList.description; + gOldListName = gEditList.dirName; + + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + document.title = bundle.formatStringFromName("mailingListTitleEdit", [ + gOldListName, + ]); + + let cards = gEditList.childCards; + if (cards.length > 0) { + let listbox = document.getElementById("addressingWidget"); + let newListBoxNode = listbox.cloneNode(false); + let templateNode = listbox.querySelector("richlistitem"); + + top.MAX_RECIPIENTS = 0; + for (let card of cards) { + let address = MailServices.headerParser + .makeMailboxObject(card.displayName, card.primaryEmail) + .toString(); + SetInputValue(address, newListBoxNode, templateNode); + } + listbox.parentNode.replaceChild(newListBoxNode, listbox); + } + + // Is this directory read-only? If so, we now need to set all the fields to + // read-only. + if (gEditList.readOnly) { + const kMailListFields = ["ListName", "ListNickName", "ListDescription"]; + + for (let i = 0; i < kMailListFields.length; ++i) { + document.getElementById(kMailListFields[i]).readOnly = true; + } + + document.querySelector("dialog").buttons = "accept"; + + // Getting a sane read-only implementation for the addressing widget would + // basically need a separate dialog. Given I'm not sure about the future of + // the mailing list dialog in its current state, let's just disable it + // completely. + document.getElementById("addressingWidget").disabled = true; + } else { + document.addEventListener("dialogaccept", EditListOKButton); + } + + if (AppConstants.MOZ_APP_NAME == "seamonkey") { + document.addEventListener("keypress", awDocumentKeyPress, true); + } + + // workaround for bug 118337 - for mailing lists that have more rows than fits inside + // the display, the value of the textbox inside the new row isn't inherited into the input - + // the first row then appears to be duplicated at the end although it is actually empty. + // see awAppendNewRow which copies first row and clears it + setTimeout(AppendLastRow, 0); + + document.querySelectorAll(`input[is="autocomplete-input"]`).forEach(input => { + input.popup.addEventListener("click", () => { + awReturnHit(input); + }); + }); +} + +function AppendLastRow() { + AppendNewRowAndSetFocus(); + awFitDummyRows(1); + + // focus on first name + let listName = document.getElementById("ListName"); + if (listName) { + listName.focus(); + } +} + +function AppendNewRowAndSetFocus() { + let lastInput = awGetInputElement(top.MAX_RECIPIENTS); + if (lastInput && lastInput.value) { + awAppendNewRow(true); + } else { + awSetFocusTo(lastInput); + } +} + +function SetInputValue(inputValue, parentNode, templateNode) { + top.MAX_RECIPIENTS++; + + var newNode = templateNode.cloneNode(true); + parentNode.appendChild(newNode); // we need to insert the new node before we set the value of the select element! + + var input = newNode.querySelector(`input[is="autocomplete-input"]`); + let label = newNode.querySelector(`label.person-icon`); + if (input) { + input.value = inputValue; + input.setAttribute("id", "addressCol1#" + top.MAX_RECIPIENTS); + label.setAttribute("for", "addressCol1#" + top.MAX_RECIPIENTS); + input.popup.addEventListener("click", () => { + awReturnHit(input); + }); + } +} + +function awClickEmptySpace(target, setFocus) { + if (target == null || target.localName != "hbox") { + return; + } + + let lastInput = awGetInputElement(top.MAX_RECIPIENTS); + + if (lastInput && lastInput.value) { + awAppendNewRow(setFocus); + } else if (setFocus) { + awSetFocusTo(lastInput); + } +} + +function awReturnHit(inputElement) { + let row = awGetRowByInputElement(inputElement); + if (inputElement.value) { + let nextInput = awGetInputElement(row + 1); + if (!nextInput) { + awAppendNewRow(true); + } else { + awSetFocusTo(nextInput); + } + } +} + +function awDeleteRow(rowToDelete) { + /* When we delete a row, we must reset the id of others row in order to not break the sequence */ + var maxRecipients = top.MAX_RECIPIENTS; + awRemoveRow(rowToDelete); + + var numberOfCols = awGetNumberOfCols(); + for (var row = rowToDelete + 1; row <= maxRecipients; row++) { + for (var col = 1; col <= numberOfCols; col++) { + awGetElementByCol(row, col).setAttribute( + "id", + "addressCol" + col + "#" + (row - 1) + ); + } + } + + awTestRowSequence(); +} + +/** + * Append a new row. + * + * @param {boolean} setFocus - Whether to set the focus on the new row. + * @returns {Element?} The input element from the new row. + */ +function awAppendNewRow(setFocus) { + let body = document.getElementById("addressingWidget"); + let listitem1 = awGetListItem(1); + let input; + let label; + + if (body && listitem1) { + let nextDummy = awGetNextDummyRow(); + let newNode = listitem1.cloneNode(true); + if (nextDummy) { + body.replaceChild(newNode, nextDummy); + } else { + body.appendChild(newNode); + } + + top.MAX_RECIPIENTS++; + + input = newNode.querySelector(`input[is="autocomplete-input"]`); + label = newNode.querySelector(`label.person-icon`); + if (input) { + input.value = ""; + input.setAttribute("id", "addressCol1#" + top.MAX_RECIPIENTS); + label.setAttribute("for", "addressCol1#" + top.MAX_RECIPIENTS); + input.popup.addEventListener("click", () => { + awReturnHit(input); + }); + } + // Focus the new input widget. + if (setFocus && input) { + awSetFocusTo(input); + } + } + return input; +} + +// functions for accessing the elements in the addressing widget + +/** + * Returns the recipient inputbox for a row. + * + * @param {integer} row - Index of the recipient row to return. Starts at 1. + * @returns {Element} This returns the input element. + */ +function awGetInputElement(row) { + return document.getElementById("addressCol1#" + row); +} + +function awGetElementByCol(row, col) { + var colID = "addressCol" + col + "#" + row; + return document.getElementById(colID); +} + +function awGetListItem(row) { + var listbox = document.getElementById("addressingWidget"); + if (listbox && row > 0) { + return listbox.getItemAtIndex(row - 1); + } + + return null; +} + +/** + * @param {Element} inputElement - The recipient input element. + * @returns {integer} The row index (starting from 1) where the input element + * is found. 0 if the element is not found. + */ +function awGetRowByInputElement(inputElement) { + if (!inputElement) { + return 0; + } + + var listitem = inputElement.parentNode.parentNode; + return ( + document.getElementById("addressingWidget").getIndexOfItem(listitem) + 1 + ); +} + +function DragOverAddressListTree(event) { + var dragSession = gDragService.getCurrentSession(); + + // XXX add support for other flavors here + if (dragSession.isDataFlavorSupported("text/x-moz-address")) { + dragSession.canDrop = true; + } +} + +function DropOnAddressListTree(event) { + let dragSession = gDragService.getCurrentSession(); + let trans; + + try { + trans = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + trans.init(getLoadContext()); + trans.addDataFlavor("text/x-moz-address"); + } catch (ex) { + return; + } + + for (let i = 0; i < dragSession.numDropItems; ++i) { + dragSession.getData(trans, i); + let dataObj = {}; + let bestFlavor = {}; + trans.getAnyTransferData(bestFlavor, dataObj); + if (dataObj) { + dataObj = dataObj.value.QueryInterface(Ci.nsISupportsString); + } + if (!dataObj) { + continue; + } + + // pull the URL out of the data object + let address = dataObj.data.substring(0, dataObj.length); + if (!address) { + continue; + } + + DropListAddress(event.target, address); + } +} + +function DropListAddress(target, address) { + // Set focus on a new available, visible row. + awClickEmptySpace(target, true); + if (top.MAX_RECIPIENTS == 0) { + top.MAX_RECIPIENTS = 1; + } + + // Break apart the MIME-ready header address into individual addressees to + // add to the dialog. + let addresses = MailServices.headerParser.parseEncodedHeader(address); + for (let addr of addresses) { + let lastInput = awGetInputElement(top.MAX_RECIPIENTS); + lastInput.value = addr.toString(); + awAppendNewRow(true); + } +} + +/** + * Handles keypress events for the email address inputs (that auto-fill) + * in the Address Book Mailing List dialogs. When a comma-separated list of + * addresses is entered on one row, split them into one address per row. Only + * add a new blank row on "Enter" key. On "Tab" key focus moves to the "Cancel" + * button. + * + * @param {KeyboardEvent} event - The DOM keypress event. + * @param {Element} element - The element that triggered the keypress event. + */ +function awAbRecipientKeyPress(event, element) { + if (event.key != "Enter" && event.key != "Tab") { + return; + } + + if (!element.value) { + if (event.key == "Enter") { + awReturnHit(element); + } + } else { + let inputElement = element; + let originalRow = awGetRowByInputElement(element); + let row; + let addresses = MailServices.headerParser.makeFromDisplayAddress( + element.value + ); + + if (addresses.length > 1) { + // Collect any existing addresses from the following rows so we don't + // simply overwrite them. + row = originalRow + 1; + inputElement = awGetInputElement(row); + + while (inputElement) { + if (inputElement.value) { + addresses.push(inputElement.value); + inputElement.value = ""; + } + row += 1; + inputElement = awGetInputElement(row); + } + } + + // Insert the addresses, adding new rows if needed. + row = originalRow; + let needNewRows = false; + + for (let address of addresses) { + if (needNewRows) { + inputElement = awAppendNewRow(false); + } else { + inputElement = awGetInputElement(row); + if (!inputElement) { + needNewRows = true; + inputElement = awAppendNewRow(false); + } + } + + if (inputElement) { + inputElement.value = address; + } + row += 1; + } + + if (event.key == "Enter") { + // Prevent the dialog from closing. "Enter" inserted a new row instead. + event.preventDefault(); + awReturnHit(inputElement); + } else if (event.key == "Tab") { + // Focus the last row to let "Tab" move focus to the "Cancel" button. + let lastRow = row - 1; + awGetInputElement(lastRow).focus(); + } + } +} + +/** + * Handle keydown event on a recipient input. + * Enables recipient row deletion with DEL or BACKSPACE and + * recipient list navigation with cursor up/down. + * + * Note that the keydown event fires for ALL keys, so this may affect + * autocomplete as user enters a recipient text. + * + * @param {KeyboardEvent} event - The keydown event fired on a recipient input. + * @param {HTMLInputElement} inputElement - The recipient input element + * on which the event fired (textbox-addressingWidget). + */ +function awRecipientKeyDown(event, inputElement) { + switch (event.key) { + // Enable deletion of empty recipient rows. + case "Delete": + case "Backspace": + if (inputElement.value.length == 1 && event.repeat) { + // User is holding down Delete or Backspace to delete recipient text + // inline and is now deleting the last character: Set flag to + // temporarily block row deletion. + top.awRecipientInlineDelete = true; + } + if (!inputElement.value && !event.altKey) { + // When user presses DEL or BACKSPACE on an empty row, and it's not an + // ongoing inline deletion, and not ALT+BACKSPACE for input undo, + // we delete the row. + if (top.awRecipientInlineDelete && !event.repeat) { + // User has released and re-pressed Delete or Backspace key + // after holding them down to delete recipient text inline: + // unblock row deletion. + top.awRecipientInlineDelete = false; + } + if (!top.awRecipientInlineDelete) { + let deleteForward = event.key == "Delete"; + awDeleteHit(inputElement, deleteForward); + } + } + break; + + // Enable browsing the list of recipients up and down with cursor keys. + case "ArrowDown": + case "ArrowUp": + // Only browse recipients if the autocomplete popup is not open. + if (!inputElement.popupOpen) { + let row = awGetRowByInputElement(inputElement); + let down = event.key == "ArrowDown"; + let noEdgeRow = down ? row < top.MAX_RECIPIENTS : row > 1; + if (noEdgeRow) { + let targetRow = down ? row + 1 : row - 1; + awSetFocusTo(awGetInputElement(targetRow)); + } + } + break; + } +} + +/** + * Delete recipient row (addressingWidgetItem) from UI. + * + * @param {HTMLInputElement} inputElement - The recipient input element. + * textbox-addressingWidget) whose parent row (addressingWidgetItem) will be + * deleted. + * @param {boolean} deleteForward - true: focus next row after deleting the row + * false: focus previous row after deleting the row + */ +function awDeleteHit(inputElement, deleteForward = false) { + let row = awGetRowByInputElement(inputElement); + + // Don't delete the row if it's the last one remaining; just reset it. + if (top.MAX_RECIPIENTS <= 1) { + inputElement.value = ""; + return; + } + + // Set the focus to the input field of the next/previous row according to + // the direction of deleting if possible. + // Note: awSetFocusTo() is asynchronous, i.e. we'll focus after row removal. + if ( + (!deleteForward && row > 1) || + (deleteForward && row == top.MAX_RECIPIENTS) + ) { + // We're deleting backwards, but not the first row, + // or forwards on the last row: Focus previous row. + awSetFocusTo(awGetInputElement(row - 1)); + } else { + // We're deleting forwards, but not the last row, + // or backwards on the first row: Focus next row. + awSetFocusTo(awGetInputElement(row + 1)); + } + + // Delete the row. + awDeleteRow(row); +} + +function awTestRowSequence() { + /* + This function is for debug and testing purpose only, normal user should not run it! + + Every time we insert or delete a row, we must be sure we didn't break the ID sequence of + the addressing widget rows. This function will run a quick test to see if the sequence still ok + + You need to define the pref mail.debug.test_addresses_sequence to true in order to activate it + */ + + if (!test_addresses_sequence) { + return true; + } + + // Debug code to verify the sequence is still good. + + let listbox = document.getElementById("addressingWidget"); + let listitems = listbox.itemChildren; + if (listitems.length >= top.MAX_RECIPIENTS) { + for (let i = 1; i <= listitems.length; i++) { + let item = listitems[i - 1]; + let inputID = item + .querySelector(`input[is="autocomplete-input"]`) + .id.split("#")[1]; + let menulist = item.querySelector("menulist"); + // In some places like the mailing list dialog there is no menulist, + // and so no popupID that needs to be kept in sequence. + let popupID = menulist && menulist.id.split("#")[1]; + if (inputID != i || (popupID && popupID != i)) { + dump( + `#ERROR: sequence broken at row ${i}, ` + + `inputID=${inputID}, popupID=${popupID}\n` + ); + return false; + } + dump("---SEQUENCE OK---\n"); + return true; + } + } else { + dump( + `#ERROR: listitems.length(${listitems.length}) < ` + + `top.MAX_RECIPIENTS(${top.MAX_RECIPIENTS})\n` + ); + } + + return false; +} + +function awRemoveRow(row) { + awGetListItem(row).remove(); + awFitDummyRows(); + + top.MAX_RECIPIENTS--; +} + +function awGetNumberOfCols() { + if (gNumberOfCols == 0) { + var listbox = document.getElementById("addressingWidget"); + var listCols = listbox.getElementsByTagName("treecol"); + gNumberOfCols = listCols.length; + if (!gNumberOfCols) { + // If no cols defined, that means we have only one! + gNumberOfCols = 1; + } + } + + return gNumberOfCols; +} + +function awCreateDummyItem(aParent) { + var listbox = document.getElementById("addressingWidget"); + var item = listbox.getItemAtIndex(0); + + var titem = document.createXULElement("richlistitem"); + titem.setAttribute("_isDummyRow", "true"); + titem.setAttribute("class", "dummy-row"); + titem.style.height = item.getBoundingClientRect().height + "px"; + + for (let i = 0; i < awGetNumberOfCols(); i++) { + let cell = awCreateDummyCell(titem); + if (item.children[i].hasAttribute("style")) { + cell.setAttribute("style", item.children[i].getAttribute("style")); + } + if (item.children[i].hasAttribute("flex")) { + cell.setAttribute("flex", item.children[i].getAttribute("flex")); + } + } + + if (aParent) { + aParent.appendChild(titem); + } + + return titem; +} + +function awFitDummyRows() { + awCalcContentHeight(); + awCreateOrRemoveDummyRows(); +} + +function awCreateOrRemoveDummyRows() { + let listbox = document.getElementById("addressingWidget"); + let listboxHeight = listbox.getBoundingClientRect().height; + + // remove rows to remove scrollbar + let kids = listbox.querySelectorAll("[_isDummyRow]"); + for ( + let i = kids.length - 1; + gAWContentHeight > listboxHeight && i >= 0; + --i + ) { + gAWContentHeight -= gAWRowHeight; + kids[i].remove(); + } + + // add rows to fill space + if (gAWRowHeight) { + while (gAWContentHeight + gAWRowHeight < listboxHeight) { + awCreateDummyItem(listbox); + gAWContentHeight += gAWRowHeight; + } + } +} + +function awCalcContentHeight() { + var listbox = document.getElementById("addressingWidget"); + var items = listbox.itemChildren; + + gAWContentHeight = 0; + if (items.length > 0) { + // all rows are forced to a uniform height in xul listboxes, so + // find the first listitem with a boxObject and use it as precedent + var i = 0; + do { + gAWRowHeight = items[i].getBoundingClientRect().height; + ++i; + } while (i < items.length && !gAWRowHeight); + gAWContentHeight = gAWRowHeight * items.length; + } +} + +/* ::::::::::: addressing widget dummy rows ::::::::::::::::: */ + +function awCreateDummyCell(aParent) { + var cell = document.createXULElement("hbox"); + cell.setAttribute("class", "addressingWidgetCell dummy-row-cell"); + if (aParent) { + aParent.appendChild(cell); + } + + return cell; +} + +function awGetNextDummyRow() { + // gets the next row from the top down + return document.querySelector("#addressingWidget > [_isDummyRow]"); +} + +/** + * Set focus to the specified element, typically a recipient input element. + * We do this asynchronously to allow other processes like adding or removing rows + * to complete before shifting focus. + * + * @param {Element} element - The element to receive focus asynchronously. + */ +function awSetFocusTo(element) { + // Remember the (input) element to focus for asynchronous focusing, so that we + // play safe if this gets called again and the original element gets removed + // before we can focus it. + top.awInputToFocus = element; + setTimeout(_awSetFocusTo, 0); +} + +function _awSetFocusTo() { + top.awInputToFocus.focus(); +} + +// returns null if abURI is not a mailing list URI +function GetParentDirectoryFromMailingListURI(abURI) { + var abURIArr = abURI.split("/"); + /* + Turn "jsaddrbook://abook.sqlite/MailList6" + into ["jsaddrbook:","","abook.sqlite","MailList6"], + then into "jsaddrbook://abook.sqlite". + + Turn "moz-aboutlookdirectory:///<top dir ID>/<ML dir ID>" + into ["moz-aboutlookdirectory:","","","<top dir ID>","<ML dir ID>"], + and then into: "moz-aboutlookdirectory:///<top dir ID>". + */ + if ( + abURIArr.length == 4 && + ["jsaddrbook:", "moz-abmdbdirectory:"].includes(abURIArr[0]) && + abURIArr[3] != "" + ) { + return abURIArr[0] + "//" + abURIArr[2]; + } else if ( + abURIArr.length == 5 && + abURIArr[0] == "moz-aboutlookdirectory:" && + abURIArr[4] != "" + ) { + return abURIArr[0] + "///" + abURIArr[3]; + } + + return null; +} diff --git a/comm/mailnews/addrbook/content/abResultsPane.js b/comm/mailnews/addrbook/content/abResultsPane.js new file mode 100644 index 0000000000..2a4bd99f3b --- /dev/null +++ b/comm/mailnews/addrbook/content/abResultsPane.js @@ -0,0 +1,482 @@ +/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 ; js-indent-level: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 ../../../mail/base/content/utilityOverlay.js */ +/* import-globals-from ../../../mail/components/addrbook/content/abCommon.js */ +/* import-globals-from abView.js */ + +/** + * Use of items in this file require: + * + * AbResultsPaneDoubleClick(card) + * Is called when the results pane is double-clicked, with the clicked card. + * GetAbViewListener() + * Called when creating a new view + */ +/* globals AbResultsPaneDoubleClick, GetAbViewListener */ // abContactsPane.js or abSearchDialog.js + +var kDefaultSortColumn = "GeneratedName"; + +// List/card selections in the results pane. +var kNothingSelected = 0; +var kListsAndCards = 1; +var kMultipleListsOnly = 2; +var kSingleListOnly = 3; +var kCardsOnly = 4; + +// Global Variables + +// Holds a reference to the "abResultsTree" document element. Initially +// set up by SetAbView. +var gAbResultsTree = null; +// gAbView is the current value of gAbResultsTree.view, without passing +// through XPCOM, so we can access extra functions if necessary. +var gAbView = null; + +function SetAbView(aURI, aSearchQuery, aSearchString) { + // If we don't have a URI, just clear the view and leave everything else + // alone. + if (!aURI) { + if (gAbView) { + CloseAbView(); + } + return; + } + + // If we do have a URI, we want to allow updating the review even if the + // URI is the same, as the search results may be different. + + var sortColumn = kDefaultSortColumn; + var sortDirection = kDefaultAscending; + + if (!gAbResultsTree) { + gAbResultsTree = document.getElementById("abResultsTree"); + gAbResultsTree.controllers.appendController(ResultsPaneController); + } + + if (gAbView) { + sortColumn = gAbView.sortColumn; + sortDirection = gAbView.sortDirection; + } else { + if (gAbResultsTree.hasAttribute("sortCol")) { + sortColumn = gAbResultsTree.getAttribute("sortCol"); + } + var sortColumnNode = document.getElementById(sortColumn); + if (sortColumnNode && sortColumnNode.hasAttribute("sortDirection")) { + sortDirection = sortColumnNode.getAttribute("sortDirection"); + } + } + + gAbView = gAbResultsTree.view = new ABView( + GetDirectoryFromURI(aURI), + aSearchQuery, + aSearchString, + GetAbViewListener(), + sortColumn, + sortDirection + ).QueryInterface(Ci.nsITreeView); + window.dispatchEvent(new CustomEvent("viewchange")); + + UpdateSortIndicators(sortColumn, sortDirection); + + // If the selected address book is LDAP and the search box is empty, + // inform the user of the empty results pane. + let abResultsTree = document.getElementById("abResultsTree"); + let cardViewOuterBox = document.getElementById("CardViewOuterBox"); + let blankResultsPaneMessageBox = document.getElementById( + "blankResultsPaneMessageBox" + ); + if (aURI.startsWith("moz-abldapdirectory://") && !aSearchQuery) { + if (abResultsTree) { + abResultsTree.hidden = true; + } + if (cardViewOuterBox) { + cardViewOuterBox.hidden = true; + } + if (blankResultsPaneMessageBox) { + blankResultsPaneMessageBox.hidden = false; + } + } else { + if (abResultsTree) { + abResultsTree.hidden = false; + } + if (cardViewOuterBox) { + cardViewOuterBox.hidden = false; + } + if (blankResultsPaneMessageBox) { + blankResultsPaneMessageBox.hidden = true; + } + } +} + +function CloseAbView() { + gAbView = null; + if (gAbResultsTree) { + gAbResultsTree.view = null; + } +} + +function GetSelectedAddresses() { + return GetAddressesForCards(GetSelectedAbCards()); +} + +function GetNumSelectedCards() { + try { + return gAbView.selection.count; + } catch (ex) {} + + // if something went wrong, return 0 for the count. + return 0; +} + +function GetSelectedCardTypes() { + var cards = GetSelectedAbCards(); + if (!cards) { + console.error("ERROR: GetSelectedCardTypes: |cards| is null."); + return kNothingSelected; // no view + } + var count = cards.length; + if (count == 0) { + // Nothing selected. + return kNothingSelected; + } + + var mailingListCnt = 0; + var cardCnt = 0; + for (let i = 0; i < count; i++) { + // We can assume no values from GetSelectedAbCards will be null. + if (cards[i].isMailList) { + mailingListCnt++; + } else { + cardCnt++; + } + } + + if (mailingListCnt == 0) { + return kCardsOnly; + } + if (cardCnt > 0) { + return kListsAndCards; + } + if (mailingListCnt == 1) { + return kSingleListOnly; + } + return kMultipleListsOnly; +} + +// NOTE, will return -1 if more than one card selected, or no cards selected. +function GetSelectedCardIndex() { + if (!gAbView) { + return -1; + } + + var treeSelection = gAbView.selection; + if (treeSelection.getRangeCount() == 1) { + var start = {}; + var end = {}; + treeSelection.getRangeAt(0, start, end); + if (start.value == end.value) { + return start.value; + } + } + + return -1; +} + +// NOTE, returns the card if exactly one card is selected, null otherwise +function GetSelectedCard() { + var index = GetSelectedCardIndex(); + return index == -1 ? null : gAbView.getCardFromRow(index); +} + +/** + * Return a (possibly empty) list of cards + * + * It pushes only non-null/empty element, if any, into the returned list. + */ +function GetSelectedAbCards() { + var abView = gAbView; + + if (!abView?.selection) { + return []; + } + + let cards = []; + var count = abView.selection.getRangeCount(); + for (let i = 0; i < count; ++i) { + let start = {}; + let end = {}; + + abView.selection.getRangeAt(i, start, end); + + for (let j = start.value; j <= end.value; ++j) { + // avoid inserting null element into the list. GetRangeAt() may be buggy. + let tmp = abView.getCardFromRow(j); + if (tmp) { + cards.push(tmp); + } + } + } + return cards; +} + +// XXX todo +// an optimization might be to make this return +// the selected ranges, which would be faster +// when the user does large selections, but for now, let's keep it simple. +function GetSelectedRows() { + var selectedRows = ""; + + if (!gAbView) { + return selectedRows; + } + + var rangeCount = gAbView.selection.getRangeCount(); + for (let i = 0; i < rangeCount; ++i) { + var start = {}; + var end = {}; + gAbView.selection.getRangeAt(i, start, end); + for (let j = start.value; j <= end.value; ++j) { + if (selectedRows) { + selectedRows += ","; + } + selectedRows += j; + } + } + + return selectedRows; +} + +function AbResultsPaneOnClick(event) { + // we only care about button 0 (left click) events + if (event.button != 0) { + return; + } + + // all we need to worry about here is double clicks + // and column header clicks. + // + // we get in here for clicks on the "treecol" (headers) + // and the "scrollbarbutton" (scrollbar buttons) + // we don't want those events to cause a "double click" + + var t = event.target; + + if (t.localName == "treecol") { + var sortDirection; + var currentDirection = t.getAttribute("sortDirection"); + + // Revert the sort order. If none is set, use Ascending. + sortDirection = + currentDirection == kDefaultAscending + ? kDefaultDescending + : kDefaultAscending; + + SortAndUpdateIndicators(t.id, sortDirection); + } else if (t.localName == "treechildren") { + // figure out what row the click was in + var row = gAbResultsTree.getRowAt(event.clientX, event.clientY); + if (row == -1) { + return; + } + + if (event.detail == 2) { + AbResultsPaneDoubleClick(gAbView.getCardFromRow(row)); + } + } +} + +function SortAndUpdateIndicators(sortColumn, sortDirection) { + UpdateSortIndicators(sortColumn, sortDirection); + + if (gAbView) { + gAbView.sortBy(sortColumn, sortDirection); + } +} + +function UpdateSortIndicators(colID, sortDirection) { + var sortedColumn = null; + + // set the sort indicator on the column we are sorted by + if (colID) { + sortedColumn = document.getElementById(colID); + if (sortedColumn) { + sortedColumn.setAttribute("sortDirection", sortDirection); + gAbResultsTree.setAttribute("sortCol", colID); + } + } + + // remove the sort indicator from all the columns + // except the one we are sorted by + var currCol = gAbResultsTree.firstElementChild.firstElementChild; + while (currCol) { + if (currCol != sortedColumn && currCol.localName == "treecol") { + currCol.removeAttribute("sortDirection"); + } + currCol = currCol.nextElementSibling; + } +} + +// Controller object for Results Pane +var ResultsPaneController = { + supportsCommand(command) { + switch (command) { + case "cmd_selectAll": + case "cmd_delete": + case "button_delete": + case "cmd_print": + case "cmd_printcard": + return true; + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_selectAll": + return true; + case "cmd_delete": + case "button_delete": { + let numSelected; + let enabled = false; + if (gAbView && gAbView.selection) { + if (gAbView.directory) { + enabled = !gAbView.directory.readOnly; + } else { + enabled = true; + } + numSelected = gAbView.selection.count; + } else { + numSelected = 0; + } + enabled = enabled && numSelected > 0; + + if (enabled && !gAbView?.directory) { + // Undefined gAbView.directory means "All Address Books" is selected. + // Disable the menu/button if any selected card is from a read only + // directory. + enabled = !GetSelectedAbCards().some( + card => + MailServices.ab.getDirectoryFromUID(card.directoryUID).readOnly + ); + } + + if (command == "cmd_delete") { + switch (GetSelectedCardTypes()) { + case kSingleListOnly: + updateDeleteControls("valueList"); + break; + case kMultipleListsOnly: + updateDeleteControls("valueLists"); + break; + case kListsAndCards: + updateDeleteControls("valueItems"); + break; + case kCardsOnly: + default: + updateDeleteControls( + numSelected < 2 ? "valueCard" : "valueCards" + ); + } + } + return enabled; + } + case "cmd_print": + // cmd_print is currently only used in SeaMonkey. + // Prevent printing when we don't have an opener (browserDOMWindow is + // null). + let enabled = window.browserDOMWindow && GetNumSelectedCards() > 0; + document.querySelectorAll("[command=cmd_print]").forEach(e => { + e.disabled = !enabled; + }); + return enabled; + case "cmd_printcard": + // Prevent printing when we don't have an opener (browserDOMWindow is + // null). + return window.browserDOMWindow && GetNumSelectedCards() > 0; + default: + return false; + } + }, + + doCommand(command) { + switch (command) { + case "cmd_selectAll": + if (gAbView) { + gAbView.selection.selectAll(); + } + break; + case "cmd_delete": + case "button_delete": + AbDelete(); + break; + } + }, +}; + +function updateDeleteControls( + labelAttribute, + accessKeyAttribute = "accesskeyDefault" +) { + goSetMenuValue("cmd_delete", labelAttribute); + goSetAccessKey("cmd_delete", accessKeyAttribute); + + // The toolbar button doesn't update itself from the command. Do that now. + let button = document.getElementById("button-abdelete"); + if (!button) { + return; + } + + let command = document.getElementById("cmd_delete"); + button.label = command.getAttribute("label"); + button.setAttribute( + "tooltiptext", + button.getAttribute( + labelAttribute == "valueCardDAV" ? "tooltipCardDAV" : "tooltipDefault" + ) + ); +} + +/** + * Generate a comma separated list of addresses from the given cards. + * + * @param {nsIAbCard[]} cards - The cards to get addresses for. + * @returns {string} A string of comma separated mailboxes. + */ +function GetAddressesForCards(cards) { + if (!cards) { + return ""; + } + + return cards + .map(makeMimeAddressFromCard) + .filter(addr => addr) + .join(","); +} + +/** + * Make a MIME encoded string output of the card. This will make a difference + * e.g. in scenarios where non-ASCII is used in the mailbox, or when then + * display name include special characters such as comma. + * + * @param {nsIAbCard} card - The card to use. + * @returns {string} A MIME encoded mailbox representation of the card. + */ +function makeMimeAddressFromCard(card) { + if (!card) { + return ""; + } + + let email; + if (card.isMailList) { + let directory = GetDirectoryFromURI(card.mailListURI); + email = directory.description || card.displayName; + } else { + email = card.emailAddresses[0]; + } + return MailServices.headerParser.makeMimeAddress(card.displayName, email); +} diff --git a/comm/mailnews/addrbook/content/abView.js b/comm/mailnews/addrbook/content/abView.js new file mode 100644 index 0000000000..f5acb7d11c --- /dev/null +++ b/comm/mailnews/addrbook/content/abView.js @@ -0,0 +1,539 @@ +/* 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/. */ + +/* globals MailServices, PROTO_TREE_VIEW, Services */ + +function ABView( + directory, + searchQuery, + searchString, + listener, + sortColumn, + sortDirection +) { + this.__proto__.__proto__ = new PROTO_TREE_VIEW(); + this.directory = directory; + this.listener = listener; + + let directories = directory ? [directory] : MailServices.ab.directories; + if (searchQuery) { + searchQuery = searchQuery.replace(/^\?+/, ""); + for (let dir of directories) { + dir.search(searchQuery, searchString, this); + } + } else { + for (let dir of directories) { + for (let card of dir.childCards) { + this._rowMap.push(new abViewCard(card, dir)); + } + } + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + } + this.sortBy(sortColumn, sortDirection); +} +ABView.nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 +); +ABView.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsITreeView", + "nsIAbDirSearchListener", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + directory: null, + listener: null, + _notifications: [ + "addrbook-directory-deleted", + "addrbook-directory-invalidated", + "addrbook-contact-created", + "addrbook-contact-updated", + "addrbook-contact-deleted", + "addrbook-list-created", + "addrbook-list-updated", + "addrbook-list-deleted", + "addrbook-list-member-added", + "addrbook-list-member-removed", + ], + + sortColumn: "", + sortDirection: "", + collator: new Intl.Collator(undefined, { numeric: true }), + + deleteSelectedCards() { + let directoryMap = new Map(); + for (let i = 0; i < this.selection.getRangeCount(); i++) { + let start = {}; + let finish = {}; + this.selection.getRangeAt(i, start, finish); + for (let j = start.value; j <= finish.value; j++) { + let card = this.getCardFromRow(j); + let cardSet = directoryMap.get(card.directoryUID); + if (!cardSet) { + cardSet = new Set(); + directoryMap.set(card.directoryUID, cardSet); + } + cardSet.add(card); + } + } + + for (let [directoryUID, cardSet] of directoryMap) { + let directory; + if (this.directory && this.directory.isMailList) { + // Removes cards from the list instead of deleting them. + directory = this.directory; + } else { + directory = MailServices.ab.getDirectoryFromUID(directoryUID); + } + + cardSet = [...cardSet]; + directory.deleteCards(cardSet.filter(card => !card.isMailList)); + for (let card of cardSet.filter(card => card.isMailList)) { + MailServices.ab.deleteAddressBook(card.mailListURI); + } + } + }, + getCardFromRow(row) { + return this._rowMap[row] ? this._rowMap[row].card : null; + }, + getDirectoryFromRow(row) { + return this._rowMap[row] ? this._rowMap[row].directory : null; + }, + sortBy(sortColumn, sortDirection, resort) { + // Remember what was selected. + let selection = this.selection; + if (selection) { + for (let i = 0; i < this._rowMap.length; i++) { + this._rowMap[i].wasSelected = selection.isSelected(i); + this._rowMap[i].wasCurrent = selection.currentIndex == i; + } + } + + // Do the sort. + if (sortColumn == this.sortColumn && !resort) { + if (sortDirection == this.sortDirection) { + return; + } + this._rowMap.reverse(); + } else { + this._rowMap.sort((a, b) => { + let aText = a.getText(sortColumn); + let bText = b.getText(sortColumn); + if (sortDirection == "descending") { + return this.collator.compare(bText, aText); + } + return this.collator.compare(aText, bText); + }); + } + + // Restore what was selected. + if (selection) { + selection.selectEventsSuppressed = true; + for (let i = 0; i < this._rowMap.length; i++) { + if (this._rowMap[i].wasSelected != selection.isSelected(i)) { + selection.toggleSelect(i); + } + } + // Can't do this until updating the selection is finished. + for (let i = 0; i < this._rowMap.length; i++) { + if (this._rowMap[i].wasCurrent) { + selection.currentIndex = i; + break; + } + } + this.selectionChanged(); + selection.selectEventsSuppressed = false; + } + + if (this.tree) { + this.tree.invalidate(); + } + this.sortColumn = sortColumn; + this.sortDirection = sortDirection; + }, + + // nsITreeView + + selectionChanged() { + if (this.listener) { + this.listener.onSelectionChanged(); + } + }, + setTree(tree) { + this.tree = tree; + for (let topic of this._notifications) { + if (tree) { + Services.obs.addObserver(this, topic, true); + } else { + try { + Services.obs.removeObserver(this, topic); + } catch (ex) { + // `this` might not be a valid observer. + } + } + } + Services.prefs.addObserver("mail.addr_book.lastnamefirst", this, true); + }, + + // nsIAbDirSearchListener + + onSearchFoundCard(card) { + // Instead of duplicating the insertion code below, just call it. + this.observe(card, "addrbook-contact-created", this.directory?.UID); + }, + onSearchFinished(status, complete, secInfo, location) { + // Special handling for Bad Cert errors. + let offerCertException = false; + try { + // If code is not an NSS error, getErrorClass() will fail. + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + let errorClass = nssErrorsService.getErrorClass(status); + if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + offerCertException = true; + } + } catch (ex) {} + + if (offerCertException) { + // Give the user the option of adding an exception for the bad cert. + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // params.exceptionAdded will be set if the user added an exception. + } + }, + + // nsIObserver + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + ABView.nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 + ); + for (let card of this._rowMap) { + delete card._getTextCache.GeneratedName; + } + if (this.tree) { + if (this.sortColumn == "GeneratedName") { + this.sortBy(this.sortColumn, this.sortDirection, true); + } else { + this.tree.invalidate(this.tree.columns.GeneratedName); + } + } + return; + } + + if (this.directory && data && this.directory.UID != data) { + return; + } + + // If we make it here, we're in the root directory, or the right directory. + + switch (topic) { + case "addrbook-directory-deleted": { + if (this.directory) { + break; + } + + subject.QueryInterface(Ci.nsIAbDirectory); + let scrollPosition = this.tree?.getFirstVisibleRow(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if (this._rowMap[i].directory.UID == subject.UID) { + this._rowMap.splice(i, 1); + if (this.tree) { + this.tree.rowCountChanged(i, -1); + } + } + } + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + if (this.tree && scrollPosition !== null) { + this.tree.scrollToRow(scrollPosition); + } + break; + } + case "addrbook-directory-invalidated": + subject.QueryInterface(Ci.nsIAbDirectory); + if (subject == this.directory) { + this._rowMap.length = 0; + for (let card of this.directory.childCards) { + this._rowMap.push(new abViewCard(card, this.directory)); + } + this.sortBy(this.sortColumn, this.sortDirection, true); + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + } + break; + case "addrbook-list-created": { + let parentDir = MailServices.ab.getDirectoryFromUID(data); + // `subject` is an nsIAbDirectory, make it the matching card instead. + subject.QueryInterface(Ci.nsIAbDirectory); + for (let card of parentDir.childCards) { + if (card.UID == subject.UID) { + subject = card; + break; + } + } + } + // Falls through. + case "addrbook-list-member-added": + case "addrbook-contact-created": + if (topic == "addrbook-list-member-added" && !this.directory) { + break; + } + + subject.QueryInterface(Ci.nsIAbCard); + let viewCard = new abViewCard(subject); + let sortText = viewCard.getText(this.sortColumn); + let addIndex = null; + for (let i = 0; addIndex === null && i < this._rowMap.length; i++) { + let comparison = this.collator.compare( + sortText, + this._rowMap[i].getText(this.sortColumn) + ); + if ( + (comparison < 0 && this.sortDirection == "ascending") || + (comparison >= 0 && this.sortDirection == "descending") + ) { + addIndex = i; + } + } + if (addIndex === null) { + addIndex = this._rowMap.length; + } + this._rowMap.splice(addIndex, 0, viewCard); + if (this.tree) { + this.tree.rowCountChanged(addIndex, 1); + } + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + break; + + case "addrbook-list-updated": { + let parentDir = this.directory; + if (!parentDir) { + parentDir = MailServices.ab.getDirectoryFromUID(data); + } + // `subject` is an nsIAbDirectory, make it the matching card instead. + subject.QueryInterface(Ci.nsIAbDirectory); + for (let card of parentDir.childCards) { + if (card.UID == subject.UID) { + subject = card; + break; + } + } + } + // Falls through. + case "addrbook-contact-updated": { + subject.QueryInterface(Ci.nsIAbCard); + let needsSort = false; + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if ( + this._rowMap[i].card.equals(subject) && + this._rowMap[i].card.directoryUID == subject.directoryUID + ) { + this._rowMap.splice(i, 1, new abViewCard(subject)); + needsSort = true; + } + } + if (needsSort) { + this.sortBy(this.sortColumn, this.sortDirection, true); + } + break; + } + + case "addrbook-list-deleted": { + subject.QueryInterface(Ci.nsIAbDirectory); + let scrollPosition = this.tree?.getFirstVisibleRow(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if (this._rowMap[i].card.UID == subject.UID) { + this._rowMap.splice(i, 1); + if (this.tree) { + this.tree.rowCountChanged(i, -1); + } + } + } + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + if (this.tree && scrollPosition !== null) { + this.tree.scrollToRow(scrollPosition); + } + break; + } + case "addrbook-list-member-removed": + if (!this.directory) { + break; + } + // Falls through. + case "addrbook-contact-deleted": { + subject.QueryInterface(Ci.nsIAbCard); + let scrollPosition = this.tree?.getFirstVisibleRow(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if ( + this._rowMap[i].card.equals(subject) && + this._rowMap[i].card.directoryUID == subject.directoryUID + ) { + this._rowMap.splice(i, 1); + if (this.tree) { + this.tree.rowCountChanged(i, -1); + } + } + } + if (this.listener) { + this.listener.onCountChanged(this.rowCount); + } + if (this.tree && scrollPosition !== null) { + this.tree.scrollToRow(scrollPosition); + } + break; + } + } + }, +}; + +/** + * Representation of a card, used as a table row in ABView. + * + * @param {nsIAbCard} card - contact or mailing list card for this row. + * @param {nsIAbDirectory} [directoryHint] - the directory containing card, + * if available (this is a performance optimization only). + */ +function abViewCard(card, directoryHint) { + this.card = card; + this._getTextCache = {}; + if (directoryHint) { + this._directory = directoryHint; + } else { + this._directory = MailServices.ab.getDirectoryFromUID( + this.card.directoryUID + ); + } +} +abViewCard.listFormatter = new Services.intl.ListFormat( + Services.appinfo.name == "xpcshell" ? "en-US" : undefined, + { type: "unit" } +); +abViewCard.prototype = { + _getText(columnID) { + try { + let { getProperty, supportsVCard, vCardProperties } = this.card; + + if (this.card.isMailList) { + if (columnID == "GeneratedName") { + return this.card.displayName; + } + if (["NickName", "Notes"].includes(columnID)) { + return getProperty(columnID, ""); + } + return ""; + } + + switch (columnID) { + case "addrbook": + case "Addrbook": + return this._directory.dirName; + case "GeneratedName": + return this.card.generateName(ABView.nameFormat); + case "_PhoneticName": + return this.card.generatePhoneticName(true); + case "ChatName": + return this.card.isMailList ? "" : this.card.generateChatName(); + case "EmailAddresses": + return abViewCard.listFormatter.format(this.card.emailAddresses); + case "PhoneNumbers": { + let phoneNumbers; + if (supportsVCard) { + phoneNumbers = vCardProperties.getAllValues("tel"); + } else { + phoneNumbers = [ + getProperty("WorkPhone", ""), + getProperty("HomePhone", ""), + getProperty("CellularNumber", ""), + getProperty("FaxNumber", ""), + getProperty("PagerNumber", ""), + ]; + } + return abViewCard.listFormatter.format(phoneNumbers.filter(Boolean)); + } + case "JobTitle": + case "Title": + if (supportsVCard) { + return vCardProperties.getFirstValue("title"); + } + return getProperty("JobTitle", ""); + case "Department": + if (supportsVCard) { + let vCardValue = vCardProperties.getFirstValue("org"); + if (Array.isArray(vCardValue)) { + return vCardValue[1] || ""; + } + return ""; + } + return getProperty(columnID, ""); + case "Company": + case "Organization": + if (supportsVCard) { + let vCardValue = vCardProperties.getFirstValue("org"); + if (Array.isArray(vCardValue)) { + return vCardValue[0] || ""; + } + return vCardValue; + } + return getProperty("Company", ""); + case "NickName": + if (supportsVCard) { + return vCardProperties.getFirstValue("nickname"); + } + return getProperty(columnID, ""); + default: + return getProperty(columnID, ""); + } + } catch (ex) { + return ""; + } + }, + getText(columnID) { + if (!(columnID in this._getTextCache)) { + this._getTextCache[columnID] = this._getText(columnID)?.trim() ?? ""; + } + return this._getTextCache[columnID]; + }, + get id() { + return this.card.UID; + }, + get open() { + return false; + }, + get level() { + return 0; + }, + get children() { + return []; + }, + getProperties() { + return this.card.isMailList ? "MailList" : ""; + }, + get directory() { + return this._directory; + }, +}; diff --git a/comm/mailnews/addrbook/content/map-list.js b/comm/mailnews/addrbook/content/map-list.js new file mode 100644 index 0000000000..102cb09522 --- /dev/null +++ b/comm/mailnews/addrbook/content/map-list.js @@ -0,0 +1,217 @@ +/* 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"; + +/* global MozElements */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozMapList widget behaves as a popup menu showing available map options + * for an address. It is a part of the card view in the addressbook. + * + * @augments {MozElements.MozMenuPopup} + */ + class MozMapList extends MozElements.MozMenuPopup { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.setAttribute("is", "map-list"); + + this.addEventListener("command", event => { + this._chooseMapService(event.target); + event.stopPropagation(); + }); + + this.addEventListener("popupshowing", event => { + this._listMapServices(); + }); + + this._setWidgetDisabled(true); + } + + get mapURL() { + return this._createMapItURL(); + } + + /** + * Initializes the necessary address data from an addressbook card. + * + * @param {nsIAbCard} card - The card to get the address data from. + * @param {string} addrPrefix - Card property prefix: "Home" or "Work", + * to make the map use either HomeAddress or WorkAddress. + */ + initMapAddressFromCard(card, addrPrefix) { + let mapItURLFormat = this._getMapURLPref(); + let doNotShowMap = !mapItURLFormat || !addrPrefix || !card; + this._setWidgetDisabled(doNotShowMap); + if (doNotShowMap) { + return; + } + + this.address1 = card.getProperty(addrPrefix + "Address"); + this.address2 = card.getProperty(addrPrefix + "Address2"); + this.city = card.getProperty(addrPrefix + "City"); + this._state = card.getProperty(addrPrefix + "State"); + this.zip = card.getProperty(addrPrefix + "ZipCode"); + this.country = card.getProperty(addrPrefix + "Country"); + } + + /** + * Sets the disabled/enabled state of the parent widget (e.g. a button). + */ + _setWidgetDisabled(disabled) { + this.parentNode.disabled = disabled; + } + + /** + * Returns the Map service URL from localized pref. Returns null if there + * is none at the given index. + * + * @param {integer} [index=0] - The index of the service to return. + * 0 is the default service. + */ + _getMapURLPref(index = 0) { + let url = null; + if (!index) { + url = Services.prefs.getComplexValue( + "mail.addr_book.mapit_url.format", + Ci.nsIPrefLocalizedString + ).data; + } else { + try { + url = Services.prefs.getComplexValue( + "mail.addr_book.mapit_url." + index + ".format", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + } + + return url; + } + + /** + * Builds menuitem elements representing map services defined in prefs + * and attaches them to the specified button. + */ + _listMapServices() { + let index = 1; + let itemFound = true; + let defaultFound = false; + const kUserIndex = 100; + let mapList = this; + while (mapList.hasChildNodes()) { + mapList.lastChild.remove(); + } + + let defaultUrl = this._getMapURLPref(); + + // Creates the menuitem with supplied data. + function addMapService(url, name) { + let item = document.createXULElement("menuitem"); + item.setAttribute("url", url); + item.setAttribute("label", name); + item.setAttribute("type", "radio"); + item.setAttribute("name", "mapit_service"); + if (url == defaultUrl) { + item.setAttribute("checked", "true"); + } + mapList.appendChild(item); + } + + // Generates a useful generic name by cutting out only the host address. + function generateName(url) { + return new URL(url).hostname; + } + + // Add all defined map services as menuitems. + while (itemFound) { + let urlName; + let urlTemplate = this._getMapURLPref(index); + if (!urlTemplate) { + itemFound = false; + } else { + // Name is not mandatory, generate one if not found. + try { + urlName = Services.prefs.getComplexValue( + "mail.addr_book.mapit_url." + index + ".name", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) { + urlName = generateName(urlTemplate); + } + } + if (itemFound) { + addMapService(urlTemplate, urlName); + index++; + if (urlTemplate == defaultUrl) { + defaultFound = true; + } + } else if (index < kUserIndex) { + // After iterating the base region provided urls, check for user defined ones. + index = kUserIndex; + itemFound = true; + } + } + if (!defaultFound) { + // If user had put a customized map URL into mail.addr_book.mapit_url.format + // preserve it as a new map service named with the URL. + // 'index' now points to the first unused entry in prefs. + let defaultName = generateName(defaultUrl); + addMapService(defaultUrl, defaultName); + Services.prefs.setCharPref( + "mail.addr_book.mapit_url." + index + ".format", + defaultUrl + ); + Services.prefs.setCharPref( + "mail.addr_book.mapit_url." + index + ".name", + defaultName + ); + } + } + + /** + * Save user selected mapping service. + * + * @param {Element} item - The chosen menuitem with map service. + */ + _chooseMapService(item) { + // Save selected URL as the default. + let defaultUrl = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + defaultUrl.data = item.getAttribute("url"); + Services.prefs.setComplexValue( + "mail.addr_book.mapit_url.format", + Ci.nsIPrefLocalizedString, + defaultUrl + ); + } + + /** + * Generate the map URL used to open the link on clicking the menulist button. + * + * @returns {urlFormat} - the map url generated from the address. + */ + _createMapItURL() { + let urlFormat = this._getMapURLPref(); + if (!urlFormat) { + return null; + } + + urlFormat = urlFormat.replace("@A1", encodeURIComponent(this.address1)); + urlFormat = urlFormat.replace("@A2", encodeURIComponent(this.address2)); + urlFormat = urlFormat.replace("@CI", encodeURIComponent(this.city)); + urlFormat = urlFormat.replace("@ST", encodeURIComponent(this._state)); + urlFormat = urlFormat.replace("@ZI", encodeURIComponent(this.zip)); + urlFormat = urlFormat.replace("@CO", encodeURIComponent(this.country)); + + return urlFormat; + } + } + + customElements.define("map-list", MozMapList, { extends: "menupopup" }); +} |