summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/addrbook/content/aboutAddressBook.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/addrbook/content/aboutAddressBook.js')
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.js4445
1 files changed, 4445 insertions, 0 deletions
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.js b/comm/mail/components/addrbook/content/aboutAddressBook.js
new file mode 100644
index 0000000000..8f0eeca693
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.js
@@ -0,0 +1,4445 @@
+/* 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 ABView */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "ABQueryUtils", function () {
+ return ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+});
+XPCOMUtils.defineLazyGetter(this, "AddrBookUtils", function () {
+ return ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
+});
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookUtils: "resource:///modules/AddrBookUtils.jsm",
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalMetronome: "resource:///modules/CalMetronome.jsm",
+ CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm",
+ GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ ICAL: "resource:///modules/calendar/Ical.jsm",
+ MailE10SUtils: "resource:///modules/MailE10SUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+});
+XPCOMUtils.defineLazyGetter(this, "SubDialog", function () {
+ const { SubDialogManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/SubDialog.sys.mjs"
+ );
+ return new SubDialogManager({
+ dialogStack: document.getElementById("dialogStack"),
+ dialogTemplate: document.getElementById("dialogTemplate"),
+ dialogOptions: {
+ styleSheets: [
+ "chrome://messenger/skin/preferences/dialog.css",
+ "chrome://messenger/skin/shared/preferences/subdialog.css",
+ "chrome://messenger/skin/abFormFields.css",
+ ],
+ resizeCallback: ({ title, frame }) => {
+ UIFontSize.registerWindow(frame.contentWindow);
+
+ // Resize the dialog to fit the content with edited font size.
+ requestAnimationFrame(() => {
+ let dialogs = frame.ownerGlobal.SubDialog._dialogs;
+ let dialog = dialogs.find(
+ d => d._frame.contentDocument == frame.contentDocument
+ );
+ if (dialog) {
+ UIFontSize.resizeSubDialog(dialog);
+ }
+ });
+ },
+ },
+ });
+});
+
+UIDensity.registerWindow(window);
+UIFontSize.registerWindow(window);
+
+var booksList;
+
+window.addEventListener("load", () => {
+ document
+ .getElementById("toolbarCreateBook")
+ .addEventListener("command", event => {
+ let type = event.target.value || "JS_DIRECTORY_TYPE";
+ createBook(Ci.nsIAbManager[type]);
+ });
+ document
+ .getElementById("toolbarCreateContact")
+ .addEventListener("command", () => createContact());
+ document
+ .getElementById("toolbarCreateList")
+ .addEventListener("command", () => createList());
+ document
+ .getElementById("toolbarImport")
+ .addEventListener("command", () => importBook());
+
+ document.getElementById("bookContext").addEventListener("command", event => {
+ switch (event.target.id) {
+ case "bookContextProperties":
+ booksList.showPropertiesOfSelected();
+ break;
+ case "bookContextSynchronize":
+ booksList.synchronizeSelected();
+ break;
+ case "bookContextPrint":
+ booksList.printSelected();
+ break;
+ case "bookContextExport":
+ booksList.exportSelected();
+ break;
+ case "bookContextDelete":
+ booksList.deleteSelected();
+ break;
+ case "bookContextRemove":
+ booksList.deleteSelected();
+ break;
+ case "bookContextStartupDefault":
+ if (event.target.hasAttribute("checked")) {
+ booksList.setSelectedAsStartupDefault();
+ } else {
+ booksList.clearStartupDefault();
+ }
+ break;
+ }
+ });
+
+ booksList = document.getElementById("books");
+ cardsPane.init();
+ detailsPane.init();
+ photoDialog.init();
+
+ setKeyboardShortcuts();
+
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ let startupURI = Services.prefs.getStringPref(
+ "mail.addr_book.view.startupURI",
+ ""
+ );
+ if (startupURI) {
+ for (let index = 0; index < booksList.rows.length; index++) {
+ let row = booksList.rows[index];
+ if (row._book?.URI == startupURI || row._list?.URI == startupURI) {
+ booksList.selectedIndex = index;
+ break;
+ }
+ }
+ }
+
+ if (booksList.selectedIndex == 0) {
+ // Index 0 was selected before we started listening.
+ booksList.dispatchEvent(new CustomEvent("select"));
+ }
+
+ cardsPane.searchInput.focus();
+
+ window.dispatchEvent(new CustomEvent("about-addressbook-ready"));
+});
+
+window.addEventListener("unload", () => {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let pref = "mail.addr_book.view.startupURI";
+ if (booksList.selectedIndex === 0) {
+ Services.prefs.clearUserPref(pref);
+ } else {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let directory = row._book || row._list;
+ Services.prefs.setCharPref(pref, directory.URI);
+ }
+ }
+
+ // Disconnect the view (if there is one) and tree, so that the view cleans
+ // itself up and stops listening for observer service notifications.
+ cardsPane.cardsList.view = null;
+ detailsPane.uninit();
+});
+
+window.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used.
+ if (
+ event.key == " " &&
+ detailsPane.isEditing &&
+ document.activeElement.tagName == "body"
+ ) {
+ event.preventDefault();
+ }
+});
+
+/**
+ * Add a keydown document event listener for international keyboard shortcuts.
+ */
+async function setKeyboardShortcuts() {
+ let [newContactKey] = await document.l10n.formatValues([
+ { id: "about-addressbook-new-contact-key" },
+ ]);
+
+ document.addEventListener("keydown", event => {
+ if (
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ ["Shift", "Control", "Meta"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // Always use lowercase to compare the key and avoid OS inconsistencies:
+ // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A".
+ switch (event.key.toLowerCase()) {
+ // Always prevent the default behavior of the keydown if we intercepted
+ // the key in order to avoid triggering OS specific shortcuts.
+ case newContactKey.toLowerCase(): {
+ // Ctrl/Cmd+n.
+ event.preventDefault();
+ if (!detailsPane.isEditing) {
+ createContact();
+ }
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Called on load from `toAddressBook` to create, display or edit a card.
+ *
+ * @param {"create"|"display"|"edit"|"create_ab_*"} action - What to do with the args given.
+ * @param {?string} address - Create a new card with this email address.
+ * @param {?string} vCard - Create a new card from this vCard.
+ * @param {?nsIAbCard} card - Display or edit this card.
+ */
+function externalAction({ action, address, card, vCard } = {}) {
+ if (action == "create") {
+ if (address) {
+ detailsPane.editNewContact(
+ `BEGIN:VCARD\r\nEMAIL:${address}\r\nEND:VCARD\r\n`
+ );
+ } else {
+ detailsPane.editNewContact(vCard);
+ }
+ } else if (action == "display" || action == "edit") {
+ if (!card || !card.directoryUID) {
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ if (!book) {
+ return;
+ }
+
+ booksList.selectedIndex = booksList.getIndexForUID(card.directoryUID);
+ cardsPane.cardsList.selectedIndex = cardsPane.cardsList.view.getIndexForUID(
+ card.UID
+ );
+
+ if (action == "edit" && book && !book.readOnly) {
+ detailsPane.editCurrentContact();
+ }
+ } else if (action == "print") {
+ if (document.activeElement == booksList) {
+ booksList.printSelected();
+ } else {
+ cardsPane.printSelected();
+ }
+ } else if (action == "create_ab_JS") {
+ createBook();
+ } else if (action == "create_ab_CARDDAV") {
+ createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ } else if (action == "create_ab_LDAP") {
+ createBook(Ci.nsIAbManager.LDAP_DIRECTORY_TYPE);
+ }
+}
+
+/**
+ * Show UI to create a new address book of the type specified.
+ *
+ * @param {integer} [type=Ci.nsIAbManager.JS_DIRECTORY_TYPE] - One of the
+ * nsIAbManager directory type constants.
+ */
+function createBook(type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ const typeURLs = {
+ [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/pref-directory-add.xhtml",
+ [Ci.nsIAbManager.JS_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml",
+ [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml",
+ };
+
+ let url = typeURLs[type];
+ if (!url) {
+ throw new Components.Exception(
+ `Unexpected type: ${type}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let params = {};
+ SubDialog.open(
+ url,
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newDirectoryUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(
+ params.newDirectoryUID
+ );
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Show UI to create a new contact in the current address book.
+ */
+function createContact() {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ }
+
+ detailsPane.editNewContact();
+}
+
+/**
+ * Show UI to create a new list in the current address book.
+ * For now this loads the old list UI, the intention is to replace it.
+ *
+ * @param {nsIAbCard[]} cards - The contacts, if any, to add to the list.
+ */
+function createList(cards) {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ let params = { cards };
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ if (!book.supportsMailingLists) {
+ throw new Components.Exception(
+ "Address book does not support lists",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ params.selectedAB = book.URI;
+ }
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml",
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newListUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(params.newListUID);
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Import an address book from a file. This shows the generic Thunderbird
+ * import wizard, which isn't ideal but better than nothing.
+ */
+function importBook() {
+ let createdDirectory;
+ let observer = function (subject) {
+ // It might be possible for more than one directory to be imported, select
+ // the first one.
+ if (!createdDirectory) {
+ createdDirectory = subject.QueryInterface(Ci.nsIAbDirectory);
+ }
+ };
+
+ Services.obs.addObserver(observer, "addrbook-directory-created");
+ window.browsingContext.topChromeWindow.toImport("addressBook");
+ Services.obs.removeObserver(observer, "addrbook-directory-created");
+
+ // Select the directory after the import UI closes, so the user sees the change.
+ if (createdDirectory) {
+ booksList.selectedIndex = booksList.getIndexForUID(createdDirectory.UID);
+ }
+}
+
+/**
+ * Sets the total count for the current selected address book at the bottom
+ * of the address book view.
+ */
+async function updateAddressBookCount() {
+ let cardCount = document.getElementById("cardCount");
+ let { rowCount: count, directory } = cardsPane.cardsList.view;
+
+ if (directory) {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count", {
+ name: directory.dirName,
+ count,
+ });
+ } else {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count-all", {
+ count,
+ });
+ }
+}
+
+/**
+ * Update the shared splitter between the cardsPane and detailsPane in order to
+ * properly set its properties to handle the correct pane based on the layout.
+ *
+ * @param {boolean} isTableLayout - If the current body layout is a table.
+ */
+function updateSharedSplitter(isTableLayout) {
+ let splitter = document.getElementById("sharedSplitter");
+ splitter.resizeDirection = isTableLayout ? "vertical" : "horizontal";
+ splitter.resizeElement = document.getElementById(
+ isTableLayout ? "detailsPane" : "cardsPane"
+ );
+
+ splitter.isCollapsed =
+ document.getElementById("detailsPane").hidden && isTableLayout;
+}
+
+// Books
+
+/**
+ * The list of address books.
+ *
+ * @augments {TreeListbox}
+ */
+class AbTreeListbox extends customElements.get("tree-listbox") {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+ this.setAttribute("is", "ab-tree-listbox");
+
+ this.addEventListener("select", this);
+ this.addEventListener("collapsed", this);
+ this.addEventListener("expanded", this);
+ this.addEventListener("keypress", this);
+ this.addEventListener("contextmenu", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("dragleave", this);
+ this.addEventListener("drop", this);
+
+ for (let book of MailServices.ab.directories) {
+ this.appendChild(this._createBookRow(book));
+ }
+
+ this._abObserver.observe = this._abObserver.observe.bind(this);
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.addObserver(this._abObserver, topic, true);
+ }
+
+ window.addEventListener("unload", this);
+
+ // Add event listener to update the total count of the selected address
+ // book.
+ this.addEventListener("select", e => {
+ updateAddressBookCount();
+ });
+
+ // Row 0 is the "All Address Books" item.
+ document.body.classList.toggle("all-ab-selected", this.selectedIndex === 0);
+ }
+
+ destroy() {
+ this.removeEventListener("select", this);
+ this.removeEventListener("collapsed", this);
+ this.removeEventListener("expanded", this);
+ this.removeEventListener("keypress", this);
+ this.removeEventListener("contextmenu", this);
+ this.removeEventListener("dragover", this);
+ this.removeEventListener("dragleave", this);
+ this.removeEventListener("drop", this);
+
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.removeObserver(this._abObserver, topic);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+
+ switch (event.type) {
+ case "select":
+ this._onSelect(event);
+ break;
+ case "collapsed":
+ this._onCollapsed(event);
+ break;
+ case "expanded":
+ this._onExpanded(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._clearDropTarget(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "unload":
+ this.destroy();
+ break;
+ }
+ }
+
+ _createBookRow(book) {
+ let row = document
+ .getElementById("bookRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `book-${book.UID}`;
+ row.setAttribute("aria-label", book.dirName);
+ row.title = book.dirName;
+ if (
+ Services.xulStore.getValue(cardsPane.URL, row.id, "collapsed") == "true"
+ ) {
+ row.classList.add("collapsed");
+ }
+ if (book.isRemote) {
+ row.classList.add("remote");
+ }
+ if (book.readOnly) {
+ row.classList.add("readOnly");
+ }
+ if (
+ ["ldap_2.servers.history", "ldap_2.servers.pab"].includes(book.dirPrefId)
+ ) {
+ row.classList.add("noDelete");
+ }
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ row.classList.add("carddav");
+ }
+ row.dataset.uid = book.UID;
+ row._book = book;
+ row.querySelector("span").textContent = book.dirName;
+
+ for (let list of book.childNodes) {
+ row.querySelector("ul").appendChild(this._createListRow(book.UID, list));
+ }
+ return row;
+ }
+
+ _createListRow(bookUID, list) {
+ let row = document
+ .getElementById("listRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `list-${list.UID}`;
+ row.setAttribute("aria-label", list.dirName);
+ row.title = list.dirName;
+ row.dataset.uid = list.UID;
+ row.dataset.book = bookUID;
+ row._list = list;
+ row.querySelector("span").textContent = list.dirName;
+ return row;
+ }
+
+ /**
+ * Get the index of the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {integer} - Index of the book or list.
+ */
+ getIndexForUID(uid) {
+ if (!uid) {
+ return 0;
+ }
+ return this.rows.findIndex(r => r.dataset.uid == uid);
+ }
+
+ /**
+ * Get the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {HTMLLIElement} - Row of the book or list.
+ */
+ getRowForUID(uid) {
+ if (!uid) {
+ return this.firstElementChild;
+ }
+ return this.querySelector(`li[data-uid="${uid}"]`);
+ }
+
+ /**
+ * Show UI to modify the selected address book or list.
+ */
+ showPropertiesOfSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot modify the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: list.URI }
+ );
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+
+ SubDialog.open(
+ book.propertiesChromeURI,
+ { features: "resizable=no" },
+ { selectedDirectory: book }
+ );
+ }
+
+ /**
+ * Synchronize the selected address book. (CardDAV only.)
+ */
+ synchronizeSelected() {
+ let row = this.rows[this.selectedIndex];
+ if (!row.classList.contains("carddav")) {
+ throw new Components.Exception(
+ "Attempting to synchronize a non-CardDAV book.",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let directory = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ directory = CardDAVDirectory.forFile(directory.fileName);
+ directory.syncWithServer().then(res => {
+ updateAddressBookCount();
+ });
+ }
+
+ /**
+ * Print the selected address book.
+ */
+ printSelected() {
+ if (this.selectedIndex === 0) {
+ printHandler.printDirectory();
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+ printHandler.printDirectory(list);
+ } else {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ printHandler.printDirectory(book);
+ }
+ }
+
+ /**
+ * Export the selected address book to a file.
+ */
+ exportSelected() {
+ if (this.selectedIndex == 0) {
+ return;
+ }
+
+ let row = this.getRowAtIndex(this.selectedIndex);
+ let directory = row._book || row._list;
+ AddrBookUtils.exportDirectory(directory);
+ }
+
+ /**
+ * Prompt the user and delete the selected address book.
+ */
+ async deleteSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot delete the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("noDelete")) {
+ throw new Components.Exception(
+ "Refusing to delete a built-in address book",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let action, name, uri;
+ if (row.classList.contains("listRow")) {
+ action = "delete-lists";
+ name = row._list.dirName;
+ uri = row._list.URI;
+ } else {
+ if (
+ [
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE,
+ ].includes(row._book.dirType)
+ ) {
+ action = "remove-remote-book";
+ } else {
+ action = "delete-book";
+ }
+
+ name = row._book.dirName;
+ uri = row._book.URI;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count: 1 } },
+ {
+ id: `about-addressbook-confirm-${action}`,
+ args: { name, count: 1 },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ MailServices.ab.deleteAddressBook(uri);
+ }
+ }
+
+ /**
+ * Set the selected directory to be the one opened when the page opens.
+ */
+ setSelectedAsStartupDefault() {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ if (this.selectedIndex === 0) {
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ let directory = row._book || row._list;
+ Services.prefs.setStringPref(
+ "mail.addr_book.view.startupURI",
+ directory.URI
+ );
+ }
+
+ /**
+ * Clear the directory to be opened when the page opens. Instead, the
+ * last-selected directory will be opened.
+ */
+ clearStartupDefault() {
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ }
+
+ _onSelect() {
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ cardsPane.displayList(row.dataset.book, row.dataset.uid);
+ } else {
+ cardsPane.displayBook(row.dataset.uid);
+ }
+
+ // Row 0 is the "All Address Books" item.
+ if (this.selectedIndex === 0) {
+ document.getElementById("toolbarCreateContact").disabled = false;
+ document.getElementById("toolbarCreateList").disabled = false;
+ document.body.classList.add("all-ab-selected");
+ } else {
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+
+ document.getElementById("toolbarCreateContact").disabled = book.readOnly;
+ document.getElementById("toolbarCreateList").disabled =
+ book.readOnly || !book.supportsMailingLists;
+ document.body.classList.remove("all-ab-selected");
+ }
+ }
+
+ _onCollapsed(event) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ event.target.id,
+ "collapsed",
+ "true"
+ );
+ }
+
+ _onExpanded(event) {
+ Services.xulStore.removeValue(cardsPane.URL, event.target.id, "collapsed");
+ }
+
+ _onKeyPress(event) {
+ if (event.altKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Delete":
+ this.deleteSelected();
+ break;
+ }
+ }
+
+ _onClick(event) {
+ super._onClick(event);
+
+ // Only handle left-clicks. Right-clicking on the menu button will cause
+ // the menu to appear anyway, and other buttons can be ignored.
+ if (
+ event.button !== 0 ||
+ !event.target.closest(".bookRow-menu, .listRow-menu")
+ ) {
+ return;
+ }
+
+ this._showContextMenu(event);
+ }
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ }
+
+ _onDragOver(event) {
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ if (!cards) {
+ return;
+ }
+ if (cards.some(c => c.isMailList)) {
+ return;
+ }
+
+ // TODO: Handle dropping a vCard here.
+
+ let row = event.target.closest("li");
+ if (!row || row.classList.contains("readOnly")) {
+ return;
+ }
+
+ let rowIsList = row.classList.contains("listRow");
+ event.dataTransfer.effectAllowed = rowIsList ? "link" : "copyMove";
+
+ if (rowIsList) {
+ let bookUID = row.dataset.book;
+ for (let card of cards) {
+ if (card.directoryUID != bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "link";
+ } else {
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ // Prevent dropping a card where it already is.
+ if (card.directoryUID == bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = event.ctrlKey ? "copy" : "move";
+ }
+
+ this._clearDropTarget();
+ row.classList.add("drop-target");
+
+ event.preventDefault();
+ }
+
+ _clearDropTarget() {
+ this.querySelector(".drop-target")?.classList.remove("drop-target");
+ }
+
+ _onDrop(event) {
+ this._clearDropTarget();
+ if (event.dataTransfer.dropEffect == "none") {
+ // Somehow this is possible. It should not be possible.
+ return;
+ }
+
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ let row = event.target.closest("li");
+
+ if (row.classList.contains("listRow")) {
+ for (let card of cards) {
+ row._list.addCard(card);
+ }
+ } else if (event.dataTransfer.dropEffect == "copy") {
+ for (let card of cards) {
+ row._book.dropCard(card, true);
+ }
+ } else {
+ let booksMap = new Map();
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ if (bookUID == card.directoryUID) {
+ continue;
+ }
+ row._book.dropCard(card, false);
+ let bookSet = booksMap.get(card.directoryUID);
+ if (!bookSet) {
+ bookSet = new Set();
+ booksMap.set(card.directoryUID, bookSet);
+ }
+ bookSet.add(card);
+ }
+ for (let [uid, bookSet] of booksMap) {
+ MailServices.ab.getDirectoryFromUID(uid).deleteCards([...bookSet]);
+ }
+ }
+
+ event.preventDefault();
+ }
+
+ _showContextMenu(event) {
+ let row =
+ event.target == this
+ ? this.rows[this.selectedIndex]
+ : event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ let popup = document.getElementById("bookContext");
+ let synchronizeItem = document.getElementById("bookContextSynchronize");
+ let exportItem = document.getElementById("bookContextExport");
+ let deleteItem = document.getElementById("bookContextDelete");
+ let removeItem = document.getElementById("bookContextRemove");
+ let startupDefaultItem = document.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+
+ this.selectedIndex = this.rows.indexOf(row);
+ this.focus();
+ if (this.selectedIndex === 0) {
+ // All Address Books - only the startup default item is relevant.
+ for (let item of popup.children) {
+ item.hidden = item != startupDefaultItem;
+ }
+
+ isDefault =
+ isDefault &&
+ !Services.prefs.prefHasUserValue("mail.addr_book.view.startupURI");
+ } else {
+ for (let item of popup.children) {
+ item.hidden = false;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("bookContextProperties"),
+ row.classList.contains("listRow")
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-properties"
+ );
+
+ synchronizeItem.hidden = !row.classList.contains("carddav");
+ exportItem.hidden = row.classList.contains("remote");
+
+ deleteItem.disabled = row.classList.contains("noDelete");
+ deleteItem.hidden = row.classList.contains("carddav");
+
+ removeItem.disabled = row.classList.contains("noDelete");
+ removeItem.hidden = !row.classList.contains("carddav");
+
+ let directory = row._book || row._list;
+ isDefault =
+ isDefault &&
+ Services.prefs.getStringPref("mail.addr_book.view.startupURI") ==
+ directory.URI;
+ }
+
+ if (isDefault) {
+ startupDefaultItem.setAttribute("checked", "true");
+ } else {
+ startupDefaultItem.removeAttribute("checked");
+ }
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a click on the menu button, or the context menu key was
+ // pressed. Open near the menu button.
+ popup.openPopup(
+ row.querySelector(".bookRow-container, .listRow-container"),
+ {
+ triggerEvent: event,
+ position: "end_before",
+ x: -26,
+ y: 30,
+ }
+ );
+ }
+ event.preventDefault();
+ }
+
+ _abObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-directory-request-start",
+ "addrbook-directory-request-end",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ ],
+
+ // Bound to `booksList`.
+ observe(subject, topic, data) {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ let row = this._createBookRow(subject);
+ let next = this.children[1];
+ while (next) {
+ if (
+ AddrBookUtils.compareAddressBooks(
+ subject,
+ MailServices.ab.getDirectoryFromUID(next.dataset.uid)
+ ) < 0
+ ) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ this.insertBefore(row, next);
+ break;
+ }
+ case "addrbook-directory-updated":
+ case "addrbook-list-updated": {
+ let row = this.getRowForUID(subject.UID);
+ row.querySelector(".bookRow-name, .listRow-name").textContent =
+ subject.dirName;
+ row.setAttribute("aria-label", subject.dirName);
+ if (cardsPane.cardsList.view.directory?.UID == subject.UID) {
+ document.l10n.setAttributes(
+ cardsPane.searchInput,
+ "about-addressbook-search",
+ { name: subject.dirName }
+ );
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ this.getRowForUID(subject.UID).remove();
+ break;
+ }
+ case "addrbook-directory-request-start":
+ this.getRowForUID(data).classList.add("requesting");
+ break;
+ case "addrbook-directory-request-end":
+ this.getRowForUID(data).classList.remove("requesting");
+ break;
+ case "addrbook-list-created": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ if (!childList) {
+ childList = row.appendChild(document.createElement("ul"));
+ }
+
+ let listRow = this._createListRow(data, subject);
+ let next = childList.firstElementChild;
+ while (next) {
+ if (AddrBookUtils.compareAddressBooks(subject, next._list) < 0) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ childList.insertBefore(listRow, next);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ let listRow = childList.querySelector(`[data-uid="${subject.UID}"]`);
+ listRow.remove();
+ if (childList.childElementCount == 0) {
+ setTimeout(() => childList.remove());
+ }
+ break;
+ }
+ }
+ },
+ };
+}
+customElements.define("ab-tree-listbox", AbTreeListbox, { extends: "ul" });
+
+// Cards
+
+/**
+ * Search field for card list. An HTML port of MozSearchTextbox.
+ */
+class AbCardSearchInput extends HTMLInputElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this._fireCommand = this._fireCommand.bind(this);
+
+ this.addEventListener("input", this);
+ this.addEventListener("keypress", this);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "input":
+ this._onInput(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ }
+ }
+
+ _onInput() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = setTimeout(this._fireCommand, 500, this);
+ }
+
+ _onKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ if (this._clearSearch()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ break;
+ case "Return":
+ this._enterSearch();
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ }
+ }
+
+ _fireCommand() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = null;
+ this.dispatchEvent(new CustomEvent("command"));
+ }
+
+ _enterSearch() {
+ this._fireCommand();
+ }
+
+ _clearSearch() {
+ if (this.value) {
+ this.value = "";
+ this._fireCommand();
+ return true;
+ }
+ return false;
+ }
+}
+customElements.define("ab-card-search-input", AbCardSearchInput, {
+ extends: "input",
+});
+
+customElements.whenDefined("tree-view-table-row").then(() => {
+ /**
+ * A row in the list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 46;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ this.cell = document.createElement("td");
+
+ let container = this.cell.appendChild(document.createElement("div"));
+ container.classList.add("card-container");
+
+ this.avatar = container.appendChild(document.createElement("div"));
+ this.avatar.classList.add("recipient-avatar");
+ let dataContainer = container.appendChild(document.createElement("div"));
+ dataContainer.classList.add("ab-card-row-data");
+
+ this.firstLine = dataContainer.appendChild(document.createElement("p"));
+ this.firstLine.classList.add("ab-card-first-line");
+ this.name = this.firstLine.appendChild(document.createElement("span"));
+ this.name.classList.add("name");
+
+ let secondLine = dataContainer.appendChild(document.createElement("p"));
+ secondLine.classList.add("ab-card-second-line");
+ this.address = secondLine.appendChild(document.createElement("span"));
+ this.address.classList.add("address");
+
+ this.appendChild(this.cell);
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.name.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+
+ // Add the address book name for All Address Books if in the sort Context
+ // Address Book is checked. This is done for the list view only.
+ if (
+ document.getElementById("books").selectedIndex == "0" &&
+ document
+ .getElementById("sortContext")
+ .querySelector(`menuitem[value="addrbook"]`)
+ .getAttribute("checked") === "true"
+ ) {
+ let addressBookName = this.querySelector(".address-book-name");
+ if (!addressBookName) {
+ addressBookName = document.createElement("span");
+ addressBookName.classList.add("address-book-name");
+ this.firstLine.appendChild(addressBookName);
+ }
+ addressBookName.textContent = this.view.getCellText(index, {
+ id: "addrbook",
+ });
+ } else {
+ this.querySelector(".address-book-name")?.remove();
+ }
+
+ // Don't try to fetch the avatar or show the parent AB if this is a list.
+ if (!card.isMailList) {
+ this.classList.remove("MailList");
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = this.name.textContent;
+ img.src = photoURL;
+ this.avatar.replaceChildren(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(
+ this.name.textContent
+ )[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ this.avatar.replaceChildren(letter);
+ }
+ this.address.textContent = card.primaryEmail;
+ } else {
+ this.classList.add("MailList");
+ let img = document.createElement("img");
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ this.avatar.replaceChildren(img);
+ this.avatar.classList.add("is-mail-list");
+ this.address.textContent = "";
+ }
+
+ this.cell.setAttribute("aria-label", this.name.textContent);
+ }
+ }
+ customElements.define("ab-card-row", AbCardRow, { extends: "tr" });
+
+ /**
+ * A row in the table list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbTableCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 22;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ for (let column of cardsPane.COLUMNS) {
+ this.appendChild(document.createElement("td")).classList.add(
+ `${column.id.toLowerCase()}-column`
+ );
+ }
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.classList.toggle("MailList", card.isMailList);
+
+ for (let column of cardsPane.COLUMNS) {
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ if (!column.hidden) {
+ cell.textContent = this.view.getCellText(index, { id: column.id });
+ continue;
+ }
+
+ cell.hidden = true;
+ }
+
+ this.setAttribute("aria-label", this.firstElementChild.textContent);
+ }
+ }
+ customElements.define("ab-table-card-row", AbTableCardRow, {
+ extends: "tr",
+ });
+});
+
+var cardsPane = {
+ /**
+ * The document URL for saving and retrieving values in the XUL Store.
+ *
+ * @type {string}
+ */
+ URL: "about:addressbook",
+
+ /**
+ * The array of columns for the table layout.
+ *
+ * @type {Array}
+ */
+ COLUMNS: [
+ {
+ id: "GeneratedName",
+ l10n: {
+ header: "about-addressbook-column-header-generatedname2",
+ menuitem: "about-addressbook-column-label-generatedname2",
+ },
+ },
+ {
+ id: "EmailAddresses",
+ l10n: {
+ header: "about-addressbook-column-header-emailaddresses2",
+ menuitem: "about-addressbook-column-label-emailaddresses2",
+ },
+ },
+ {
+ id: "NickName",
+ l10n: {
+ header: "about-addressbook-column-header-nickname2",
+ menuitem: "about-addressbook-column-label-nickname2",
+ },
+ hidden: true,
+ },
+ {
+ id: "PhoneNumbers",
+ l10n: {
+ header: "about-addressbook-column-header-phonenumbers2",
+ menuitem: "about-addressbook-column-label-phonenumbers2",
+ },
+ },
+ {
+ id: "Addresses",
+ l10n: {
+ header: "about-addressbook-column-header-addresses2",
+ menuitem: "about-addressbook-column-label-addresses2",
+ },
+ },
+ {
+ id: "Title",
+ l10n: {
+ header: "about-addressbook-column-header-title2",
+ menuitem: "about-addressbook-column-label-title2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Department",
+ l10n: {
+ header: "about-addressbook-column-header-department2",
+ menuitem: "about-addressbook-column-label-department2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Organization",
+ l10n: {
+ header: "about-addressbook-column-header-organization2",
+ menuitem: "about-addressbook-column-label-organization2",
+ },
+ hidden: true,
+ },
+ {
+ id: "addrbook",
+ l10n: {
+ header: "about-addressbook-column-header-addrbook2",
+ menuitem: "about-addressbook-column-label-addrbook2",
+ },
+ hidden: true,
+ },
+ ],
+
+ /**
+ * Make the list rows density aware.
+ */
+ densityChange() {
+ let rowClass = customElements.get("ab-card-row");
+ let tableRowClass = customElements.get("ab-table-card-row");
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ rowClass.ROW_HEIGHT = 36;
+ tableRowClass.ROW_HEIGHT = 18;
+ break;
+ case UIDensity.MODE_TOUCH:
+ rowClass.ROW_HEIGHT = 60;
+ tableRowClass.ROW_HEIGHT = 32;
+ break;
+ default:
+ rowClass.ROW_HEIGHT = 46;
+ tableRowClass.ROW_HEIGHT = 22;
+ break;
+ }
+ this.cardsList.reset();
+ },
+
+ searchInput: null,
+
+ cardsList: null,
+
+ init() {
+ this.searchInput = document.getElementById("searchInput");
+ this.displayButton = document.getElementById("displayButton");
+ this.sortContext = document.getElementById("sortContext");
+ this.cardContext = document.getElementById("cardContext");
+
+ this.cardsList = document.getElementById("cards");
+ this.table = this.cardsList.table;
+ this.table.editable = true;
+ this.table.setBodyID("cardsBody");
+ this.cardsList.setAttribute("rows", "ab-card-row");
+
+ if (
+ Services.xulStore.getValue(cardsPane.URL, "cardsPane", "layout") ==
+ "table"
+ ) {
+ this.toggleLayout(true);
+ }
+
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ this.sortContext
+ .querySelector(`[name="format"][value="${nameFormat}"]`)
+ ?.setAttribute("checked", "true");
+
+ let columns = Services.xulStore.getValue(cardsPane.URL, "cards", "columns");
+ if (columns) {
+ columns = columns.split(",");
+ for (let column of cardsPane.COLUMNS) {
+ column.hidden = !columns.includes(column.id);
+ }
+ }
+
+ this.table.setColumns(cardsPane.COLUMNS);
+ this.table.restoreColumnsWidths(cardsPane.URL);
+
+ // Only add the address book toggle to the filter button outside the table
+ // layout view. All other toggles are only for a table context.
+ let abColumn = cardsPane.COLUMNS.find(c => c.id == "addrbook");
+ let menuitem = this.sortContext.insertBefore(
+ document.createXULElement("menuitem"),
+ this.sortContext.querySelector("menuseparator:last-of-type")
+ );
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("name", "toggle");
+ menuitem.setAttribute("value", abColumn.id);
+ menuitem.setAttribute("closemenu", "none");
+ if (abColumn.l10n?.menuitem) {
+ document.l10n.setAttributes(menuitem, abColumn.l10n.menuitem);
+ }
+ if (!abColumn.hidden) {
+ menuitem.setAttribute("checked", "true");
+ }
+
+ menuitem.addEventListener("command", event =>
+ this._onColumnsChanged({ target: menuitem, value: abColumn.id })
+ );
+
+ this.searchInput.addEventListener("command", this);
+ this.displayButton.addEventListener("click", this);
+ this.sortContext.addEventListener("command", this);
+ this.table.addEventListener("columns-changed", this);
+ this.table.addEventListener("sort-changed", this);
+ this.table.addEventListener("column-resized", this);
+ this.cardsList.addEventListener("select", this);
+ this.cardsList.addEventListener("keydown", this);
+ this.cardsList.addEventListener("dblclick", this);
+ this.cardsList.addEventListener("dragstart", this);
+ this.cardsList.addEventListener("contextmenu", this);
+ this.cardsList.addEventListener("rowcountchange", () => {
+ if (
+ document.activeElement == this.cardsList &&
+ this.cardsList.view.rowCount == 0
+ ) {
+ this.searchInput.focus();
+ }
+ });
+ this.cardsList.addEventListener("searchstatechange", () =>
+ this._updatePlaceholder()
+ );
+ this.cardContext.addEventListener("command", this);
+
+ window.addEventListener("uidensitychange", () => cardsPane.densityChange());
+ customElements
+ .whenDefined("ab-table-card-row")
+ .then(() => cardsPane.densityChange());
+
+ document
+ .getElementById("placeholderCreateContact")
+ .addEventListener("click", () => createContact());
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "command":
+ this._onCommand(event);
+ break;
+ case "click":
+ this._onClick(event);
+ break;
+ case "select":
+ this._onSelect(event);
+ break;
+ case "keydown":
+ this._onKeyDown(event);
+ break;
+ case "dblclick":
+ this._onDoubleClick(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "columns-changed":
+ this._onColumnsChanged(event.detail);
+ break;
+ case "sort-changed":
+ this._onSortChanged(event);
+ break;
+ case "column-resized":
+ this._onColumnResized(event);
+ break;
+ }
+ },
+
+ /**
+ * Store the resized column value in the xul store.
+ *
+ * @param {DOMEvent} event - The dom event bubbling from the resized action.
+ */
+ _onColumnResized(event) {
+ this.table.setColumnsWidths(cardsPane.URL, event);
+ },
+
+ _onSortChanged(event) {
+ const { sortColumn, sortDirection } = this.cardsList.view;
+ const column = event.detail.column;
+ this.sortRows(
+ column,
+ sortColumn == column && sortDirection == "ascending"
+ ? "descending"
+ : "ascending"
+ );
+ },
+
+ _onColumnsChanged(data) {
+ let column = data.value;
+ let checked = data.target.hasAttribute("checked");
+
+ for (let columnDef of cardsPane.COLUMNS) {
+ if (columnDef.id == column) {
+ columnDef.hidden = !checked;
+ break;
+ }
+ }
+
+ this.table.updateColumns(cardsPane.COLUMNS);
+ this.cardsList.reset();
+
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "columns",
+ cardsPane.COLUMNS.filter(c => !c.hidden)
+ .map(c => c.id)
+ .join(",")
+ );
+ },
+
+ /**
+ * Switch between list and table layouts.
+ *
+ * @param {?boolean} isTableLayout - Use table layout if `true` or list
+ * layout if `false`. If unspecified, switch layouts.
+ */
+ toggleLayout(isTableLayout) {
+ isTableLayout = document.body.classList.toggle(
+ "layout-table",
+ isTableLayout
+ );
+
+ updateSharedSplitter(isTableLayout);
+
+ this.cardsList.setAttribute(
+ "rows",
+ isTableLayout ? "ab-table-card-row" : "ab-card-row"
+ );
+ this.cardsList.setSpacersColspan(
+ isTableLayout ? cardsPane.COLUMNS.filter(c => !c.hidden).length : 0
+ );
+ if (isTableLayout) {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .setAttribute("checked", "true");
+ } else {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .removeAttribute("checked");
+ }
+
+ if (this.cardsList.selectedIndex > -1) {
+ this.cardsList.scrollToIndex(this.cardsList.selectedIndex);
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cardsPane",
+ "layout",
+ isTableLayout ? "table" : "list"
+ );
+ },
+
+ /**
+ * Gets an address book query string based on the value of the search input.
+ *
+ * @returns {string}
+ */
+ getQuery() {
+ if (!this.searchInput.value) {
+ return null;
+ }
+
+ let searchWords = ABQueryUtils.getSearchTokens(this.searchInput.value);
+ let queryURIFormat = ABQueryUtils.getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ return ABQueryUtils.generateQueryURI(queryURIFormat, searchWords);
+ },
+
+ /**
+ * Display an address book, or all address books.
+ *
+ * @param {string|null} uid - The UID of the book or list to display, or null
+ * for All Address Books.
+ */
+ displayBook(uid) {
+ let book = uid ? MailServices.ab.getDirectoryFromUID(uid) : null;
+ if (book) {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search",
+ { name: book.dirName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search-all"
+ );
+ }
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ book,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ /**
+ * Display a list.
+ *
+ * @param {bookUID} uid - The UID of the address book containing the list.
+ * @param {string} uid - The UID of the list to display.
+ */
+ displayList(bookUID, uid) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ let list = book.childNodes.find(l => l.UID == uid);
+ document.l10n.setAttributes(this.searchInput, "about-addressbook-search", {
+ name: list.dirName,
+ });
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ list,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ get selectedCards() {
+ return this.cardsList.selectedIndices.map(i =>
+ this.cardsList.view.getCardFromRow(i)
+ );
+ },
+
+ /**
+ * Display the right message in the cards list placeholder. The placeholder
+ * is only visible if there are no cards in the list, but it's kept
+ * up-to-date at all times, so we don't have to keep track of the size of
+ * the list.
+ */
+ _updatePlaceholder() {
+ let { directory, searchState } = this.cardsList.view;
+
+ let idsToShow;
+ switch (searchState) {
+ case ABView.NOT_SEARCHING:
+ if (directory?.isRemote && !Services.io.offline) {
+ idsToShow = ["placeholderSearchOnly"];
+ } else {
+ idsToShow = ["placeholderEmptyBook"];
+ if (!directory?.readOnly && !directory?.isMailList) {
+ idsToShow.push("placeholderCreateContact");
+ }
+ }
+ break;
+ case ABView.SEARCHING:
+ idsToShow = ["placeholderSearching"];
+ break;
+ case ABView.SEARCH_COMPLETE:
+ idsToShow = ["placeholderNoSearchResults"];
+ break;
+ }
+
+ this.cardsList.updatePlaceholders(idsToShow);
+ },
+
+ /**
+ * Set the name format to be displayed.
+ *
+ * @param {integer} format - One of the nsIAbCard.GENERATE_* constants.
+ */
+ setNameFormat(event) {
+ // ABView will detect this change and update automatically.
+ Services.prefs.setIntPref(
+ "mail.addr_book.lastnamefirst",
+ event.target.value
+ );
+ },
+
+ /**
+ * Change the sort order of the rows being displayed. If `column` and
+ * `direction` match the existing values no sorting occurs but the UI items
+ * are always updated.
+ *
+ * @param {string} column
+ * @param {"ascending"|"descending"} direction
+ */
+ sortRows(column, direction) {
+ // Uncheck the sort button menu item for the previously sorted column, if
+ // there is one, then check the sort button menu item for the column to be
+ // sorted.
+ this.sortContext
+ .querySelector(`[name="sort"][checked]`)
+ ?.removeAttribute("checked");
+ this.sortContext
+ .querySelector(`[name="sort"][value="${column} ${direction}"]`)
+ ?.setAttribute("checked", "true");
+
+ // Unmark the header of previously sorted column, then mark the header of
+ // the column to be sorted.
+ this.table
+ .querySelector(".sorting")
+ ?.classList.remove("sorting", "ascending", "descending");
+ this.table
+ .querySelector(`#${column} button`)
+ ?.classList.add("sorting", direction);
+
+ if (
+ this.cardsList.view.sortColumn == column &&
+ this.cardsList.view.sortDirection == direction
+ ) {
+ return;
+ }
+
+ this.cardsList.view.sortBy(column, direction);
+
+ Services.xulStore.setValue(cardsPane.URL, "cards", "sortColumn", column);
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "sortDirection",
+ direction
+ );
+ },
+
+ /**
+ * Start a new message to the given addresses.
+ *
+ * @param {string[]} addresses
+ */
+ writeTo(addresses) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.composeFields.to = addresses.join(",");
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ },
+
+ /**
+ * Start a new message to the selected contact(s) and/or mailing list(s).
+ */
+ writeToSelected() {
+ let selectedAddresses = [];
+
+ for (let card of this.selectedCards) {
+ let email;
+ if (card.isMailList) {
+ email = card.getProperty("Notes", "") || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+
+ if (email) {
+ selectedAddresses.push(
+ MailServices.headerParser.makeMimeAddress(card.displayName, email)
+ );
+ }
+ }
+
+ this.writeTo(selectedAddresses);
+ },
+
+ /**
+ * Print delete the selected card(s).
+ */
+ printSelected() {
+ let selectedCards = this.selectedCards;
+ if (selectedCards.length) {
+ // Some cards are selected. Print them.
+ printHandler.printCards(selectedCards);
+ } else if (this.cardsList.view.searchString) {
+ // Nothing's selected, so print everything. But this is a search, so we
+ // can't just print the selected book/list.
+ let allCards = [];
+ for (let i = 0; i < this.cardsList.view.rowCount; i++) {
+ allCards.push(this.cardsList.view.getCardFromRow(i));
+ }
+ printHandler.printCards(allCards);
+ } else {
+ // Nothing's selected, so print the selected book/list.
+ booksList.printSelected();
+ }
+ },
+
+ /**
+ * Export the selected mailing list to a file.
+ */
+ exportSelected() {
+ let card = this.selectedCards[0];
+ if (!card || !card.isMailList) {
+ return;
+ }
+ let row = booksList.getRowForUID(card.UID);
+ AddrBookUtils.exportDirectory(row._list);
+ },
+
+ _canModifySelected() {
+ if (this.cardsList.view.directory?.readOnly) {
+ return false;
+ }
+
+ let seenDirectories = new Set();
+ for (let index of this.cardsList.selectedIndices) {
+ let { directoryUID } = this.cardsList.view.getCardFromRow(index);
+ if (seenDirectories.has(directoryUID)) {
+ continue;
+ }
+ if (MailServices.ab.getDirectoryFromUID(directoryUID).readOnly) {
+ return false;
+ }
+ seenDirectories.add(directoryUID);
+ }
+ return true;
+ },
+
+ /**
+ * Prompt the user and delete the selected card(s).
+ */
+ async deleteSelected() {
+ if (!this._canModifySelected()) {
+ return;
+ }
+
+ let selectedLists = [];
+ let selectedContacts = [];
+
+ for (let index of this.cardsList.selectedIndices) {
+ let card = this.cardsList.view.getCardFromRow(index);
+ if (card.isMailList) {
+ selectedLists.push(card);
+ } else {
+ selectedContacts.push(card);
+ }
+ }
+
+ if (selectedLists.length + selectedContacts.length == 0) {
+ return;
+ }
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let count = selectedLists.length + selectedContacts.length;
+ let selectedDir = this.cardsList.view.directory;
+
+ if (selectedLists.length && selectedContacts.length) {
+ action = "delete-mixed";
+ } else if (selectedLists.length) {
+ action = "delete-lists";
+ name = selectedLists[0].displayName;
+ } else {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = selectedContacts[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString =
+ count > 1 ? "delete-contacts-multi" : "delete-contacts-single";
+ break;
+ case "remove-contacts":
+ actionString =
+ count > 1 ? "remove-contacts-multi" : "remove-contacts-single";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count } },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: { count, name, list },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) !== 0
+ ) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = this.cardsList.currentIndex;
+ // Delete cards from address books or mailing lists.
+ this.cardsList.view.deleteSelectedCards();
+ this.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ this.cardsList.view.rowCount - 1
+ );
+ },
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ },
+
+ _showContextMenu(event) {
+ let row;
+ if (event.target == this.cardsList.table.body) {
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ } else {
+ row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ }
+ if (!row) {
+ return;
+ }
+ if (!this.cardsList.selectedIndices.includes(row.index)) {
+ this.cardsList.selectedIndex = row.index;
+ // Re-fetch the row in case it was replaced.
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ }
+
+ this.cardsList.table.body.focus();
+
+ let writeMenuItem = document.getElementById("cardContextWrite");
+ let writeMenu = document.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = document.getElementById(
+ "cardContextWriteSeparator"
+ );
+ let editItem = document.getElementById("cardContextEdit");
+ // Always reset the edit item to its default string.
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit"
+ );
+ let exportItem = document.getElementById("cardContextExport");
+ if (this.cardsList.selectedIndices.length == 1) {
+ let card = this.cardsList.view.getCardFromRow(
+ this.cardsList.selectedIndex
+ );
+ if (card.isMailList) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = !this._canModifySelected();
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit-list"
+ );
+ exportItem.hidden = false;
+ } else {
+ let addresses = card.emailAddresses;
+
+ if (addresses.length == 0) {
+ writeMenuItem.hidden =
+ writeMenu.hidden =
+ writeMenuSeparator.hidden =
+ true;
+ } else if (addresses.length == 1) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ } else {
+ while (writeMenu.menupopup.lastChild) {
+ writeMenu.menupopup.lastChild.remove();
+ }
+
+ for (let address of addresses) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.label = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ address
+ );
+ menuitem.addEventListener("command", () =>
+ this.writeTo([menuitem.label])
+ );
+ writeMenu.menupopup.appendChild(menuitem);
+ }
+
+ writeMenuItem.hidden = true;
+ writeMenu.hidden = writeMenuSeparator.hidden = false;
+ }
+
+ editItem.hidden = !this._canModifySelected();
+ exportItem.hidden = true;
+ }
+ } else {
+ writeMenuItem.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = true;
+ exportItem.hidden = true;
+ }
+
+ let deleteItem = document.getElementById("cardContextDelete");
+ let removeItem = document.getElementById("cardContextRemove");
+
+ let inMailList = this.cardsList.view.directory?.isMailList;
+ deleteItem.hidden = inMailList;
+ removeItem.hidden = !inMailList;
+ deleteItem.disabled = removeItem.disabled = !this._canModifySelected();
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ this.cardContext.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a context menu key press. Open near the middle of the row.
+ this.cardContext.openPopup(row, {
+ triggerEvent: event,
+ position: "overlap",
+ x: row.clientWidth / 2,
+ y: row.clientHeight / 2,
+ });
+ }
+ event.preventDefault();
+ },
+
+ _onCommand(event) {
+ if (event.target == this.searchInput) {
+ this.cardsList.view = new ABView(
+ this.cardsList.view.directory,
+ this.getQuery(),
+ this.searchInput.value,
+ this.cardsList.view.sortColumn,
+ this.cardsList.view.sortDirection
+ );
+ this._updatePlaceholder();
+ detailsPane.displayCards();
+ return;
+ }
+
+ switch (event.target.id) {
+ case "sortContextTableLayout":
+ this.toggleLayout(event.target.getAttribute("checked") === "true");
+ break;
+ case "cardContextWrite":
+ this.writeToSelected();
+ return;
+ case "cardContextEdit":
+ detailsPane.editCurrent();
+ return;
+ case "cardContextPrint":
+ this.printSelected();
+ return;
+ case "cardContextExport":
+ this.exportSelected();
+ return;
+ case "cardContextDelete":
+ this.deleteSelected();
+ return;
+ case "cardContextRemove":
+ this.deleteSelected();
+ return;
+ }
+
+ if (event.target.getAttribute("name") == "format") {
+ this.setNameFormat(event);
+ }
+ if (event.target.getAttribute("name") == "sort") {
+ let [column, direction] = event.target.value.split(" ");
+ this.sortRows(column, direction);
+ }
+ },
+
+ _onClick(event) {
+ if (event.target.closest("button") == this.displayButton) {
+ this.sortContext.openPopup(this.displayButton, { triggerEvent: event });
+ event.preventDefault();
+ }
+ },
+
+ _onSelect(event) {
+ detailsPane.displayCards(this.selectedCards);
+ },
+
+ _onKeyDown(event) {
+ if (event.altKey || event.shiftKey) {
+ return;
+ }
+
+ let modifier = event.ctrlKey;
+ let antiModifier = event.metaKey;
+ if (AppConstants.platform == "macosx") {
+ [modifier, antiModifier] = [antiModifier, modifier];
+ }
+ if (antiModifier) {
+ return;
+ }
+
+ switch (event.key) {
+ case "a":
+ if (modifier) {
+ this.cardsList.view.selection.selectAll();
+ this.cardsList.dispatchEvent(new CustomEvent("select"));
+ event.preventDefault();
+ }
+ break;
+ case "Delete":
+ if (!modifier) {
+ this.deleteSelected();
+ event.preventDefault();
+ }
+ break;
+ case "Enter":
+ if (!modifier) {
+ if (this.cardsList.currentIndex >= 0) {
+ this._activateRow(this.cardsList.currentIndex);
+ }
+ event.preventDefault();
+ }
+ break;
+ }
+ },
+
+ _onDoubleClick(event) {
+ if (
+ event.button != 0 ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return;
+ }
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (row) {
+ this._activateRow(row.index);
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * "Activate" the row by opening the corresponding card for editing. This will
+ * necessarily change the selection to the given index.
+ *
+ * @param {number} index - The index of the row to activate.
+ */
+ _activateRow(index) {
+ if (detailsPane.isEditing) {
+ return;
+ }
+ // Change selection to just the target.
+ this.cardsList.selectedIndex = index;
+ // We expect the selection to change the detailsPane immediately.
+ detailsPane.editCurrent();
+ },
+
+ _onDragStart(event) {
+ function makeMimeAddressFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = MailServices.ab.getDirectory(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+ if (!email) {
+ return "";
+ }
+ return MailServices.headerParser.makeMimeAddress(card.displayName, email);
+ }
+
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let indices = this.cardsList.selectedIndices;
+ if (!indices.includes(row.index)) {
+ indices = [row.index];
+ }
+ let cards = indices.map(index => this.cardsList.view.getCardFromRow(index));
+
+ let addresses = cards.map(makeMimeAddressFromCard);
+ event.dataTransfer.mozSetDataAt("moz/abcard-array", cards, 0);
+ event.dataTransfer.setData("text/x-moz-address", addresses);
+ event.dataTransfer.setData("text/plain", addresses);
+
+ let card = this.cardsList.view.getCardFromRow(row.index);
+ 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",
+ this._flavorDataProvider
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ event.dataTransfer.effectAllowed = "all";
+ let bcr = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ row,
+ event.clientX - bcr.x,
+ event.clientY - bcr.y
+ );
+ },
+
+ _flavorDataProvider: {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(transferable, flavor, data) {
+ if (flavor == "application/x-moz-file-promise") {
+ let primitive = {};
+ transferable.getTransferData("text/vcard", primitive);
+ let vCard = primitive.value.QueryInterface(Ci.nsISupportsString).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dest-filename",
+ primitive
+ );
+ let leafName = primitive.value.QueryInterface(
+ Ci.nsISupportsString
+ ).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ primitive
+ );
+ let localFile = primitive.value.QueryInterface(Ci.nsIFile).clone();
+ localFile.append(leafName);
+
+ let ofStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ ofStream.init(localFile, -1, -1, 0);
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(ofStream, null);
+ converter.writeString(vCard);
+ converter.close();
+
+ data.value = localFile;
+ }
+ },
+ },
+};
+
+/**
+ * Object holding the contact view pane to show all vcard info and handle data
+ * changes and mutations between the view and edit state of a contact.
+ */
+var detailsPane = {
+ currentCard: null,
+
+ dirtyFields: new Set(),
+
+ _notifications: [
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-removed",
+ ],
+
+ init() {
+ let booksSplitter = document.getElementById("booksSplitter");
+ let booksSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width"
+ );
+ if (booksSplitterWidth) {
+ booksSplitter.width = booksSplitterWidth;
+ }
+ booksSplitter.addEventListener("splitter-resized", () =>
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width",
+ booksSplitter.width
+ )
+ );
+
+ let isTableLayout = document.body.classList.contains("layout-table");
+ updateSharedSplitter(isTableLayout);
+
+ this.splitter = document.getElementById("sharedSplitter");
+ let sharedSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width"
+ );
+ if (sharedSplitterWidth) {
+ this.splitter.width = sharedSplitterWidth;
+ }
+ let sharedSplitterHeight = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height"
+ );
+ if (sharedSplitterHeight) {
+ this.splitter.height = sharedSplitterHeight;
+ }
+ this.splitter.addEventListener("splitter-resized", () => {
+ if (isTableLayout) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height",
+ this.splitter.height
+ );
+ return;
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width",
+ this.splitter.width
+ );
+ });
+
+ this.node = document.getElementById("detailsPane");
+ this.actions = document.getElementById("detailsActions");
+ this.writeButton = document.getElementById("detailsWriteButton");
+ this.eventButton = document.getElementById("detailsEventButton");
+ this.searchButton = document.getElementById("detailsSearchButton");
+ this.newListButton = document.getElementById("detailsNewListButton");
+ this.editButton = document.getElementById("editButton");
+ this.selectedCardsSection = document.getElementById("selectedCards");
+ this.form = document.getElementById("editContactForm");
+ this.vCardEdit = this.form.querySelector("vcard-edit");
+ this.deleteButton = document.getElementById("detailsDeleteButton");
+ this.addContactBookList = document.getElementById("addContactBookList");
+ this.cancelEditButton = document.getElementById("cancelEditButton");
+ this.saveEditButton = document.getElementById("saveEditButton");
+
+ this.actions.addEventListener("click", this);
+ document.getElementById("detailsFooter").addEventListener("click", this);
+
+ let photoImage = document.getElementById("viewContactPhoto");
+ photoImage.addEventListener("error", event => {
+ if (!detailsPane.currentCard) {
+ return;
+ }
+
+ let vCard = detailsPane.currentCard.getProperty("_vCard", "");
+ let match = /^PHOTO.*/im.exec(vCard);
+ if (match) {
+ console.warn(
+ `Broken contact photo, vCard data starts with: ${match[0]}`
+ );
+ } else {
+ console.warn(`Broken contact photo, source is: ${photoImage.src}`);
+ }
+ });
+
+ this.form.addEventListener("input", event => {
+ let { type, checked, value, _originalValue } = event.target;
+ let changed;
+ if (type == "checkbox") {
+ changed = checked != _originalValue;
+ } else {
+ changed = value != _originalValue;
+ }
+ if (changed) {
+ this.dirtyFields.add(event.target);
+ } else {
+ this.dirtyFields.delete(event.target);
+ }
+
+ // If there are no dirty fields, clear the flag, otherwise set it.
+ this.isDirty = this.dirtyFields.size > 0;
+ });
+ this.form.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on a button or
+ // checkbox.
+ if (
+ event.key == " " &&
+ ["button", "checkbox"].includes(document.activeElement.type)
+ ) {
+ event.preventDefault();
+ }
+
+ if (event.key != "Escape") {
+ return;
+ }
+
+ event.preventDefault();
+ this.form.reset();
+ });
+ this.form.addEventListener("reset", async event => {
+ event.preventDefault();
+ if (this.isDirty) {
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-unsaved-changes-prompt-title` },
+ { id: `about-addressbook-unsaved-changes-prompt` },
+ ]);
+
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPrompt.BUTTON_TITLE_SAVE * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE * Ci.nsIPrompt.BUTTON_POS_2,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (buttonPressed === 0) {
+ // Don't call this.form.submit, the submit event won't fire.
+ this.validateBeforeSaving();
+ return;
+ } else if (buttonPressed === 1) {
+ return;
+ }
+ }
+ this.isEditing = false;
+ if (this.currentCard) {
+ // Refresh the card from the book to get exactly what was saved.
+ let book = MailServices.ab.getDirectoryFromUID(
+ this.currentCard.directoryUID
+ );
+ let card = book.childCards.find(c => c.UID == this.currentCard.UID);
+ this.displayContact(card);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ } else {
+ this.displayCards(cardsPane.selectedCards);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ cardsPane.searchInput.focus();
+ }
+ }
+ });
+ this.form.addEventListener("submit", event => {
+ event.preventDefault();
+ this.validateBeforeSaving();
+ });
+
+ this.photoInput = document.getElementById("photoInput");
+ // NOTE: We put the paste handler on the button parent because the
+ // html:button will not be targeted by the paste event.
+ this.photoInput.addEventListener("paste", photoDialog);
+ this.photoInput.addEventListener("dragover", photoDialog);
+ this.photoInput.addEventListener("drop", photoDialog);
+
+ let photoButton = document.getElementById("photoButton");
+ photoButton.addEventListener("click", () => {
+ if (this._photoDetails.sourceURL) {
+ photoDialog.showWithURL(
+ this._photoDetails.sourceURL,
+ this._photoDetails.cropRect,
+ true
+ );
+ } else {
+ photoDialog.showEmpty();
+ }
+ });
+
+ this.cancelEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+ this.saveEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ },
+
+ uninit() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ this._onClick(event);
+ break;
+ }
+ },
+
+ async observe(subject, topic, data) {
+ let hadFocus =
+ this.node.contains(document.activeElement) ||
+ document.activeElement == document.body;
+
+ switch (topic) {
+ case "addrbook-contact-created":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ this.currentCard.UID != subject.getProperty("_originalUID", "")
+ ) {
+ break;
+ }
+
+ // The card being displayed had its UID changed by the server. Select
+ // the new card to display it. (If we're already editing the new card
+ // when the server responds, that's just tough luck.)
+ this.isEditing = false;
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(subject.UID);
+ break;
+ case "addrbook-contact-updated":
+ subject.QueryInterface(Ci.nsIAbCard);
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ !this.currentCard.equals(subject)
+ ) {
+ break;
+ }
+
+ // If there's editing in progress, we could attempt to update the
+ // editing interface with the changes, which is difficult, or alert
+ // the user. For now, changes will be overwritten if the edit is saved.
+
+ if (!this.isEditing) {
+ this.displayContact(subject);
+ }
+ break;
+ case "addrbook-contact-deleted":
+ case "addrbook-list-member-removed":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+
+ const directoryUID =
+ topic == "addrbook-contact-deleted"
+ ? this.currentCard?.directoryUID
+ : cardsPane.cardsList.view.directory?.UID;
+ if (directoryUID == data && this.currentCard?.equals(subject)) {
+ // The card being displayed was deleted.
+ this.isEditing = false;
+ this.displayCards();
+
+ if (hadFocus) {
+ // Ensure this happens *after* the view handles this notification.
+ Services.tm.dispatchToMainThread(() => {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ });
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (li._card.equals(subject)) {
+ // A selected card was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ case "addrbook-list-updated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ this.displayList(this.currentList);
+ }
+ break;
+ case "addrbook-list-deleted":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ // The list being displayed was deleted.
+ this.displayCards();
+
+ if (hadFocus) {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (
+ li._card.directoryUID == data &&
+ li._card.mailListURI == subject.URI
+ ) {
+ // A selected list was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ },
+
+ /**
+ * Is a card being edited?
+ *
+ * @type {boolean}
+ */
+ get isEditing() {
+ return document.body.classList.contains("is-editing");
+ },
+
+ set isEditing(editing) {
+ if (editing == this.isEditing) {
+ return;
+ }
+
+ document.body.classList.toggle("is-editing", editing);
+
+ // Disable the toolbar buttons when starting to edit. Remember their state
+ // to restore it when editing stops.
+ for (let toolbarButton of document.querySelectorAll(
+ "#toolbox > toolbar > toolbarbutton"
+ )) {
+ if (editing) {
+ toolbarButton._wasDisabled = toolbarButton.disabled;
+ toolbarButton.disabled = true;
+ } else {
+ toolbarButton.disabled = toolbarButton._wasDisabled;
+ delete toolbarButton._wasDisabled;
+ }
+ }
+
+ // Remove these elements from (or add them back to) the tab focus cycle.
+ for (let id of ["books", "searchInput", "displayButton", "cardsBody"]) {
+ document.getElementById(id).tabIndex = editing ? -1 : 0;
+ }
+
+ if (editing) {
+ this.addContactBookList.hidden = !!this.currentCard;
+ this.addContactBookList.previousElementSibling.hidden =
+ !!this.currentCard;
+
+ let book = booksList
+ .getRowAtIndex(booksList.selectedIndex)
+ .closest(".bookRow")._book;
+ if (book) {
+ // TODO: convert this to UID.
+ this.addContactBookList.value = book.URI;
+ }
+ } else {
+ this.isDirty = false;
+ }
+ },
+
+ /**
+ * If a card is being edited, has any field changed?
+ *
+ * @type {boolean}
+ */
+ get isDirty() {
+ return this.isEditing && document.body.classList.contains("is-dirty");
+ },
+
+ set isDirty(dirty) {
+ if (!dirty) {
+ this.dirtyFields.clear();
+ }
+ document.body.classList.toggle("is-dirty", this.isEditing && dirty);
+ },
+
+ clearDisplay() {
+ this.currentCard = null;
+ this.currentList = null;
+
+ for (let section of document.querySelectorAll(
+ "#viewContact :is(.contact-header, .list-header, .selection-header), #detailsBody > section"
+ )) {
+ section.hidden = true;
+ }
+ },
+
+ displayCards(cards = []) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+
+ if (cards.length == 0) {
+ this.node.hidden = true;
+ this.splitter.isCollapsed =
+ document.body.classList.contains("layout-table");
+ return;
+ }
+ if (cards.length == 1) {
+ if (cards[0].isMailList) {
+ this.displayList(cards[0]);
+ } else {
+ this.displayContact(cards[0]);
+ }
+ return;
+ }
+
+ let contacts = cards.filter(c => !c.isMailList);
+ let contactsWithAddresses = contacts.filter(c => c.primaryEmail);
+ let lists = cards.filter(c => c.isMailList);
+
+ document.querySelector("#viewContact .selection-header").hidden = false;
+ let headerString;
+ if (contacts.length) {
+ if (lists.length) {
+ headerString = "about-addressbook-selection-mixed-header2";
+ } else {
+ headerString = "about-addressbook-selection-contacts-header2";
+ }
+ } else {
+ headerString = "about-addressbook-selection-lists-header2";
+ }
+ document.l10n.setAttributes(
+ document.getElementById("viewSelectionCount"),
+ headerString,
+ { count: cards.length }
+ );
+
+ this.writeButton.hidden = contactsWithAddresses.length + lists.length == 0;
+ this.eventButton.hidden =
+ !contactsWithAddresses.length ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = contactsWithAddresses.length == 0;
+ this.editButton.hidden = true;
+
+ this.actions.hidden = this.writeButton.hidden;
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+
+ if (!card.isMailList) {
+ name.textContent = card.generateName(ABView.nameFormat);
+ address.textContent = card.primaryEmail;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ } else {
+ name.textContent = card.displayName;
+
+ let img = avatar.appendChild(document.createElement("img"));
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ avatar.classList.add("is-mail-list");
+ }
+ }
+ this.selectedCardsSection.hidden = false;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Show a read-only representation of a card in the details pane.
+ *
+ * @param {nsIAbCard?} card - The card to display. This should not be a
+ * mailing list card. Pass null to hide the details pane.
+ */
+ displayContact(card) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!card || card.isMailList) {
+ return;
+ }
+ this.currentCard = card;
+
+ this.fillContactDetails(document.getElementById("viewContact"), card);
+ document.getElementById("viewContactPhoto").hidden = document.querySelector(
+ "#viewContact .contact-headings"
+ ).hidden = false;
+ document.querySelector("#viewContact .contact-header").hidden = false;
+
+ this.writeButton.hidden = this.searchButton.hidden = !card.primaryEmail;
+ this.eventButton.hidden =
+ !card.primaryEmail ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.newListButton.hidden = true;
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ this.editButton.hidden = book.readOnly;
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.isEditing = false;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Set all the values for displaying a contact.
+ *
+ * @param {HTMLElement} element - The element to fill, either the on-screen
+ * contact display or a clone of the printing template.
+ * @param {nsIAbCard} card - The card to display. This should not be a
+ * mailing list card.
+ */
+ fillContactDetails(element, card) {
+ let vCardProperties = card.supportsVCard
+ ? card.vCardProperties
+ : VCardProperties.fromPropertyMap(
+ new Map(card.properties.map(p => [p.name, p.value]))
+ );
+
+ element.querySelector(".contact-photo").src =
+ card.photoURL || "chrome://messenger/skin/icons/new/compact/user.svg";
+ element.querySelector(".contact-heading-name").textContent =
+ card.generateName(ABView.nameFormat);
+ let nickname = element.querySelector(".contact-heading-nickname");
+ let nicknameValue = vCardProperties.getFirstValue("nickname");
+ nickname.hidden = !nicknameValue;
+ nickname.textContent = nicknameValue;
+ element.querySelector(".contact-heading-email").textContent =
+ card.primaryEmail;
+
+ let template = document.getElementById("entryItem");
+ let createEntryItem = function (name) {
+ let li = template.content.firstElementChild.cloneNode(true);
+ if (name) {
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-name-${name}`
+ );
+ }
+ return li;
+ };
+ let setEntryType = function (li, entry, allowed = ["work", "home"]) {
+ if (!entry.params.type) {
+ return;
+ }
+ let lowerTypes = Array.isArray(entry.params.type)
+ ? entry.params.type.map(t => t.toLowerCase())
+ : [entry.params.type.toLowerCase()];
+ let lowerType = lowerTypes.find(t => allowed.includes(t));
+ if (!lowerType) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-type-${lowerType}`
+ );
+ };
+
+ let section = element.querySelector(".details-email-addresses");
+ let list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("email")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let addr = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ entry.value
+ );
+ let a = document.createElement("a");
+ a.href = "mailto:" + encodeURIComponent(addr);
+ a.textContent = entry.value;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-phone-numbers");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("tel")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry, ["work", "home", "fax", "cell", "pager"]);
+ let a = document.createElement("a");
+ // Handle tel: uri, some other scheme, or plain text number.
+ let number = entry.value.replace(/^[a-z\+]{3,}:/, "");
+ let scheme = entry.value.split(/([a-z\+]{3,}):/)[1] || "tel";
+ a.href = `${scheme}:${number.replaceAll(/[^\d\+]/g, "")}`;
+ a.textContent = number;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-addresses");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("adr")) {
+ let parts = entry.value.flat();
+ // Put extended address after street address.
+ parts[2] = parts.splice(1, 1, parts[2])[0];
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let span = li.querySelector(".entry-value");
+ for (let part of parts.filter(Boolean)) {
+ if (span.firstChild) {
+ span.appendChild(document.createElement("br"));
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-notes");
+ let note = vCardProperties.getFirstValue("note");
+ if (note) {
+ section.querySelector("div").textContent = note;
+ section.hidden = false;
+ } else {
+ section.hidden = true;
+ }
+
+ section = element.querySelector(".details-websites");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ for (let entry of vCardProperties.getAllEntries("url")) {
+ let value = entry.value;
+ if (!/https?:\/\//.test(value)) {
+ continue;
+ }
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let a = document.createElement("a");
+ a.href = value;
+ let url = new URL(value);
+ a.textContent =
+ url.pathname == "/" && !url.search
+ ? url.host
+ : `${url.host}${url.pathname}${url.search}`;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-instant-messaging");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ this._screenNamesToIMPPs(card);
+ for (let entry of vCardProperties.getAllEntries("impp")) {
+ let li = list.appendChild(createEntryItem());
+ let url;
+ try {
+ url = new URL(entry.value);
+ } catch (e) {
+ li.querySelector(".entry-value").textContent = entry.value;
+ continue;
+ }
+ let a = document.createElement("a");
+ a.href = entry.value;
+ a.target = "_blank";
+ a.textContent = url.toString();
+ li.querySelector(".entry-value").append(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-other-info");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ let formatDate = function (date) {
+ try {
+ date = ICAL.VCardTime.fromDateAndOrTimeString(date);
+ } catch (ex) {
+ console.error(ex);
+ return "";
+ }
+ if (date.year && date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date.year, date.month - 1, date.day));
+ }
+ if (date.year && date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ }).format(new Date(date.year, date.month - 1, 1));
+ }
+ if (date.year) {
+ return date.year;
+ }
+ if (date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ day: "numeric",
+ }).format(new Date(2024, date.month - 1, date.day));
+ }
+ if (date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ }).format(new Date(2024, date.month - 1, 1));
+ }
+ if (date.day) {
+ return date.day;
+ }
+ return "";
+ };
+
+ let bday = vCardProperties.getFirstValue("bday");
+ if (bday) {
+ let value = formatDate(bday);
+ if (value) {
+ let li = list.appendChild(createEntryItem("birthday"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let anniversary = vCardProperties.getFirstValue("anniversary");
+ if (anniversary) {
+ let value = formatDate(anniversary);
+ if (value) {
+ let li = list.appendChild(createEntryItem("anniversary"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let title = vCardProperties.getFirstValue("title");
+ if (title) {
+ let li = list.appendChild(createEntryItem("title"));
+ li.querySelector(".entry-value").textContent = title;
+ }
+
+ let role = vCardProperties.getFirstValue("role");
+ if (role) {
+ let li = list.appendChild(createEntryItem("role"));
+ li.querySelector(".entry-value").textContent = role;
+ }
+
+ let org = vCardProperties.getFirstValue("org");
+ if (Array.isArray(org)) {
+ let li = list.appendChild(createEntryItem("organization"));
+ let span = li.querySelector(".entry-value");
+ for (let part of org.filter(Boolean).reverse()) {
+ if (span.firstChild) {
+ span.append(" • ");
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ } else if (org) {
+ let li = list.appendChild(createEntryItem("organization"));
+ li.querySelector(".entry-value").textContent = org;
+ }
+
+ let tz = vCardProperties.getFirstValue("tz");
+ if (tz) {
+ let li = list.appendChild(createEntryItem("time-zone"));
+ try {
+ li.querySelector(".entry-value").textContent =
+ cal.timezoneService.getTimezone(tz).displayName;
+ } catch {
+ li.querySelector(".entry-value").textContent = tz;
+ }
+ li.querySelector(".entry-value").appendChild(
+ document.createElement("br")
+ );
+
+ let time = document.createElement("span", { is: "active-time" });
+ time.setAttribute("tz", tz);
+ li.querySelector(".entry-value").appendChild(time);
+ }
+
+ for (let key of ["custom1", "custom2", "custom3", "custom4"]) {
+ let value = vCardProperties.getFirstValue(`x-${key}`);
+ if (value) {
+ let li = list.appendChild(createEntryItem(key));
+ li.querySelector(".entry-type").style.setProperty(
+ "white-space",
+ "nowrap"
+ );
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ section.hidden = list.childElementCount == 0;
+ },
+
+ /**
+ * Show this given contact photo in the edit form.
+ *
+ * @param {?string} url - The URL of the photo to display, or null to
+ * display none.
+ */
+ showEditPhoto(url) {
+ this.photoInput.querySelector(".contact-photo").src =
+ url || "chrome://messenger/skin/icons/new/compact/user.svg";
+ },
+
+ /**
+ * Store the given photo details to save later, and display the photo in the
+ * edit form.
+ *
+ * @param {?object} details - The photo details to save, or null to remove the
+ * photo.
+ * @param {Blob} details.blob - The image blob of the photo to save.
+ * @param {string} details.sourceURL - The image basis of the photo, before
+ * cropping.
+ * @param {DOMRect} details.cropRect - The cropping rectangle for the photo.
+ */
+ setPhoto(details) {
+ this._photoChanged = true;
+ this._photoDetails = details || {};
+ this.showEditPhoto(
+ details?.blob ? URL.createObjectURL(details.blob) : null
+ );
+ this.dirtyFields.add(this.photoInput);
+ this.isDirty = true;
+ },
+
+ /**
+ * Show controls for editing a new card.
+ *
+ * @param {?string} vCard - A vCard containing properties for the new card.
+ */
+ async editNewContact(vCard) {
+ this.currentCard = null;
+ this.editCurrentContact(vCard);
+ if (!vCard) {
+ this.vCardEdit.contactNameHeading.textContent =
+ await document.l10n.formatValue("about-addressbook-new-contact-header");
+ }
+ },
+
+ /**
+ * Takes old nsIAbCard chat names and put them on the card as IMPP URIs.
+ *
+ * @param {nsIAbCard?} card - The card to change.
+ */
+ _screenNamesToIMPPs(card) {
+ if (!card.supportsVCard) {
+ return;
+ }
+
+ let existingIMPPValues = card.vCardProperties.getAllValues("impp");
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ let value = card.getProperty(key, "");
+ if (!value) {
+ continue;
+ }
+ switch (key) {
+ case "_GoogleTalk":
+ value = `gtalk:chat?jid=${value}`;
+ break;
+ case "_AimScreenName":
+ value = `aim:goim?screenname=${value}`;
+ break;
+ case "_Yahoo":
+ value = `ymsgr:sendIM?${value}`;
+ break;
+ case "_Skype":
+ value = `skype:${value}`;
+ break;
+ case "_QQ":
+ value = `mqq://${value}`;
+ break;
+ case "_MSN":
+ value = `msnim:chat?contact=${value}`;
+ break;
+ case "_ICQ":
+ value = `icq:message?uin=${value}`;
+ break;
+ case "_JabberId":
+ value = `xmpp:${value}`;
+ break;
+ case "_IRC":
+ // Guess host, in case we have an irc account configured.
+ let host =
+ IMServices.accounts
+ .getAccounts()
+ .find(a => a.protocol.normalizedName == "irc")
+ ?.name.split("@", 2)[1] || "irc.example.org";
+ value = `ircs://${host}/${value},isuser`;
+ break;
+ }
+ if (!existingIMPPValues.includes(value)) {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(`impp`, {}, "uri", value)
+ );
+ }
+ }
+ },
+
+ /**
+ * Show controls for editing the currently displayed card.
+ *
+ * @param {?string} vCard - A vCard containing properties for a new card.
+ */
+ editCurrentContact(vCard) {
+ let card = this.currentCard;
+ this.deleteButton.hidden = !card;
+ if (card && card.supportsVCard) {
+ this._screenNamesToIMPPs(card);
+
+ this.vCardEdit.vCardProperties = card.vCardProperties;
+ // getProperty may return a "1" or "0" string, we want a boolean.
+ this.vCardEdit.preferDisplayName.checked =
+ // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
+ card.getProperty("PreferDisplayName", true) == true;
+ } else {
+ this.vCardEdit.vCardString = vCard ?? "";
+ card = new AddrBookCard();
+ card.setProperty("_vCard", vCard);
+ }
+
+ this.showEditPhoto(card?.photoURL);
+ this._photoDetails = { sourceURL: card?.photoURL };
+ this._photoChanged = false;
+ this.isEditing = true;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ this.form.querySelector(".contact-details-scroll").scrollTo(0, 0);
+ // If we enter editing directly from the cards list we want to return to it
+ // once we are done.
+ this._focusOnCardsList =
+ document.activeElement == cardsPane.cardsList.table.body;
+ this.vCardEdit.setFocus();
+ },
+
+ /**
+ * Edit the currently displayed contact or list.
+ */
+ editCurrent() {
+ // The editButton is disabled if the book is readOnly.
+ if (this.editButton.hidden) {
+ return;
+ }
+ if (this.currentCard) {
+ this.editCurrentContact();
+ } else if (this.currentList) {
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: this.currentList.mailListURI }
+ );
+ }
+ },
+
+ /**
+ * Properly handle a failed form validation.
+ */
+ handleInvalidForm() {
+ // FIXME: Drop this in favor of an inline notification with fluent strings.
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ Services.prompt.alert(
+ window,
+ bundle.GetStringFromName("cardRequiredDataMissingTitle"),
+ bundle.GetStringFromName("cardRequiredDataMissingMessage")
+ );
+ },
+
+ /**
+ * Make sure the data is valid before saving the contact.
+ */
+ validateBeforeSaving() {
+ // Make sure the minimum required data is present.
+ if (!this.vCardEdit.checkMinimumRequirements()) {
+ this.handleInvalidForm();
+ return;
+ }
+
+ // Make sure the dates are filled properly.
+ if (!this.vCardEdit.validateDates()) {
+ // Simply return as the validateDates() will handle focus and visual cue.
+ return;
+ }
+
+ // Extra validation for any form field that has validatity requirements
+ // set on them (through pattern etc.).
+ if (!this.form.checkValidity()) {
+ this.form.querySelector("input:invalid").focus();
+ return;
+ }
+
+ this.saveCurrentContact();
+ },
+
+ /**
+ * Save the currently displayed card.
+ */
+ async saveCurrentContact() {
+ let card = this.currentCard;
+ let book;
+
+ if (card) {
+ book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ } else {
+ card = new AddrBookCard();
+
+ // TODO: convert this to UID.
+ book = MailServices.ab.getDirectory(this.addContactBookList.value);
+ if (book.getBoolValue("carddav.vcard3", false)) {
+ // This is a CardDAV book, and the server discards photos unless the
+ // vCard 3 format is used. Since we know this is a new card, setting
+ // the version here won't cause a problem.
+ this.vCardEdit.vCardProperties.addValue("version", "3.0");
+ }
+ }
+ if (!book || book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ // Tell vcard-edit to read the input fields. Setting the _vCard property
+ // MUST happen before accessing `card.vCardProperties` or creating new
+ // cards will fail.
+ this.vCardEdit.saveVCard();
+ card.setProperty("_vCard", this.vCardEdit.vCardString);
+ card.setProperty(
+ "PreferDisplayName",
+ this.vCardEdit.preferDisplayName.checked
+ );
+
+ // Old screen names should by now be on the vCard. Delete them.
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ card.deleteProperty(key);
+ }
+
+ // No photo or a new photo. Delete the old one.
+ if (this._photoChanged) {
+ let oldLeafName = card.getProperty("PhotoName", "");
+ if (oldLeafName) {
+ let oldPath = PathUtils.join(
+ PathUtils.profileDir,
+ "Photos",
+ oldLeafName
+ );
+ await IOUtils.remove(oldPath);
+
+ card.setProperty("PhotoName", "");
+ card.setProperty("PhotoType", "");
+ card.setProperty("PhotoURI", "");
+ }
+ if (card.supportsVCard) {
+ for (let entry of card.vCardProperties.getAllEntries("photo")) {
+ card.vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ // Save the new photo.
+ if (this._photoChanged && this._photoDetails.blob) {
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ let reader = new FileReader();
+ await new Promise(resolve => {
+ reader.onloadend = resolve;
+ reader.readAsDataURL(this._photoDetails.blob);
+ });
+ if (card.vCardProperties.getFirstValue("version") == "4.0") {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "uri", reader.result)
+ );
+ } else {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B" },
+ "binary",
+ reader.result.substring(reader.result.indexOf(",") + 1)
+ )
+ );
+ }
+ } else {
+ let leafName = `${AddrBookUtils.newUID()}.jpg`;
+ let path = PathUtils.join(PathUtils.profileDir, "Photos", leafName);
+ let buffer = await this._photoDetails.blob.arrayBuffer();
+ await IOUtils.write(path, new Uint8Array(buffer));
+ card.setProperty("PhotoName", leafName);
+ }
+ }
+ this._photoChanged = false;
+ this.isEditing = false;
+
+ if (!card.directoryUID) {
+ card = book.addCard(card);
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(card.UID);
+ // The selection change will update the UI.
+ } else {
+ book.modifyCard(card);
+ // The addrbook-contact-updated notification will update the UI.
+ }
+
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ },
+
+ /**
+ * Delete the currently displayed card.
+ */
+ async deleteCurrentContact() {
+ let card = this.currentCard;
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+
+ if (!book) {
+ throw new Components.Exception(
+ "Card doesn't have a book to delete from",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let name = card.displayName;
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: "about-addressbook-confirm-delete-contacts-title",
+ args: { count: 1 },
+ },
+ {
+ id: "about-addressbook-confirm-delete-contacts-single",
+ args: { name },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = cardsPane.cardsList.currentIndex;
+ book.deleteCards([card]);
+ cardsPane.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ cardsPane.cardsList.view.rowCount - 1
+ );
+ // The addrbook-contact-deleted notification will update the details pane UI.
+ }
+ },
+
+ displayList(listCard) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!listCard || !listCard.isMailList) {
+ return;
+ }
+ this.currentList = listCard;
+
+ let listDirectory = MailServices.ab.getDirectory(listCard.mailListURI);
+
+ document.querySelector("#viewContact .list-header").hidden = false;
+ document.querySelector(
+ "#viewContact .list-header > h1"
+ ).textContent = `${listDirectory.dirName}`;
+
+ let cards = Array.from(listDirectory.childCards, card => {
+ return {
+ name: card.generateName(ABView.nameFormat),
+ email: card.primaryEmail,
+ photoURL: card.photoURL,
+ };
+ });
+ let { sortColumn, sortDirection } = cardsPane.cardsList.view;
+ let key = sortColumn == "EmailAddresses" ? "email" : "name";
+ cards.sort((a, b) => {
+ if (sortDirection == "descending") {
+ [b, a] = [a, b];
+ }
+ return ABView.prototype.collator.compare(a[key], b[key]);
+ });
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+ name.textContent = card.name;
+ address.textContent = card.email;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ }
+ this.selectedCardsSection.hidden = list.childElementCount == 0;
+
+ let book = MailServices.ab.getDirectoryFromUID(listCard.directoryUID);
+ this.writeButton.hidden = list.childElementCount == 0;
+ this.eventButton.hidden = this.writeButton.hidden;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = true;
+ this.editButton.hidden = book.readOnly;
+
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ _onClick(event) {
+ let selectedContacts = cardsPane.selectedCards.filter(
+ card => !card.isMailList && card.primaryEmail
+ );
+
+ switch (event.target.id) {
+ case "detailsWriteButton":
+ cardsPane.writeToSelected();
+ break;
+ case "detailsEventButton": {
+ let contacts;
+ if (this.currentList) {
+ let directory = MailServices.ab.getDirectory(
+ this.currentList.mailListURI
+ );
+ contacts = directory.childCards;
+ } else {
+ contacts = selectedContacts;
+ }
+ let attendees = contacts.map(card => {
+ let attendee = new CalAttendee();
+ attendee.id = `mailto:${card.primaryEmail}`;
+ attendee.commonName = card.displayName;
+ return attendee;
+ });
+ if (attendees.length) {
+ window.browsingContext.topChromeWindow.createEventWithDialog(
+ null,
+ null,
+ null,
+ null,
+ null,
+ false,
+ attendees
+ );
+ }
+ break;
+ }
+ case "detailsSearchButton":
+ if (this.currentCard.primaryEmail) {
+ let searchString = this.currentCard.emailAddresses.join(" ");
+ window.browsingContext.topChromeWindow.tabmail.openTab("glodaFacet", {
+ searcher: new GlodaMsgSearcher(null, searchString, false),
+ });
+ }
+ break;
+ case "detailsNewListButton":
+ if (selectedContacts.length) {
+ createList(selectedContacts);
+ }
+ break;
+ case "editButton":
+ this.editCurrent();
+ break;
+ case "detailsDeleteButton":
+ this.deleteCurrentContact();
+ break;
+ }
+ },
+};
+
+var photoDialog = {
+ /**
+ * The ratio of pixels in the source image to pixels in the preview.
+ *
+ * @type {number}
+ */
+ _scale: null,
+
+ /**
+ * The square to which the image will be cropped, in preview pixels.
+ *
+ * @type {DOMRect}
+ */
+ _cropRect: null,
+
+ /**
+ * The bounding rectangle of the image in the preview, in preview pixels.
+ * Cached for efficiency.
+ *
+ * @type {DOMRect}
+ */
+ _previewRect: null,
+
+ init() {
+ this._dialog = document.getElementById("photoDialog");
+ this._dialog.saveButton = this._dialog.querySelector(".accept");
+ this._dialog.cancelButton = this._dialog.querySelector(".cancel");
+ this._dialog.discardButton = this._dialog.querySelector(".extra1");
+
+ this._dropTarget = this._dialog.querySelector("#photoDropTarget");
+ this._svg = this._dialog.querySelector("svg");
+ this._preview = this._svg.querySelector("image");
+ this._cropMask = this._svg.querySelector("path");
+ this._dragRect = this._svg.querySelector("rect");
+ this._corners = this._svg.querySelectorAll("rect.corner");
+
+ this._dialog.addEventListener("dragover", this);
+ this._dialog.addEventListener("drop", this);
+ this._dialog.addEventListener("paste", this);
+ this._dropTarget.addEventListener("click", event => {
+ if (event.button != 0) {
+ return;
+ }
+ this._showFilePicker();
+ });
+ this._dropTarget.addEventListener("keydown", event => {
+ if (event.key != " " && event.key != "Enter") {
+ return;
+ }
+ this._showFilePicker();
+ });
+
+ class Mover {
+ constructor(element) {
+ element.addEventListener("mousedown", this);
+ }
+
+ handleEvent(event) {
+ if (event.type == "mousedown") {
+ if (event.buttons != 1) {
+ return;
+ }
+ this.onMouseDown(event);
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ } else if (event.type == "mousemove") {
+ if (event.buttons != 1) {
+ // The button was released and we didn't get a mouseup event, or the
+ // button(s) pressed changed. Either way, stop dragging.
+ this.onMouseUp();
+ return;
+ }
+ this.onMouseMove(event);
+ } else {
+ this.onMouseUp(event);
+ }
+ }
+
+ onMouseUp(event) {
+ delete this._dragPosition;
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ }
+ }
+
+ new (class extends Mover {
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect.x,
+ y: event.clientY - photoDialog._cropRect.y,
+ };
+ }
+
+ onMouseMove(event) {
+ photoDialog._cropRect.x = Math.min(
+ Math.max(0, event.clientX - this._dragPosition.x),
+ photoDialog._previewRect.width - photoDialog._cropRect.width
+ );
+ photoDialog._cropRect.y = Math.min(
+ Math.max(0, event.clientY - this._dragPosition.y),
+ photoDialog._previewRect.height - photoDialog._cropRect.height
+ );
+ photoDialog._redrawCropRect();
+ }
+ })(this._dragRect);
+
+ class CornerMover extends Mover {
+ constructor(element, xEdge, yEdge) {
+ super(element);
+ this.xEdge = xEdge;
+ this.yEdge = yEdge;
+ }
+
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect[this.xEdge],
+ y: event.clientY - photoDialog._cropRect[this.yEdge],
+ };
+ }
+
+ onMouseMove(event) {
+ let { width, height } = photoDialog._previewRect;
+ let { top, right, bottom, left } = photoDialog._cropRect;
+ let { x, y } = this._dragPosition;
+
+ // New coordinates of the dragged corner, constrained to the image size.
+ x = Math.max(0, Math.min(width, event.clientX - x));
+ y = Math.max(0, Math.min(height, event.clientY - y));
+
+ // New size based on the dragged corner and a minimum size of 80px.
+ let newWidth = this.xEdge == "right" ? x - left : right - x;
+ let newHeight = this.yEdge == "bottom" ? y - top : bottom - y;
+ let newSize = Math.max(80, Math.min(newWidth, newHeight));
+
+ photoDialog._cropRect.width = newSize;
+ if (this.xEdge == "left") {
+ photoDialog._cropRect.x = right - photoDialog._cropRect.width;
+ }
+ photoDialog._cropRect.height = newSize;
+ if (this.yEdge == "top") {
+ photoDialog._cropRect.y = bottom - photoDialog._cropRect.height;
+ }
+ photoDialog._redrawCropRect();
+ }
+ }
+
+ new CornerMover(this._corners[0], "left", "top");
+ new CornerMover(this._corners[1], "right", "top");
+ new CornerMover(this._corners[2], "right", "bottom");
+ new CornerMover(this._corners[3], "left", "bottom");
+
+ this._dialog.saveButton.addEventListener("click", () => this._save());
+ this._dialog.cancelButton.addEventListener("click", () => this._cancel());
+ this._dialog.discardButton.addEventListener("click", () => this._discard());
+ },
+
+ _setState(state) {
+ if (state == "preview") {
+ this._dropTarget.hidden = true;
+ this._svg.toggleAttribute("hidden", false);
+ this._dialog.saveButton.disabled = false;
+ return;
+ }
+
+ this._dropTarget.classList.toggle("drop-target", state == "target");
+ this._dropTarget.classList.toggle("drop-loading", state == "loading");
+ this._dropTarget.classList.toggle("drop-error", state == "error");
+ document.l10n.setAttributes(
+ this._dropTarget.querySelector(".label"),
+ `about-addressbook-photo-drop-${state}`
+ );
+
+ this._dropTarget.hidden = false;
+ this._svg.toggleAttribute("hidden", true);
+ this._dialog.saveButton.disabled = true;
+ },
+
+ /**
+ * Show the photo dialog, with no displayed image.
+ */
+ showEmpty() {
+ this._setState("target");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = true;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Show the photo dialog, with `file` as the displayed image.
+ *
+ * @param {File} file
+ */
+ showWithFile(file) {
+ this.showWithURL(URL.createObjectURL(file));
+ },
+
+ /**
+ * Show the photo dialog, with `URL` as the displayed image and (optionally)
+ * a pre-set crop rectangle
+ *
+ * @param {string} url - The URL of the image.
+ * @param {?DOMRect} cropRect - The rectangle used to crop the image.
+ * @param {boolean} [showDiscard=false] - Whether to show a discard button
+ * when opening the dialog.
+ */
+ showWithURL(url, cropRect, showDiscard = false) {
+ // Load the image from the URL, to figure out the scale factor.
+ let img = document.createElement("img");
+ img.addEventListener("load", () => {
+ const PREVIEW_SIZE = 500;
+
+ let { naturalWidth, naturalHeight } = img;
+ this._scale = Math.max(
+ 1,
+ img.naturalWidth / PREVIEW_SIZE,
+ img.naturalHeight / PREVIEW_SIZE
+ );
+
+ let previewWidth = naturalWidth / this._scale;
+ let previewHeight = naturalHeight / this._scale;
+ let smallDimension = Math.min(previewWidth, previewHeight);
+
+ this._previewRect = new DOMRect(0, 0, previewWidth, previewHeight);
+ if (cropRect) {
+ this._cropRect = DOMRect.fromRect(cropRect);
+ } else {
+ this._cropRect = new DOMRect(
+ (this._previewRect.width - smallDimension) / 2,
+ (this._previewRect.height - smallDimension) / 2,
+ smallDimension,
+ smallDimension
+ );
+ }
+
+ this._preview.setAttribute("href", url);
+ this._preview.setAttribute("width", previewWidth);
+ this._preview.setAttribute("height", previewHeight);
+
+ this._svg.setAttribute("width", previewWidth + 20);
+ this._svg.setAttribute("height", previewHeight + 20);
+ this._svg.setAttribute(
+ "viewBox",
+ `-10 -10 ${previewWidth + 20} ${previewHeight + 20}`
+ );
+
+ this._redrawCropRect();
+ this._setState("preview");
+ this._dialog.saveButton.focus();
+ });
+ img.addEventListener("error", () => this._setState("error"));
+ img.src = url;
+
+ this._setState("loading");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = !showDiscard;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Resize the crop controls to match the current _cropRect.
+ */
+ _redrawCropRect() {
+ let { top, right, bottom, left, width, height } = this._cropRect;
+
+ this._cropMask.setAttribute(
+ "d",
+ `M0 0H${this._previewRect.width}V${this._previewRect.height}H0Z M${left} ${top}V${bottom}H${right}V${top}Z`
+ );
+
+ this._dragRect.setAttribute("x", left);
+ this._dragRect.setAttribute("y", top);
+ this._dragRect.setAttribute("width", width);
+ this._dragRect.setAttribute("height", height);
+
+ this._corners[0].setAttribute("x", left - 10);
+ this._corners[0].setAttribute("y", top - 10);
+ this._corners[1].setAttribute("x", right - 30);
+ this._corners[1].setAttribute("y", top - 10);
+ this._corners[2].setAttribute("x", right - 30);
+ this._corners[2].setAttribute("y", bottom - 30);
+ this._corners[3].setAttribute("x", left - 10);
+ this._corners[3].setAttribute("y", bottom - 30);
+ },
+
+ /**
+ * Crop, shrink, convert the image to a JPEG, then assign it to the photo
+ * element and close the dialog. Doesn't save the JPEG to disk, that happens
+ * when (if) the contact is saved.
+ */
+ async _save() {
+ const DOUBLE_SIZE = 600;
+ const FINAL_SIZE = 300;
+
+ let source = this._preview;
+ let { x, y, width, height } = this._cropRect;
+ x *= this._scale;
+ y *= this._scale;
+ width *= this._scale;
+ height *= this._scale;
+
+ // If the image is much larger than our target size, draw an intermediate
+ // version at twice the size first. This produces better-looking results.
+ if (width > DOUBLE_SIZE) {
+ let canvas1 = document.createElement("canvas");
+ canvas1.width = canvas1.height = DOUBLE_SIZE;
+ let context1 = canvas1.getContext("2d");
+ context1.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ DOUBLE_SIZE,
+ DOUBLE_SIZE
+ );
+
+ source = canvas1;
+ x = y = 0;
+ width = height = DOUBLE_SIZE;
+ }
+
+ let canvas2 = document.createElement("canvas");
+ canvas2.width = canvas2.height = FINAL_SIZE;
+ let context2 = canvas2.getContext("2d");
+ context2.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ FINAL_SIZE,
+ FINAL_SIZE
+ );
+
+ let blob = await new Promise(resolve =>
+ canvas2.toBlob(resolve, "image/jpeg")
+ );
+
+ detailsPane.setPhoto({
+ blob,
+ sourceURL: this._preview.getAttribute("href"),
+ cropRect: DOMRect.fromRect(this._cropRect),
+ });
+
+ this._dialog.close();
+ },
+
+ /**
+ * Just close the dialog.
+ */
+ _cancel() {
+ this._dialog.close();
+ },
+
+ /**
+ * Throw away the contact's existing photo, and close the dialog. Doesn't
+ * remove the existing photo from disk, that happens when (if) the contact
+ * is saved.
+ */
+ _discard() {
+ this._dialog.close();
+ detailsPane.setPhoto(null);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "paste":
+ this._onPaste(event);
+ break;
+ }
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {File|null}
+ */
+ _getUseableFile(dataTransfer) {
+ if (
+ dataTransfer.files.length &&
+ dataTransfer.files[0].type.startsWith("image/")
+ ) {
+ return dataTransfer.files[0];
+ }
+ return null;
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {string|null}
+ */
+ _getUseableURL(dataTransfer) {
+ let data = dataTransfer.getData("text/plain");
+
+ return /^https?:\/\//.test(data) ? data : null;
+ },
+
+ _onDragOver(event) {
+ if (
+ this._getUseableFile(event.dataTransfer) ||
+ this._getUseableURL(event.clipboardData)
+ ) {
+ event.dataTransfer.dropEffect = "move";
+ event.preventDefault();
+ }
+ },
+
+ _onDrop(event) {
+ let file = this._getUseableFile(event.dataTransfer);
+ if (file) {
+ this.showWithFile(file);
+ event.preventDefault();
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ event.preventDefault();
+ }
+ }
+ },
+
+ _onPaste(event) {
+ let file = this._getUseableFile(event.clipboardData);
+ if (file) {
+ this.showWithFile(file);
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ }
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * Show a file picker to choose an image.
+ */
+ async _showFilePicker() {
+ let title = await document.l10n.formatValue(
+ "about-addressbook-photo-filepicker-title"
+ );
+
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ picker.init(
+ window.browsingContext.topChromeWindow,
+ title,
+ Ci.nsIFilePicker.modeOpen
+ );
+ picker.appendFilters(Ci.nsIFilePicker.filterImages);
+ let result = await new Promise(resolve => picker.open(resolve));
+
+ if (result != Ci.nsIFilePicker.returnOK) {
+ return;
+ }
+
+ this.showWithFile(await File.createFromNsIFile(picker.file));
+ },
+};
+
+// Printing
+
+var printHandler = {
+ printDirectory(directory) {
+ let title = directory ? directory.dirName : document.title;
+
+ let cards;
+ if (directory) {
+ cards = directory.childCards;
+ } else {
+ cards = [];
+ for (let directory of MailServices.ab.directories) {
+ cards = cards.concat(directory.childCards);
+ }
+ }
+
+ this._printCards(title, cards);
+ },
+
+ printCards(cards) {
+ this._printCards(document.title, cards);
+ },
+
+ async _printCards(title, cards) {
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+
+ cards.sort((a, b) => {
+ let aName = a.generateName(nameFormat);
+ let bName = b.generateName(nameFormat);
+ return collator.compare(aName, bName);
+ });
+
+ let printDocument = document.implementation.createHTMLDocument();
+ printDocument.title = title;
+ printDocument.head
+ .appendChild(printDocument.createElement("meta"))
+ .setAttribute("charset", "utf-8");
+ let link = printDocument.head.appendChild(
+ printDocument.createElement("link")
+ );
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", "chrome://messagebody/skin/abPrint.css");
+
+ let printTemplate = document.getElementById("printTemplate");
+
+ for (let card of cards) {
+ if (card.isMailList) {
+ continue;
+ }
+
+ let div = printDocument.createElement("div");
+ div.append(printTemplate.content.cloneNode(true));
+ detailsPane.fillContactDetails(div, card);
+ let photo = div.querySelector(".contact-photo");
+ if (photo.src.startsWith("chrome:")) {
+ photo.hidden = true;
+ }
+ await document.l10n.translateFragment(div);
+ printDocument.body.appendChild(div);
+ }
+
+ let html = new XMLSerializer().serializeToString(printDocument);
+ this._printURL(URL.createObjectURL(new File([html], "text/html")));
+ },
+
+ async _printURL(url) {
+ let topWindow = window.browsingContext.topChromeWindow;
+ await topWindow.PrintUtils.loadPrintBrowser(url);
+ topWindow.PrintUtils.startPrintWindow(
+ topWindow.PrintUtils.printBrowser.browsingContext,
+ {}
+ );
+ },
+};
+
+/**
+ * A span that displays the current time in a given time zone.
+ * The time is updated every minute.
+ */
+class ActiveTime extends HTMLSpanElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ this.hasConnected = true;
+ this.setAttribute("is", "active-time");
+
+ try {
+ this.formatter = new Services.intl.DateTimeFormat(undefined, {
+ timeZone: this.getAttribute("tz"),
+ weekday: "long",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ // DateTimeFormat will throw if the time zone is unknown.
+ // If it does this will just be an empty span.
+ return;
+ }
+ this.update = this.update.bind(this);
+ this.update();
+
+ CalMetronome.on("minute", this.update);
+ window.addEventListener("unload", this, { once: true });
+ }
+
+ disconnectedCallback() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ handleEvent() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ update() {
+ this.textContent = this.formatter.format(new Date());
+ }
+}
+customElements.define("active-time", ActiveTime, { extends: "span" });