summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/addrbook/content
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/addrbook/content')
-rw-r--r--comm/mail/components/addrbook/content/abCommon.js145
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.js374
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.xhtml234
-rw-r--r--comm/mail/components/addrbook/content/abEditListDialog.xhtml99
-rw-r--r--comm/mail/components/addrbook/content/abMailListDialog.xhtml116
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.js408
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.xhtml200
-rw-r--r--comm/mail/components/addrbook/content/abView-new.js577
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.js4445
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.xhtml460
-rw-r--r--comm/mail/components/addrbook/content/addressBookTab.js172
-rw-r--r--comm/mail/components/addrbook/content/menulist-addrbooks.js271
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/adr.mjs149
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/custom.mjs60
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/edit.mjs1094
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/email.mjs135
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/fn.mjs71
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs12
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/impp.mjs97
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/n.mjs186
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/nickname.mjs59
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/note.mjs82
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/org.mjs197
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/special-date.mjs269
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tel.mjs83
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tz.mjs86
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/url.mjs89
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml398
28 files changed, 10568 insertions, 0 deletions
diff --git a/comm/mail/components/addrbook/content/abCommon.js b/comm/mail/components/addrbook/content/abCommon.js
new file mode 100644
index 0000000000..36f251206e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abCommon.js
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gAbView = null;
+
+var kDefaultAscending = "ascending";
+var kDefaultDescending = "descending";
+var kAllDirectoryRoot = "moz-abdirectory://";
+var kPersonalAddressbookURI = "jsaddrbook://abook.sqlite";
+
+async function AbDelete() {
+ let types = GetSelectedCardTypes();
+ if (types == kNothingSelected) {
+ return;
+ }
+
+ let cards = GetSelectedAbCards();
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let selectedDir = gAbView.directory;
+
+ switch (types) {
+ case kListsAndCards:
+ action = "delete-mixed";
+ break;
+ case kSingleListOnly:
+ case kMultipleListsOnly:
+ action = "delete-lists";
+ name = cards[0].displayName;
+ break;
+ default: {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = cards[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ break;
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString = !cards.length
+ ? "delete-contacts-single"
+ : "delete-contacts-multi";
+ break;
+ case "remove-contacts":
+ actionString = !cards.length
+ ? "remove-contacts-single"
+ : "remove-contacts-multi";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: `about-addressbook-confirm-${action}-title`,
+ args: { count: cards.length },
+ },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: {
+ count: cards.length,
+ name,
+ list,
+ },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (!Services.prompt.confirm(window, title, message)) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // Delete cards from address books or mailing lists.
+ gAbView.deleteSelectedCards();
+}
+
+function AbNewMessage(address) {
+ 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);
+
+ if (address) {
+ params.composeFields.to = address;
+ } else {
+ params.composeFields.to = GetSelectedAddresses();
+ }
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
+
+/**
+ * Make a mailbox string from the card, for use in the UI.
+ *
+ * @param {nsIAbCard} - The card to use.
+ * @returns {string} A mailbox representation of the card.
+ */
+function makeMailboxObjectFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = GetDirectoryFromURI(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.primaryEmail;
+ }
+
+ return MailServices.headerParser
+ .makeMailboxObject(card.displayName, email)
+ .toString();
+}
+
+function GetDirectoryFromURI(uri) {
+ if (uri.startsWith("moz-abdirectory://")) {
+ return null;
+ }
+ return MailServices.ab.getDirectory(uri);
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.js b/comm/mail/components/addrbook/content/abContactsPanel.js
new file mode 100644
index 0000000000..c1e3481318
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/editMenuOverlay.js */
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { getSearchTokens, getModelQuery, generateQueryURI } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+
+// A boolean variable determining whether AB column should be shown
+// in Contacts Sidebar in compose window.
+var gShowAbColumnInComposeSidebar = false;
+var gQueryURIFormat = null;
+
+UIDensity.registerWindow(window);
+
+function GetAbViewListener() {
+ // the ab panel doesn't care if the total changes, or if the selection changes
+ return null;
+}
+
+/**
+ * Handle the command event on abContextMenuButton (click, Enter, spacebar).
+ */
+function abContextMenuButtonOnCommand(event) {
+ showContextMenu("sidebarAbContextMenu", event, [
+ event.target,
+ "after_end",
+ 0,
+ 0,
+ true,
+ ]);
+}
+
+/**
+ * Handle the context menu event of results tree (right-click, context menu key
+ * press, etc.). Show the respective context menu for selected contact(s) or
+ * results tree blank space (work around for XUL tree bug 1331377).
+ *
+ * @param aEvent a context menu event (right-click, context menu key press, etc.)
+ */
+function contactsListOnContextMenu(aEvent) {
+ let target = aEvent.target;
+ let contextMenuID;
+ let positionArray;
+
+ // For right-click on column header or column picker, don't show context menu.
+ if (target.localName == "treecol" || target.localName == "treecolpicker") {
+ return;
+ }
+
+ // On treechildren, if there's no selection, show "sidebarAbContextMenu".
+ if (gAbView.selection.count == 0) {
+ contextMenuID = gAbResultsTree.getAttribute("contextNoSelection");
+ // If "sidebarAbContextMenu" menu was activated by keyboard,
+ // position it in the topleft corner of gAbResultsTree.
+ if (!aEvent.button) {
+ positionArray = [gAbResultsTree, "overlap", 0, 0, true];
+ }
+ // If there's a selection, show "cardProperties" context menu.
+ } else {
+ contextMenuID = gAbResultsTree.getAttribute("contextSelection");
+ updateCardPropertiesMenu();
+ }
+ showContextMenu(contextMenuID, aEvent, positionArray);
+}
+
+/**
+ * Update the single row card properties context menu to show or hide the "Edit"
+ * menu item only depending on the selection type.
+ */
+function updateCardPropertiesMenu() {
+ let cards = GetSelectedAbCards();
+
+ let separator = document.getElementById("abContextBeforeEditContact");
+ let menuitem = document.getElementById("abContextEditContact");
+
+ // Only show the Edit item if one item is selected, is not a mailing list, and
+ // the contact is not part of a readOnly address book.
+ if (
+ cards.length != 1 ||
+ cards.some(c => c.isMailList) ||
+ MailServices.ab.getDirectoryFromUID(cards[0].directoryUID)?.readOnly
+ ) {
+ separator.hidden = true;
+ menuitem.hidden = true;
+ return;
+ }
+
+ separator.hidden = false;
+ menuitem.hidden = false;
+}
+
+/**
+ * Handle the click event of the results tree (workaround for XUL tree
+ * bug 1331377).
+ *
+ * @param aEvent a click event
+ */
+function contactsListOnClick(aEvent) {
+ CommandUpdate_AddressBook();
+
+ let target = aEvent.target;
+
+ // Left click on column header: Change sort direction.
+ if (target.localName == "treecol" && aEvent.button == 0) {
+ let sortDirection =
+ target.getAttribute("sortDirection") == kDefaultDescending
+ ? kDefaultAscending
+ : kDefaultDescending;
+ SortAndUpdateIndicators(target.id, sortDirection);
+ return;
+ }
+ // Any click on gAbResultsTree view (rows or blank space).
+ if (target.localName == "treechildren") {
+ let row = gAbResultsTree.getRowAt(aEvent.clientX, aEvent.clientY);
+ if (row < 0 || row >= gAbResultsTree.view.rowCount) {
+ // Any click on results tree whitespace.
+ if ((aEvent.detail == 1 && aEvent.button == 0) || aEvent.button == 2) {
+ // Single left click or any right click on results tree blank space:
+ // Clear selection. This also triggers on the first click of any
+ // double-click, but that's ok. MAC OS X doesn't return event.detail==1
+ // for single right click, so we also let this trigger for the second
+ // click of right double-click.
+ gAbView.selection.clearSelection();
+ }
+ } else if (aEvent.button == 0 && aEvent.detail == 2) {
+ // Any click on results tree rows.
+ // Double-click on a row: Go ahead and add the entry.
+ addSelectedAddresses("addr_to");
+ }
+ }
+}
+
+/**
+ * Appends the currently selected cards as new recipients in the composed message.
+ *
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ */
+function addSelectedAddresses(recipientType) {
+ var cards = GetSelectedAbCards();
+
+ // Turn each card into a properly formatted address.
+ let addresses = cards.map(makeMailboxObjectFromCard).filter(addr => addr);
+ parent.addressRowAddRecipientsArray(
+ parent.document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ addresses
+ );
+}
+
+/**
+ * Open the address book tab and trigger the edit of the selected contact.
+ */
+function editSelectedAddress() {
+ let cards = GetSelectedAbCards();
+ window.top.toAddressBook({ action: "edit", card: cards[0] });
+}
+
+function AddressBookMenuListChange(aValue) {
+ let searchInput = document.getElementById("peopleSearchInput");
+ if (searchInput.value && !searchInput.showingSearchCriteria) {
+ onEnterInSearchBar();
+ } else {
+ ChangeDirectoryByURI(aValue);
+ }
+
+ // Hide the addressbook column if the selected addressbook isn't
+ // "All address books". Since the column is redundant in all other cases.
+ let abList = document.getElementById("addressbookList");
+ let addrbookColumn = document.getElementById("addrbook");
+ if (abList.value.startsWith(kAllDirectoryRoot + "?")) {
+ addrbookColumn.hidden = !gShowAbColumnInComposeSidebar;
+ addrbookColumn.removeAttribute("ignoreincolumnpicker");
+ } else {
+ addrbookColumn.hidden = true;
+ addrbookColumn.setAttribute("ignoreincolumnpicker", "true");
+ }
+
+ CommandUpdate_AddressBook();
+}
+
+var mutationObs = null;
+
+function AbPanelLoad() {
+ if (location.search == "?focus") {
+ document.getElementById("peopleSearchInput").focus();
+ }
+
+ document.title = parent.document.getElementById("contactsTitle").value;
+
+ // Get the URI of the directory to display.
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+ // If the URI is a mailing list, use the parent directory instead, since
+ // mailing lists are not displayed here.
+ startupURI = startupURI.replace(/^(jsaddrbook:\/\/[\w\.-]*)\/.*$/, "$1");
+
+ let abPopup = document.getElementById("addressbookList");
+ abPopup.value = startupURI;
+
+ // If provided directory is not on abPopup, fall back to All Address Books.
+ if (!abPopup.selectedItem) {
+ abPopup.selectedIndex = 0;
+ }
+
+ // Postpone the slow contacts load so that the sidebar document
+ // gets a chance to display quickly.
+ setTimeout(ChangeDirectoryByURI, 0, abPopup.value);
+
+ mutationObs = new MutationObserver(function (aMutations) {
+ aMutations.forEach(function (mutation) {
+ if (
+ getSelectedDirectoryURI() == kAllDirectoryRoot + "?" &&
+ mutation.type == "attributes" &&
+ mutation.attributeName == "hidden"
+ ) {
+ let curState = document.getElementById("addrbook").hidden;
+ gShowAbColumnInComposeSidebar = !curState;
+ }
+ });
+ });
+
+ document.getElementById("addrbook").hidden = !gShowAbColumnInComposeSidebar;
+
+ mutationObs.observe(document.getElementById("addrbook"), {
+ attributes: true,
+ childList: true,
+ });
+}
+
+function AbPanelUnload() {
+ mutationObs.disconnect();
+
+ // If there's no default startupURI, save the last used URI as new startupURI.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ getSelectedDirectoryURI()
+ );
+ }
+
+ CloseAbView();
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // double click for ab panel means "send mail to this person / list"
+ AbNewMessage();
+}
+
+function CommandUpdate_AddressBook() {
+ // Toggle disable state of to,cc,bcc buttons.
+ let disabled = GetNumSelectedCards() == 0 ? "true" : "false";
+ document.getElementById("cmd_addrTo").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrCc").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrBcc").setAttribute("disabled", disabled);
+
+ goUpdateCommand("cmd_delete");
+}
+
+/**
+ * Handle the onpopupshowing event of #sidebarAbContextMenu.
+ * Update the checkmark of #sidebarAbContext-startupDir menuitem when context
+ * menu opens, so as to always be in sync with changes from the main AB window.
+ */
+function onAbContextShowing() {
+ let startupItem = document.getElementById("sidebarAbContext-startupDir");
+ if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let startupURI = Services.prefs.getCharPref(
+ "mail.addr_book.view.startupURI"
+ );
+ startupItem.setAttribute(
+ "checked",
+ startupURI == getSelectedDirectoryURI()
+ );
+ } else {
+ startupItem.setAttribute("checked", "false");
+ }
+}
+
+function onEnterInSearchBar() {
+ if (!gQueryURIFormat) {
+ // Get model query from pref. We don't want the query starting with "?"
+ // as we have to prefix "?and" to this format.
+ /* eslint-disable no-global-assign */
+ gQueryURIFormat = getModelQuery("mail.addr_book.quicksearchquery.format");
+ /* eslint-enable no-global-assign */
+ }
+
+ let searchURI = getSelectedDirectoryURI();
+ let searchQuery;
+ let searchInput = document.getElementById("peopleSearchInput");
+
+ // Use helper method to split up search query to multi-word search
+ // query against multiple fields.
+ if (searchInput) {
+ let searchWords = getSearchTokens(searchInput.value);
+ searchQuery = generateQueryURI(gQueryURIFormat, searchWords);
+ }
+
+ SetAbView(searchURI, searchQuery, searchInput ? searchInput.value : "");
+}
+
+/**
+ * Open a menupopup as a context menu
+ *
+ * @param aContextMenuID The ID of a menupopup to be shown as context menu
+ * @param aEvent The event which triggered this.
+ * @param positionArray An optional array containing the parameters for openPopup() method;
+ * if omitted, mouse pointer position will be used.
+ */
+function showContextMenu(aContextMenuID, aEvent, aPositionArray) {
+ let theContextMenu = document.getElementById(aContextMenuID);
+ if (!aPositionArray) {
+ aPositionArray = [null, "", aEvent.clientX, aEvent.clientY, true];
+ }
+ theContextMenu.openPopup(...aPositionArray);
+}
+
+/**
+ * Get the URI of the selected directory.
+ *
+ * @returns The URI of the currently selected directory
+ */
+function getSelectedDirectoryURI() {
+ return document.getElementById("addressbookList").value;
+}
+
+function abToggleSelectedDirStartup() {
+ let selectedDirURI = getSelectedDirectoryURI();
+ if (!selectedDirURI) {
+ return;
+ }
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+
+ if (isDefault && startupURI == selectedDirURI) {
+ // The current directory has been the default startup view directory;
+ // toggle that off now. So there's no default startup view directory any more.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ } else {
+ // The current directory will now be the default view
+ // when starting up the main AB window.
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ selectedDirURI
+ );
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ }
+
+ // Update the checkbox in the menuitem.
+ goUpdateCommand("cmd_abToggleStartupDir");
+}
+
+function ChangeDirectoryByURI(uri = kPersonalAddressbookURI) {
+ SetAbView(uri);
+
+ // Actively de-selecting if there are any pre-existing selections
+ // in the results list.
+ if (gAbView && gAbView.selection && gAbView.getCardFromRow(0)) {
+ gAbView.selection.clearSelection();
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.xhtml b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
new file mode 100644
index 0000000000..18163eafda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
@@ -0,0 +1,234 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abContactsPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+%abResultsPaneDTD;
+<!ENTITY % abContactsPanelDTD SYSTEM "chrome://messenger/locale/addressbook/abContactsPanel.dtd" >
+%abContactsPanelDTD;
+<!ENTITY % abMainWindowDTD SYSTEM "chrome://messenger/locale/addressbook/abMainWindow.dtd" >
+%abMainWindowDTD; ]>
+
+<window
+ id="abContactsPanel"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="AbPanelLoad();"
+ onunload="AbPanelUnload();"
+>
+ <html:link
+ rel="localization"
+ href="messenger/addressbook/aboutAddressBook.ftl"
+ />
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/addressbook/abDragDrop.js" />
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js" />
+ <script src="chrome://messenger/content/addressbook/abContactsPanel.js" />
+ <script src="chrome://messenger/content/jsTreeView.js" />
+ <script src="chrome://messenger/content/addressbook/abView.js" />
+
+ <commandset
+ id="CommandUpdate_AddressBook"
+ commandupdater="true"
+ events="focus,addrbook-select"
+ oncommandupdate="CommandUpdate_AddressBook()"
+ >
+ <command
+ id="cmd_addrTo"
+ oncommand="addSelectedAddresses('addr_to')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrCc"
+ oncommand="addSelectedAddresses('addr_cc')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrBcc"
+ oncommand="addSelectedAddresses('addr_bcc')"
+ disabled="true"
+ />
+ <command id="cmd_delete" oncommand="goDoCommand('cmd_delete');" />
+ </commandset>
+
+ <keyset id="keyset_abContactsPanel">
+ <!-- This key (key_delete) does not trigger any command, but it is used
+ only to show the hotkey on the corresponding menuitem. -->
+ <key id="key_delete" keycode="VK_DELETE" internal="true" />
+ </keyset>
+
+ <menupopup id="cardProperties">
+ <menuitem
+ label="&addtoToFieldMenu.label;"
+ accesskey="&addtoToFieldMenu.accesskey;"
+ command="cmd_addrTo"
+ />
+ <menuitem
+ label="&addtoCcFieldMenu.label;"
+ accesskey="&addtoCcFieldMenu.accesskey;"
+ command="cmd_addrCc"
+ />
+ <menuitem
+ label="&addtoBccFieldMenu.label;"
+ accesskey="&addtoBccFieldMenu.accesskey;"
+ command="cmd_addrBcc"
+ />
+ <menuseparator />
+ <menuitem
+ label="&deleteAddrBookCard.label;"
+ accesskey="&deleteAddrBookCard.accesskey;"
+ key="key_delete"
+ command="cmd_delete"
+ />
+ <menuseparator id="abContextBeforeEditContact" hidden="true" />
+ <menuitem
+ id="abContextEditContact"
+ label="&editContactContext.label;"
+ accesskey="&editContactContext.accesskey;"
+ oncommand="editSelectedAddress();"
+ hidden="true"
+ />
+ </menupopup>
+
+ <menupopup
+ id="sidebarAbContextMenu"
+ class="no-accel-menupopup"
+ onpopupshowing="onAbContextShowing();"
+ >
+ <menuitem
+ id="sidebarAbContext-startupDir"
+ label="&showAsDefault.label;"
+ accesskey="&showAsDefault.accesskey;"
+ type="checkbox"
+ checked="false"
+ oncommand="abToggleSelectedDirStartup();"
+ />
+ </menupopup>
+
+ <vbox id="results_box" flex="1">
+ <separator class="thin" />
+ <hbox id="AbPickerHeader" class="themeable-full">
+ <label
+ value="&addressbookPicker.label;"
+ accesskey="&addressbookPicker.accesskey;"
+ control="addressbookList"
+ />
+ <spacer flex="1" />
+ <button
+ id="abContextMenuButton"
+ tooltiptext="&abContextMenuButton.tooltip;"
+ oncommand="abContextMenuButtonOnCommand(event);"
+ />
+ </hbox>
+ <hbox id="panel-bar" class="themeable-full" align="center">
+ <menulist
+ is="menulist-addrbooks"
+ id="addressbookList"
+ alladdressbooks="true"
+ oncommand="AddressBookMenuListChange(this.value);"
+ flex="1"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <vbox>
+ <label
+ value="&searchContacts.label;"
+ accesskey="&searchContacts.accesskey;"
+ control="peopleSearchInput"
+ />
+ <search-textbox
+ id="peopleSearchInput"
+ class="searchBox"
+ flex="1"
+ timeout="800"
+ placeholder="&SearchNameOrEmail.label;"
+ oncommand="onEnterInSearchBar();"
+ />
+ </vbox>
+
+ <separator class="thin" />
+
+ <tree
+ id="abResultsTree"
+ flex="1"
+ class="plain"
+ sortCol="GeneratedName"
+ persist="sortCol"
+ contextSelection="cardProperties"
+ contextNoSelection="sidebarAbContextMenu"
+ oncontextmenu="contactsListOnContextMenu(event);"
+ onclick="contactsListOnClick(event);"
+ onselect="this.view.selectionChanged(); document.commandDispatcher.updateCommands('addrbook-select');"
+ >
+ <treecols>
+ <!-- these column ids must match up to the mork column names, see nsIAddrDatabase.idl -->
+ <treecol
+ id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="addrbook"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"
+ />
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);" />
+ </tree>
+
+ <separator class="thin" />
+
+ <hbox pack="center">
+ <vbox>
+ <button
+ id="toButton"
+ label="&toButton.label;"
+ accesskey="&toButton.accesskey;"
+ command="cmd_addrTo"
+ />
+ <button
+ id="ccButton"
+ label="&ccButton.label;"
+ accesskey="&ccButton.accesskey;"
+ command="cmd_addrCc"
+ />
+ <button
+ id="bccButton"
+ label="&bccButton.label;"
+ accesskey="&bccButton.accesskey;"
+ command="cmd_addrBcc"
+ />
+ </vbox>
+ </hbox>
+
+ <separator class="thin" />
+ </vbox>
+</window>
diff --git a/comm/mail/components/addrbook/content/abEditListDialog.xhtml b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
new file mode 100644
index 0000000000..bf775c274b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
@@ -0,0 +1,99 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadEditList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abMailListDialog.xhtml b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
new file mode 100644
index 0000000000..5b0cf11dda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
@@ -0,0 +1,116 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadNewMailList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <hbox align="center">
+ <label
+ control="abPopup"
+ value="&addToAddressBook.label;"
+ accesskey="&addToAddressBook.accesskey;"
+ />
+ <menulist
+ is="menulist-addrbooks"
+ id="abPopup"
+ supportsmaillists="true"
+ flex="1"
+ writable="true"
+ />
+ </hbox>
+
+ <spacer style="height: 1em" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.js b/comm/mail/components/addrbook/content/abSearchDialog.js
new file mode 100644
index 0000000000..694d17c12b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.js
@@ -0,0 +1,408 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../../mailnews/base/content/dateFormat.js */
+/* import-globals-from ../../../../mailnews/search/content/searchTerm.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { encodeABTermValue } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+var searchSessionContractID = "@mozilla.org/messenger/searchSession;1";
+var gSearchSession;
+
+var nsMsgSearchScope = Ci.nsMsgSearchScope;
+var nsMsgSearchOp = Ci.nsMsgSearchOp;
+var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib;
+
+var gStatusText;
+var gSearchBundle;
+var gAddressBookBundle;
+
+var gSearchStopButton;
+var gPropertiesCmd;
+var gComposeCmd;
+var gDeleteCmd;
+var gSearchPhoneticName = "false";
+
+var gSearchAbViewListener = {
+ onSelectionChanged() {
+ UpdateCardView();
+ },
+ onCountChanged(aTotal) {
+ let statusText;
+ if (aTotal == 0) {
+ statusText = gAddressBookBundle.GetStringFromName("noMatchFound");
+ } else {
+ statusText = PluralForm.get(
+ aTotal,
+ gAddressBookBundle.GetStringFromName("matchesFound1")
+ ).replace("#1", aTotal);
+ }
+
+ gStatusText.setAttribute("value", statusText);
+ },
+};
+
+function searchOnLoad() {
+ initializeSearchWidgets();
+ initializeSearchWindowWidgets();
+
+ gSearchBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/search.properties"
+ );
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+ gAddressBookBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ gSearchSession = Cc[searchSessionContractID].createInstance(
+ Ci.nsIMsgSearchSession
+ );
+
+ // initialize a flag for phonetic name search
+ gSearchPhoneticName = Services.prefs.getComplexValue(
+ "mail.addr_book.show_phonetic_fields",
+ Ci.nsIPrefLocalizedString
+ ).data;
+
+ if (window.arguments && window.arguments[0]) {
+ SelectDirectory(window.arguments[0].directory);
+ } else {
+ SelectDirectory(
+ document.getElementById("abPopup-menupopup").firstElementChild.value
+ );
+ }
+
+ onMore(null);
+}
+
+function searchOnUnload() {
+ CloseAbView();
+}
+
+function disableCommands() {
+ gPropertiesCmd.setAttribute("disabled", "true");
+ gComposeCmd.setAttribute("disabled", "true");
+ gDeleteCmd.setAttribute("disabled", "true");
+}
+
+function initializeSearchWindowWidgets() {
+ gSearchStopButton = document.getElementById("search-button");
+ gPropertiesCmd = document.getElementById("cmd_properties");
+ gComposeCmd = document.getElementById("cmd_compose");
+ gDeleteCmd = document.getElementById("cmd_deleteCard");
+ gStatusText = document.getElementById("statusText");
+ disableCommands();
+ // matchAll doesn't make sense for address book search
+ hideMatchAllItem();
+}
+
+function onSearchStop() {}
+
+function onAbSearchReset(event) {
+ disableCommands();
+ CloseAbView();
+
+ onReset(event);
+ gStatusText.setAttribute("value", "");
+}
+
+function SelectDirectory(aURI) {
+ // set popup with address book names
+ let abPopup = document.getElementById("abPopup");
+ if (abPopup) {
+ if (aURI) {
+ abPopup.value = aURI;
+ } else {
+ abPopup.selectedIndex = 0;
+ }
+ }
+
+ setSearchScope(GetScopeForDirectoryURI(aURI));
+}
+
+function GetScopeForDirectoryURI(aURI) {
+ let directory;
+ if (aURI && aURI != "moz-abdirectory://?") {
+ directory = MailServices.ab.getDirectory(aURI);
+ }
+ let booleanAnd = gSearchBooleanRadiogroup.selectedItem.value == "and";
+
+ if (directory?.isRemote) {
+ if (booleanAnd) {
+ return nsMsgSearchScope.LDAPAnd;
+ }
+ return nsMsgSearchScope.LDAP;
+ }
+
+ if (booleanAnd) {
+ return nsMsgSearchScope.LocalABAnd;
+ }
+ return nsMsgSearchScope.LocalAB;
+}
+
+function onEnterInSearchTerm() {
+ // on enter
+ // if not searching, start the search
+ // if searching, stop and then start again
+ if (
+ gSearchStopButton.getAttribute("label") ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ onSearch();
+ }
+}
+
+function onSearch() {
+ gStatusText.setAttribute("value", "");
+ disableCommands();
+
+ gSearchSession.clearScopes();
+
+ var currentAbURI = document.getElementById("abPopup").getAttribute("value");
+
+ gSearchSession.addDirectoryScopeTerm(GetScopeForDirectoryURI(currentAbURI));
+ gSearchSession.searchTerms = saveSearchTerms(
+ gSearchSession.searchTerms,
+ gSearchSession
+ );
+
+ let searchUri = "?(";
+ for (let i = 0; i < gSearchSession.searchTerms.length; i++) {
+ let searchTerm = gSearchSession.searchTerms[i];
+ if (!searchTerm.value.str) {
+ continue;
+ }
+ // get the "and" / "or" value from the first term
+ if (i == 0) {
+ if (searchTerm.booleanAnd) {
+ searchUri += "and";
+ } else {
+ searchUri += "or";
+ }
+ }
+
+ var attrs;
+
+ switch (searchTerm.attrib) {
+ case nsMsgSearchAttrib.Name:
+ if (gSearchPhoneticName != "true") {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ ];
+ } else {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ "PhoneticFirstName",
+ "PhoneticLastName",
+ ];
+ }
+ break;
+ case nsMsgSearchAttrib.DisplayName:
+ attrs = ["DisplayName"];
+ break;
+ case nsMsgSearchAttrib.Email:
+ attrs = ["PrimaryEmail"];
+ break;
+ case nsMsgSearchAttrib.PhoneNumber:
+ attrs = [
+ "HomePhone",
+ "WorkPhone",
+ "FaxNumber",
+ "PagerNumber",
+ "CellularNumber",
+ ];
+ break;
+ case nsMsgSearchAttrib.Organization:
+ attrs = ["Company"];
+ break;
+ case nsMsgSearchAttrib.Department:
+ attrs = ["Department"];
+ break;
+ case nsMsgSearchAttrib.City:
+ attrs = ["WorkCity"];
+ break;
+ case nsMsgSearchAttrib.Street:
+ attrs = ["WorkAddress"];
+ break;
+ case nsMsgSearchAttrib.Nickname:
+ attrs = ["NickName"];
+ break;
+ case nsMsgSearchAttrib.WorkPhone:
+ attrs = ["WorkPhone"];
+ break;
+ case nsMsgSearchAttrib.HomePhone:
+ attrs = ["HomePhone"];
+ break;
+ case nsMsgSearchAttrib.Fax:
+ attrs = ["FaxNumber"];
+ break;
+ case nsMsgSearchAttrib.Pager:
+ attrs = ["PagerNumber"];
+ break;
+ case nsMsgSearchAttrib.Mobile:
+ attrs = ["CellularNumber"];
+ break;
+ case nsMsgSearchAttrib.Title:
+ attrs = ["JobTitle"];
+ break;
+ case nsMsgSearchAttrib.AdditionalEmail:
+ attrs = ["SecondEmail"];
+ break;
+ case nsMsgSearchAttrib.ScreenName:
+ attrs = ["_AimScreenName"];
+ break;
+ default:
+ dump("XXX " + searchTerm.attrib + " not a supported search attr!\n");
+ attrs = ["DisplayName"];
+ break;
+ }
+
+ var opStr;
+
+ switch (searchTerm.op) {
+ case nsMsgSearchOp.Contains:
+ opStr = "c";
+ break;
+ case nsMsgSearchOp.DoesntContain:
+ opStr = "!c";
+ break;
+ case nsMsgSearchOp.Is:
+ opStr = "=";
+ break;
+ case nsMsgSearchOp.Isnt:
+ opStr = "!=";
+ break;
+ case nsMsgSearchOp.BeginsWith:
+ opStr = "bw";
+ break;
+ case nsMsgSearchOp.EndsWith:
+ opStr = "ew";
+ break;
+ case nsMsgSearchOp.SoundsLike:
+ opStr = "~=";
+ break;
+ default:
+ opStr = "c";
+ break;
+ }
+
+ // currently, we can't do "and" and "or" searches at the same time
+ // (it's either all "and"s or all "or"s)
+ var max_attrs = attrs.length;
+
+ for (var j = 0; j < max_attrs; j++) {
+ // append the term(s) to the searchUri
+ searchUri +=
+ "(" +
+ attrs[j] +
+ "," +
+ opStr +
+ "," +
+ encodeABTermValue(searchTerm.value.str) +
+ ")";
+ }
+ }
+
+ searchUri += ")";
+ if (searchUri == "?()") {
+ // Empty search.
+ searchUri = "";
+ }
+ SetAbView(currentAbURI, searchUri, "");
+}
+
+// used to toggle functionality for Search/Stop button.
+function onSearchButton(event) {
+ if (
+ event.target.label ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ }
+}
+
+function GetAbViewListener() {
+ return gSearchAbViewListener;
+}
+
+function onProperties() {
+ if (!gPropertiesCmd.hasAttribute("disabled")) {
+ window.opener.toAddressBook({ action: "display", card: GetSelectedCard() });
+ }
+}
+
+function onCompose() {
+ if (!gComposeCmd.hasAttribute("disabled")) {
+ AbNewMessage();
+ }
+}
+
+function onDelete() {
+ if (!gDeleteCmd.hasAttribute("disabled")) {
+ AbDelete();
+ }
+}
+
+function AbResultsPaneKeyPress(event) {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_RETURN:
+ onProperties();
+ break;
+ case KeyEvent.DOM_VK_DELETE:
+ case KeyEvent.DOM_VK_BACK_SPACE:
+ onDelete();
+ }
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // Kept for abResultsPane.js.
+}
+
+function UpdateCardView() {
+ disableCommands();
+ let numSelected = GetNumSelectedCards();
+
+ if (!numSelected) {
+ return;
+ }
+
+ if (MailServices.accounts.allIdentities.length > 0) {
+ gComposeCmd.removeAttribute("disabled");
+ }
+
+ gDeleteCmd.removeAttribute("disabled");
+ if (numSelected == 1) {
+ gPropertiesCmd.removeAttribute("disabled");
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.xhtml b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
new file mode 100644
index 0000000000..75a40df839
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
@@ -0,0 +1,200 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abResultsPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/abSearchDialog.css" type="text/css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+ %abResultsPaneDTD;
+ <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd">
+ %SearchDialogDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+<window id="searchAddressBookWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="searchOnLoad();"
+ onunload="searchOnUnload();"
+ onclose="onSearchStop();"
+ windowtype="mailnews:absearch"
+ title="&abSearchDialogTitle.label;"
+ style="min-width: 52em; min-height: 34em;"
+ lightweightthemes="true"
+ persist="screenX screenY width height sizemode">
+ <html:link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://messenger/content/addressbook/abSearchDialog.js"/>
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js"/>
+ <script src="chrome://messenger/content/addressbook/abCommon.js"/>
+ <script src="chrome://messenger/content/searchTerm.js"/>
+ <script src="chrome://messenger/content/searchWidgets.js"/>
+ <script src="chrome://messenger/content/dateFormat.js"/>
+ <script src="chrome://messenger/content/jsTreeView.js"/>
+ <script src="chrome://messenger/content/addressbook/abView.js"/>
+
+ <keyset id="mailKeys">
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="onSearchStop(); window.close();"/>
+ <key keycode="VK_ESCAPE" oncommand="onSearchStop(); window.close();"/>
+ </keyset>
+
+ <commandset id="AbCommands">
+ <command id="cmd_properties" oncommand="onProperties();"/>
+ <command id="cmd_compose" oncommand="onCompose();"/>
+ <command id="cmd_deleteCard" oncommand="onDelete();"/>
+ </commandset>
+
+ <vbox id="searchTerms" class="themeable-brighttext" persist="height">
+ <vbox>
+ <hbox align="center">
+ <label value="&abSearchHeading.label;" accesskey="&abSearchHeading.accesskey;" control="abPopup"/>
+ <menulist is="menulist-addrbooks" id="abPopup"
+ oncommand="SelectDirectory(this.value);"
+ alladdressbooks="true"
+ flex="1"/>
+ <spacer style="flex: 3 3;"/>
+ <button id="search-button" oncommand="onSearchButton(event);" default="true"/>
+ </hbox>
+ <hbox align="center">
+ <spacer flex="1"/>
+ <button label="&resetButton.label;" oncommand="onAbSearchReset(event);" accesskey="&resetButton.accesskey;"/>
+ </hbox>
+ </vbox>
+
+ <hbox flex="1">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+ <splitter id="gray_horizontal_splitter" orient="vertical"/>
+
+ <vbox id="searchResults" persist="height">
+ <vbox id="searchResultListBox">
+ <tree id="abResultsTree" flex="1" enableColumnDrag="true" class="plain"
+ onclick="AbResultsPaneOnClick(event);"
+ onkeypress="AbResultsPaneKeyPress(event);"
+ onselect="this.view.selectionChanged();"
+ sortCol="GeneratedName"
+ persist="sortCol">
+
+ <treecols id="abResultsTreeCols">
+ <!-- these column ids must match up to the mork column names, except for GeneratedName and ChatName, see nsIAddrDatabase.idl -->
+ <treecol id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="ChatName"
+ hidden="true"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&ChatName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Company"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Company.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="NickName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&NickName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="SecondEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&SecondEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Department"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Department.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="JobTitle"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&JobTitle.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="CellularNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&CellularNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PagerNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PagerNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="FaxNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&FaxNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="HomePhone"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&HomePhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="WorkPhone"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&WorkPhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Addrbook"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"/>
+ <!-- LOCALIZATION NOTE: _PhoneticName may be enabled for Japanese builds. -->
+ <!--
+ <treecol id="_PhoneticName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&_PhoneticName.label;"/>
+ <splitter class="tree-splitter"/>
+ -->
+
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);"/>
+ </tree>
+ </vbox>
+ <hbox align="start">
+ <button label="&propertiesButton.label;"
+ accesskey="&propertiesButton.accesskey;"
+ command="cmd_properties"/>
+ <button label="&composeButton.label;"
+ accesskey="&composeButton.accesskey;"
+ command="cmd_compose"/>
+ <button label="&deleteCardButton.label;"
+ accesskey="&deleteCardButton.accesskey;"
+ command="cmd_deleteCard"/>
+ </hbox>
+ </vbox>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <label id="statusText" class="statusbarpanel" crop="end" flex="1"/>
+ </hbox>
+
+</window>
diff --git a/comm/mail/components/addrbook/content/abView-new.js b/comm/mail/components/addrbook/content/abView-new.js
new file mode 100644
index 0000000000..cb3eca969c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abView-new.js
@@ -0,0 +1,577 @@
+/* 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 PROTO_TREE_VIEW */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+function ABView(
+ directory,
+ searchQuery,
+ searchString,
+ sortColumn,
+ sortDirection
+) {
+ this.__proto__.__proto__ = new PROTO_TREE_VIEW();
+ this.directory = directory;
+ this.searchString = searchString;
+
+ let directories = directory ? [directory] : MailServices.ab.directories;
+ if (searchQuery) {
+ this._searchesInProgress = directories.length;
+ searchQuery = searchQuery.replace(/^\?+/, "");
+ for (let dir of directories) {
+ dir.search(searchQuery, searchString, this);
+ }
+ } else {
+ for (let dir of directories) {
+ for (let card of dir.childCards) {
+ this._rowMap.push(new abViewCard(card, dir));
+ }
+ }
+ }
+ this.sortBy(sortColumn, sortDirection);
+}
+ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+);
+ABView.NOT_SEARCHING = 0;
+ABView.SEARCHING = 1;
+ABView.SEARCH_COMPLETE = 2;
+ABView.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsITreeView",
+ "nsIAbDirSearchListener",
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ directory: null,
+ _notifications: [
+ "addrbook-directory-deleted",
+ "addrbook-directory-invalidated",
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ],
+
+ sortColumn: "",
+ sortDirection: "",
+ collator: new Intl.Collator(undefined, { numeric: true }),
+
+ deleteSelectedCards() {
+ let directoryMap = new Map();
+ for (let i of this._tree.selectedIndices) {
+ let card = this.getCardFromRow(i);
+ let cardSet = directoryMap.get(card.directoryUID);
+ if (!cardSet) {
+ cardSet = new Set();
+ directoryMap.set(card.directoryUID, cardSet);
+ }
+ cardSet.add(card);
+ }
+
+ for (let [directoryUID, cardSet] of directoryMap) {
+ let directory;
+ if (this.directory && this.directory.isMailList) {
+ // Removes cards from the list instead of deleting them.
+ directory = this.directory;
+ } else {
+ directory = MailServices.ab.getDirectoryFromUID(directoryUID);
+ }
+
+ cardSet = [...cardSet];
+ directory.deleteCards(cardSet.filter(card => !card.isMailList));
+ for (let card of cardSet.filter(card => card.isMailList)) {
+ MailServices.ab.deleteAddressBook(card.mailListURI);
+ }
+ }
+ },
+ getCardFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].card : null;
+ },
+ getDirectoryFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].directory : null;
+ },
+ getIndexForUID(uid) {
+ return this._rowMap.findIndex(row => row.id == uid);
+ },
+ sortBy(sortColumn, sortDirection, resort) {
+ let selectionExists = false;
+ if (this._tree) {
+ let { selectedIndices, currentIndex } = this._tree;
+ selectionExists = selectedIndices.length;
+ // Remember what was selected.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+ }
+
+ // Do the sort.
+ if (sortColumn == this.sortColumn && !resort) {
+ if (sortDirection == this.sortDirection) {
+ return;
+ }
+ this._rowMap.reverse();
+ } else {
+ this._rowMap.sort((a, b) => {
+ let aText = a.getText(sortColumn);
+ let bText = b.getText(sortColumn);
+ if (sortDirection == "descending") {
+ return this.collator.compare(bText, aText);
+ }
+ return this.collator.compare(aText, bText);
+ });
+ }
+
+ // Restore what was selected.
+ if (this._tree) {
+ this._tree.reset();
+ if (selectionExists) {
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ this.selectionChanged();
+ }
+ }
+ this.sortColumn = sortColumn;
+ this.sortDirection = sortDirection;
+ },
+ get searchState() {
+ if (this._searchesInProgress === undefined) {
+ return ABView.NOT_SEARCHING;
+ }
+ return this._searchesInProgress ? ABView.SEARCHING : ABView.SEARCH_COMPLETE;
+ },
+
+ // nsITreeView
+
+ selectionChanged() {},
+ setTree(tree) {
+ this._tree = tree;
+ for (let topic of this._notifications) {
+ if (tree) {
+ Services.obs.addObserver(this, topic, true);
+ } else {
+ try {
+ Services.obs.removeObserver(this, topic);
+ } catch (ex) {
+ // `this` might not be a valid observer.
+ }
+ }
+ }
+ Services.prefs.addObserver("mail.addr_book.lastnamefirst", this, true);
+ },
+
+ // nsIAbDirSearchListener
+
+ onSearchFoundCard(card) {
+ // Instead of duplicating the insertion code below, just call it.
+ this.observe(card, "addrbook-contact-created", this.directory?.UID);
+ },
+ onSearchFinished(status, complete, secInfo, location) {
+ // Special handling for Bad Cert errors.
+ let offerCertException = false;
+ try {
+ // If code is not an NSS error, getErrorClass() will fail.
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ offerCertException = true;
+ }
+ } catch (ex) {}
+
+ if (offerCertException) {
+ // Give the user the option of adding an exception for the bad cert.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // params.exceptionAdded will be set if the user added an exception.
+ }
+
+ this._searchesInProgress--;
+ if (!this._searchesInProgress && this._tree) {
+ this._tree.dispatchEvent(new CustomEvent("searchstatechange"));
+ }
+ },
+
+ // nsIObserver
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ for (let card of this._rowMap) {
+ delete card._getTextCache.GeneratedName;
+ }
+ if (this._tree) {
+ if (this.sortColumn == "GeneratedName") {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ } else {
+ // Remember what was selected.
+ let { selectedIndices, currentIndex } = this._tree;
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+
+ this._tree.reset();
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ if (this.directory && data && this.directory.UID != data) {
+ return;
+ }
+
+ // If we make it here, we're in the root directory, or the right directory.
+
+ switch (topic) {
+ case "addrbook-directory-deleted": {
+ if (this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].directory.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-directory-invalidated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (subject == this.directory) {
+ this._rowMap.length = 0;
+ for (let card of this.directory.childCards) {
+ this._rowMap.push(new abViewCard(card, this.directory));
+ }
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ case "addrbook-list-created": {
+ let parentDir = MailServices.ab.getDirectoryFromUID(data);
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-list-member-added":
+ case "addrbook-contact-created":
+ if (topic == "addrbook-list-member-added" && !this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbCard);
+ let viewCard = new abViewCard(subject);
+ let sortText = viewCard.getText(this.sortColumn);
+ let addIndex = null;
+ for (let i = 0; addIndex === null && i < this._rowMap.length; i++) {
+ let comparison = this.collator.compare(
+ sortText,
+ this._rowMap[i].getText(this.sortColumn)
+ );
+ if (
+ (comparison < 0 && this.sortDirection == "ascending") ||
+ (comparison >= 0 && this.sortDirection == "descending")
+ ) {
+ addIndex = i;
+ }
+ }
+ if (addIndex === null) {
+ addIndex = this._rowMap.length;
+ }
+ this._rowMap.splice(addIndex, 0, viewCard);
+ if (this._tree) {
+ this._tree.rowCountChanged(addIndex, 1);
+ }
+ break;
+
+ case "addrbook-list-updated": {
+ let parentDir = this.directory;
+ if (!parentDir) {
+ parentDir = MailServices.ab.getDirectoryFromUID(data);
+ }
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-contact-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let needsSort = false;
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1, new abViewCard(subject));
+ needsSort = true;
+ }
+ }
+ if (needsSort) {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ }
+
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].card.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-list-member-removed":
+ if (!this.directory) {
+ break;
+ }
+ // Falls through.
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ }
+ },
+};
+
+/**
+ * Representation of a card, used as a table row in ABView.
+ *
+ * @param {nsIAbCard} card - contact or mailing list card for this row.
+ * @param {nsIAbDirectory} [directoryHint] - the directory containing card,
+ * if available (this is a performance optimization only).
+ */
+function abViewCard(card, directoryHint) {
+ this.card = card;
+ this._getTextCache = {};
+ if (directoryHint) {
+ this._directory = directoryHint;
+ } else {
+ this._directory = MailServices.ab.getDirectoryFromUID(
+ this.card.directoryUID
+ );
+ }
+}
+abViewCard.listFormatter = new Services.intl.ListFormat(
+ Services.appinfo.name == "xpcshell" ? "en-US" : undefined,
+ { type: "unit" }
+);
+abViewCard.prototype = {
+ _getText(columnID) {
+ try {
+ let { getProperty, supportsVCard, vCardProperties } = this.card;
+
+ if (this.card.isMailList) {
+ if (columnID == "GeneratedName") {
+ return this.card.displayName;
+ }
+ if (["NickName", "Notes"].includes(columnID)) {
+ return getProperty(columnID, "");
+ }
+ if (columnID == "addrbook") {
+ return MailServices.ab.getDirectoryFromUID(this.card.directoryUID)
+ .dirName;
+ }
+ return "";
+ }
+
+ switch (columnID) {
+ case "addrbook":
+ return this._directory.dirName;
+ case "GeneratedName":
+ return this.card.generateName(ABView.nameFormat);
+ case "EmailAddresses":
+ return abViewCard.listFormatter.format(this.card.emailAddresses);
+ case "PhoneNumbers": {
+ let phoneNumbers;
+ if (supportsVCard) {
+ phoneNumbers = vCardProperties.getAllValues("tel");
+ } else {
+ phoneNumbers = [
+ getProperty("WorkPhone", ""),
+ getProperty("HomePhone", ""),
+ getProperty("CellularNumber", ""),
+ getProperty("FaxNumber", ""),
+ getProperty("PagerNumber", ""),
+ ];
+ }
+ return abViewCard.listFormatter.format(phoneNumbers.filter(Boolean));
+ }
+ case "Addresses": {
+ let addresses;
+ if (supportsVCard) {
+ addresses = vCardProperties
+ .getAllValues("adr")
+ .map(v => v.join(" ").trim());
+ } else {
+ addresses = [
+ this.formatAddress("Work"),
+ this.formatAddress("Home"),
+ ];
+ }
+ return abViewCard.listFormatter.format(addresses.filter(Boolean));
+ }
+ case "JobTitle":
+ case "Title":
+ if (supportsVCard) {
+ return vCardProperties.getFirstValue("title");
+ }
+ return getProperty("JobTitle", "");
+ case "Department":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[1] || "";
+ }
+ return "";
+ }
+ return getProperty(columnID, "");
+ case "Company":
+ case "Organization":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[0] || "";
+ }
+ return vCardValue;
+ }
+ return getProperty("Company", "");
+ default:
+ return getProperty(columnID, "");
+ }
+ } catch (ex) {
+ return "";
+ }
+ },
+ getText(columnID) {
+ if (!(columnID in this._getTextCache)) {
+ this._getTextCache[columnID] = this._getText(columnID)?.trim() ?? "";
+ }
+ return this._getTextCache[columnID];
+ },
+ get id() {
+ return this.card.UID;
+ },
+ get open() {
+ return false;
+ },
+ get level() {
+ return 0;
+ },
+ get children() {
+ return [];
+ },
+ getProperties() {
+ return "";
+ },
+ get directory() {
+ return this._directory;
+ },
+
+ /**
+ * Creates a string representation of an address from card properties.
+ *
+ * @param {"Work"|"Home"} prefix
+ * @returns {string}
+ */
+ formatAddress(prefix) {
+ return Array.from(
+ ["Address", "Address2", "City", "State", "ZipCode", "Country"],
+ field => this.card.getProperty(`${prefix}${field}`, "")
+ )
+ .join(" ")
+ .trim();
+ },
+};
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" });
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.xhtml b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
new file mode 100644
index 0000000000..51a689106a
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
@@ -0,0 +1,460 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true">
+<head>
+ <meta charset="utf-8" />
+ <title data-l10n-id="about-addressbook-title"></title>
+ <meta http-equiv="Content-Security-Policy"
+ content="default-src chrome:; script-src chrome: 'unsafe-inline'; img-src blob: chrome: data: http: https:; style-src chrome: 'unsafe-inline'; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/address-book.svg" />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/primaryToolbar.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/avatars.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/aboutAddressBook.css" />
+
+ <link rel="localization" href="messenger/treeView.ftl" />
+ <link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"></script>
+ <script src="chrome://global/content/editMenuOverlay.js"></script>
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <script src="chrome://messenger/content/tree-listbox.js"></script>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="chrome://messenger/content/jsTreeView.js"></script>
+ <script src="chrome://messenger/content/addressbook/abView-new.js"></script>
+ <script src="chrome://messenger/content/addressbook/aboutAddressBook.js"></script>
+</head>
+<body>
+ <xul:toolbox id="toolbox" class="contentTabToolbox" labelalign="end">
+ <xul:toolbar class="chromeclass-toolbar contentTabToolbar themeable-full" mode="full">
+ <xul:toolbarbutton id="toolbarCreateBook" is="toolbarbutton-menu-button" type="menu-button"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-address-book"
+ tabindex="0">
+ <xul:menupopup>
+ <xul:menuitem data-l10n-id="about-addressbook-toolbar-new-address-book"/>
+ <xul:menuitem value="CARDDAV_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-carddav-address-book"/>
+ <xul:menuitem value="LDAP_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-ldap-address-book"/>
+ </xul:menupopup>
+ </xul:toolbarbutton>
+ <xul:toolbarbutton id="toolbarCreateContact"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-contact"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarCreateList"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-list"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarImport"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-import"
+ tabindex="0"/>
+ </xul:toolbar>
+ </xul:toolbox>
+ <div id="booksPane" class="no-overscroll">
+ <ul is="ab-tree-listbox" id="books" role="tree">
+ <li id="allAddressBooks"
+ class="bookRow noDelete readOnly"
+ data-l10n-id="all-address-books-row">
+ <div class="bookRow-container">
+ <div class="twisty"></div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1" data-l10n-id="all-address-books"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ </li>
+ </ul>
+ <div id="cardCount"></div>
+ <template id="bookRow">
+ <li class="bookRow">
+ <div class="bookRow-container">
+ <div class="twisty">
+ <img class="twisty-icon" src="chrome://messenger/skin/icons/new/nav-down-sm.svg" alt="" />
+ </div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ <ul></ul>
+ </li>
+ </template>
+ <template id="listRow">
+ <li class="listRow">
+ <div class="listRow-container">
+ <div class="listRow-icon"></div>
+ <span class="listRow-name" tabindex="-1"></span>
+ <div class="listRow-menu"></div>
+ </div>
+ </li>
+ </template>
+ </div>
+ <hr is="pane-splitter" id="booksSplitter"
+ resize-direction="horizontal"
+ resize-id="booksPane"/>
+ <div id="cardsPane">
+ <div id="cardsPaneHeader">
+ <input is="ab-card-search-input" id="searchInput"
+ type="search"
+ data-l10n-attrs="placeholder" />
+ <button id="displayButton"
+ class="button icon-button icon-only button-flat"
+ data-l10n-id="about-addressbook-sort-button2">
+ </button>
+ </div>
+
+ <tree-view id="cards">
+ <slot name="placeholders">
+ <div id="placeholderEmptyBook"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-empty-book"></div>
+ <button id="placeholderCreateContact"
+ class="icon-button"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-new-contact"></button>
+ <div id="placeholderSearchOnly"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-search-only"></div>
+ <div id="placeholderSearching"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-searching"></div>
+ <div id="placeholderNoSearchResults"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-no-search-results"></div>
+ </slot>
+ </tree-view>
+ </div>
+ <!-- We will dynamically switch this splitter to be horizontal or vertical and
+ affect the cardsPane or detailsPane based on the required layout. -->
+ <hr is="pane-splitter" id="sharedSplitter" />
+ <div id="detailsPane" hidden="hidden">
+ <article id="viewContact" class="contact-details-scroll">
+ <!-- If you're changing this, you probably want to change #printTemplate too. -->
+ <header>
+ <div class="contact-header">
+ <img id="viewContactPhoto" class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 id="viewContactName" class="contact-heading-name"></h1>
+ <p id="viewContactNickName" class="contact-heading-nickname"></p>
+ <p id="viewPrimaryEmail" class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="list-header">
+ <div class="recipient-avatar is-mail-list">
+ <img alt="" src="chrome://messenger/skin/icons/new/compact/user-list-alt.svg" />
+ </div>
+ <h1 id="viewListName" class="contact-heading-name"></h1>
+ </div>
+ <div class="selection-header">
+ <h1 id="viewSelectionCount" class="contact-heading-name"></h1>
+ </div>
+ </header>
+ <div id="detailsBody">
+ <section id="detailsActions" class="button-block">
+ <div>
+ <button type="button" id="detailsWriteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-write-action-button"></button>
+ <button type="button" id="detailsEventButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-event-action-button"></button>
+ <button type="button" id="detailsSearchButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-search-action-button"></button>
+ <button type="button" id="detailsNewListButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-new-list-action-button"></button>
+ </div>
+ <div class="edit-block">
+ <button type="button" id="editButton"
+ data-l10n-id="about-addressbook-begin-edit-contact-button"></button>
+ </div>
+ </section>
+ <section id="emailAddresses" class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="phoneNumbers" class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="addresses" class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="notes" class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section id="websites" class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="instantMessaging" class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="otherInfo" class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="selectedCards">
+ <ul></ul>
+ </section>
+ <template id="entryItem">
+ <li class="entry-item">
+ <span class="entry-type"></span>
+ <span class="entry-value"></span>
+ </li>
+ </template>
+ <template id="selectedCard">
+ <li class="selected-card">
+ <div class="recipient-avatar"></div>
+ <div class="ab-card-row-data">
+ <p class="ab-card-first-line">
+ <span class="name"></span>
+ </p>
+ <p class="ab-card-second-line">
+ <span class="address"></span>
+ </p>
+ </div>
+ </li>
+ </template>
+ </div>
+ </article>
+ <form id="editContactForm"
+ autocomplete="off"
+ aria-labelledby="editContactHeadingName">
+ <div class="contact-details-scroll">
+ <div class="contact-header">
+ <div class="contact-headings">
+ <h1 id="editContactHeadingName" class="contact-heading-name"></h1>
+ <p id="editContactHeadingNickName" class="contact-heading-nickname">
+ </p>
+ <p id="editContactHeadingEmail" class="contact-heading-email"></p>
+ </div>
+ <!-- NOTE: We place the photo 'input' after the headings, since it is
+ - functionally a form control. However, we style the photo to
+ - appear at the inline-start of the contact-header. -->
+ <!-- NOTE: We wrap the button with a plain div because the button
+ - itself will not receive the paste event. -->
+ <div id="photoInput">
+ <button type="button" id="photoButton"
+ class="plain-button"
+ data-l10n-id="about-addressbook-details-edit-photo">
+ <img class="contact-photo" alt="" />
+ <div id="photoOverlay"></div>
+ </button>
+ </div>
+ </div>
+ #include vcard-edit/vCardTemplates.inc.xhtml
+ <vcard-edit />
+ </div>
+ <div id="detailsFooter" class="button-block">
+ <div>
+ <button type="button" id="detailsDeleteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-delete-edit-contact-button"></button>
+ </div>
+ <div>
+ <xul:label control="addContactBookList"
+ data-l10n-id="about-addressbook-add-contact-to"/>
+ <xul:menulist is="menulist-addrbooks" id="addContactBookList"
+ writable="true"/>
+ <button type="reset" id="cancelEditButton"
+ data-l10n-id="about-addressbook-cancel-edit-contact-button"></button>
+ <button type="submit" id="saveEditButton"
+ class="primary"
+ data-l10n-id="about-addressbook-save-edit-contact-button"></button>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div id="detailsPaneBackdrop"><!--
+ When editing a card, this element covers everything except #detailsPane,
+ preventing change to another card.
+ --></div>
+
+ <dialog id="photoDialog">
+ <div id="photoDialogInner">
+ <!-- FIXME: The dialog is not semantic or accessible.
+ - We use a tabindex and role="alert" as a temporary solution. -->
+ <div id="photoDropTarget" role="alert" tabindex="0">
+ <div class="icon"></div>
+ <div class="label" data-l10n-id="about-addressbook-photo-drop-target"></div>
+ </div>
+ <svg xmlns="http://www.w3.org/2000/svg" width="520" height="520" viewBox="-10 -10 520 520">
+ <image/>
+ <path fill="#000000" fill-opacity="0.5" d="M0 0H500V500H0Z M200 200V300H300V200Z"/>
+ <rect x="0" y="0" width="500" height="500"/>
+ <rect class="corner nw" width="40" height="40"/>
+ <rect class="corner ne" width="40" height="40"/>
+ <rect class="corner se" width="40" height="40"/>
+ <rect class="corner sw" width="40" height="40"/>
+ </svg>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button class="extra1" data-l10n-id="about-addressbook-photo-discard"></button>
+ <button class="cancel" data-l10n-id="about-addressbook-photo-cancel"></button>
+ <button class="accept primary" data-l10n-id="about-addressbook-photo-save"></button>
+ </menu>
+ </dialog>
+
+ <!-- In-content dialogs. -->
+ <xul:stack id="dialogStack" hidden="true"/>
+ <xul:vbox id="dialogTemplate"
+ class="dialogOverlay"
+ align="center"
+ pack="center"
+ topmost="true"
+ hidden="true">
+ <xul:vbox class="dialogBox"
+ pack="end"
+ role="dialog"
+ aria-labelledby="dialogTitle">
+ <xul:hbox class="dialogTitleBar" align="center">
+ <xul:label class="dialogTitle" flex="1"/>
+ <xul:button class="dialogClose close-icon" data-l10n-id="close-button"/>
+ </xul:hbox>
+ <xul:browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </xul:vbox>
+ </xul:vbox>
+
+ <template id="printTemplate">
+ <!-- If you're changing this, you probably want to change #viewContact too. -->
+ <div class="contact-header">
+ <img class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 class="contact-heading-name"></h1>
+ <p class="contact-heading-nickname"></p>
+ <p class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="contact-body">
+ <section class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ </div>
+ </template>
+</body>
+<xul:menupopup id="bookContext">
+ <xul:menuitem id="bookContextProperties"/>
+ <xul:menuitem id="bookContextSynchronize"
+ data-l10n-id="about-addressbook-books-context-synchronize"/>
+ <xul:menuitem id="bookContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="bookContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="bookContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="bookContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="bookContextStartupDefault" type="checkbox"
+ data-l10n-id="about-addressbook-books-context-startup-default"/>
+</xul:menupopup>
+<xul:menupopup id="sortContext"
+ position="bottomleft topleft">
+ <xul:menuitem type="radio"
+ name="format"
+ value="0"
+ checked="true"
+ data-l10n-id="about-addressbook-name-format-display"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="2"
+ data-l10n-id="about-addressbook-name-format-firstlast"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="1"
+ data-l10n-id="about-addressbook-name-format-lastfirst"/>
+ <xul:menuseparator/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName ascending"
+ checked="true"
+ data-l10n-id="about-addressbook-sort-name-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName descending"
+ data-l10n-id="about-addressbook-sort-name-descending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses ascending"
+ data-l10n-id="about-addressbook-sort-email-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses descending"
+ data-l10n-id="about-addressbook-sort-email-descending"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="sortContextTableLayout"
+ type="checkbox"
+ data-l10n-id="about-addressbook-table-layout"/>
+</xul:menupopup>
+<xul:menupopup id="cardContext">
+ <xul:menuitem id="cardContextWrite"
+ data-l10n-id="about-addressbook-cards-context-write"/>
+ <xul:menu id="cardContextWriteMenu"
+ data-l10n-id="about-addressbook-cards-context-write">
+ <xul:menupopup>
+ <!-- Filled dynamically. -->
+ </xul:menupopup>
+ </xul:menu>
+ <xul:menuseparator id="cardContextWriteSeparator"/>
+ <xul:menuitem id="cardContextEdit"
+ data-l10n-id="about-addressbook-books-context-edit"/>
+ <xul:menuitem id="cardContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="cardContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="cardContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="cardContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+</xul:menupopup>
+</html>
diff --git a/comm/mail/components/addrbook/content/addressBookTab.js b/comm/mail/components/addrbook/content/addressBookTab.js
new file mode 100644
index 0000000000..5605612daf
--- /dev/null
+++ b/comm/mail/components/addrbook/content/addressBookTab.js
@@ -0,0 +1,172 @@
+/* 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/. */
+
+// mail/base/content/specialTabs.js
+/* globals contentTabBaseType, DOMLinkHandler */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+/**
+ * A tab to show the Address Book.
+ */
+var addressBookTabType = {
+ __proto__: contentTabBaseType,
+ name: "addressBookTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ bundle: Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ ),
+ protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ ),
+
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ addressBookTab: {
+ type: "addressBookTab",
+ },
+ },
+
+ shouldSwitchTo(aArgs) {
+ if (!this.tab) {
+ return -1;
+ }
+
+ if ("onLoad" in aArgs) {
+ if (this.tab.browser.contentDocument.readyState != "complete") {
+ this.tab.browser.addEventListener(
+ "about-addressbook-ready",
+ event => aArgs.onLoad(event, this.tab.browser),
+ {
+ capture: true,
+ once: true,
+ }
+ );
+ } else {
+ aArgs.onLoad(null, this.tab.browser);
+ }
+ }
+ return document.getElementById("tabmail").tabInfo.indexOf(this.tab);
+ },
+
+ closeTab(aTab) {
+ this.tab = null;
+ },
+
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("preferencesTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "addressBookTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ aTab.panel.setAttribute("id", "addressBookTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+
+ // Start setting up the browser.
+ aTab.browser = aTab.panel.querySelector("browser");
+ aTab.browser.setAttribute(
+ "id",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.panel.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ aTab.url = "about:addressbook";
+ aTab.paneID = aArgs.paneID;
+ aTab.scrollPaneTo = aArgs.scrollPaneTo;
+ aTab.otherArgs = aArgs.otherArgs;
+
+ // Now set up the listeners.
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ // Wait for full loading of the tab and the automatic selecting of last tab.
+ // Then run the given onload code.
+ aTab.browser.addEventListener(
+ "about-addressbook-ready",
+ function (event) {
+ aTab.pageLoading = false;
+ aTab.pageLoaded = true;
+
+ if ("onLoad" in aArgs) {
+ // Let selection of the initial pane complete before selecting another.
+ // Otherwise we can end up with two panes selected at once.
+ aTab.browser.contentWindow.setTimeout(() => {
+ // By now, the tab could already be closed. Check that it isn't.
+ if (aTab.panel) {
+ aArgs.onLoad(event, aTab.browser);
+ }
+ });
+ }
+ },
+ {
+ capture: true,
+ once: true,
+ }
+ );
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = true;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser);
+ let params = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ postData: aArgs.postData || null,
+ };
+ aTab.browser.loadURI(Services.io.newURI("about:addressbook"), params);
+
+ this.tab = aTab;
+ this.lastBrowserId++;
+ },
+
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ return {};
+ },
+
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("addressBookTab", {});
+ },
+
+ doCommand(aCommand, aTab) {
+ if (aCommand == "cmd_print") {
+ aTab.browser.contentWindow.externalAction({ action: "print" });
+ return;
+ }
+ this.__proto__.doCommand(aCommand, aTab);
+ },
+};
diff --git a/comm/mail/components/addrbook/content/menulist-addrbooks.js b/comm/mail/components/addrbook/content/menulist-addrbooks.js
new file mode 100644
index 0000000000..6d919d98ad
--- /dev/null
+++ b/comm/mail/components/addrbook/content/menulist-addrbooks.js
@@ -0,0 +1,271 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+// The menulist CE is defined lazily. Create one now to get menulist defined,
+// allowing us to inherit from it.
+if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+}
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ /**
+ * MozMenulistAddrbooks is a menulist widget that is automatically
+ * populated with the complete address book list.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistAddrbooks extends customElements.get("menulist") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ if (this.menupopup) {
+ return;
+ }
+
+ this._directories = [];
+
+ this._rebuild();
+
+ // Store as a member of `this` so there's a strong reference.
+ this._addressBookListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-reloaded",
+ ],
+
+ init() {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic, true);
+ }
+ window.addEventListener("unload", this);
+ },
+
+ cleanUp() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ window.removeEventListener("unload", this);
+ },
+
+ handleEvent(event) {
+ this.cleanUp();
+ },
+
+ observe: (subject, topic, data) => {
+ // Test-only reload of the address book manager.
+ if (topic == "addrbook-reloaded") {
+ this._rebuild();
+ return;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ if (this._matches(subject)) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-updated": {
+ // Find the item in the list to rename.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var oldIndex = len - 1; oldIndex >= 0; oldIndex--) {
+ if (this._directories[oldIndex] == subject) {
+ break;
+ }
+ }
+ if (oldIndex != -1) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ // Find the item in the list to remove.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var index = len - 1; index >= 0; index--) {
+ if (this._directories[index] == subject) {
+ break;
+ }
+ }
+ if (index != -1) {
+ this._directories.splice(index, 1);
+ // Are we removing the selected directory?
+ if (
+ this.selectedItem ==
+ this.menupopup.removeChild(this.menupopup.children[index])
+ ) {
+ // If so, try to select the first directory, if available.
+ if (this.menupopup.hasChildNodes()) {
+ this.menupopup.firstElementChild.doCommand();
+ } else {
+ this.selectedItem = null;
+ }
+ }
+ }
+ break;
+ }
+ }
+ },
+ };
+
+ this._addressBookListener.init();
+ }
+
+ /**
+ * Returns the address book type based on the remoteonly attribute
+ * of the menulist.
+ *
+ * "URI" Local Address Book
+ * "dirPrefId" Remote LDAP Directory
+ */
+ get _type() {
+ return this.getAttribute("remoteonly") ? "dirPrefId" : "URI";
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._addressBookListener.cleanUp();
+ this._teardown();
+ }
+
+ _rebuild() {
+ // Init the address book cache.
+ this._directories.length = 0;
+
+ for (let ab of MailServices.ab.directories) {
+ if (this._matches(ab)) {
+ this._directories.push(ab);
+
+ if (this.getAttribute("mailinglists") == "true") {
+ // Also append contained mailinglists.
+ for (let list of ab.childNodes) {
+ if (this._matches(list)) {
+ this._directories.push(list);
+ }
+ }
+ }
+ }
+ }
+
+ this._teardown();
+
+ if (this.hasAttribute("none")) {
+ // Create a dummy menuitem representing no selection.
+ this._directories.unshift(null);
+ let listItem = this.appendItem(this.getAttribute("none"), "");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ }
+
+ if (this.hasAttribute("alladdressbooks")) {
+ // Insert a menuitem representing All Addressbooks.
+ let allABLabel = this.getAttribute("alladdressbooks");
+ if (allABLabel == "true") {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ allABLabel = bundle.GetStringFromName("allAddressBooks");
+ }
+
+ this._directories.unshift(null);
+ let listItem = this.appendItem(allABLabel, "moz-abdirectory://?");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+
+ // Now create menuitems for all displayed directories.
+ let type = this._type;
+ for (let ab of this._directories) {
+ if (!ab) {
+ // Skip the empty members added above.
+ continue;
+ }
+
+ let listItem = this.appendItem(ab.dirName, ab[type]);
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+
+ // Style the items by type.
+ if (ab.isMailList) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/user-list.svg"
+ );
+ } else if (ab.isRemote && ab.isSecure) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe-secure.svg"
+ );
+ } else if (ab.isRemote) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe.svg"
+ );
+ } else {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+ }
+
+ // Attempt to select the persisted or otherwise first directory.
+ this.selectedIndex = this._directories.findIndex(d => {
+ return d && d[type] == this.value;
+ });
+
+ if (!this.selectedItem && this.menupopup.hasChildNodes()) {
+ this.selectedIndex = 0;
+ }
+ }
+
+ _teardown() {
+ // Empty out anything in the list.
+ while (this.menupopup && this.menupopup.hasChildNodes()) {
+ this.menupopup.lastChild.remove();
+ }
+ }
+
+ _matches(ab) {
+ // This condition is used for instance when creating cards
+ if (this.getAttribute("writable") == "true" && ab.readOnly) {
+ return false;
+ }
+
+ // This condition is used for instance when creating mailing lists
+ if (
+ this.getAttribute("supportsmaillists") == "true" &&
+ !ab.supportsMailingLists
+ ) {
+ return false;
+ }
+
+ return (
+ this.getAttribute(ab.isRemote ? "localonly" : "remoteonly") != "true"
+ );
+ }
+ }
+
+ customElements.define("menulist-addrbooks", MozMenulistAddrbooks, {
+ extends: "menulist",
+ });
+}
diff --git a/comm/mail/components/addrbook/content/vcard-edit/adr.mjs b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
new file mode 100644
index 0000000000..2f395173f3
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ADR
+ */
+export class VCardAdrComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("adr", {}, "text", [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-adr");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.streetEl = this.querySelector('textarea[name="street"]');
+ this.assignIds(this.streetEl, this.querySelector('label[for="street"]'));
+ this.streetEl.addEventListener("input", () => {
+ this.resizeStreetEl();
+ });
+
+ this.localityEl = this.querySelector('input[name="locality"]');
+ this.assignIds(
+ this.localityEl,
+ this.querySelector('label[for="locality"]')
+ );
+
+ this.regionEl = this.querySelector('input[name="region"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="region"]'));
+
+ this.codeEl = this.querySelector('input[name="code"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="code"]'));
+
+ this.countryEl = this.querySelector('input[name="country"]');
+ this.assignIds(this.countryEl, this.querySelector('label[for="country"]'));
+
+ // Create the adr type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.fromVCardPropertyEntryToUI();
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ if (Array.isArray(this.vCardPropertyEntry.value[2])) {
+ this.streetEl.value = this.vCardPropertyEntry.value[2].join("\n");
+ } else {
+ this.streetEl.value = this.vCardPropertyEntry.value[2] || "";
+ }
+ // Per RFC 6350, post office box and extended address SHOULD be empty.
+ let pobox = this.vCardPropertyEntry.value[0] || "";
+ let extendedAddr = this.vCardPropertyEntry.value[1] || "";
+ if (extendedAddr) {
+ this.streetEl.value = this.streetEl.value + "\n" + extendedAddr.trim();
+ delete this.vCardPropertyEntry.value[1];
+ }
+ if (pobox) {
+ this.streetEl.value = pobox.trim() + "\n" + this.streetEl.value;
+ delete this.vCardPropertyEntry.value[0];
+ }
+
+ this.resizeStreetEl();
+ this.localityEl.value = this.vCardPropertyEntry.value[3] || "";
+ this.regionEl.value = this.vCardPropertyEntry.value[4] || "";
+ this.codeEl.value = this.vCardPropertyEntry.value[5] || "";
+ this.countryEl.value = this.vCardPropertyEntry.value[6] || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ let streetValue = this.streetEl.value || "";
+ streetValue = streetValue.trim();
+ if (streetValue.includes("\n")) {
+ streetValue = streetValue.replaceAll("\r", "");
+ streetValue = streetValue.split("\n");
+ }
+
+ this.vCardPropertyEntry.value = [
+ "",
+ "",
+ streetValue,
+ this.localityEl.value || "",
+ this.regionEl.value || "",
+ this.codeEl.value || "",
+ this.countryEl.value || "",
+ ];
+ }
+
+ valueIsEmpty() {
+ return [
+ this.streetEl,
+ this.localityEl,
+ this.regionEl,
+ this.codeEl,
+ this.countryEl,
+ ].every(e => !e.value);
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+
+ resizeStreetEl() {
+ this.streetEl.rows = Math.max(1, this.streetEl.value.split("\n").length);
+ }
+}
+
+customElements.define("vcard-adr", VCardAdrComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/custom.mjs b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
new file mode 100644
index 0000000000..bcdb1f6531
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+export class VCardCustomComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry[]} */
+ vCardPropertyEntries = null;
+ /** @type {HTMLInputElement[]} */
+ inputEls = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-custom");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputEls = this.querySelectorAll("input");
+ let labelEls = this.querySelectorAll("label");
+ for (let i = 0; i < 4; i++) {
+ let inputId = vCardIdGen.next().value;
+ document.l10n.setAttributes(
+ labelEls[i],
+ `about-addressbook-entry-name-custom${i + 1}`
+ );
+ labelEls[i].htmlFor = inputId;
+ this.inputEls[i].id = inputId;
+ }
+ this.fromVCardPropertyEntryToUI();
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-custom").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ for (let i = 0; i < 4; i++) {
+ this.inputEls[i].value = this.vCardPropertyEntries[i].value;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ for (let i = 0; i < 4; i++) {
+ this.vCardPropertyEntries[i].value = this.inputEls[i].value;
+ }
+ }
+}
+
+customElements.define("vcard-custom", VCardCustomComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/edit.mjs b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
new file mode 100644
index 0000000000..90463e33bb
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
@@ -0,0 +1,1094 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+import { VCardAdrComponent } from "./adr.mjs";
+import { VCardCustomComponent } from "./custom.mjs";
+import { VCardEmailComponent } from "./email.mjs";
+import { VCardIMPPComponent } from "./impp.mjs";
+import { VCardNComponent } from "./n.mjs";
+import { VCardFNComponent } from "./fn.mjs";
+import { VCardNickNameComponent } from "./nickname.mjs";
+import { VCardNoteComponent } from "./note.mjs";
+import {
+ VCardOrgComponent,
+ VCardRoleComponent,
+ VCardTitleComponent,
+} from "./org.mjs";
+import { VCardSpecialDateComponent } from "./special-date.mjs";
+import { VCardTelComponent } from "./tel.mjs";
+import { VCardTZComponent } from "./tz.mjs";
+import { VCardURLComponent } from "./url.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardProperties",
+ "resource:///modules/VCardUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+class VCardEdit extends HTMLElement {
+ constructor() {
+ super();
+
+ this.contactNameHeading = document.getElementById("editContactHeadingName");
+ this.contactNickNameHeading = document.getElementById(
+ "editContactHeadingNickName"
+ );
+ this.contactEmailHeading = document.getElementById(
+ "editContactHeadingEmail"
+ );
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.updateView();
+
+ this.addEventListener("vcard-remove-property", e => {
+ if (e.target.vCardPropertyEntries) {
+ for (let entry of e.target.vCardPropertyEntries) {
+ this.vCardProperties.removeEntry(entry);
+ }
+ } else {
+ this.vCardProperties.removeEntry(e.target.vCardPropertyEntry);
+ }
+
+ // Move the focus to the first available valid element of the fieldset.
+ let sibling =
+ e.target.nextElementSibling || e.target.previousElementSibling;
+ // If we got a button, focus it since it's the "add row" button.
+ if (sibling?.type == "button") {
+ sibling.focus();
+ return;
+ }
+
+ // Otherwise we have a row field, so try to find a focusable element.
+ if (sibling && this.moveFocusIntoElement(sibling)) {
+ return;
+ }
+
+ // If we reach this point, the markup was unpredictable and we should
+ // move the focus to a valid element to avoid focus lost.
+ e.target
+ .closest("fieldset")
+ .querySelector(".add-property-button")
+ .focus();
+ });
+ }
+ }
+
+ disconnectedCallback() {
+ this.replaceChildren();
+ }
+
+ get vCardString() {
+ return this._vCardProperties.toVCard();
+ }
+
+ set vCardString(value) {
+ if (value) {
+ try {
+ this.vCardProperties = lazy.VCardProperties.fromVCard(value);
+ return;
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ this.vCardProperties = new lazy.VCardProperties("4.0");
+ }
+
+ get vCardProperties() {
+ return this._vCardProperties;
+ }
+
+ set vCardProperties(value) {
+ this._vCardProperties = value;
+ // If no n property is present set one.
+ if (!this._vCardProperties.getFirstEntry("n")) {
+ this._vCardProperties.addEntry(VCardNComponent.newVCardPropertyEntry());
+ }
+ // If no fn property is present set one.
+ if (!this._vCardProperties.getFirstEntry("fn")) {
+ this._vCardProperties.addEntry(VCardFNComponent.newVCardPropertyEntry());
+ }
+ // If no nickname property is present set one.
+ if (!this._vCardProperties.getFirstEntry("nickname")) {
+ this._vCardProperties.addEntry(
+ VCardNickNameComponent.newVCardPropertyEntry()
+ );
+ }
+ // If no email property is present set one.
+ if (!this._vCardProperties.getFirstEntry("email")) {
+ let emailEntry = VCardEmailComponent.newVCardPropertyEntry();
+ emailEntry.params.pref = "1"; // Set as default email.
+ this._vCardProperties.addEntry(emailEntry);
+ }
+ // If one of the organizational properties is present,
+ // make sure they all are.
+ let title = this._vCardProperties.getFirstEntry("title");
+ let role = this._vCardProperties.getFirstEntry("role");
+ let org = this._vCardProperties.getFirstEntry("org");
+ if (title || role || org) {
+ if (!title) {
+ this._vCardProperties.addEntry(
+ VCardTitleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!role) {
+ this._vCardProperties.addEntry(
+ VCardRoleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!org) {
+ this._vCardProperties.addEntry(
+ VCardOrgComponent.newVCardPropertyEntry()
+ );
+ }
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ this.updateView();
+ }
+
+ updateView() {
+ // Create new DOM and replacing other vCardProperties.
+ let template = document.getElementById("template-addr-book-edit");
+ let clonedTemplate = template.content.cloneNode(true);
+ // Making the next two calls in one go causes a console error to be logged.
+ this.replaceChildren();
+ this.append(clonedTemplate);
+
+ if (!this.vCardProperties) {
+ return;
+ }
+
+ this.addFieldsetActions();
+
+ // Insert the vCard property entries.
+ for (let vCardPropertyEntry of this.vCardProperties.entries) {
+ this.insertVCardElement(vCardPropertyEntry, false);
+ }
+
+ let customProperties = ["x-custom1", "x-custom2", "x-custom3", "x-custom4"];
+ if (customProperties.some(key => this.vCardProperties.getFirstValue(key))) {
+ // If one of these properties has a value, display all of them.
+ let customFieldset = this.querySelector("#addr-book-edit-custom");
+ let customEl =
+ customFieldset.querySelector("vcard-custom") ||
+ new VCardCustomComponent();
+ customEl.vCardPropertyEntries = customProperties.map(key =>
+ this._vCardProperties.getFirstEntry(key)
+ );
+ let addCustom = document.getElementById("vcard-add-custom");
+ customFieldset.insertBefore(customEl, addCustom);
+ addCustom.hidden = true;
+ }
+
+ let nameEl = this.querySelector("vcard-n");
+ this.firstName = nameEl.firstNameEl.querySelector("input");
+ this.lastName = nameEl.lastNameEl.querySelector("input");
+ this.prefixName = nameEl.prefixEl.querySelector("input");
+ this.middleName = nameEl.middleNameEl.querySelector("input");
+ this.suffixName = nameEl.suffixEl.querySelector("input");
+ this.displayName = this.querySelector("vcard-fn").displayEl;
+
+ [
+ this.firstName,
+ this.lastName,
+ this.prefixName,
+ this.middleName,
+ this.suffixName,
+ this.displayName,
+ ].forEach(element => {
+ element.addEventListener("input", event =>
+ this.generateContactName(event)
+ );
+ });
+
+ // Only set the strings and define this selector if we're inside the
+ // address book edit panel.
+ if (document.getElementById("detailsPane")) {
+ this.preferDisplayName = this.querySelector("vcard-fn").preferDisplayEl;
+ document.l10n.setAttributes(
+ this.preferDisplayName.closest(".vcard-checkbox").querySelector("span"),
+ "about-addressbook-prefer-display-name"
+ );
+ }
+
+ this.nickName = this.querySelector("vcard-nickname").nickNameEl;
+ this.nickName.addEventListener("input", () => this.updateNickName());
+
+ if (this.vCardProperties) {
+ this.toggleDefaultEmailView();
+ this.checkForBdayOccurrences();
+ }
+
+ this.updateNickName();
+ this.updateEmailHeading();
+ this.generateContactName();
+ }
+
+ /**
+ * Update the contact name to reflect the users' choice.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ async generateContactName(event = null) {
+ // Don't generate any preview if the contact name element is not available,
+ // which it might happen since this component is used in other areas outside
+ // the address book UI.
+ if (!this.contactNameHeading) {
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ let result = "";
+ let pref = Services.prefs.getIntPref("mail.addr_book.lastnamefirst");
+ switch (pref) {
+ case Ci.nsIAbCard.GENERATE_DISPLAY_NAME:
+ result = this.buildDefaultName();
+ break;
+
+ case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER:
+ if (this.lastName.value) {
+ result = bundle.formatStringFromName("lastFirstFormat", [
+ this.lastName.value,
+ [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ } else {
+ // Get the generic name if we don't have a last name.
+ result = this.buildDefaultName();
+ }
+ break;
+
+ default:
+ result = bundle.formatStringFromName("firstLastFormat", [
+ [this.prefixName.value, this.firstName.value, this.middleName.value]
+ .filter(Boolean)
+ .join(" "),
+ [this.lastName.value, this.suffixName.value]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ break;
+ }
+
+ if (result == "" || result == ", ") {
+ // We don't have anything to show as a contact name, so let's find the
+ // default email and show that, if we have it, otherwise pass an empty
+ // string to remove any leftover data.
+ let email = this.getDefaultEmail();
+ result = email ? email.split("@", 1)[0] : "";
+ }
+
+ this.contactNameHeading.textContent = result;
+ this.fillDisplayName(event);
+ }
+
+ /**
+ * Returns the name to show for this contact if the display name is available
+ * or it generates one from the available N data.
+ *
+ * @returns {string} - The name to show for this contact.
+ */
+ buildDefaultName() {
+ return this.displayName.isDirty
+ ? this.displayName.value
+ : [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.lastName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ }
+
+ /**
+ * Update the nickname value of the contact header.
+ */
+ updateNickName() {
+ // Don't generate any preview if the contact nickname element is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactNickNameHeading) {
+ return;
+ }
+
+ let value = this.nickName.value.trim();
+ this.contactNickNameHeading.hidden = !value;
+ this.contactNickNameHeading.textContent = value;
+ }
+
+ /**
+ * Update the email value of the contact header.
+ *
+ * @param {?string} email - The email value the user is currently typing.
+ */
+ updateEmailHeading(email = null) {
+ // Don't generate any preview if the contact nickname email is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactEmailHeading) {
+ return;
+ }
+
+ // If no email string was passed, it means this method was called when the
+ // view or edit pane refreshes, therefore we need to fetch the correct
+ // default email address.
+ let value = email ?? this.getDefaultEmail();
+ this.contactEmailHeading.hidden = !value;
+ this.contactEmailHeading.textContent = value;
+ }
+
+ /**
+ * Find the default email used for this contact.
+ *
+ * @returns {VCardEmailComponent}
+ */
+ getDefaultEmail() {
+ let emails = document.getElementById("vcard-email").children;
+ if (emails.length == 1) {
+ return emails[0].emailEl.value;
+ }
+
+ let defaultEmail = [...emails].find(
+ el => el.vCardPropertyEntry.params.pref === "1"
+ );
+
+ // If no email is marked as preferred, use the first one.
+ if (!defaultEmail) {
+ defaultEmail = emails[0];
+ }
+
+ return defaultEmail.emailEl.value;
+ }
+
+ /**
+ * Auto fill the display name only if the pref is set, the user is not
+ * editing the display name field, and the field was never edited.
+ * The intention is to prefill while entering a new contact. Don't fill
+ * if we don't have a proper default name to show, but only a placeholder.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ fillDisplayName(event = null) {
+ if (
+ Services.prefs.getBoolPref("mail.addr_book.displayName.autoGeneration") &&
+ event?.originalTarget.id != "vCardDisplayName" &&
+ !this.displayName.isDirty &&
+ this.buildDefaultName()
+ ) {
+ this.displayName.value = this.contactNameHeading.textContent;
+ }
+ }
+
+ /**
+ * Inserts a custom element for a {VCardPropertyEntry}
+ *
+ * - Assigns rich data (not bind to a html attribute) and therefore
+ * the reference.
+ * - Inserts the element in the form at the correct position.
+ *
+ * @param {VCardPropertyEntry} entry
+ * @param {boolean} addEntry Adds the entry to the vCardProperties.
+ * @returns {VCardPropertyEntryView | undefined}
+ */
+ insertVCardElement(entry, addEntry) {
+ // Add the entry to the vCardProperty data.
+ if (addEntry) {
+ this.vCardProperties.addEntry(entry);
+ }
+
+ let fieldset;
+ let addButton;
+ switch (entry.name) {
+ case "n":
+ let n = new VCardNComponent();
+ n.vCardPropertyEntry = entry;
+ fieldset = document.getElementById("addr-book-edit-n");
+ let displayNicknameContainer = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(n, displayNicknameContainer);
+ return n;
+ case "fn":
+ let fn = new VCardFNComponent();
+ fn.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(fn, fieldset.firstElementChild);
+ return fn;
+ case "nickname":
+ let nickname = new VCardNickNameComponent();
+ nickname.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(
+ nickname,
+ fieldset.firstElementChild?.nextElementSibling
+ );
+ return nickname;
+ case "email":
+ let email = document.createElement("tr", { is: "vcard-email" });
+ email.vCardPropertyEntry = entry;
+ document.getElementById("vcard-email").appendChild(email);
+ return email;
+ case "url":
+ let url = new VCardURLComponent();
+ url.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-url");
+ addButton = document.getElementById("vcard-add-url");
+ fieldset.insertBefore(url, addButton);
+ return url;
+ case "tel":
+ let tel = new VCardTelComponent();
+ tel.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tel");
+ addButton = document.getElementById("vcard-add-tel");
+ fieldset.insertBefore(tel, addButton);
+ return tel;
+ case "tz":
+ let tz = new VCardTZComponent();
+ tz.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tz");
+ addButton = document.getElementById("vcard-add-tz");
+ fieldset.insertBefore(tz, addButton);
+ addButton.hidden = true;
+ return tz;
+ case "impp":
+ let impp = new VCardIMPPComponent();
+ impp.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-impp");
+ addButton = document.getElementById("vcard-add-impp");
+ fieldset.insertBefore(impp, addButton);
+ return impp;
+ case "anniversary":
+ let anniversary = new VCardSpecialDateComponent();
+ anniversary.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(anniversary, addButton);
+ return anniversary;
+ case "bday":
+ let bday = new VCardSpecialDateComponent();
+ bday.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(bday, addButton);
+ return bday;
+ case "adr":
+ let address = new VCardAdrComponent();
+ address.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-address");
+ addButton = document.getElementById("vcard-add-adr");
+ fieldset.insertBefore(address, addButton);
+ return address;
+ case "note":
+ let note = new VCardNoteComponent();
+ note.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-note");
+ addButton = document.getElementById("vcard-add-note");
+ fieldset.insertBefore(note, addButton);
+ // Only one note is allowed via UI.
+ addButton.hidden = true;
+ return note;
+ case "title":
+ let title = new VCardTitleComponent();
+ title.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ title,
+ fieldset.querySelector("vcard-role, vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one title is allowed via UI.
+ addButton.hidden = true;
+ return title;
+ case "role":
+ let role = new VCardRoleComponent();
+ role.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ role,
+ fieldset.querySelector("vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one role is allowed via UI.
+ addButton.hidden = true;
+ return role;
+ case "org":
+ let org = new VCardOrgComponent();
+ org.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(org, addButton);
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one org is allowed via UI.
+ addButton.hidden = true;
+ return org;
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Creates a VCardPropertyEntry with a matching
+ * name to the vCard spec.
+ *
+ * @param {string} entryName - A name which should be a vCard spec property.
+ * @returns {VCardPropertyEntry | undefined}
+ */
+ static createVCardProperty(entryName) {
+ switch (entryName) {
+ case "n":
+ return VCardNComponent.newVCardPropertyEntry();
+ case "fn":
+ return VCardFNComponent.newVCardPropertyEntry();
+ case "nickname":
+ return VCardNickNameComponent.newVCardPropertyEntry();
+ case "email":
+ return VCardEmailComponent.newVCardPropertyEntry();
+ case "url":
+ return VCardURLComponent.newVCardPropertyEntry();
+ case "tel":
+ return VCardTelComponent.newVCardPropertyEntry();
+ case "tz":
+ return VCardTZComponent.newVCardPropertyEntry();
+ case "impp":
+ return VCardIMPPComponent.newVCardPropertyEntry();
+ case "bday":
+ return VCardSpecialDateComponent.newBdayVCardPropertyEntry();
+ case "anniversary":
+ return VCardSpecialDateComponent.newAnniversaryVCardPropertyEntry();
+ case "adr":
+ return VCardAdrComponent.newVCardPropertyEntry();
+ case "note":
+ return VCardNoteComponent.newVCardPropertyEntry();
+ case "title":
+ return VCardTitleComponent.newVCardPropertyEntry();
+ case "role":
+ return VCardRoleComponent.newVCardPropertyEntry();
+ case "org":
+ return VCardOrgComponent.newVCardPropertyEntry();
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Mutates the referenced vCardPropertyEntry(s).
+ * If the value of a VCardPropertyEntry is empty, the entry gets
+ * removed from the vCardProperty.
+ */
+ saveVCard() {
+ for (let node of [
+ ...this.querySelectorAll("vcard-adr"),
+ ...this.querySelectorAll("vcard-custom"),
+ ...document.getElementById("vcard-email").children,
+ ...this.querySelectorAll("vcard-fn"),
+ ...this.querySelectorAll("vcard-impp"),
+ ...this.querySelectorAll("vcard-n"),
+ ...this.querySelectorAll("vcard-nickname"),
+ ...this.querySelectorAll("vcard-note"),
+ ...this.querySelectorAll("vcard-org"),
+ ...this.querySelectorAll("vcard-role"),
+ ...this.querySelectorAll("vcard-title"),
+ ...this.querySelectorAll("vcard-special-date"),
+ ...this.querySelectorAll("vcard-tel"),
+ ...this.querySelectorAll("vcard-tz"),
+ ...this.querySelectorAll("vcard-url"),
+ ]) {
+ if (typeof node.fromUIToVCardPropertyEntry === "function") {
+ node.fromUIToVCardPropertyEntry();
+ }
+
+ // Filter out empty fields.
+ if (typeof node.valueIsEmpty === "function" && node.valueIsEmpty()) {
+ this.vCardProperties.removeEntry(node.vCardPropertyEntry);
+ }
+ }
+
+ // If no email has a pref value of 1, set it to the first email.
+ let emailEntries = this.vCardProperties.getAllEntries("email");
+ if (
+ emailEntries.length >= 1 &&
+ emailEntries.every(entry => entry.params.pref !== "1")
+ ) {
+ emailEntries[0].params.pref = "1";
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ let entry = this._vCardProperties.getFirstEntry(`x-custom${i}`);
+ if (entry && !entry.value) {
+ this._vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ /**
+ * Move focus into the form.
+ */
+ setFocus() {
+ this.querySelector("vcard-n input:not([hidden])").focus();
+ }
+
+ /**
+ * Move focus to the first visible form element below the given element.
+ *
+ * @param {Element} element - The element to move focus into.
+ * @returns {boolean} - If the focus was moved into the element.
+ */
+ moveFocusIntoElement(element) {
+ for (let child of element.querySelectorAll(
+ "select,input,textarea,button"
+ )) {
+ // Make sure it is visible.
+ if (child.clientWidth != 0 && child.clientHeight != 0) {
+ child.focus();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Add buttons and further actions of the groupings for vCard property
+ * entries.
+ */
+ addFieldsetActions() {
+ // Add email button.
+ let addEmail = document.getElementById("vcard-add-email");
+ this.registerAddButton(addEmail, "email", () => {
+ this.toggleDefaultEmailView();
+ });
+
+ // Add listener to update the email written in the contact header.
+ this.addEventListener("vcard-email-default-changed", event => {
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ });
+
+ // Add listener to be sure that only one checkbox from the emails is ticked.
+ this.addEventListener("vcard-email-default-checkbox", event => {
+ // Show the newly selected default email in the contact header.
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ for (let vCardEmailComponent of document.getElementById("vcard-email")
+ .children) {
+ if (event.target !== vCardEmailComponent) {
+ vCardEmailComponent.checkboxEl.checked = false;
+ }
+ }
+ });
+
+ // Handling the VCardPropertyEntry change with the select.
+ let specialDatesFieldset = document.getElementById(
+ "addr-book-edit-bday-anniversary"
+ );
+ specialDatesFieldset.addEventListener(
+ "vcard-bday-anniversary-change",
+ event => {
+ let newVCardPropertyEntry = new lazy.VCardPropertyEntry(
+ event.detail.name,
+ event.target.vCardPropertyEntry.params,
+ event.target.vCardPropertyEntry.type,
+ event.target.vCardPropertyEntry.value
+ );
+ this.vCardProperties.removeEntry(event.target.vCardPropertyEntry);
+ event.target.vCardPropertyEntry = newVCardPropertyEntry;
+ this.vCardProperties.addEntry(newVCardPropertyEntry);
+ this.checkForBdayOccurrences();
+ }
+ );
+
+ // Add special date button.
+ let addSpecialDate = document.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.addEventListener("click", e => {
+ let newVCardProperty;
+ if (!this.vCardProperties.getFirstEntry("bday")) {
+ newVCardProperty = VCardEdit.createVCardProperty("bday");
+ } else {
+ newVCardProperty = VCardEdit.createVCardProperty("anniversary");
+ }
+ let el = this.insertVCardElement(newVCardProperty, true);
+ this.checkForBdayOccurrences();
+ this.moveFocusIntoElement(el);
+ });
+
+ // Organizational Properties.
+ let addOrg = document.getElementById("vcard-add-org");
+ addOrg.addEventListener("click", event => {
+ let title = VCardEdit.createVCardProperty("title");
+ let role = VCardEdit.createVCardProperty("role");
+ let org = VCardEdit.createVCardProperty("org");
+
+ let titleEl = this.insertVCardElement(title, true);
+ this.insertVCardElement(role, true);
+ this.insertVCardElement(org, true);
+
+ this.moveFocusIntoElement(titleEl);
+ addOrg.hidden = true;
+ });
+
+ let addAddress = document.getElementById("vcard-add-adr");
+ this.registerAddButton(addAddress, "adr");
+
+ let addURL = document.getElementById("vcard-add-url");
+ this.registerAddButton(addURL, "url");
+
+ let addTel = document.getElementById("vcard-add-tel");
+ this.registerAddButton(addTel, "tel");
+
+ let addTZ = document.getElementById("vcard-add-tz");
+ this.registerAddButton(addTZ, "tz", () => {
+ addTZ.hidden = true;
+ });
+
+ let addIMPP = document.getElementById("vcard-add-impp");
+ this.registerAddButton(addIMPP, "impp");
+
+ let addNote = document.getElementById("vcard-add-note");
+ this.registerAddButton(addNote, "note", () => {
+ addNote.hidden = true;
+ });
+
+ let addCustom = document.getElementById("vcard-add-custom");
+ addCustom.addEventListener("click", event => {
+ let el = new VCardCustomComponent();
+
+ // When the custom properties are deleted and added again ensure that
+ // the properties are set.
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ el.vCardPropertyEntries = [
+ this._vCardProperties.getFirstEntry("x-custom1"),
+ this._vCardProperties.getFirstEntry("x-custom2"),
+ this._vCardProperties.getFirstEntry("x-custom3"),
+ this._vCardProperties.getFirstEntry("x-custom4"),
+ ];
+ addCustom.parentNode.insertBefore(el, addCustom);
+
+ this.moveFocusIntoElement(el);
+ addCustom.hidden = true;
+ });
+
+ // Delete button for Organization Properties. This property has multiple
+ // fields, so we should dispatch the remove event only once after everything
+ // has been removed.
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).addEventListener("click", event => {
+ this.querySelector("vcard-title").remove();
+ this.querySelector("vcard-role").remove();
+ let org = this.querySelector("vcard-org");
+ // Reveal the "Add" button so we can focus it.
+ document.getElementById("vcard-add-org").hidden = false;
+ // Dispatch the event before removing the element so we can handle focus.
+ org.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ org.remove();
+ event.target.hidden = true;
+ });
+ }
+
+ /**
+ * Registers a click event for addButton which creates a new vCardProperty
+ * and inserts it.
+ *
+ * @param {HTMLButtonElement} addButton
+ * @param {string} VCardPropertyName RFC6350 vCard property name.
+ * @param {(vCardElement) => {}} callback For further refinement.
+ * Like different focus instead of an input field.
+ */
+ registerAddButton(addButton, VCardPropertyName, callback) {
+ addButton.addEventListener("click", event => {
+ let newVCardProperty = VCardEdit.createVCardProperty(VCardPropertyName);
+ let el = this.insertVCardElement(newVCardProperty, true);
+
+ this.moveFocusIntoElement(el);
+ if (callback) {
+ callback(el);
+ }
+ });
+ }
+
+ /**
+ * If one BDAY vCardPropertyEntry is present disable
+ * the option to change an Anniversary to a BDAY.
+ *
+ * @see VCardSpecialDateComponent
+ */
+ checkForBdayOccurrences() {
+ let bdayOccurrence = this.vCardProperties.getFirstEntry("bday");
+ this.querySelectorAll("vcard-special-date").forEach(specialDate => {
+ specialDate.birthdayAvailability({ hasBday: !!bdayOccurrence });
+ });
+ }
+
+ /**
+ * Hide the default checkbox if we only have one email field.
+ */
+ toggleDefaultEmailView() {
+ let hideDefault =
+ document.getElementById("vcard-email").children.length <= 1;
+ let defaultColumn = this.querySelector(".default-column");
+ if (defaultColumn) {
+ defaultColumn.hidden = hideDefault;
+ }
+ document.getElementById("addr-book-edit-email-default").hidden =
+ hideDefault;
+
+ // Add class to position legend absolute.
+ document
+ .getElementById("addr-book-edit-email")
+ .classList.toggle("default-table-header", !hideDefault);
+ }
+
+ /**
+ * Validate the form with the minimum required data to save or update a
+ * contact. We can't use the built-in checkValidity() since our fields
+ * are not handled properly by the form element.
+ *
+ * @returns {boolean} - If the form is valid or not.
+ */
+ checkMinimumRequirements() {
+ let hasEmail = [...document.getElementById("vcard-email").children].find(
+ s => {
+ let field = s.querySelector(`input[type="email"]`);
+ return field.value.trim() && field.checkValidity();
+ }
+ );
+ let hasOrg = [...this.querySelectorAll("vcard-org")].find(n =>
+ n.orgEl.value.trim()
+ );
+
+ return (
+ this.firstName.value.trim() ||
+ this.lastName.value.trim() ||
+ this.displayName.value.trim() ||
+ hasEmail ||
+ hasOrg
+ );
+ }
+
+ /**
+ * Validate the special date fields making sure that we have a valid
+ * DATE-AND-OR-TIME. See date, date-noreduc.
+ * That is, valid if any of the fields are valid, but the combination of
+ * only year and day is not valid.
+ *
+ * @returns {boolean} - True all created special date fields are valid.
+ * @see https://datatracker.ietf.org/doc/html/rfc6350#section-4.3.4
+ */
+ validateDates() {
+ for (let field of document.querySelectorAll("vcard-special-date")) {
+ let y = field.querySelector(`input[type="number"][name="year"]`);
+ let m = field.querySelector(`select[name="month"]`);
+ let d = field.querySelector(`select[name="day"]`);
+ if (!y.checkValidity()) {
+ y.focus();
+ return false;
+ }
+ if (y.value && d.value && !m.value) {
+ m.required = true;
+ m.focus();
+ return false;
+ }
+ }
+ return true;
+ }
+}
+customElements.define("vcard-edit", VCardEdit);
+
+/**
+ * Responsible for the type selection of a vCard property.
+ *
+ * Couples the given vCardPropertyEntry with a <select> element.
+ * This is safe because contact editing always creates a new contact, even
+ * when an existing contact is selected for editing.
+ *
+ * @see RFC6350 TYPE
+ */
+class VCardTypeSelectionComponent extends HTMLElement {
+ /**
+ * The select element created by this custom element.
+ *
+ * @type {HTMLSelectElement}
+ */
+ selectEl;
+
+ /**
+ * Initializes the type selector elements to control the given
+ * vCardPropertyEntry.
+ *
+ * @param {VCardPropertyEntry} vCardPropertyEntry - The VCardPropertyEntry
+ * this element should control.
+ * @param {boolean} [options.createLabel] - Whether a Type label should be
+ * created for the selectEl element. If this is not `true`, then the label
+ * for the selectEl should be provided through some other means, such as the
+ * labelledBy property.
+ * @param {string} [options.labelledBy] - Optional `id` of the element that
+ * should label the selectEl element (through aria-labelledby).
+ * @param {string} [options.propertyType] - Specifies the set of types that
+ * should be available and shown for the corresponding property. Set as
+ * "tel" to use the set of telephone types. Otherwise defaults to only using
+ * the `home`, `work` and `(None)` types.
+ */
+ createTypeSelection(vCardPropertyEntry, options) {
+ let template;
+ let types;
+ switch (options.propertyType) {
+ case "tel":
+ types = ["work", "home", "cell", "fax", "pager"];
+ template = document.getElementById("template-vcard-edit-type-tel");
+ break;
+ default:
+ types = ["work", "home"];
+ template = document.getElementById("template-vcard-edit-type");
+ break;
+ }
+
+ let clonedTemplate = template.content.cloneNode(true);
+ this.replaceChildren(clonedTemplate);
+
+ this.selectEl = this.querySelector("select");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+
+ // Just abandon any values we don't have UI for. We don't have any way to
+ // know whether to keep them or not, and they're very rarely used.
+ let paramsType = vCardPropertyEntry.params.type;
+ // toLowerCase is called because other vCard sources are saving the type
+ // in upper case. E.g. from Google.
+ if (Array.isArray(paramsType)) {
+ let lowerCaseTypes = paramsType.map(type => type.toLowerCase());
+ this.selectEl.value = lowerCaseTypes.find(t => types.includes(t)) || "";
+ } else if (paramsType && types.includes(paramsType.toLowerCase())) {
+ this.selectEl.value = paramsType.toLowerCase();
+ }
+
+ // Change the value on the vCardPropertyEntry.
+ this.selectEl.addEventListener("change", e => {
+ if (this.selectEl.value) {
+ vCardPropertyEntry.params.type = this.selectEl.value;
+ } else {
+ delete vCardPropertyEntry.params.type;
+ }
+ });
+
+ // Set an aria-labelledyby on the select.
+ if (options.labelledBy) {
+ if (!document.getElementById(options.labelledBy)) {
+ throw new Error(`No such label element with id ${options.labelledBy}`);
+ }
+ this.querySelector("select").setAttribute(
+ "aria-labelledby",
+ options.labelledBy
+ );
+ }
+
+ // Create a label element for the select.
+ if (options.createLabel) {
+ let labelEl = document.createElement("label");
+ labelEl.htmlFor = selectId;
+ labelEl.setAttribute("data-l10n-id", "vcard-entry-type-label");
+ labelEl.classList.add("screen-reader-only");
+ this.insertBefore(labelEl, this.selectEl);
+ }
+ }
+}
+
+customElements.define("vcard-type", VCardTypeSelectionComponent);
+
+/**
+ * Interface for vCard Fields in the edit view.
+ *
+ * @interface VCardPropertyEntryView
+ */
+
+/**
+ * Getter/Setter for rich data do not use HTMLAttributes for this.
+ * Keep the reference intact through vCardProperties for proper saving.
+ *
+ * @property
+ * @name VCardPropertyEntryView#vCardPropertyEntry
+ */
+
+/**
+ * fromUIToVCardPropertyEntry should directly change data with the reference
+ * through vCardPropertyEntry.
+ * It's there for an action to read the user input values into the
+ * vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromUIToVCardPropertyEntry
+ * @returns {void}
+ */
+
+/**
+ * Updates the UI accordingly to the vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromVCardPropertyEntryToUI
+ * @returns {void}
+ */
+
+/**
+ * Checks if the value of VCardPropertyEntry is empty.
+ *
+ * @function
+ * @name VCardPropertyEntryView#valueIsEmpty
+ * @returns {boolean}
+ */
+
+/**
+ * Creates a new VCardPropertyEntry for usage in the a new Field.
+ *
+ * @function
+ * @name VCardPropertyEntryView#newVCardPropertyEntry
+ * @static
+ * @returns {VCardPropertyEntry}
+ */
diff --git a/comm/mail/components/addrbook/content/vcard-edit/email.mjs b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
new file mode 100644
index 0000000000..751399ac6c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 EMAIL
+ */
+export class VCardEmailComponent extends HTMLTableRowElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ emailEl;
+ /** @type {HTMLInputElement} */
+ checkboxEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("email", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-email");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.emailEl = this.querySelector('input[type="email"]');
+ this.checkboxEl = this.querySelector('input[type="checkbox"]');
+
+ this.emailEl.addEventListener("input", () => {
+ // Dispatch the event only if this field is the currently selected
+ // default/preferred email address.
+ if (this.checkboxEl.checked) {
+ this.dispatchEvent(VCardEmailComponent.EmailEvent());
+ }
+ });
+
+ // Uncheck the checkbox of other VCardEmailComponents if this one is
+ // checked.
+ this.checkboxEl.addEventListener("change", event => {
+ if (event.target.checked === true) {
+ this.dispatchEvent(VCardEmailComponent.CheckboxEvent());
+ }
+ });
+
+ // Create the email type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ labelledBy: "addr-book-edit-email-type",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ document.querySelector("vcard-edit").toggleDefaultEmailView();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.emailEl.value = this.vCardPropertyEntry.value;
+
+ let pref = this.vCardPropertyEntry.params.pref;
+ if (pref === "1") {
+ this.checkboxEl.checked = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.emailEl.value;
+
+ if (this.checkboxEl.checked) {
+ this.vCardPropertyEntry.params.pref = "1";
+ } else if (
+ this.vCardPropertyEntry.params.pref &&
+ this.vCardPropertyEntry.params.pref === "1"
+ ) {
+ // Only delete the pref if a pref of 1 is set and the checkbox is not
+ // checked. The pref mechanic is not fully supported yet. Leave all other
+ // prefs untouched.
+ delete this.vCardPropertyEntry.params.pref;
+ }
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ /**
+ * This event is fired when the checkbox is checked and we need to uncheck the
+ * other checkboxes from each VCardEmailComponent.
+ * FIXME: This should be a radio button part of radiogroup.
+ *
+ * @returns {CustomEvent}
+ */
+ static CheckboxEvent() {
+ return new CustomEvent("vcard-email-default-checkbox", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+
+ /**
+ * This event is fired when the value of an email input field is changed. The
+ * event is fired only if the current email si set as default/preferred.
+ *
+ * @returns {CustomEvent}
+ */
+ static EmailEvent() {
+ return new CustomEvent("vcard-email-default-changed", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+}
+
+customElements.define("vcard-email", VCardEmailComponent, { extends: "tr" });
diff --git a/comm/mail/components/addrbook/content/vcard-edit/fn.mjs b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
new file mode 100644
index 0000000000..446a262f28
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 FN
+ */
+export class VCardFNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ displayEl;
+ /** @type {HTMLElement} */
+ preferDisplayEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("fn", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-fn");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.displayEl = this.querySelector("#vCardDisplayName");
+ this.displayEl.addEventListener(
+ "input",
+ () => {
+ this.displayEl.isDirty = true;
+ },
+ { once: true }
+ );
+ this.preferDisplayEl = this.querySelector("#vCardPreferDisplayName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.displayEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.displayEl.value = this.vCardPropertyEntry.value;
+ this.displayEl.isDirty = !!this.displayEl.value.trim();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.displayEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-fn", VCardFNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
new file mode 100644
index 0000000000..b4ce37bfda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
@@ -0,0 +1,12 @@
+/* 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/. */
+
+function* vCardHtmlIdGen() {
+ let internalId = 0;
+ while (true) {
+ yield `vcard-id-${internalId++}`;
+ }
+}
+
+export let vCardIdGen = vCardHtmlIdGen();
diff --git a/comm/mail/components/addrbook/content/vcard-edit/impp.mjs b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
new file mode 100644
index 0000000000..232925942e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 IMPP
+ */
+export class VCardIMPPComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ imppEl;
+ /** @type {HTMLSelectElement} */
+ protocolEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("impp", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-impp");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.imppEl = this.querySelector('input[name="impp"]');
+ document.l10n
+ .formatValue("vcard-impp-input-title")
+ .then(t => (this.imppEl.title = t));
+
+ this.protocolEl = this.querySelector('select[name="protocol"]');
+ this.protocolEl.id = vCardIdGen.next().value;
+
+ let protocolLabel = this.querySelector('label[for="protocol"]');
+ protocolLabel.htmlFor = this.protocolEl.id;
+
+ this.protocolEl.addEventListener("change", event => {
+ let entered = this.imppEl.value.split(":", 1)[0]?.toLowerCase();
+ if (entered) {
+ this.protocolEl.value =
+ [...this.protocolEl.options].find(o => o.value.startsWith(entered))
+ ?.value || "";
+ }
+ this.imppEl.placeholder = this.protocolEl.value;
+ this.imppEl.pattern = this.protocolEl.selectedOptions[0].dataset.pattern;
+ });
+
+ this.imppEl.id = vCardIdGen.next().value;
+ let imppLabel = this.querySelector('label[for="impp"]');
+ imppLabel.htmlFor = this.imppEl.id;
+ document.l10n.setAttributes(imppLabel, "vcard-impp-label");
+ this.imppEl.addEventListener("change", event => {
+ this.protocolEl.dispatchEvent(new CustomEvent("change"));
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ this.imppEl.dispatchEvent(new CustomEvent("change"));
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.imppEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.imppEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-impp", VCardIMPPComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/n.mjs b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
new file mode 100644
index 0000000000..ae5d386d93
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 N
+ */
+export class VCardNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ prefixEl;
+ /** @type {HTMLElement} */
+ firstNameEl;
+ /** @type {HTMLElement} */
+ middleNameEl;
+ /** @type {HTMLElement} */
+ lastNameEl;
+ /** @type {HTMLElement} */
+ suffixEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-n");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.registerListComponents();
+ this.fromVCardPropertyEntryToUI();
+ this.sortAsOrder();
+ }
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("n", {}, "text", ["", "", "", "", ""]);
+ }
+
+ /**
+ * Assigns the vCardPropertyEntry values to the individual
+ * NListComponentText elements.
+ *
+ * @TODO sort-as param should be used for the order.
+ * The use-case is that not every language has the order of
+ * prefix, firstName, middleName, lastName, suffix.
+ * Aswell that the user is able to change the sorting as he like
+ * on a per contact base.
+ */
+ sortAsOrder() {
+ if (!this.vCardPropertyEntry.params["sort-as"]) {
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ /**
+ * @TODO
+ * The sort-as DOM Mutation
+ */
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let prefixVal = this.vCardPropertyEntry.value[3] || "";
+ let prefixInput = this.prefixEl.querySelector("input");
+ prefixInput.value = prefixVal;
+ if (prefixVal) {
+ this.prefixEl.querySelector("button").hidden = true;
+ } else {
+ this.prefixEl.classList.add("hasButton");
+ this.prefixEl.querySelector("label").hidden = true;
+ prefixInput.hidden = true;
+ }
+
+ // First Name is always shown.
+ this.firstNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[1] || "";
+
+ let middleNameVal = this.vCardPropertyEntry.value[2] || "";
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ middleNameInput.value = middleNameVal;
+ if (middleNameVal) {
+ this.middleNameEl.querySelector("button").hidden = true;
+ } else {
+ this.middleNameEl.classList.add("hasButton");
+ this.middleNameEl.querySelector("label").hidden = true;
+ middleNameInput.hidden = true;
+ }
+
+ // Last Name is always shown.
+ this.lastNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[0] || "";
+
+ let suffixVal = this.vCardPropertyEntry.value[4] || "";
+ let suffixInput = this.suffixEl.querySelector("input");
+ suffixInput.value = suffixVal;
+ if (suffixVal) {
+ this.suffixEl.querySelector("button").hidden = true;
+ } else {
+ this.suffixEl.classList.add("hasButton");
+ this.suffixEl.querySelector("label").hidden = true;
+ suffixInput.hidden = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [
+ this.lastNameEl.querySelector("input").value,
+ this.firstNameEl.querySelector("input").value,
+ this.middleNameEl.querySelector("input").value,
+ this.prefixEl.querySelector("input").value,
+ this.suffixEl.querySelector("input").value,
+ ];
+ }
+
+ valueIsEmpty() {
+ let noEmptyStrings = [
+ this.prefixEl,
+ this.firstNameEl,
+ this.middleNameEl,
+ this.lastNameEl,
+ this.suffixEl,
+ ].filter(node => {
+ return node.querySelector("input").value !== "";
+ });
+ return noEmptyStrings.length === 0;
+ }
+
+ registerListComponents() {
+ this.prefixEl = this.querySelector("#n-list-component-prefix");
+ let prefixInput = this.prefixEl.querySelector("input");
+ let prefixButton = this.prefixEl.querySelector("button");
+ prefixButton.addEventListener("click", e => {
+ this.prefixEl.querySelector("label").hidden = false;
+ prefixInput.hidden = false;
+ prefixButton.hidden = true;
+ this.prefixEl.classList.remove("hasButton");
+ prefixInput.focus();
+ });
+
+ this.firstNameEl = this.querySelector("#n-list-component-firstname");
+
+ this.middleNameEl = this.querySelector("#n-list-component-middlename");
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ let middleNameButton = this.middleNameEl.querySelector("button");
+ middleNameButton.addEventListener("click", e => {
+ this.middleNameEl.querySelector("label").hidden = false;
+ middleNameInput.hidden = false;
+ middleNameButton.hidden = true;
+ this.middleNameEl.classList.remove("hasButton");
+ middleNameInput.focus();
+ });
+
+ this.lastNameEl = this.querySelector("#n-list-component-lastname");
+
+ this.suffixEl = this.querySelector("#n-list-component-suffix");
+ let suffixInput = this.suffixEl.querySelector("input");
+ let suffixButton = this.suffixEl.querySelector("button");
+ suffixButton.addEventListener("click", e => {
+ this.suffixEl.querySelector("label").hidden = false;
+ suffixInput.hidden = false;
+ suffixButton.hidden = true;
+ this.suffixEl.classList.remove("hasButton");
+ suffixInput.focus();
+ });
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.prefixEl = null;
+ this.firstNameEl = null;
+ this.middleNameEl = null;
+ this.lastNameEl = null;
+ this.suffixEl = null;
+ }
+ }
+}
+customElements.define("vcard-n", VCardNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
new file mode 100644
index 0000000000..3622b28997
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 NICKNAME
+ */
+export class VCardNickNameComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLElement} */
+ nickNameEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-nickname");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("nickname", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.nickNameEl = this.querySelector("#vCardNickName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.nickNameEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.nickNameEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.nickNameEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-nickname", VCardNickNameComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/note.mjs b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
new file mode 100644
index 0000000000..f78f4a16d8
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 Note
+ */
+export class VCardNoteComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLTextAreaElement} */
+ textAreaEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("note", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-note");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.textAreaEl = this.querySelector("textarea");
+ this.textAreaEl.addEventListener("input", () => {
+ this.resizeTextAreaEl();
+ });
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-note").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.textAreaEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.textAreaEl.value = this.vCardPropertyEntry.value;
+ this.resizeTextAreaEl();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.textAreaEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ resizeTextAreaEl() {
+ this.textAreaEl.rows = Math.min(
+ 15,
+ Math.max(5, this.textAreaEl.value.split("\n").length)
+ );
+ }
+}
+
+customElements.define("vcard-note", VCardNoteComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/org.mjs b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
new file mode 100644
index 0000000000..fb788c3043
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TITLE
+ */
+export class VCardTitleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ titleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("title", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-title");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.titleEl = this.querySelector('input[name="title"]');
+ this.assignIds(this.titleEl, this.querySelector('label[for="title"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.titleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.titleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.titleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-title", VCardTitleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ROLE
+ */
+export class VCardRoleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ roleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("role", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-role");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.roleEl = this.querySelector('input[name="role"]');
+ this.assignIds(this.roleEl, this.querySelector('label[for="role"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.roleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.roleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.roleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-role", VCardRoleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ORG
+ */
+export class VCardOrgComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLInputElement} */
+ orgEl;
+ /** @type {HTMLInputElement} */
+ unitEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("org", {}, "text", ["", ""]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-org");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.orgEl = this.querySelector('input[name="org"]');
+ this.orgEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="org"]').htmlFor = this.orgEl.id;
+
+ this.unitEl = this.querySelector('input[name="orgUnit"]');
+ this.unitEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="orgUnit"]').htmlFor = this.unitEl.id;
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let values = this.vCardPropertyEntry.value;
+ if (!values) {
+ this.orgEl.value = "";
+ this.unitEl.value = "";
+ return;
+ }
+ if (!Array.isArray(values)) {
+ values = [values];
+ }
+ this.orgEl.value = values.shift() || "";
+ // In case data had more levels of units, just pull them together.
+ this.unitEl.value = values.join(", ");
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [this.orgEl.value.trim()];
+ if (this.unitEl.value.trim()) {
+ this.vCardPropertyEntry.value.push(this.unitEl.value.trim());
+ }
+ }
+
+ valueIsEmpty() {
+ return (
+ !this.vCardPropertyEntry.value ||
+ (Array.isArray(this.vCardPropertyEntry.value) &&
+ this.vCardPropertyEntry.value.every(v => v === ""))
+ );
+ }
+}
+customElements.define("vcard-org", VCardOrgComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
new file mode 100644
index 0000000000..17c7df493b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
@@ -0,0 +1,269 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+/**
+ * ANNIVERSARY and BDAY both have a cardinality of
+ * 1 ("Exactly one instance per vCard MAY be present.").
+ *
+ * For Anniversary we changed the cardinality to
+ * ("One or more instances per vCard MAY be present.")".
+ *
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ANNIVERSARY and BDAY
+ */
+export class VCardSpecialDateComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+ /** @type {HTMLInputElement} */
+ year;
+ /** @type {HTMLSelectElement} */
+ month;
+ /** @type {HTMLSelectElement} */
+ day;
+
+ /**
+ * Object containing the available days for each month.
+ *
+ * @type {object}
+ */
+ monthDays = {
+ 1: 31,
+ 2: 28,
+ 3: 31,
+ 4: 30,
+ 5: 31,
+ 6: 30,
+ 7: 31,
+ 8: 31,
+ 9: 30,
+ 10: 31,
+ 11: 30,
+ 12: 31,
+ };
+
+ static newAnniversaryVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("anniversary", {}, "date", "");
+ }
+
+ static newBdayVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("bday", {}, "date", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById(
+ "template-vcard-edit-bday-anniversary"
+ );
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.selectEl = this.querySelector(".vcard-type-selection");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+ this.querySelector(".vcard-type-label").htmlFor = selectId;
+
+ this.selectEl.addEventListener("change", event => {
+ this.dispatchEvent(
+ VCardSpecialDateComponent.ChangeVCardPropertyEntryEvent(
+ event.target.value
+ )
+ );
+ });
+
+ this.month = this.querySelector("#month");
+ let monthId = vCardIdGen.next().value;
+ this.month.id = monthId;
+ this.querySelector('label[for="month"]').htmlFor = monthId;
+ this.month.addEventListener("change", () => {
+ this.fillDayOptions();
+ });
+
+ this.day = this.querySelector("#day");
+ let dayId = vCardIdGen.next().value;
+ this.day.id = dayId;
+ this.querySelector('label[for="day"]').htmlFor = dayId;
+
+ this.year = this.querySelector("#year");
+ let yearId = vCardIdGen.next().value;
+ this.year.id = yearId;
+ this.querySelector('label[for="year"]').htmlFor = yearId;
+ this.year.addEventListener("input", () => {
+ this.fillDayOptions();
+ });
+
+ document.l10n.formatValues([{ id: "vcard-date-year" }]).then(yearLabel => {
+ this.year.placeholder = yearLabel;
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fillMonthOptions();
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.name;
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ this.hidden = true;
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue;
+ try {
+ dateValue = ICAL.VCardTime.fromDateAndOrTimeString(
+ this.vCardPropertyEntry.value || "",
+ "date-and-or-time"
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ // Always set the month first since that controls the available days.
+ this.month.value = dateValue?.month || "";
+ this.fillDayOptions();
+ this.day.value = dateValue?.day || "";
+ this.year.value = dateValue?.year || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue = new ICAL.VCardTime({}, null, "date");
+ // Set the properties directly instead of using the VCardTime
+ // constructor argument, which causes null values to become 0.
+ dateValue.year = this.year.value ? Number(this.year.value) : null;
+ dateValue.month = this.month.value ? Number(this.month.value) : null;
+ dateValue.day = this.day.value ? Number(this.day.value) : null;
+ this.vCardPropertyEntry.value = dateValue.toString();
+ }
+
+ valueIsEmpty() {
+ return !this.year.value && !this.month.value && !this.day.value;
+ }
+
+ /**
+ * @param {"bday" | "anniversary"} entryName
+ * @returns {CustomEvent}
+ */
+ static ChangeVCardPropertyEntryEvent(entryName) {
+ return new CustomEvent("vcard-bday-anniversary-change", {
+ detail: {
+ name: entryName,
+ },
+ bubbles: true,
+ });
+ }
+
+ /**
+ * Check if the specified year is a leap year in order to add or remove the
+ * extra day to February.
+ *
+ * @returns {boolean} True if the currently specified year is a leap year,
+ * or if no valid year value is available.
+ */
+ isLeapYear() {
+ // If the year is empty, we can't know if it's a leap year so must assume
+ // it is. Otherwise year-less dates can't show Feb 29.
+ if (!this.year.checkValidity() || this.year.value === "") {
+ return true;
+ }
+
+ let year = parseInt(this.year.value);
+ return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
+ }
+
+ fillMonthOptions() {
+ let formatter = Intl.DateTimeFormat(undefined, { month: "long" });
+ for (let m = 1; m <= 12; m++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", m);
+ option.setAttribute("label", formatter.format(new Date(2000, m - 1, 2)));
+ this.month.appendChild(option);
+ }
+ }
+
+ /**
+ * Update the Day select element to reflect the available days of the selected
+ * month.
+ */
+ fillDayOptions() {
+ let prevDay = 0;
+ // Save the previously selected day if we have one.
+ if (this.day.childNodes.length > 1) {
+ prevDay = this.day.value;
+ }
+
+ // Always clear old options.
+ let defaultOption = document.createElement("option");
+ defaultOption.value = "";
+ document.l10n
+ .formatValues([{ id: "vcard-date-day" }])
+ .then(([dayLabel]) => {
+ defaultOption.textContent = dayLabel;
+ });
+ this.day.replaceChildren(defaultOption);
+
+ let monthValue = this.month.value || 1;
+ // Add a day to February if this is a leap year and we're in February.
+ if (monthValue === "2") {
+ this.monthDays["2"] = this.isLeapYear() ? 29 : 28;
+ }
+
+ let formatter = Intl.DateTimeFormat(undefined, { day: "numeric" });
+ for (let d = 1; d <= this.monthDays[monthValue]; d++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", d);
+ option.setAttribute("label", formatter.format(new Date(2000, 0, d)));
+ this.day.appendChild(option);
+ }
+ // Reset the previously selected day, if it's available in the currently
+ // selected month.
+ this.day.value = prevDay <= this.monthDays[monthValue] ? prevDay : "";
+ }
+
+ /**
+ * @param {boolean} options.hasBday
+ */
+ birthdayAvailability(options) {
+ if (this.vCardPropertyEntry.name === "bday") {
+ return;
+ }
+ Array.from(this.selectEl.options).forEach(option => {
+ if (option.value === "bday") {
+ option.disabled = options.hasBday;
+ }
+ });
+ }
+}
+
+customElements.define("vcard-special-date", VCardSpecialDateComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tel.mjs b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
new file mode 100644
index 0000000000..a5eb30c6d5
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TEL
+ *
+ * @TODO missing type-param-tel support.
+ * "text, voice, video, textphone"
+ */
+export class VCardTelComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ inputElement;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tel", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-tel");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputElement = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.inputElement.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ document.l10n.setAttributes(urlLabel, "vcard-tel-label");
+ this.inputElement.type = "tel";
+
+ // Create the tel type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ propertyType: "tel",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.inputElement.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.inputElement.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tel", VCardTelComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tz.mjs b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
new file mode 100644
index 0000000000..cf77114db6
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "cal",
+ "resource:///modules/calendar/calUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardTZComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tz", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-tz");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.selectEl = this.querySelector("select");
+ for (let tzid of lazy.cal.timezoneService.timezoneIds) {
+ let option = this.selectEl.appendChild(
+ document.createElement("option")
+ );
+ option.value = tzid;
+ option.textContent =
+ lazy.cal.timezoneService.getTimezone(tzid).displayName;
+ }
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-tz").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.selectEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.selectEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tz", VCardTZComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/url.mjs b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
new file mode 100644
index 0000000000..98a1b42951
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardURLComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ urlEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("url", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-type-text");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.urlEl = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.urlEl.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ this.urlEl.type = "url";
+ document.l10n.setAttributes(urlLabel, "vcard-url-label");
+
+ this.urlEl.addEventListener("input", () => {
+ // Auto add https:// if the url is missing scheme.
+ if (
+ this.urlEl.value.length > "https://".length &&
+ !/^https?:\/\//.test(this.urlEl.value)
+ ) {
+ this.urlEl.value = "https://" + this.urlEl.value;
+ }
+ });
+
+ // Create the url type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.urlEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.urlEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-url", VCardURLComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
new file mode 100644
index 0000000000..56d53f57f1
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
@@ -0,0 +1,398 @@
+# 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/.
+
+<!-- Styles -->
+<link rel="stylesheet" href="chrome://messenger/skin/vcard.css" />
+
+<!-- Scripts -->
+<script type="module" src="chrome://messenger/content/addressbook/edit/edit.mjs"></script>
+
+<!-- Localization -->
+<link rel="localization" href="messenger/addressbook/vcard.ftl" />
+
+<!-- Edit View -->
+<template id="template-addr-book-edit">
+ <!-- Name -->
+ <fieldset id="addr-book-edit-n" class="addr-book-edit-fieldset fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-name-header"/>
+ <div class="addr-book-edit-display-nickname">
+ </div>
+ </fieldset>
+ <fieldset id="addr-book-edit-email" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-email-header"/>
+ <table>
+ <thead>
+ <tr>
+ <th id="addr-book-edit-email-type" scope="col">
+ <!-- NOTE: We use the <span> so we can apply the screen-reader-only
+ - class to the <span> rather than the <th> element. If we apply
+ - the class to the <th> element directly it causes problems with
+ - Orca's "browse mode" table navigation. See bug 1776644. -->
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-entry-type-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-label" scope="col">
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-email-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-default" scope="col">
+ <span data-l10n-id="vcard-primary-email-label"></span>
+ </th>
+ </tr>
+ </thead>
+ <tbody id="vcard-email"></tbody>
+ </table>
+ <button id="vcard-add-email"
+ data-l10n-id="vcard-email-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- URL -->
+ <fieldset id="addr-book-edit-url" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-url-header"/>
+ <button id="vcard-add-url"
+ data-l10n-id="vcard-url-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Address -->
+ <fieldset id="addr-book-edit-address" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-adr-header"/>
+ <button id="vcard-add-adr"
+ data-l10n-id="vcard-adr-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Tel -->
+ <fieldset id="addr-book-edit-tel" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tel-header"/>
+ <button id="vcard-add-tel"
+ data-l10n-id="vcard-tel-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Time Zone -->
+ <fieldset id="addr-book-edit-tz" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tz-header"/>
+ <button id="vcard-add-tz"
+ data-l10n-id="vcard-tz-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- IMPP (Chat) -->
+ <fieldset id="addr-book-edit-impp" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-impp2-header"/>
+ <button id="vcard-add-impp"
+ data-l10n-id="vcard-impp-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Birthday and Anniversary (Special dates) -->
+ <fieldset id="addr-book-edit-bday-anniversary" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-bday-anniversary-header"/>
+ <button id="vcard-add-bday-anniversary"
+ data-l10n-id="vcard-bday-anniversary-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Notes -->
+ <fieldset id="addr-book-edit-note" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-note-header"/>
+ <button id="vcard-add-note"
+ data-l10n-id="vcard-note-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Organization Info -->
+ <fieldset id="addr-book-edit-org" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-org-header"/>
+ <button id="vcard-add-org"
+ data-l10n-id="vcard-org-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"
+ hidden="hidden"></button>
+ </fieldset>
+ <!-- Custom -->
+ <fieldset id="addr-book-edit-custom" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-custom-header"/>
+ <button id="vcard-add-custom"
+ data-l10n-id="vcard-custom-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+</template>
+
+<!-- Individual fields -->
+
+<!-- N field -->
+<template id="template-vcard-edit-n">
+ <div id="n-list-component-prefix" class="n-list-component">
+ <label for="vcard-n-prefix" data-l10n-id="vcard-n-prefix" />
+ <input id="vcard-n-prefix"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-prefix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-firstname" class="n-list-component">
+ <label for="vcard-n-firstname" data-l10n-id="vcard-n-firstname" />
+ <input id="vcard-n-firstname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-middlename" class="n-list-component">
+ <label for="vcard-n-middlename" data-l10n-id="vcard-n-middlename" />
+ <input id="vcard-n-middlename"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-middlename"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-lastname" class="n-list-component">
+ <label for="vcard-n-lastname" data-l10n-id="vcard-n-lastname" />
+ <input id="vcard-n-lastname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-suffix" class="n-list-component">
+ <label for="vcard-n-suffix" data-l10n-id="vcard-n-suffix" />
+ <button class="primary" data-l10n-id="vcard-n-add-suffix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ <input id="vcard-n-suffix"
+ type="text"
+ autocomplete="off" />
+ </div>
+</template>
+
+<!-- FN field. -->
+<template id="template-vcard-edit-fn">
+ <label for="vCardDisplayName" data-l10n-id="vcard-displayname"></label>
+ <input id="vCardDisplayName" type="text"/>
+ <label id="vCardDisplayNameCheckbox" class="vcard-checkbox">
+ <!-- There is no l10n ID on this element because the vCard edit form is
+ also used in other sections that don't use this checkbox and don't have
+ access to the fluent string. The string is added when needed by the
+ address book edit.js file. -->
+ <input type="checkbox" id="vCardPreferDisplayName" checked="checked" />
+ <!-- SPAN element needed for fluent string. -->
+ <span></span>
+ </label>
+</template>
+
+<!-- NICKNAME field. -->
+<template id="template-vcard-edit-nickname">
+ <label for="vCardNickName" data-l10n-id="vcard-nickname"></label>
+ <input id="vCardNickName" type="text"/>
+</template>
+
+<!-- Email -->
+<template id="template-vcard-edit-email">
+ <td>
+ <vcard-type></vcard-type>
+ </td>
+ <td class="email-column">
+ <input type="email"
+ aria-labelledby="addr-book-edit-email-label" />
+ </td>
+ <td class="default-column">
+ <input type="checkbox"
+ aria-labelledby="addr-book-edit-email-default" />
+ </td>
+ <td>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </td>
+</template>
+
+<!-- Phone -->
+<template id="template-vcard-edit-tel">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Field with type and text -->
+<template id="template-vcard-edit-type-text">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Time Zone -->
+<template id="template-vcard-edit-tz">
+ <select>
+ <option value=""></option>
+ </select>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- IMPP -->
+<template id="template-vcard-edit-impp">
+ <label class="screen-reader-only" for="protocol" data-l10n-id="vcard-impp-select"></label>
+ <select name="protocol" class="vcard-type-selection">
+ <option value="matrix:u/john:example.org" data-pattern="matrix:.+/.+:.+">Matrix</option>
+ <option value="xmpp:john@example.org" data-pattern="xmpp:.+@.+">XMPP</option>
+ <option value="ircs://irc.example.org/john,isuser" data-pattern="ircs?://.+/.+,.+">IRC</option>
+ <option value="sip:1-555-123-4567@voip.example.org" data-pattern="sip:.+@.+">SIP</option>
+ <option value="skype:johndoe" data-pattern="skype:[A-Za-z\d\-\._]{6,32}">Skype</option>
+ <option value="" data-l10n-id="vcard-impp-option-other" data-pattern="..+:..+"></option>
+ </select>
+ <label class="screen-reader-only" for="impp" data-l10n-id="vcard-impp-input-label"></label>
+ <input type="text" name="impp" pattern="..+:..+" />
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Birthday and Anniversary -->
+<template id="template-vcard-edit-bday-anniversary">
+ <label class="vcard-type-label screen-reader-only"
+ data-l10n-id="vcard-entry-type-label"></label>
+ <select class="vcard-type-selection">
+ <option value="bday" data-l10n-id="vcard-bday-label" selected="selected"/>
+ <option value="anniversary" data-l10n-id="vcard-anniversary-label"/>
+ </select>
+
+ <div class="vcard-year-month-day-container">
+ <label class="screen-reader-only" for="year" data-l10n-id="vcard-date-year"></label>
+ <input id="year" name="year" type="number" min="1000" max="9999" pattern="[0-9]{4}" class="size5" />
+
+ <label class="screen-reader-only" for="month" data-l10n-id="vcard-date-month"></label>
+ <select id="month" name="month" class="vcard-month-select">
+ <option value="" data-l10n-id="vcard-date-month" selected="selected"></option>
+ </select>
+
+ <label class="screen-reader-only" for="day" data-l10n-id="vcard-date-day"></label>
+ <select id="day" name="day" class="vcard-day-select">
+ <option value="" data-l10n-id="vcard-date-day" selected="selected"></option>
+ </select>
+
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </div>
+</template>
+
+<!-- Address -->
+<template id="template-vcard-edit-adr">
+ <fieldset class="fieldset-grid fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-adr-label"/>
+ <vcard-type></vcard-type>
+ <div class="vcard-adr-inputs">
+ <label for="street" data-l10n-id="vcard-adr-street"/>
+ <textarea name="street"></textarea>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="locality" data-l10n-id="vcard-adr-locality"/>
+ <input type="text" name="locality"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="region" data-l10n-id="vcard-adr-region"/>
+ <input type="text" name="region"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="code" data-l10n-id="vcard-adr-code"/>
+ <input type="text" name="code"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="country" data-l10n-id="vcard-adr-country"/>
+ <input type="text" name="country"/>
+ </div>
+ </fieldset>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Notes -->
+<template id="template-vcard-edit-note">
+ <textarea></textarea>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Organization Info -->
+<template id="template-vcard-edit-title">
+ <div class="vcard-adr-inputs">
+ <label for="title" data-l10n-id="vcard-org-title"/>
+ <input type="text" data-l10n-id="vcard-org-title-input" name="title" />
+ </div>
+</template>
+<template id="template-vcard-edit-role">
+ <div class="vcard-adr-inputs">
+ <label for="role" data-l10n-id="vcard-org-role"/>
+ <input type="text" data-l10n-id="vcard-org-role-input" name="role" />
+ </div>
+</template>
+<template id="template-vcard-edit-org">
+ <div class="vcard-adr-inputs">
+ <label for="org" data-l10n-id="vcard-org-org" />
+ <input type="text" name="org" data-l10n-id="vcard-org-org-input" />
+ <label for="orgUnit" data-l10n-id="vcard-org-org-unit" class="screen-reader-only"/>
+ <input type="text" name="orgUnit" data-l10n-id="vcard-org-org-unit-input" />
+ </div>
+</template>
+
+<!-- Custom -->
+<template id="template-vcard-edit-custom">
+ <div class="vcard-adr-inputs">
+ <label for="custom1"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom2"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom3"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom4"/>
+ <input type="text"/>
+ </div>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<template id="template-vcard-edit-type">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>
+
+<template id="template-vcard-edit-type-tel">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="cell" data-l10n-id="vcard-entry-type-cell"/>
+ <option value="fax" data-l10n-id="vcard-entry-type-fax"/>
+ <option value="pager" data-l10n-id="vcard-entry-type-pager"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>