summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/addrbook
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/addrbook
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/addrbook')
-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
-rw-r--r--comm/mail/components/addrbook/jar.mn35
-rw-r--r--comm/mail/components/addrbook/moz.build10
-rw-r--r--comm/mail/components/addrbook/test/browser/browser.ini37
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js664
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js143
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js245
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js138
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js470
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_tree.js1261
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_directory_tree.js982
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_card.js1020
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_multiple.js468
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_drag_drop.js417
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_async.js363
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_card.js3517
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_photo.js866
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_ldap_search.js180
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_mailing_lists.js474
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_open_actions.js157
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_search.js139
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_telemetry.js59
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbook.sjs47
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbooks.sjs62
-rw-r--r--comm/mail/components/addrbook/test/browser/data/auth_headers.sjs26
-rw-r--r--comm/mail/components/addrbook/test/browser/data/dns.sjs48
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo1.jpgbin0 -> 36775 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo2.jpgbin0 -> 38826 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/principal.sjs38
-rw-r--r--comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs21
-rw-r--r--comm/mail/components/addrbook/test/browser/data/token.sjs36
-rw-r--r--comm/mail/components/addrbook/test/browser/head.js445
59 files changed, 22936 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>
diff --git a/comm/mail/components/addrbook/jar.mn b/comm/mail/components/addrbook/jar.mn
new file mode 100644
index 0000000000..48d6cc9b2f
--- /dev/null
+++ b/comm/mail/components/addrbook/jar.mn
@@ -0,0 +1,35 @@
+# 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/.
+
+messenger.jar:
+ content/messenger/addressbook/abCommon.js (content/abCommon.js)
+ content/messenger/addressbook/abEditListDialog.xhtml (content/abEditListDialog.xhtml)
+ content/messenger/addressbook/abMailListDialog.xhtml (content/abMailListDialog.xhtml)
+ content/messenger/addressbook/abContactsPanel.xhtml (content/abContactsPanel.xhtml)
+ content/messenger/addressbook/abContactsPanel.js (content/abContactsPanel.js)
+* content/messenger/addressbook/abSearchDialog.xhtml (content/abSearchDialog.xhtml)
+ content/messenger/addressbook/abSearchDialog.js (content/abSearchDialog.js)
+ content/messenger/addressbook/menulist-addrbooks.js (content/menulist-addrbooks.js)
+
+ content/messenger/addressbook/aboutAddressBook.js (content/aboutAddressBook.js)
+* content/messenger/addressbook/aboutAddressBook.xhtml (content/aboutAddressBook.xhtml)
+ content/messenger/addressbook/addressBookTab.js (content/addressBookTab.js)
+# TODO: Rename this after removal of mailnews/addrbook/content/abView.js.
+ content/messenger/addressbook/abView-new.js (content/abView-new.js)
+# Edit view
+ content/messenger/addressbook/edit/adr.mjs (content/vcard-edit/adr.mjs)
+ content/messenger/addressbook/edit/custom.mjs (content/vcard-edit/custom.mjs)
+ content/messenger/addressbook/edit/edit.mjs (content/vcard-edit/edit.mjs)
+ content/messenger/addressbook/edit/email.mjs (content/vcard-edit/email.mjs)
+ content/messenger/addressbook/edit/fn.mjs (content/vcard-edit/fn.mjs)
+ content/messenger/addressbook/edit/impp.mjs (content/vcard-edit/impp.mjs)
+ content/messenger/addressbook/edit/n.mjs (content/vcard-edit/n.mjs)
+ content/messenger/addressbook/edit/nickname.mjs (content/vcard-edit/nickname.mjs)
+ content/messenger/addressbook/edit/note.mjs (content/vcard-edit/note.mjs)
+ content/messenger/addressbook/edit/org.mjs (content/vcard-edit/org.mjs)
+ content/messenger/addressbook/edit/special-date.mjs (content/vcard-edit/special-date.mjs)
+ content/messenger/addressbook/edit/tel.mjs (content/vcard-edit/tel.mjs)
+ content/messenger/addressbook/edit/tz.mjs (content/vcard-edit/tz.mjs)
+ content/messenger/addressbook/edit/url.mjs (content/vcard-edit/url.mjs)
+ content/messenger/addressbook/edit/id-gen.mjs (content/vcard-edit/id-gen.mjs)
diff --git a/comm/mail/components/addrbook/moz.build b/comm/mail/components/addrbook/moz.build
new file mode 100644
index 0000000000..7ca81b6ae6
--- /dev/null
+++ b/comm/mail/components/addrbook/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/addrbook/test/browser/browser.ini b/comm/mail/components/addrbook/test/browser/browser.ini
new file mode 100644
index 0000000000..99d7d9190d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+head = head.js
+prefs =
+ carddav.setup.loglevel=Debug
+ carddav.sync.loglevel=Debug
+ ldap_2.servers.osx.dirType=-1
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.oauth.loglevel=Debug
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ signon.rememberSignons=true
+subsuite = thunderbird
+support-files = data/**
+tags = addrbook
+
+[browser_cardDAV_init.js]
+[browser_cardDAV_oAuth.js]
+tags = oauth
+[browser_cardDAV_properties.js]
+[browser_cardDAV_sync.js]
+[browser_contact_sidebar.js]
+[browser_contact_tree.js]
+[browser_directory_tree.js]
+[browser_display_card.js]
+[browser_display_multiple.js]
+[browser_drag_drop.js]
+[browser_edit_async.js]
+[browser_edit_card.js]
+[browser_edit_photo.js]
+[browser_ldap_search.js]
+support-files = ../../../../../mailnews/addrbook/test/unit/data/ldap_contacts.json
+[browser_mailing_lists.js]
+[browser_open_actions.js]
+[browser_search.js]
+[browser_telemetry.js]
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
new file mode 100644
index 0000000000..36e44a84c7
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
@@ -0,0 +1,664 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+// A list of books returned by CardDAVServer unless changed.
+const DEFAULT_BOOKS = [
+ {
+ label: "Not This One",
+ url: "/addressbooks/me/default/",
+ },
+ {
+ label: "CardDAV Test",
+ url: "/addressbooks/me/test/",
+ },
+];
+
+async function wrappedTest(testInitCallback, ...attemptArgs) {
+ Services.logins.removeAllLogins();
+
+ CardDAVServer.open("alice", "alice");
+ if (testInitCallback) {
+ await testInitCallback();
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ for (let args of attemptArgs) {
+ if (args.url?.startsWith("/")) {
+ args.url = CardDAVServer.origin + args.url;
+ }
+ await attemptInit(dialogWindow, args);
+ }
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ CardDAVServer.resetHandlers();
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "no faulty logins were saved");
+}
+
+async function attemptInit(
+ dialogWindow,
+ {
+ username,
+ url,
+ certError,
+ password,
+ savePassword,
+ expectedStatus = "carddav-connection-error",
+ expectedBooks = [],
+ }
+) {
+ let dialogDocument = dialogWindow.document;
+ let acceptButton = dialogDocument.querySelector("dialog").getButton("accept");
+
+ let usernameInput = dialogDocument.getElementById("carddav-username");
+ let urlInput = dialogDocument.getElementById("carddav-location");
+ let statusMessage = dialogDocument.getElementById("carddav-statusMessage");
+ let availableBooks = dialogDocument.getElementById("carddav-availableBooks");
+
+ if (username) {
+ usernameInput.select();
+ EventUtils.sendString(username, dialogWindow);
+ }
+ if (url) {
+ urlInput.select();
+ EventUtils.sendString(url, dialogWindow);
+ }
+
+ let certPromise =
+ certError === undefined ? Promise.resolve() : handleCertError();
+ let promptPromise =
+ password === undefined
+ ? Promise.resolve()
+ : handlePasswordPrompt(username, password, savePassword);
+
+ acceptButton.click();
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ "carddav-loading",
+ "Correct status message"
+ );
+
+ await certPromise;
+ await promptPromise;
+ await BrowserTestUtils.waitForEvent(dialogWindow, "status-changed");
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ expectedStatus,
+ "Correct status message"
+ );
+
+ Assert.equal(
+ availableBooks.childElementCount,
+ expectedBooks.length,
+ "Expected number of address books found"
+ );
+ for (let i = 0; i < expectedBooks.length; i++) {
+ Assert.equal(availableBooks.children[i].label, expectedBooks[i].label);
+ if (expectedBooks[i].url.startsWith("/")) {
+ Assert.equal(
+ availableBooks.children[i].value,
+ `${CardDAVServer.origin}${expectedBooks[i].url}`
+ );
+ } else {
+ Assert.equal(availableBooks.children[i].value, expectedBooks[i].url);
+ }
+ Assert.ok(availableBooks.children[i].checked);
+ }
+}
+
+function handleCertError() {
+ return BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://pippki/content/exceptionDialog.xhtml"
+ );
+}
+
+function handlePasswordPrompt(expectedUsername, password, savePassword = true) {
+ return BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ async callback(prompt) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == prompt,
+ "waiting for prompt to become active"
+ );
+
+ if (!password) {
+ prompt.document.querySelector("dialog").getButton("cancel").click();
+ return;
+ }
+
+ if (expectedUsername) {
+ Assert.equal(
+ prompt.document.getElementById("loginTextbox").value,
+ expectedUsername
+ );
+ } else {
+ prompt.document.getElementById("loginTextbox").value = "alice";
+ }
+ prompt.document.getElementById("password1Textbox").value = password;
+
+ let checkbox = prompt.document.getElementById("checkbox");
+ Assert.greater(checkbox.getBoundingClientRect().width, 0);
+ Assert.ok(checkbox.checked);
+
+ if (!savePassword) {
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prompt);
+ Assert.ok(!checkbox.checked);
+ }
+
+ prompt.document.querySelector("dialog").getButton("accept").click();
+ },
+ });
+}
+
+/** Test URLs that don't respond. */
+add_task(function testBadURLs() {
+ return wrappedTest(
+ null,
+ { url: "mochi.test:8888" },
+ { url: "http://mochi.test:8888" },
+ { url: "https://mochi.test:8888" }
+ );
+});
+
+/** Test a server with a certificate problem. */
+add_task(function testBadSSL() {
+ return wrappedTest(null, {
+ url: "https://expired.example.com/",
+ certError: true,
+ });
+});
+
+/** Test an ordinary HTTP server that doesn't support CardDAV. */
+add_task(function testNotACardDAVServer() {
+ return wrappedTest(
+ () => {
+ CardDAVServer.server.registerPathHandler("/", null);
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null);
+ },
+ {
+ url: "/",
+ }
+ );
+});
+
+/** Test a CardDAV server without the /.well-known/carddav response. */
+add_task(function testNoWellKnown() {
+ return wrappedTest(
+ () =>
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null),
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test cancelling the password prompt when it appears. */
+add_task(function testPasswordCancelled() {
+ return wrappedTest(null, {
+ url: "/",
+ password: null,
+ });
+});
+
+/** Test entering the wrong password, then retrying with the right one. */
+add_task(function testBadPassword() {
+ return wrappedTest(
+ null,
+ {
+ url: "/",
+ password: "bob",
+ },
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering the full URL of a book links to (only) that book. */
+add_task(function testDirectLink() {
+ return wrappedTest(null, {
+ url: "/addressbooks/me/test/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[1]],
+ });
+});
+
+/** Test that entering only a username finds the right URL. */
+add_task(function testEmailGoodPreset() {
+ return wrappedTest(
+ async () => {
+ // The server is open but we need it on a specific port.
+ await CardDAVServer.close();
+ CardDAVServer.open("alice@test.invalid", "alice", 9999);
+ },
+ {
+ username: "alice@test.invalid",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering only a bad username fails appropriately. */
+add_task(function testEmailBadPreset() {
+ return wrappedTest(null, {
+ username: "alice@bad.invalid",
+ expectedStatus: "carddav-known-incompatible",
+ });
+});
+
+/**
+ * Test that we correctly use DNS discovery. This uses the mochitest server
+ * (files in the data directory) instead of CardDAVServer because the latter
+ * can't speak HTTPS, and we only do DNS discovery for HTTPS.
+ */
+add_task(async function testDNS() {
+ let _srv = DNS.srv;
+ let _txt = DNS.txt;
+
+ DNS.srv = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ };
+ DNS.txt = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [
+ {
+ data: "path=/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs",
+ },
+ ];
+ };
+
+ let abWindow = await openAddressBookWindow();
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ username: "carol@dnstest.invalid",
+ password: "carol",
+ expectedStatus: null,
+ expectedBooks: [
+ {
+ label: "You found me!",
+ url: "https://example.org/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs",
+ },
+ ],
+ });
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test doing everything correctly, including creating the directory and
+ * doing the initial sync.
+ */
+add_task(async function testEveryThingOK() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests adding a second directory on the same server. The auth prompt should
+ * show again, even though we've saved the credentials in the previous test.
+ */
+add_task(async function testEveryThingOKAgain() {
+ // Ensure at least a second has passed since the previous test, since we use
+ // context identifiers based on the current time in seconds.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[0]],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.altURL
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 5);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(3).querySelector(".bookRow-name")
+ .textContent,
+ "Not This One"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 3, "new book got selected");
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let otherDirectory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ await promiseDirectoryRemoved(directory.URI);
+ await promiseDirectoryRemoved(otherDirectory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Test setting up a directory but not saving the password. The username
+ * should be saved and no further password prompt should appear. We can't test
+ * restarting Thunderbird but if we could the password prompt would appear
+ * next time the directory makes a request.
+ */
+add_task(async function testNoSavePassword() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ savePassword: false,
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 0, "login was NOT saved");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Disable sync as we're going to start the address book manager again.
+ directory.setIntValue("carddav.syncinterval", 0);
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests saving a previously unsaved password. This uses the directory from
+ * the previous test and simulates a restart of the address book manager.
+ */
+add_task(async function testSavePasswordLater() {
+ let reloadPromise = TestUtils.topicObserved("addrbook-reloaded");
+ Services.obs.notifyObservers(null, "addrbook-reload");
+ await reloadPromise;
+
+ Assert.equal(MailServices.ab.directories.length, 3);
+ let directory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ let promptPromise = handlePasswordPrompt("alice", "alice");
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ davDirectory.fetchAllFromServer();
+ await promptPromise;
+ await syncPromise;
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice",
+ "username was saved"
+ );
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ await CardDAVServer.close();
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Tests that an address book can still be created if the server returns no
+ * name. The hostname of the server is used instead.
+ */
+add_task(async function testNoName() {
+ CardDAVServer._books = CardDAVServer.books;
+ CardDAVServer.books = { "/addressbooks/me/noname/": undefined };
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [{ label: "noname", url: "/addressbooks/me/noname/" }],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ `${CardDAVServer.origin}/addressbooks/me/noname/`
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "noname"
+ );
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+ CardDAVServer.books = CardDAVServer._books;
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
new file mode 100644
index 0000000000..137a13e221
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+// Creates address books in various configurations (current and legacy) and
+// performs requests in each of them to prove that OAuth2 authentication is
+// working as expected.
+
+var { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+
+var LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+const PATH = "comm/mail/components/addrbook/test/browser/data/";
+const URL = `http://mochi.test:8888/browser/${PATH}`;
+
+/**
+ * Set a string pref for the given directory.
+ *
+ * @param {string} dirPrefId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(dirPrefId, key, value) {
+ Services.prefs.setStringPref(`ldap_2.servers.${dirPrefId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm,
+ * username, and password.
+ */
+function setLogins(...logins) {
+ Services.logins.removeAllLogins();
+ for (let [origin, realm, username, password] of logins) {
+ Services.logins.addLogin(
+ new LoginInfo(origin, null, realm, username, password, "", "")
+ );
+ }
+}
+
+/**
+ * Create a directory with the given id, perform a request, and check that the
+ * correct authorisation header was used. If the user is required to
+ * re-authenticate with the provider, check that the new token is stored in the
+ * right place.
+ *
+ * @param {string} dirPrefId - Pref ID of the new directory.
+ * @param {string} uid - UID of the new directory.
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen
+ * and the new token stored with this user name.
+ */
+async function subtest(dirPrefId, uid, newTokenUsername) {
+ let directory = new CardDAVDirectory();
+ directory._dirPrefId = dirPrefId;
+ directory._uid = uid;
+ directory.__prefBranch = Services.prefs.getBranch(
+ `ldap_2.servers.${dirPrefId}.`
+ );
+ directory.__prefBranch.setStringPref("carddav.url", URL);
+
+ let response = await directory._makeRequest("auth_headers.sjs");
+ Assert.equal(response.status, 200);
+ let headers = JSON.parse(response.text);
+
+ if (newTokenUsername) {
+ Assert.equal(headers.authorization, "Bearer new_access_token");
+
+ let logins = Services.logins
+ .findLogins(ORIGIN, null, SCOPE)
+ .filter(l => l.username == newTokenUsername);
+ Assert.equal(logins.length, 1);
+ Assert.equal(logins[0].username, newTokenUsername);
+ Assert.equal(logins[0].password, "new_refresh_token");
+ } else {
+ Assert.equal(headers.authorization, "Bearer bobs_access_token");
+ }
+
+ Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username set. */
+add_task(function testAddressBookOAuth_uid_none() {
+ let dirPrefId = "uid_none";
+ let uid = "testAddressBookOAuth_uid_none";
+ return subtest(dirPrefId, uid, uid);
+});
+
+// Test making a request when there IS a matching token, but the server rejects
+// it. Currently a new token is not requested on failure.
+
+/** Expired token stored with UID. */
+add_task(function testAddressBookOAuth_uid_expired() {
+ let dirPrefId = "uid_expired";
+ let uid = "testAddressBookOAuth_uid_expired";
+ setLogins([ORIGIN, SCOPE, uid, "expired_token"]);
+ return subtest(dirPrefId, uid, uid);
+}).skip(); // Broken.
+
+// Test making a request with a valid token.
+
+/** Valid token stored with UID. This is the old way of storing the token. */
+add_task(function testAddressBookOAuth_uid_valid() {
+ let dirPrefId = "uid_valid";
+ let uid = "testAddressBookOAuth_uid_valid";
+ setLogins([ORIGIN, SCOPE, uid, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testAddressBookOAuth_username_validSingle() {
+ let dirPrefId = "username_validSingle";
+ let uid = "testAddressBookOAuth_username_validSingle";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins(
+ [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+ [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+ );
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, many scopes. */
+add_task(function testAddressBookOAuth_username_validMultiple() {
+ let dirPrefId = "username_validMultiple";
+ let uid = "testAddressBookOAuth_username_validMultiple";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
new file mode 100644
index 0000000000..0acd0b3540
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
@@ -0,0 +1,245 @@
+/* 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/. */
+
+/**
+ * Tests CardDAV properties dialog.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ const INTERVAL_PREF = "ldap_2.servers.props.carddav.syncinterval";
+ const TOKEN_PREF = "ldap_2.servers.props.carddav.token";
+ const TOKEN_VALUE = "http://mochi.test/sync/0";
+ const URL_PREF = "ldap_2.servers.props.carddav.url";
+ const URL_VALUE = "https://mochi.test/carddav/test";
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "props",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.props");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ registerCleanupFunction(async () => {
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+ });
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setIntPref(INTERVAL_PREF, 0);
+ Services.prefs.setStringPref(TOKEN_PREF, TOKEN_VALUE);
+ Services.prefs.setStringPref(URL_PREF, URL_VALUE);
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory._syncToken, TOKEN_VALUE);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ openDirectory(directory);
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(directory.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextProperties");
+
+ let subtest = async function (expectedValues, newValues, buttonAction) {
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList.getRowAtIndex(2),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+
+ Assert.ok(BrowserTestUtils.is_visible(menuItem));
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("carddav-name");
+ Assert.equal(nameInput.value, expectedValues.name);
+ if ("name" in newValues) {
+ nameInput.value = newValues.name;
+ }
+
+ let urlInput = dialogDocument.getElementById("carddav-url");
+ Assert.equal(urlInput.value, expectedValues.url);
+ if ("url" in newValues) {
+ urlInput.value = newValues.url;
+ }
+
+ let refreshActiveInput = dialogDocument.getElementById(
+ "carddav-refreshActive"
+ );
+ let refreshIntervalInput = dialogDocument.getElementById(
+ "carddav-refreshInterval"
+ );
+
+ Assert.equal(refreshActiveInput.checked, expectedValues.refreshActive);
+ Assert.equal(
+ refreshIntervalInput.disabled,
+ !expectedValues.refreshActive
+ );
+ if (
+ "refreshActive" in newValues &&
+ newValues.refreshActive != expectedValues.refreshActive
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ refreshActiveInput,
+ {},
+ dialogWindow
+ );
+ Assert.equal(refreshIntervalInput.disabled, !newValues.refreshActive);
+ }
+
+ Assert.equal(refreshIntervalInput.value, expectedValues.refreshInterval);
+ if ("refreshInterval" in newValues) {
+ refreshIntervalInput.value = newValues.refreshInterval;
+ }
+
+ let readOnlyInput = dialogDocument.getElementById("carddav-readOnly");
+
+ Assert.equal(readOnlyInput.checked, expectedValues.readOnly);
+ if ("readOnly" in newValues) {
+ readOnlyInput.checked = newValues.readOnly;
+ }
+
+ dialogDocument.querySelector("dialog").getButton(buttonAction).click();
+ });
+ menu.activateItem(menuItem);
+ await dialogPromise;
+
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ };
+
+ info("Open the dialog and cancel it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "cancel"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and change the values.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {
+ name: "CardDAV Properties Test",
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.equal(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "same sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and change the interval.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ { refreshInterval: 60 },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 60);
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "new sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ await promiseDirectoryRemoved(directory.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
new file mode 100644
index 0000000000..1c4e4fb07a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
@@ -0,0 +1,138 @@
+/* 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/. */
+
+/**
+ * Tests CardDAV synchronization.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ CardDAVServer.open();
+ registerCleanupFunction(async () => {
+ await CardDAVServer.close();
+ });
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "sync",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.sync");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.token",
+ "http://mochi.test/sync/0"
+ );
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.url",
+ CardDAVServer.url
+ );
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, CardDAVServer.url);
+ Assert.equal(davDirectory._syncToken, "http://mochi.test/sync/0");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ // This test becomes unreliable if we don't pause for a moment.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 500));
+
+ openDirectory(directory);
+ checkNamesListed();
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextSynchronize");
+ let openContext = async (index, itemHidden) => {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.booksList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+ Assert.equal(menuItem.hidden, itemHidden);
+ };
+
+ for (let index of [1, 3]) {
+ await openContext(index, true);
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ CardDAVServer.putCardInternal(
+ "first.vcf",
+ "BEGIN:VCARD\r\nUID:first\r\nFN:First\r\nEND:VCARD\r\n"
+ );
+
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+
+ let syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.notEqual(davDirectory._syncTimer, null, "first sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First");
+
+ CardDAVServer.putCardInternal(
+ "second.vcf",
+ "BEGIN:VCARD\r\nUID:second\r\nFN:Second\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "second sync not the same as the first"
+ );
+ currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First", "Second");
+
+ CardDAVServer.deleteCardInternal("second.vcf");
+ CardDAVServer.putCardInternal(
+ "third.vcf",
+ "BEGIN:VCARD\r\nUID:third\r\nFN:Third\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "third sync not the same as the second"
+ );
+
+ checkNamesListed("First", "Third");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(directory.URI);
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
new file mode 100644
index 0000000000..3fb0f70b25
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
@@ -0,0 +1,470 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+add_task(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let book1 = createAddressBook("Book 1");
+ book1.addCard(createContact("daniel", "test"));
+ book1.addCard(createContact("jonathan", "test"));
+ book1.addCard(createContact("năthån", "test"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addCard(createContact("danielle", "test"));
+ book2.addCard(createContact("katherine", "test"));
+ book2.addCard(createContact("natalie", "test"));
+ book2.addCard(createContact("sūsãnáh", "test"));
+
+ let list = createMailingList("pèóplë named tēst");
+ book2.addMailList(list);
+
+ registerCleanupFunction(async function () {
+ MailServices.accounts.removeAccount(account, true);
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+ });
+
+ // Open a compose window.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+ let ccAddrInput = composeDocument.getElementById("ccAddrInput");
+ let ccAddrRow = composeDocument.getElementById("addressRowCc");
+ let bccAddrInput = composeDocument.getElementById("bccAddrInput");
+ let bccAddrRow = composeDocument.getElementById("addressRowBcc");
+
+ // The compose window waits before deciding whether to open the sidebar.
+ // We must wait longer.
+ await new Promise(resolve => composeWindow.setTimeout(resolve, 100));
+
+ // Make sure the contacts sidebar is open.
+
+ let sidebar = composeDocument.getElementById("contactsSidebar");
+ if (BrowserTestUtils.is_hidden(sidebar)) {
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ }
+ let sidebarBrowser = composeDocument.getElementById("contactsBrowser");
+ await TestUtils.waitForCondition(
+ () =>
+ sidebarBrowser.currentURI.spec.includes("abContactsPanel.xhtml") &&
+ sidebarBrowser.contentDocument.readyState == "complete"
+ );
+ let sidebarWindow = sidebarBrowser.contentWindow;
+ let sidebarDocument = sidebarBrowser.contentDocument;
+
+ let abList = sidebarDocument.getElementById("addressbookList");
+ let searchBox = sidebarDocument.getElementById("peopleSearchInput");
+ let cardsList = sidebarDocument.getElementById("abResultsTree");
+ let cardsContext = sidebarDocument.getElementById("cardProperties");
+ let toButton = sidebarDocument.getElementById("toButton");
+ let ccButton = sidebarDocument.getElementById("ccButton");
+ let bccButton = sidebarDocument.getElementById("bccButton");
+
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ Assert.equal(cardsList.view.selection.count, 0, "no contact selected");
+ Assert.ok(toButton.disabled, "to button disabled with no contact selected");
+ Assert.ok(ccButton.disabled, "cc button disabled with no contact selected");
+ Assert.ok(bccButton.disabled, "bcc button disabled with no contact selected");
+
+ function clickOnRow(row, event) {
+ mailTestUtils.treeClick(
+ EventUtils,
+ sidebarWindow,
+ cardsList,
+ row,
+ 0,
+ event
+ );
+ }
+
+ async function doMenulist(value) {
+ let shownPromise = BrowserTestUtils.waitForEvent(abList, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(abList, {}, sidebarWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(abList, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(
+ abList.querySelector(`[value="${value}"]`),
+ {},
+ sidebarWindow
+ );
+ await hiddenPromise;
+ }
+
+ async function doContextMenu(row, command) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="${command}"]`)
+ );
+ await hiddenPromise;
+ }
+
+ function checkListNames(expectedNames, message) {
+ let actualNames = [];
+ for (let row = 0; row < cardsList.view.rowCount; row++) {
+ actualNames.push(
+ cardsList.view.getCellText(row, cardsList.columns.GeneratedName)
+ );
+ }
+
+ Assert.deepEqual(actualNames, expectedNames, message);
+ }
+
+ function checkPills(row, expectedPills) {
+ let actualPills = Array.from(
+ row.querySelectorAll("mail-address-pill"),
+ p => p.label
+ );
+ Assert.deepEqual(
+ actualPills,
+ expectedPills,
+ "message recipients match expected"
+ );
+ }
+
+ function clearPills() {
+ for (let input of [toAddrInput, ccAddrInput, bccAddrInput]) {
+ EventUtils.synthesizeMouseAtCenter(input, {}, composeWindow);
+ EventUtils.synthesizeKey(
+ "a",
+ {
+ accelKey: AppConstants.platform == "macosx",
+ ctrlKey: AppConstants.platform != "macosx",
+ },
+ composeWindow
+ );
+ EventUtils.synthesizeKey("KEY_Delete", {}, composeWindow);
+ }
+ checkPills(toAddrRow, []);
+ checkPills(ccAddrRow, []);
+ checkPills(bccAddrRow, []);
+ }
+
+ async function inABEditingMode() {
+ let topWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let abWindow = await topWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+ let tabmail = topWindow.document.getElementById("tabmail");
+ let tab = tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+ tabmail.closeTab(tab);
+ }
+
+ /**
+ * Make sure the "edit contact" menuitem only shows up for the correct
+ * contacts, and it properly opens the address book tab.
+ *
+ * @param {int} row - The row index to activate.
+ * @param {boolean} isEditable - If the selected contact should be editable.
+ */
+ async function checkEditContact(row, isEditable) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+
+ Assert.equal(
+ cardsContext.querySelector("#abContextBeforeEditContact").hidden,
+ !isEditable
+ );
+ Assert.equal(
+ cardsContext.querySelector("#abContextEditContact").hidden,
+ !isEditable
+ );
+
+ // If it's an editable row, we should see the edit contact menu items.
+ if (isEditable) {
+ cardsContext.activateItem(
+ cardsContext.querySelector("#abContextEditContact")
+ );
+ await hiddenPromise;
+ await inABEditingMode();
+ composeWindow.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ } else {
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="cmd_addrBcc"]`)
+ );
+ await hiddenPromise;
+ }
+ }
+
+ // Click on a contact and make sure is editable.
+ await checkEditContact(2, true);
+ // Click on a mailing list and make sure is NOT editable.
+ await checkEditContact(6, false);
+
+ // Check that the address book picker works.
+
+ await doMenulist(book1.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ ["daniel test", "jonathan test", "năthån test"],
+ "book1 contacts are shown"
+ );
+
+ await doMenulist(book2.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 3);
+ checkListNames(
+ [
+ "danielle test",
+ "katherine test",
+ "natalie test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "book2 contacts are shown"
+ );
+
+ await doMenulist("moz-abdirectory://?");
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 5);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the search works.
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, sidebarWindow);
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("dan", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ ["daniel test", "danielle test"],
+ "matching contacts are shown"
+ );
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("kat", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 2);
+ checkListNames(["katherine test"], "matching contacts are shown");
+
+ EventUtils.synthesizeKey("KEY_Escape", { accelKey: true }, sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 1);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that double-clicking works.
+
+ clickOnRow(1, { clickCount: 2 });
+ checkPills(toAddrRow, ["danielle test <danielle.test@invalid>"]);
+
+ clickOnRow(3, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ ]);
+
+ clickOnRow(6, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ "pèóplë named tēst <pèóplë named tēst>",
+ ]);
+
+ clearPills();
+
+ // Check that drag and drop to the recipients section works.
+
+ clickOnRow(5, {});
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList,
+ toAddrInput,
+ null,
+ null,
+ sidebarWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+ checkPills(toAddrRow, ["năthån test <năthån.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the "Add to" buttons work.
+
+ clickOnRow(7, {});
+
+ Assert.ok(!toButton.disabled, "to button enabled with a contact selected");
+ Assert.ok(!ccButton.disabled, "cc button enabled with a contact selected");
+ Assert.ok(!bccButton.disabled, "bcc button enabled with a contact selected");
+
+ EventUtils.synthesizeMouseAtCenter(toButton, {}, sidebarWindow);
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ clickOnRow(0, {});
+ EventUtils.synthesizeMouseAtCenter(ccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(ccAddrRow), "cc row visible");
+ checkPills(ccAddrRow, ["daniel test <daniel.test@invalid>"]);
+
+ clickOnRow(2, {});
+ EventUtils.synthesizeMouseAtCenter(bccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(bccAddrRow), "bcc row visible");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the context menu works.
+
+ await doContextMenu(7, "cmd_addrTo");
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ await doContextMenu(4, "cmd_addrCc");
+ checkPills(ccAddrRow, ["natalie test <natalie.test@invalid>"]);
+
+ await doContextMenu(2, "cmd_addrBcc");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "daniel test"
+ );
+ doContextMenu(0, "cmd_delete");
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ [
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the keyboard commands work.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "danielle test"
+ );
+ clickOnRow(0, {});
+ EventUtils.synthesizeKey("KEY_Delete", {}, sidebarWindow);
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 7);
+ checkListNames(
+ [
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // TODO sidebar context menu
+
+ // Close the compose window and clean up.
+
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_hidden(sidebar));
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let closePromise = BrowserTestUtils.windowClosed(composeWindow);
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+ await closePromise;
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_tree.js b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
new file mode 100644
index 0000000000..f502fe855a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
@@ -0,0 +1,1261 @@
+/* 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 rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let menu = abWindow.document.getElementById("cardContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed, or not
+ * displayed if they happen outside the current address book.
+ */
+add_task(async function test_additions_and_removals() {
+ async function deleteRowWithPrompt(index) {
+ let promptPromise = BrowserTestUtils.promiseAlertDialogOpen("accept");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await new Promise(r => abWindow.setTimeout(r));
+ await new Promise(r => abWindow.setTimeout(r));
+ }
+
+ let bookA = createAddressBook("book A");
+ let contactA1 = bookA.addCard(createContact("contact", "A1"));
+ let bookB = createAddressBook("book B");
+ let contactB1 = bookB.addCard(createContact("contact", "B1"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ await openAllAddressBooks();
+ info("Performing check #1");
+ checkCardsListed(contactA1, contactB1);
+
+ // While in bookA, add a contact and list. Check that they show up.
+ openDirectory(bookA);
+ checkCardsListed(contactA1);
+ let contactA2 = bookA.addCard(createContact("contact", "A2")); // Add A2.
+ checkCardsListed(contactA1, contactA2);
+ let listC = bookA.addMailList(createMailingList("list C")); // Add C.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ listC.addCard(contactA1);
+ checkCardsListed(contactA1, contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #2");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in listC, add a member and remove a member. Check that they show up
+ // or disappear as appropriate.
+ openDirectory(listC);
+ checkCardsListed(contactA1);
+ listC.addCard(contactA2);
+ checkCardsListed(contactA1, contactA2);
+ await deleteRowWithPrompt(0);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+
+ await openAllAddressBooks();
+ info("Performing check #3");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in bookA, delete a contact. Check it disappears.
+ openDirectory(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ await deleteRowWithPrompt(0); // Delete A1.
+ checkCardsListed(contactA2, listC);
+ Assert.equal(cardsList.currentIndex, 0);
+ // Now do some things in an unrelated book. Check nothing changes here.
+ let contactB2 = bookB.addCard(createContact("contact", "B2")); // Add B2.
+ checkCardsListed(contactA2, listC);
+ let listD = bookB.addMailList(createMailingList("list D")); // Add D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ listD.addCard(contactB1);
+ checkCardsListed(contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #4");
+ checkCardsListed(contactA2, contactB1, contactB2, listC, listD);
+
+ // While in listC, do some things in an unrelated list. Check nothing
+ // changes here.
+ openDirectory(listC);
+ checkCardsListed(contactA2);
+ listD.addCard(contactB2);
+ checkCardsListed(contactA2);
+ listD.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+ bookB.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+
+ await openAllAddressBooks();
+ info("Performing check #5");
+ checkCardsListed(contactA2, contactB2, listC, listD);
+
+ // While in bookA, do some things in an unrelated book. Check nothing
+ // changes here.
+ openDirectory(bookA);
+ checkCardsListed(contactA2, listC);
+ bookB.deleteDirectory(listD); // Delete D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ await deleteRowWithPrompt(1); // Delete C.
+ checkCardsListed(contactA2);
+
+ // While in "All Address Books", make some changes and check that things
+ // appear or disappear as appropriate.
+ await openAllAddressBooks();
+ info("Performing check #6");
+ checkCardsListed(contactA2, contactB2);
+ let listE = bookB.addMailList(createMailingList("list E")); // Add E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.addCard(contactB2);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.deleteCards([contactB2]);
+ checkCardsListed(contactA2, contactB2, listE);
+ bookB.deleteDirectory(listE); // Delete E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2);
+ await deleteRowWithPrompt(1);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+ bookA.deleteCards([contactA2]);
+ checkCardsListed();
+ Assert.equal(cardsList.currentIndex, -1);
+
+ // While in "All Address Books", delete a directory that has contacts and
+ // mailing lists. They should disappear.
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add A3.
+ checkCardsListed(contactA3);
+ let listF = bookA.addMailList(createMailingList("list F")); // Add F.
+ checkCardsListed(contactA3, listF);
+ await promiseDirectoryRemoved(bookA.URI);
+ checkCardsListed();
+
+ abWindow.close();
+
+ await promiseDirectoryRemoved(bookB.URI);
+});
+
+/**
+ * Tests that added contacts are inserted in the right place in the list.
+ */
+add_task(async function test_insertion_order() {
+ await openAddressBookWindow();
+
+ let bookA = createAddressBook("book A");
+ openDirectory(bookA);
+ checkCardsListed();
+ let contactA2 = bookA.addCard(createContact("contact", "A2"));
+ checkCardsListed(contactA2);
+ let contactA1 = bookA.addCard(createContact("contact", "A1")); // Add first.
+ checkCardsListed(contactA1, contactA2);
+ let contactA5 = bookA.addCard(createContact("contact", "A5")); // Add last.
+ checkCardsListed(contactA1, contactA2, contactA5);
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add in the middle.
+ checkCardsListed(contactA1, contactA2, contactA3, contactA5);
+
+ // Flip sort direction.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkCardsListed(contactA5, contactA3, contactA2, contactA1);
+ let contactA4 = bookA.addCard(createContact("contact", "A4")); // Add in the middle.
+ checkCardsListed(contactA5, contactA4, contactA3, contactA2, contactA1);
+ let contactA7 = bookA.addCard(createContact("contact", "A7")); // Add first.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1
+ );
+ let contactA0 = bookA.addCard(createContact("contact", "A0")); // Add last.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ contactA3.displayName = "contact A6";
+ contactA3.lastName = "contact A3";
+ contactA3.primaryEmail = "contact.A6@invalid";
+ bookA.modifyCard(contactA3); // Rename, should change position.
+ checkCardsListed(
+ contactA7,
+ contactA3, // Actually A6.
+ contactA5,
+ contactA4,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ // Restore original sort direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkCardsListed(
+ contactA0,
+ contactA1,
+ contactA2,
+ contactA4,
+ contactA5,
+ contactA3, // Actually A6.
+ contactA7
+ );
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(bookA.URI);
+});
+
+/**
+ * Tests the name column is updated when the format changes.
+ */
+add_task(async function test_name_column() {
+ const {
+ GENERATE_DISPLAY_NAME,
+ GENERATE_LAST_FIRST_ORDER,
+ GENERATE_FIRST_LAST_ORDER,
+ } = Ci.nsIAbCard;
+
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ // Check the format is display name, ascending.
+ Assert.equal(
+ Services.prefs.getIntPref("mail.addr_book.lastnamefirst"),
+ GENERATE_DISPLAY_NAME
+ );
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ // Select the "delta foxtrot" contact. This should remain selected throughout.
+ cardsList.selectedIndex = 2;
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "foxtrot, delta",
+ "mike, charlie",
+ "november, echo",
+ "tango, alpha",
+ "zulu, bravo"
+ );
+ Assert.equal(cardsList.selectedIndex, 0);
+ Assert.deepEqual(cardsList.selectedIndices, [0]);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Flip the order to descending.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkNamesListed(
+ "echo november",
+ "delta foxtrot",
+ "charlie mike",
+ "bravo zulu",
+ "alpha tango"
+ );
+ Assert.equal(cardsList.selectedIndex, 1);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "zulu, bravo",
+ "tango, alpha",
+ "november, echo",
+ "mike, charlie",
+ "foxtrot, delta"
+ );
+ Assert.equal(cardsList.selectedIndex, 4);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Sort by email address, ascending.
+ await showSortMenu("sort", "EmailAddresses ascending");
+
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "tango, alpha",
+ "zulu, bravo",
+ "mike, charlie",
+ "foxtrot, delta",
+ "november, echo"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Restore original sort column and direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that sort order and name format survive closing and reopening.
+ */
+add_task(async function test_persistence() {
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ info("sorting by GeneratedName, descending");
+ await showSortMenu("sort", "GeneratedName descending");
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ info("sorting by EmailAddresses, ascending");
+ await showSortMenu("sort", "EmailAddresses ascending");
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ info("setting name format to first last");
+ await showSortMenu("format", Ci.nsIAbCard.GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu compose items.
+ */
+add_task(async function test_context_menu_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let book = createAddressBook("Book");
+ let contactA = book.addCard(createContact("Contact", "A"));
+ let contactB = createContact("Contact", "B");
+ contactB.setProperty("SecondEmail", "b.contact@invalid");
+ contactB = book.addCard(contactB);
+ let contactC = createContact("Contact", "C");
+ contactC.primaryEmail = null;
+ contactC.setProperty("SecondEmail", "c.contact@invalid");
+ contactC = book.addCard(contactC);
+ let contactD = createContact("Contact", "D");
+ contactD.primaryEmail = null;
+ contactD = book.addCard(contactD);
+ let list = book.addMailList(createMailingList("List"));
+ list.addCard(contactA);
+ list.addCard(contactB);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let writeMenuItem = abDocument.getElementById("cardContextWrite");
+ let writeMenu = abDocument.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = abDocument.getElementById(
+ "cardContextWriteSeparator"
+ );
+
+ openDirectory(book);
+
+ // Contact A, first and only email address.
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(0);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B, first email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ let shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ let subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[0]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>"
+ );
+
+ // Contact B, second email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[1]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <b.contact@invalid>"
+ );
+
+ // Contact C, second and only email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact D, no email address.
+
+ await rightClickOnIndex(3);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(writeMenuSeparator.hidden, "write menu separator hidden");
+ menu.hidePopup();
+
+ // List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(await composeWindowPromise, "List <List>");
+
+ // Contact A and Contact D.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [0, 3];
+ await rightClickOnIndex(3);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B and Contact C.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 2];
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact B and List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 4];
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "List <List>"
+ );
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu edit items.
+ */
+add_task(async function test_context_menu_edit() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let editMenuItem = abDocument.getElementById("cardContextEdit");
+ let exportMenuItem = abDocument.getElementById("cardContextExport");
+
+ async function checkEditItems(index, hidden, isMailList = false) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ editMenuItem.hidden,
+ hidden,
+ `editMenuItem should be hidden=${hidden} on index ${index}`
+ );
+ Assert.equal(
+ exportMenuItem.hidden,
+ !isMailList,
+ `exportMenuItem should be hidden=${!isMailList} on index ${index}`
+ );
+
+ Assert.deepEqual(document.l10n.getAttributes(editMenuItem), {
+ id: isMailList
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-edit",
+ args: null,
+ });
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(0, true); // normal contact + normal list
+ await checkEditItems(1, true); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkEditItems(0, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkEditItems(0, true); // read-only contact
+ await checkEditItems(1, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkEditItems(0, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+ await checkEditItems(2, true); // read-only contact
+ await checkEditItems(3, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(1, true); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkEditItems(2, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkEditItems(3, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkEditItems(3, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests the context menu delete items.
+ */
+add_task(async function test_context_menu_delete() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let deleteMenuItem = abDocument.getElementById("cardContextDelete");
+ let removeMenuItem = abDocument.getElementById("cardContextRemove");
+
+ async function checkDeleteItems(index, deleteHidden, removeHidden, disabled) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ deleteMenuItem.hidden,
+ deleteHidden,
+ `deleteMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ deleteMenuItem.disabled,
+ disabled,
+ `deleteMenuItem.disabled on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.hidden,
+ removeHidden,
+ `removeMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.disabled,
+ disabled,
+ `removeMenuItem.disabled on index ${index}`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(0, false, true, false); // normal contact + normal list
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkDeleteItems(0, true, false, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkDeleteItems(0, false, true, true); // read-only contact
+ await checkDeleteItems(1, false, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkDeleteItems(0, true, false, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+ await checkDeleteItems(2, false, true, true); // read-only contact
+ await checkDeleteItems(3, false, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkDeleteItems(2, false, true, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkDeleteItems(3, false, true, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkDeleteItems(3, false, true, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+add_task(async function test_layout() {
+ function checkColumns(visibleColumns, sortColumn, sortDirection) {
+ let visibleHeaders = cardsHeader.querySelectorAll(
+ `th[is="tree-view-table-header-cell"]:not([hidden])`
+ );
+ Assert.deepEqual(
+ Array.from(visibleHeaders, h => h.id),
+ visibleColumns,
+ "visible columns are correct"
+ );
+
+ for (let header of visibleHeaders) {
+ let button = header.querySelector("button");
+ Assert.equal(
+ button.classList.contains("ascending"),
+ header.id == sortColumn && sortDirection == "ascending",
+ `${header.id} header is ascending`
+ );
+ Assert.equal(
+ button.classList.contains("descending"),
+ header.id == sortColumn && sortDirection == "descending",
+ `${header.id} header is descending`
+ );
+ }
+ }
+
+ function checkRowHeight(height) {
+ Assert.equal(cardsList.getRowAtIndex(0).clientHeight, height);
+ }
+
+ Services.prefs.setIntPref("mail.uidensity", 0);
+ personalBook.addCard(
+ createContact("contact", "one", undefined, "first@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "two", undefined, "second@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "three", undefined, "third@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "four", undefined, "fourth@invalid")
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ // Sanity check.
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "not table layout on opening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction is vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter is affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "GeneratedName",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact four",
+ "contact one",
+ "contact three",
+ "contact two"
+ );
+ checkRowHeight(18);
+
+ // Click the email addresses header to sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact one",
+ "contact four",
+ "contact two",
+ "contact three"
+ );
+
+ // Click the email addresses header again to flip the sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+
+ // Add a column.
+
+ await showPickerMenu("toggle", "Title");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="Title"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Remove a column.
+
+ await showPickerMenu("toggle", "Addresses");
+ await TestUtils.waitForCondition(
+ () => cardsHeader.querySelector(`[id="Addresses"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Change the density.
+
+ Services.prefs.setIntPref("mail.uidensity", 1);
+ checkRowHeight(22);
+
+ Services.prefs.setIntPref("mail.uidensity", 2);
+ checkRowHeight(32);
+
+ // Close and reopen the Address Book and check that settings were remembered.
+
+ await closeAddressBookWindow();
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ cardsList = abWindow.cardsPane.cardsList;
+ cardsHeader = abWindow.cardsPane.table.header;
+ sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "table layout preserved on reopening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction preserved as vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter preserved affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+ checkRowHeight(32);
+
+ // Reset layout to list.
+
+ await toggleLayout();
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.uidensity");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_placeholders() {
+ let writableBook = createAddressBook("Writable Book");
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let placeholderCreateContact = abWindow.document.getElementById(
+ "placeholderCreateContact"
+ );
+
+ info("checking all address books");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ let writableList = writableBook.addMailList(
+ createMailingList("Writable List")
+ );
+ checkPlaceholders();
+
+ info("checking writable list");
+ await openDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ writableBook.deleteDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ // This wouldn't happen but we need to check the state in a read-only list.
+ readOnlyBook.setBoolValue("readOnly", false);
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders();
+
+ info("checking read-only list");
+ await openDirectory(readOnlyList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ readOnlyBook.setBoolValue("readOnly", false);
+ readOnlyBook.deleteDirectory(readOnlyList);
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking button opens a new contact to edit");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+ EventUtils.synthesizeMouseAtCenter(placeholderCreateContact, {}, abWindow);
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Checks that mailling lists address books are shown in the table layout.
+ */
+add_task(async function test_list_table_layout() {
+ let book = createAddressBook("Book");
+ book.addCard(createContact("contact", "one"));
+ let list = createMailingList("list one");
+ book.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ await showPickerMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ // Check for the contact that the column is shown.
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".addrbook-column").hidden,
+ "Address book column is shown."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a contact."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a list."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the option of showing the address book for All Address Book for the
+ * list view (vertical layout).
+ */
+add_task(async function test_list_all_address_book() {
+ let firstBook = createAddressBook("First Book");
+ let secondBook = createAddressBook("Second Book");
+ firstBook.addCard(createContact("contact", "one"));
+ secondBook.addCard(createContact("contact", "two"));
+ let list = createMailingList("list two");
+ secondBook.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ info("Check that no address book suffix is present.");
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(1).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(2).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+
+ info("Toggle the option to show address books.");
+ await showSortMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".address-book-name")
+ .textContent.includes("First Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(1)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(2)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present for a list."
+ );
+
+ info(`Select another address book and check that no address book suffix is
+ present for another book besides All Address Book`);
+ await openDirectory(secondBook);
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "Address book suffix is only present in All Address Book."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(firstBook.URI);
+ await promiseDirectoryRemoved(secondBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_directory_tree.js b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
new file mode 100644
index 0000000000..ee4b31ab7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
@@ -0,0 +1,982 @@
+/* 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 rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList
+ .getRowAtIndex(index)
+ .querySelector(".bookRow-name, .listRow-name"),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed.
+ */
+add_task(async function test_additions_and_removals() {
+ function checkBooksOrder(...expected) {
+ function checkRow(index, { level, open, isList, text, uid }) {
+ info(`Row ${index}`);
+ let row = rows[index];
+
+ let containingList = row.closest("ul");
+ if (level == 1) {
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ } else if (level == 2) {
+ Assert.equal(containingList.parentNode.localName, "li");
+ containingList = containingList.parentNode.closest("ul");
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ }
+
+ let childList = row.querySelector("ul");
+ // NOTE: We're not explicitly handling open === false because no test
+ // needed it.
+ if (open) {
+ // Ancestor shouldn't have the collapsed class and the UL child list
+ // should be expanded and visible.
+ Assert.ok(!row.classList.contains("collapsed"));
+ Assert.greater(childList.clientHeight, 0);
+ } else if (childList) {
+ if (row.classList.contains("collapsed")) {
+ // If we have a UL child list and the ancestor element has a collapsed
+ // class, the child list shouldn't be visible.
+ Assert.equal(childList.clientHeight, 0);
+ } else if (childList.childNodes.length) {
+ // If the ancestor doesn't have the collapsed class, and the UL child
+ // list has at least one child node, the child list should be visible.
+ Assert.greater(childList.clientHeight, 0);
+ }
+ }
+
+ Assert.equal(row.classList.contains("listRow"), isList);
+ Assert.equal(row.querySelector("span").textContent, text);
+ Assert.equal(row.getAttribute("aria-label"), text);
+ Assert.equal(row.dataset.uid, uid);
+ }
+
+ let rows = abWindow.booksList.rows;
+ Assert.equal(rows.length, expected.length + 1);
+ for (let i = 0; i < expected.length; i++) {
+ let dir = expected[i].directory;
+ checkRow(i + 1, {
+ ...expected[i],
+ isList: dir.isMailList,
+ text: dir.dirName,
+ uid: dir.UID,
+ });
+ }
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ // Check the initial order.
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add one book, *not* using the UI, and check that we don't move to it.
+
+ let newBook1 = createAddressBook("New Book 1");
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add another book, using the UI, and check that we move to the new book.
+
+ let newBook2 = await createAddressBookWithUI("New Book 2");
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add some lists, *not* using the UI, and check that we don't move to them.
+
+ let list1 = newBook1.addMailList(createMailingList("New Book 1 - List 1"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list3 = newBook1.addMailList(createMailingList("New Book 1 - List 3"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list0 = newBook1.addMailList(createMailingList("New Book 1 - List 0"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list2 = newBook1.addMailList(createMailingList("New Book 1 - List 2"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Close the window and open it again. The tree should be as it was before.
+
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ openDirectory(newBook2);
+
+ let list4 = newBook2.addMailList(createMailingList("New Book 2 - List 4"));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add a new list, using the UI, and check that we move to it.
+
+ let list5 = await createMailingListWithUI(newBook2, "New Book 2 - List 5");
+ checkDirectoryDisplayed(list5);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list6 = await createMailingListWithUI(newBook2, "New Book 2 - List 6");
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+ // Delete a list that isn't displayed, and check that we don't move.
+
+ newBook1.deleteDirectory(list3);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select list5
+ let list5Row = abWindow.booksList.getRowForUID(list5.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ list5Row.querySelector("span"),
+ {},
+ abWindow
+ );
+ checkDirectoryDisplayed(list5);
+
+ // Delete the displayed list, and check that we move to the next list under
+ // the same book.
+
+ newBook2.deleteDirectory(list5);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the last list, and check we move to the previous list under the same
+ // book.
+ newBook2.deleteDirectory(list6);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list4);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the displayed book, and check that we move to the next book.
+
+ await promiseDirectoryRemoved(newBook2.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select a list in the first book, then delete the book. Check that we
+ // move to the next book.
+
+ openDirectory(list1);
+ await promiseDirectoryRemoved(newBook1.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that renaming or deleting books or lists is reflected in the UI.
+ */
+add_task(async function test_rename_and_delete() {
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let searchInput = abWindow.searchInput;
+ Assert.equal(booksList.rowCount, 3);
+
+ // Create a book.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ let newBook = await createAddressBookWithUI("New Book");
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "New Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "New Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New Book",
+ "search placeholder updated"
+ );
+
+ // Rename the book.
+
+ let menu = abDocument.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+
+ await rightClickOnIndex(2);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-properties",
+ args: null,
+ });
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("name");
+ Assert.equal(nameInput.value, "New Book");
+ nameInput.value = "Old Book";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "Old Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "Old Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old Book",
+ "search placeholder updated"
+ );
+
+ // Create a list.
+
+ let newList = await createMailingListWithUI(newBook, "New List");
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let listRow = booksList.getRowAtIndex(3);
+ Assert.equal(
+ listRow.compareDocumentPosition(bookRow),
+ Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_PRECEDING
+ );
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "New List");
+ Assert.equal(listRow.getAttribute("aria-label"), "New List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New List",
+ "search placeholder updated"
+ );
+
+ // Rename the list.
+
+ await rightClickOnIndex(3);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-edit-list",
+ args: null,
+ });
+
+ dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("ListName");
+ Assert.equal(nameInput.value, "New List");
+ nameInput.value = "Old List";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ listRow = booksList.getRowAtIndex(3);
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "Old List");
+ Assert.equal(listRow.getAttribute("aria-label"), "Old List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old List",
+ "search placeholder updated"
+ );
+
+ // Delete the list.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(newBook.childNodes.length, 0, "list was actually deleted");
+ await new Promise(r => abWindow.setTimeout(r));
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.getIndexForUID(newList.UID), -1);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.ok(!bookRow.classList.contains("children"));
+ Assert.ok(!bookRow.querySelector("ul, li"));
+
+ // Delete the book.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "book was actually deleted"
+ );
+
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), -1);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ // Attempt to delete the All Address Books entry.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 0;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Cannot delete the All Address Books item/,
+ "Attempting to delete All Address Books should fail."
+ );
+
+ // Attempt to delete Personal Address Book.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 1;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Personal Address Book should fail."
+ );
+
+ // Attempt to delete Collected Addresses.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 2;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Collected Addresses should fail."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the context menu of the list.
+ */
+add_task(async function test_context_menu() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+ createAddressBook("CardDAV Book", Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+ let synchronizeMenuItem = abDocument.getElementById("bookContextSynchronize");
+ let printMenuItem = abDocument.getElementById("bookContextPrint");
+ let deleteMenuItem = abDocument.getElementById("bookContextDelete");
+ let removeMenuItem = abDocument.getElementById("bookContextRemove");
+ let startupDefaultItem = abDocument.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ Assert.equal(booksList.rowCount, 6);
+
+ // Test that the menu does not show for All Address Books.
+
+ await rightClickOnIndex(0);
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let visibleItems = [...menu.children].filter(BrowserTestUtils.is_visible);
+ Assert.equal(visibleItems.length, 1);
+ Assert.equal(
+ visibleItems[0],
+ startupDefaultItem,
+ "only the startup default item should be visible"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+
+ // Test directories that can't be deleted.
+
+ for (let index of [1, booksList.rowCount - 1]) {
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+ }
+
+ // Test and delete CardDAV directory at index 4.
+
+ await rightClickOnIndex(4);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(!synchronizeMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(removeMenuItem));
+ Assert.ok(!removeMenuItem.disabled);
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(removeMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.equal(menu.state, "closed");
+
+ // Test and delete list at index 3, then directory at index 2.
+
+ for (let index of [3, 2]) {
+ await new Promise(r => abWindow.setTimeout(r, 250));
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(!deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(deleteMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ if (index == 3) {
+ Assert.equal(booksList.rowCount, 4);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ } else {
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.selectedIndex, 2);
+ }
+ Assert.equal(menu.state, "closed");
+ }
+
+ // Test that the menu does not show beyond the last book.
+
+ EventUtils.synthesizeMouseAtCenter(
+ booksList,
+ 100,
+ booksList.clientHeight - 10,
+ { type: "contextmenu" },
+ abWindow
+ );
+ Assert.equal(booksList.selectedIndex, 2);
+ await new Promise(r => abWindow.setTimeout(r, 500));
+ Assert.equal(menu.state, "closed", "menu stayed closed as expected");
+ Assert.equal(abDocument.activeElement, booksList);
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the menu button on each item.
+ */
+add_task(async function test_context_menu_button() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ for (let row of booksList.rows) {
+ info(row.querySelector(".bookRow-name, .listRow-name").textContent);
+ let button = row.querySelector(".bookRow-menu, .listRow-menu");
+ Assert.ok(BrowserTestUtils.is_hidden(button), "menu button is hidden");
+
+ EventUtils.synthesizeMouse(row, 100, 5, { type: "mousemove" }, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(button), "menu button is visible");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(button, {}, abWindow);
+ await shownPromise;
+
+ let buttonRect = button.getBoundingClientRect();
+ let menuRect = menu.getBoundingClientRect();
+ Assert.less(
+ Math.abs(menuRect.top - buttonRect.bottom),
+ 13,
+ "menu appeared near the button vertically"
+ );
+ Assert.less(
+ Math.abs(menuRect.left - buttonRect.left),
+ 20,
+ "menu appeared near the button horizontally"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that the collapsed state of books survives a reload of the page.
+ */
+add_task(async function test_collapse_expand() {
+ Services.xulStore.removeDocument("about:addressbook");
+
+ personalBook.addMailList(createMailingList("Personal List 1"));
+ personalBook.addMailList(createMailingList("Personal List 2"));
+
+ historyBook.addMailList(createMailingList("History List 1"));
+
+ let book1 = createAddressBook("Book 1");
+ book1.addMailList(createMailingList("Book 1 List 1"));
+ book1.addMailList(createMailingList("Book 1 List 2"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addMailList(createMailingList("Book 2 List 1"));
+ book2.addMailList(createMailingList("Book 2 List 2"));
+ book2.addMailList(createMailingList("Book 2 List 3"));
+
+ function getRowForBook(book) {
+ return abDocument.getElementById(`book-${book.UID}`);
+ }
+
+ function checkCollapsedState(book, expectedCollapsed) {
+ Assert.equal(
+ getRowForBook(book).classList.contains("collapsed"),
+ expectedCollapsed,
+ `${book.dirName} is ${expectedCollapsed ? "collapsed" : "expanded"}`
+ );
+ }
+
+ function toggleCollapsedState(book) {
+ let twisty = getRowForBook(book).querySelector(".twisty");
+ Assert.ok(
+ BrowserTestUtils.is_visible(twisty),
+ `twisty for ${book.dirName} is visible`
+ );
+ EventUtils.synthesizeMouseAtCenter(twisty, {}, abWindow);
+ }
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(personalBook);
+ toggleCollapsedState(book1);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, true);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(book1);
+ toggleCollapsedState(book2);
+ toggleCollapsedState(historyBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, true);
+ checkCollapsedState(historyBook, true);
+
+ toggleCollapsedState(personalBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book2.URI);
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(historyBook, true);
+
+ await closeAddressBookWindow();
+
+ personalBook.childNodes.forEach(list => personalBook.deleteDirectory(list));
+ historyBook.childNodes.forEach(list => historyBook.deleteDirectory(list));
+ await promiseDirectoryRemoved(book1.URI);
+ Services.xulStore.removeDocument("about:addressbook");
+});
+
+/**
+ * Tests that the chosen default directory (or lack thereof) is opened when
+ * the page opens.
+ */
+add_task(async function test_startup_directory() {
+ const URI_PREF = "mail.addr_book.view.startupURI";
+ const DEFAULT_PREF = "mail.addr_book.view.startupURIisDefault";
+
+ Services.prefs.clearUserPref(URI_PREF);
+ Services.prefs.clearUserPref(DEFAULT_PREF);
+
+ async function checkMenuItem(index, expectChecked, toggle = false) {
+ await rightClickOnIndex(index);
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let item = abWindow.document.getElementById("bookContextStartupDefault");
+ Assert.equal(
+ item.hasAttribute("checked"),
+ expectChecked,
+ `directory at index ${index} is the default?`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ if (toggle) {
+ menu.activateItem(item);
+ } else {
+ menu.hidePopup();
+ }
+ await hiddenPromise;
+ }
+
+ // With the defaults, All Address Books should open.
+ // No changes should be made to the prefs.
+
+ let abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Now we'll set the default to "last-used".
+ // The last-used book should be saved.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ Services.prefs.setBoolPref(DEFAULT_PREF, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // The last-used book should open.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(historyBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // The last-used book should open.
+ // We'll set a default directory again.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false, true);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // Check that the saved default opens. Change the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(2, true);
+ await checkMenuItem(1, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // Check that the saved default opens. Change the default to All Address Books.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(1, true);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Check that the saved default opens. Clear the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, true, true);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+});
+
+add_task(async function test_total_address_book_count() {
+ let book1 = createAddressBook("First Book");
+ let book2 = createAddressBook("Second Book");
+ book1.addMailList(createMailingList("Ordinary List"));
+
+ book1.addCard(createContact("contact1", "book 1"));
+ book1.addCard(createContact("contact2", "book 1"));
+ book1.addCard(createContact("contact3", "book 1"));
+
+ book2.addCard(createContact("contact1", "book 2"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let cardCount = abDocument.getElementById("cardCount");
+
+ await openAllAddressBooks();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count-all",
+ args: {
+ count: 5,
+ },
+ });
+
+ for (let [index, [name, count]] of [
+ ["Personal Address Book", 0],
+ ["First Book", 4],
+ ["Ordinary List", 0],
+ ["Second Book", 1],
+ ].entries()) {
+ booksList.getRowAtIndex(index + 1).click();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count",
+ args: { name, count },
+ });
+ }
+
+ // Create a contact and check that the count updates.
+ // Select second book.
+ booksList.getRowAtIndex(4).click();
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ book2.addCard(createContact("contact2", "book 2"));
+ await createdPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 2 },
+ },
+ "Address Book count is updated on contact creation."
+ );
+
+ // Delete a contact an check that the count updates.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ let cards = abWindow.cardsPane.cardsList;
+ EventUtils.synthesizeMouseAtCenter(cards.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await deletedPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 1 },
+ },
+ "Address Book count is updated on contact deletion."
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_card.js b/comm/mail/components/addrbook/test/browser/browser_display_card.js
new file mode 100644
index 0000000000..4d468ed646
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_card.js
@@ -0,0 +1,1020 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(aProtocolScheme) {},
+ getApplicationDescription(aScheme) {},
+ getProtocolHandlerInfo(aProtocolScheme) {},
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+ isExposedProtocol(aProtocolScheme) {},
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+add_setup(async function () {
+ // Card 0.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard("BEGIN:VCARD\r\nEND:VCARD\r\n")
+ );
+ // Card 1.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:basic person
+ EMAIL:basic@invalid
+ END:VCARD
+ `)
+ );
+ // Card 2.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:complex person
+ EMAIL:secondary@invalid
+ EMAIL;PREF=1:primary@invalid
+ EMAIL;TYPE=WORK:tertiary@invalid
+ TEL;VALUE=URI:tel:000-0000
+ TEL;TYPE=WORK,VOICE:callto:111-1111
+ TEL;TYPE=VOICE,WORK:222-2222
+ TEL;TYPE=HOME;TYPE=VIDEO:tel:333-3333
+ ADR:;;street,suburb;city;state;zip;country
+ ANNIVERSARY:2018-06-11
+ BDAY;VALUE=DATE:--0229
+ NOTE:mary had a little lamb\\nits fleece was white as snow\\nand everywhere t
+ hat mary went\\nthe lamb was sure to go
+ ORG:thunderbird;engineering
+ ROLE:sheriff
+ TITLE:senior engineering lead
+ TZ;VALUE=TEXT:Pacific/Auckland
+ URL;TYPE=work:https://www.thunderbird.net/
+ IMPP:xmpp:cowboy@example.org
+ END:VCARD
+ `)
+ );
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+
+ registerCleanupFunction(async () => {
+ personalBook.deleteCards(personalBook.childCards);
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+});
+
+/**
+ * Checks basic display.
+ */
+add_task(async function testDisplay() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ Assert.equal(cardsList.view.rowCount, personalBook.childCardCount);
+ Assert.ok(detailsPane.hidden);
+
+ // Card 0: an empty card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 1: an basic card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "basic person");
+ Assert.equal(viewPrimaryEmail.textContent, "basic@invalid");
+
+ // Action buttons.
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:basic%20person%20%3Cbasic%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "basic@invalid");
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[0].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "basic person <basic@invalid>"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 2: an complex card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "complex person");
+ Assert.equal(viewPrimaryEmail.textContent, "primary@invalid");
+
+ // Action buttons.
+ await checkActionButtons(
+ "primary@invalid",
+ "complex person",
+ "primary@invalid secondary@invalid tertiary@invalid"
+ );
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:complex%20person%20%3Csecondary%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "secondary@invalid");
+
+ Assert.equal(items[1].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[1].querySelector("a").href,
+ `mailto:complex%20person%20%3Cprimary%40invalid%3E`
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "primary@invalid");
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[2].querySelector("a").href,
+ `mailto:complex%20person%20%3Ctertiary%40invalid%3E`
+ );
+ Assert.equal(items[2].querySelector("a").textContent, "tertiary@invalid");
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[2].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "complex person <tertiary@invalid>"
+ );
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 4);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value a").href, `tel:0000000`);
+
+ Assert.equal(
+ items[1].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[1].querySelector(".entry-value").textContent, "111-1111");
+ Assert.equal(items[1].querySelector(".entry-value a").href, `callto:1111111`);
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[2].querySelector(".entry-value").textContent, "222-2222");
+
+ Assert.equal(
+ items[3].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-home"
+ );
+ Assert.equal(items[3].querySelector(".entry-value").textContent, "333-3333");
+ Assert.equal(items[3].querySelector(".entry-value a").href, `tel:3333333`);
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_visible(addressesSection));
+ items = addressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").childNodes.length, 11);
+ Assert.deepEqual(
+ Array.from(
+ items[0].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["street", "", "suburb", "", "city", "", "state", "", "zip", "", "country"]
+ );
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "mary had a little lamb\nits fleece was white as snow\nand everywhere that mary went\nthe lamb was sure to go"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://www.thunderbird.net/"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "www.thunderbird.net"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () => mockExternalProtocolService.urlLoaded("https://www.thunderbird.net/"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section
+ Assert.ok(BrowserTestUtils.is_visible(imppSection));
+ items = imppSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "xmpp:cowboy@example.org"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 6, "number of <li> in section should be correct");
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-birthday"
+ );
+ Assert.equal(items[0].children[1].textContent, "February 29");
+ Assert.equal(
+ items[1].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[1].children[1].textContent, "June 11, 2018");
+
+ Assert.equal(
+ items[2].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(items[2].children[1].textContent, "senior engineering lead");
+ Assert.equal(
+ items[3].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-role"
+ );
+ Assert.equal(items[3].children[1].textContent, "sheriff");
+ Assert.equal(
+ items[4].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-organization"
+ );
+ Assert.deepEqual(
+ Array.from(
+ items[4].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["engineering", " • ", "thunderbird"]
+ );
+ Assert.equal(
+ items[5].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-time-zone"
+ );
+ Assert.equal(items[5].children[1].firstChild.nodeValue, "Pacific/Auckland");
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("is"),
+ "active-time"
+ );
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("tz"),
+ "Pacific/Auckland"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 0, again, just to prove that everything was cleared properly.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the display of dates with various components missing.
+ */
+add_task(async function testDates() {
+ let abWindow = await openAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ // Year only.
+
+ let yearCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic3@invalid
+ ANNIVERSARY:2005
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "2005");
+
+ // Year and month.
+
+ let yearMonthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic4@invalid
+ ANNIVERSARY:2006-06
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "June 2006");
+
+ // Month only.
+ let monthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic5@invalid
+ ANNIVERSARY:--12
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "December");
+
+ // Month and day.
+ let monthDayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic6@invalid
+ ANNIVERSARY;VALUE=DATE:--0704
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "July 4");
+
+ // Day only.
+ let dayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic7@invalid
+ ANNIVERSARY:---30
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "30");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([
+ yearCard,
+ yearMonthCard,
+ monthCard,
+ monthDayCard,
+ dayCard,
+ ]);
+});
+
+/**
+ * Only an organisation name.
+ */
+add_task(async function testOrganisationNameOnly() {
+ let card = await addAndDisplayCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ ORG:organisation
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await getAddressBookWindow();
+ let viewContactName = abWindow.document.getElementById("viewContactName");
+ Assert.equal(viewContactName.textContent, "organisation");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are displayed.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = await addAndDisplayCard(card);
+
+ let abWindow = await getAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+ // Custom 1 has no value, should not display.
+ // Custom 2 has an old property value, should display that.
+
+ await TestUtils.waitForCondition(() => {
+ return items[0].children[0].textContent;
+ }, "text not created in time");
+
+ Assert.equal(items[0].children[0].textContent, "Custom 2");
+ Assert.equal(items[0].children[1].textContent, "custom two");
+ // Custom 3 has a vCard property value, should display that.
+ Assert.equal(items[1].children[0].textContent, "Custom 3");
+ Assert.equal(items[1].children[1].textContent, "x-custom three");
+ // Custom 4 has both types of value, the vCard value should be displayed.
+ Assert.equal(items[2].children[0].textContent, "Custom 4");
+ Assert.equal(items[2].children[1].textContent, "x-custom four");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Checks that the edit button is hidden for read-only contacts.
+ */
+add_task(async function testReadOnlyActions() {
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person
+ END:VCARD
+ `)
+ );
+ readOnlyList.addCard(
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person with email
+ EMAIL:read.only@invalid
+ END:VCARD
+ `)
+ )
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactView = abDocument.getElementById("viewContact");
+
+ let actions = abDocument.getElementById("detailsActions");
+ let editButton = abDocument.getElementById("editButton");
+ let editForm = abDocument.getElementById("editContactForm");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ // Check contacts with the book displayed.
+
+ openDirectory(readOnlyBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Double clicking on the item will select but not edit it.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 2 },
+ abWindow
+ );
+ // Wait one loop to see if edit form was opened.
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+ Assert.equal(
+ cardsList.table.body,
+ abDocument.activeElement,
+ "Cards list should be the active element"
+ );
+
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ // Same with Enter on the second item.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editButton),
+ "editButton should be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+
+ // Check contacts with the list displayed.
+
+ openDirectory(readOnlyList);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Check contacts with All Address Books displayed.
+
+ openAllAddressBooks();
+ Assert.equal(cardsList.view.rowCount, 6);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Basic person from Personal Address Books.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(4), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_hidden(actions), "actions section is hidden");
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(5), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Basic person again, to prove the buttons aren't hidden forever.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "en\\c:oding test");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ let items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").textContent, "01234567");
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "notes:\nnotes;\nnotes,\nnotes\\"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://host/url:url;url,url/url"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "host/url:url;url,url/url"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ mockExternalProtocolService.urlLoaded("https://host/url:url;url,url/url"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section.
+ Assert.ok(BrowserTestUtils.is_hidden(imppSection));
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(
+ items[0].children[1].textContent,
+ "title:title;title,title\\title\\:title\\;title\\,title\\\\"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+async function addAndDisplayCard(card) {
+ if (typeof card == "string") {
+ card = VCardUtils.vCardToAbCard(card);
+ }
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+ return card;
+}
+
+async function checkActionButtons(
+ primaryEmail,
+ displayName,
+ searchString = primaryEmail
+) {
+ let tabmail = document.getElementById("tabmail");
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (primaryEmail) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ `${displayName} <${primaryEmail}>`
+ );
+
+ // Search. Do this before the event test to stop a strange macOS failure.
+ Assert.ok(
+ BrowserTestUtils.is_visible(searchButton),
+ "search button is visible"
+ );
+
+ let searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, abWindow);
+ let {
+ detail: { tabInfo: searchTab },
+ } = await searchTabPromise;
+
+ let searchBox = tabmail.selectedTab.panel.querySelector(".searchBox");
+ Assert.equal(searchBox.value, searchString);
+
+ searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabClose");
+ tabmail.closeTab(searchTab);
+ await searchTabPromise;
+
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ [`${displayName} <${primaryEmail}>`],
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(writeButton),
+ "write button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_multiple.js b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
new file mode 100644
index 0000000000..02642f4408
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
@@ -0,0 +1,468 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+
+add_setup(async function () {
+ let card1 = personalBook.addCard(createContact("victor", "test"));
+ personalBook.addCard(createContact("romeo", "test", undefined, ""));
+ let card3 = personalBook.addCard(createContact("oscar", "test"));
+ personalBook.addCard(createContact("mike", "test", undefined, ""));
+ const card5 = personalBook.addCard(createContact("xray", "test"));
+ const card6 = personalBook.addCard(createContact("yankee", "test"));
+ const card7 = personalBook.addCard(createContact("zulu", "test"));
+ let list1 = personalBook.addMailList(createMailingList("list 1"));
+ list1.addCard(card1);
+ list1.addCard(card3);
+ list1.addCard(card5);
+ list1.addCard(card6);
+ list1.addCard(card7);
+ let list2 = personalBook.addMailList(createMailingList("list 2"));
+ list2.addCard(card3);
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+add_task(async function testSelectMultiple() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 1 and check the list display.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await checkHeader({ listName: "list 1" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ [],
+ [
+ "victor test <victor.test@invalid>",
+ "oscar test <oscar.test@invalid>",
+ "xray test <xray.test@invalid>",
+ "yankee test <yankee.test@invalid>",
+ "zulu test <zulu.test@invalid>",
+ ]
+ );
+ await checkList([
+ "oscar test",
+ "victor test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // list 1 and list 2.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "lists" });
+ await checkActionButtons(["list 1 <list 1>", "list 2 <list 2>"]);
+ await checkList(["list 1", "list 2"]);
+
+ // list 1 and mike (no address).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(["list 1 <list 1>"]);
+ await checkList(["list 1", "mike test"]);
+
+ // list 1 and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ ["oscar test <oscar.test@invalid>"]
+ );
+ await checkList(["list 1", "oscar test"]);
+
+ // mike (no address) and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons([], ["oscar test <oscar.test@invalid>"]);
+ await checkList(["mike test", "oscar test"]);
+
+ // mike (no address), oscar, romeo (no address) and victor.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 4, selectionType: "contacts" });
+ await checkActionButtons(
+ [],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList(["mike test", "oscar test", "romeo test", "victor test"]);
+
+ // mike and romeo (no addresses).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(4),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons();
+ await checkList(["mike test", "romeo test"]);
+
+ // Everything.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 6, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>", "list 2 <list 2>"],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList([
+ "list 1",
+ "list 2",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "victor test",
+ ]);
+
+ await closeAddressBookWindow();
+});
+
+add_task(async function testDeleteMultiple() {
+ const abWindow = await openAddressBookWindow();
+ const booksList = abWindow.booksList;
+
+ // Open mailing list list1.
+ booksList.getRowAtIndex(2).click();
+
+ const abDocument = abWindow.document;
+ const cardsList = abDocument.getElementById("cards");
+ const detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; oscar, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 5);
+ Assert.ok(detailsPane.hidden);
+
+ // Select victor and yankee.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkList(["victor test", "yankee test"]);
+
+ // Delete victor and yankee.
+ let deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing two mailing list members."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 3, selectionType: "contacts" });
+ await checkList(["oscar test", "xray test", "zulu test"]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all mailing list members."
+ );
+
+ // Open address book personalBook.
+ booksList.getRowAtIndex(1).click();
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 2 and victor.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkList(["list 2", "victor test"]);
+
+ // Delete list 2 and victor.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 7);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after deleting one list and one contact."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(6),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 7, selectionType: "mixed" });
+ await checkList([
+ "list 1",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all contacts."
+ );
+ await closeAddressBookWindow();
+});
+
+function checkHeader({ listName, selectionCount, selectionType } = {}) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let contactPhoto = abDocument.getElementById("viewContactPhoto");
+ let contactName = abDocument.getElementById("viewContactName");
+ let listHeader = abDocument.getElementById("viewListName");
+ let selectionHeader = abDocument.getElementById("viewSelectionCount");
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactPhoto),
+ "contact photo should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactName),
+ "contact name should be hidden"
+ );
+ if (listName) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(listHeader),
+ "list header should be visible"
+ );
+ Assert.equal(
+ listHeader.textContent,
+ listName,
+ "list header text is correct"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(selectionHeader),
+ "selection header should be hidden"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(listHeader),
+ "list header should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(selectionHeader),
+ "selection header should be visible"
+ );
+ Assert.deepEqual(abDocument.l10n.getAttributes(selectionHeader), {
+ id: `about-addressbook-selection-${selectionType}-header2`,
+ args: {
+ count: selectionCount,
+ },
+ });
+ }
+}
+
+async function checkActionButtons(
+ listAddresses = [],
+ cardAddresses = [],
+ eventAddresses = cardAddresses
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (cardAddresses.length || listAddresses.length) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ ...listAddresses,
+ ...cardAddresses
+ );
+ }
+
+ if (eventAddresses.length) {
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ eventAddresses,
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ }
+
+ if (cardAddresses.length) {
+ // New List.
+ Assert.ok(
+ BrowserTestUtils.is_visible(newListButton),
+ "new list button is visible"
+ );
+ let listWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(newListButton, {}, abWindow);
+ let listWindow = await listWindowPromise;
+ let memberNames = listWindow.document.querySelectorAll(
+ ".textbox-addressingWidget"
+ );
+ Assert.deepEqual(
+ Array.from(memberNames, aw => aw.value),
+ [...cardAddresses, ""],
+ "list members are correct"
+ );
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, listWindow);
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+}
+
+function checkList(names) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+ let otherSections = abDocument.querySelectorAll(
+ "#detailsBody > section:not(#detailsActions, #selectedCards)"
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(selectedCardsSection));
+ for (let section of otherSections) {
+ Assert.ok(BrowserTestUtils.is_hidden(section), `${section.id} is hidden`);
+ }
+
+ Assert.deepEqual(
+ Array.from(
+ selectedCardsSection.querySelectorAll("li .name"),
+ li => li.textContent
+ ),
+ names
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_drag_drop.js b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
new file mode 100644
index 0000000000..4f3c23aa5b
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
@@ -0,0 +1,417 @@
+/* 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/. */
+
+let dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+function doDrag(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ let destElement = abWindow.document.body;
+ if (destIndex !== null) {
+ destElement = booksList.getRowAtIndex(destIndex);
+ }
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndex),
+ destElement,
+ null,
+ null,
+ abWindow,
+ abWindow,
+ modifiers
+ );
+
+ Assert.equal(dataTransfer.effectAllowed, "all");
+ Assert.equal(dataTransfer.dropEffect, expectedEffect);
+
+ return [result, dataTransfer];
+}
+
+function doDragToBooksList(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ let [result, dataTransfer] = doDrag(
+ sourceIndex,
+ destIndex,
+ modifiers,
+ expectedEffect
+ );
+
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ booksList.getRowAtIndex(destIndex),
+ abWindow,
+ modifiers
+ );
+
+ dragService.endDragSession(true);
+}
+
+async function doDragToComposeWindow(sourceIndices, expectedPills) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ cardsList.selectedIndices = sourceIndices;
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndices[0]),
+ toAddrInput,
+ null,
+ null,
+ abWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedPills.length);
+ for (let i = 0; i < expectedPills.length; i++) {
+ Assert.equal(pills[i].label, expectedPills[i]);
+ }
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+}
+
+function checkCardsInDirectory(directory, expectedCards = [], copiedCard) {
+ let actualCards = directory.childCards.slice();
+
+ for (let card of expectedCards) {
+ let index = actualCards.findIndex(c => c.UID == card.UID);
+ Assert.greaterOrEqual(index, 0);
+ actualCards.splice(index, 1);
+ }
+
+ if (copiedCard) {
+ Assert.equal(actualCards.length, 1);
+ Assert.equal(actualCards[0].firstName, copiedCard.firstName);
+ Assert.equal(actualCards[0].lastName, copiedCard.lastName);
+ Assert.equal(actualCards[0].primaryEmail, copiedCard.primaryEmail);
+ Assert.notEqual(actualCards[0].UID, copiedCard.UID);
+ } else {
+ Assert.equal(actualCards.length, 0);
+ }
+}
+
+add_task(async function test_drag() {
+ let sourceBook = createAddressBook("Source Book");
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ // Drag just contact1.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ let [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ let transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact1));
+
+ let transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 1 <contact.1@invalid>");
+
+ let transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag contact2 without selecting it.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ [, dataTransfer] = doDrag(1, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact2));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 2 <contact.2@invalid>");
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact2.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag all contacts.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 3);
+ Assert.ok(transferCards[0].equals(contact1));
+ Assert.ok(transferCards[1].equals(contact2));
+ Assert.ok(transferCards[2].equals(contact3));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(
+ transferUnicode,
+ "contact 1 <contact.1@invalid>,contact 2 <contact.2@invalid>,contact 3 <contact.3@invalid>"
+ );
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
+
+add_task(async function test_drop_on_books_list() {
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+ let destBook = createAddressBook("Destination Book");
+ let destList = destBook.addMailList(createMailingList("Destination List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ Assert.equal(booksList.rowCount, 7);
+ openDirectory(sourceBook);
+
+ // Check drag effect set correctly for dragging a card.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(0, 0, {}, "none"); // All Address Books
+ doDrag(0, 0, { ctrlKey: true }, "none");
+
+ doDrag(0, 1, {}, "move"); // Personal Address Book
+ doDrag(0, 1, { ctrlKey: true }, "copy");
+
+ doDrag(0, 2, {}, "move"); // Destination Book
+ doDrag(0, 2, { ctrlKey: true }, "copy");
+
+ doDrag(0, 3, {}, "none"); // Destination List
+ doDrag(0, 3, { ctrlKey: true }, "none");
+
+ doDrag(0, 4, {}, "none"); // Source Book
+ doDrag(0, 4, { ctrlKey: true }, "none");
+
+ doDrag(0, 5, {}, "link"); // Source List
+ doDrag(0, 5, { ctrlKey: true }, "link");
+
+ doDrag(0, 6, {}, "move"); // Collected Addresses
+ doDrag(0, 6, { ctrlKey: true }, "copy");
+
+ dragService.endDragSession(true);
+
+ // Check drag effect set correctly for dragging a list.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(3), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(3, 0, {}, "none"); // All Address Books
+ doDrag(3, 0, { ctrlKey: true }, "none");
+
+ doDrag(3, 1, {}, "none"); // Personal Address Book
+ doDrag(3, 1, { ctrlKey: true }, "none");
+
+ doDrag(3, 2, {}, "none"); // Destination Book
+ doDrag(3, 2, { ctrlKey: true }, "none");
+
+ doDrag(3, 3, {}, "none"); // Destination List
+ doDrag(3, 3, { ctrlKey: true }, "none");
+
+ doDrag(3, 4, {}, "none"); // Source Book
+ doDrag(3, 4, { ctrlKey: true }, "none");
+
+ doDrag(3, 5, {}, "none"); // Source List
+ doDrag(3, 5, { ctrlKey: true }, "none");
+
+ doDrag(3, 6, {}, "none"); // Collected Addresses
+ doDrag(3, 6, { ctrlKey: true }, "none");
+
+ dragService.endDragSession(true);
+
+ // Drag contact1 into sourceList.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 5, {}, "link");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList, [contact1]);
+
+ // Drag contact1 into destList. Nothing should happen.
+
+ doDragToBooksList(0, 3, {}, "none");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ // Drag contact1 into destBook. It should be moved into destBook.
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [contact1, destList]);
+
+ // Drag contact2 into destBook with Ctrl pressed.
+ // It should be copied into destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 2, { ctrlKey: true }, "copy");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [contact1, destList], contact2);
+ checkCardsInDirectory(destList);
+
+ // Delete the cards from destBook as it's confusing.
+
+ destBook.deleteCards(destBook.childCards.filter(c => !c.isMailList));
+ checkCardsInDirectory(destBook, [destList]);
+
+ // Drag contact2 and contact3 to destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [sourceList]);
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag contact2 to the book it's already in. Nothing should happen.
+ // This test doesn't actually catch the bug it was written for, but maybe
+ // one day it will catch something.
+
+ openDirectory(destBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ doDragToBooksList(0, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag destList to the book it's already in. Nothing should happen.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ doDragToBooksList(2, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+ await promiseDirectoryRemoved(destBook.URI);
+});
+
+add_task(async function test_drop_on_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+ sourceList.addCard(contact1);
+ sourceList.addCard(contact2);
+ sourceList.addCard(contact3);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ Assert.equal(cardsList.view.rowCount, 4);
+
+ // One contact.
+
+ await doDragToComposeWindow([0], ["contact 1 <contact.1@invalid>"]);
+
+ // Multiple contacts.
+
+ await doDragToComposeWindow(
+ [0, 1, 2],
+ [
+ "contact 1 <contact.1@invalid>",
+ "contact 2 <contact.2@invalid>",
+ "contact 3 <contact.3@invalid>",
+ ]
+ );
+
+ // A mailing list.
+
+ await doDragToComposeWindow([3], [`Source List <"Source List">`]);
+
+ // A mailing list and a contact.
+
+ await doDragToComposeWindow(
+ [3, 2],
+ ["contact 3 <contact.3@invalid>", `Source List <"Source List">`]
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_async.js b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
new file mode 100644
index 0000000000..76588aee76
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
@@ -0,0 +1,363 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+let book;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+add_setup(async function () {
+ CardDAVServer.open("alice", "alice");
+
+ book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+});
+
+registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test the UI as we create/modify/delete a card and wait for responses from
+ * the server.
+ */
+add_task(async function testCreateCard() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise2;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Edit the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "edited contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise3 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise3;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise4;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Delete the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise5 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise5;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the UI as we create a card and wait for responses from the server.
+ * In this test the server will assign the card a new UID, which means the
+ * client code has to do things differently, but the UI should behave as it
+ * did in the previous test.
+ */
+add_task(async function testCreateCardWithUIDChange() {
+ CardDAVServer.modifyCardOnPut = true;
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ let initialCard = abWindow.detailsPane.currentCard;
+ Assert.equal(initialCard.getProperty("_href", "RIGHT"), "RIGHT");
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-created");
+ let promise3 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay.resolve();
+ let [changedCard] = await promise2;
+ let [deletedCard] = await promise3;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.equal(changedCard.UID, [...initialCard.UID].reverse().join(""));
+ Assert.equal(
+ changedCard.getProperty("_originalUID", "WRONG"),
+ initialCard.UID
+ );
+ Assert.equal(deletedCard.UID, initialCard.UID);
+
+ let displayedCard = abWindow.detailsPane.currentCard;
+ Assert.equal(displayedCard.directoryUID, book.UID);
+ Assert.notEqual(displayedCard.getProperty("_href", "WRONG"), "WRONG");
+ Assert.equal(displayedCard.UID, [...initialCard.UID].reverse().join(""));
+
+ // Delete the contact. This would fail if the UI hadn't been updated.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise4;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test that a modification to the card being edited causes a prompt to appear
+ * when saving the card.
+ */
+add_task(async function testModificationUpdatesUI() {
+ let card = personalBook.addCard(createContact("a", "person"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactName = abDocument.getElementById("viewContactName");
+ let editButton = abDocument.getElementById("editButton");
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ openDirectory(personalBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+
+ // Display a card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+
+ // Modify the card and check the display is updated.
+
+ let updatePromise = BrowserTestUtils.waitForMutationCondition(
+ detailsPane,
+ { childList: true, subtree: true },
+ () => true
+ );
+ card.vCardProperties.addValue("email", "person.a@lastfirst.invalid");
+ personalBook.modifyCard(card);
+
+ await updatePromise;
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+ Assert.equal(
+ items[1].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode. Clear one of the email addresses.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ Assert.equal(abWindow.detailsPane.vCardEdit.displayName.value, "a person");
+ abDocument.querySelector(`#vcard-email tr input[type="email"]`).value = "";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ personalBook.modifyCard(card);
+
+ // Click to save.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ [card] = personalBook.childCards;
+ Assert.equal(
+ card.displayName,
+ "a person",
+ "programmatic changes were overwritten"
+ );
+ Assert.deepEqual(
+ card.emailAddresses,
+ ["person.a@lastfirst.invalid"],
+ "UI changes were saved"
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode again. Change the display name.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ abWindow.detailsPane.vCardEdit.displayName.value = "a changed person";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ card.vCardProperties.addValue("email", "a.person@invalid");
+ personalBook.modifyCard(card);
+
+ // Click to cancel. The modified card should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(contactName.textContent, "a different person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "a.person@invalid");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_card.js b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
new file mode 100644
index 0000000000..27cabfa4d4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
@@ -0,0 +1,3517 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+requestLongerTimeout(2);
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "Waiting on entering editing mode"
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be visible"
+ );
+ checkToolbarState(false);
+}
+
+/**
+ * Wait until we are no longer in editing mode.
+ *
+ * @param {Element} expectedFocus - The element that is expected to have focus
+ * after leaving editing.
+ */
+async function notInEditingMode(expectedFocus) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be hidden"
+ );
+ checkToolbarState(true);
+ Assert.equal(
+ abDocument.activeElement,
+ expectedFocus,
+ `Focus should be on #${expectedFocus.id}`
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "PreferDisplayName":
+ return abDocument.querySelector("vcard-fn #vCardPreferDisplayName");
+ case "NickName":
+ return abDocument.querySelector("vcard-nickname #vCardNickName");
+ case "Prefix":
+ let prefixInput = abDocument.querySelector("vcard-n #vcard-n-prefix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(prefixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-prefix button"),
+ {},
+ abWindow
+ );
+ }
+ return prefixInput;
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "MiddleName":
+ let middleNameInput = abDocument.querySelector(
+ "vcard-n #vcard-n-middlename"
+ );
+ if (addIfNeeded && BrowserTestUtils.is_hidden(middleNameInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(
+ "vcard-n #n-list-component-middlename button"
+ ),
+ {},
+ abWindow
+ );
+ }
+ return middleNameInput;
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "Suffix":
+ let suffixInput = abDocument.querySelector("vcard-n #vcard-n-suffix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(suffixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-suffix button"),
+ {},
+ abWindow
+ );
+ }
+ return suffixInput;
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "PrimaryEmailCheckbox":
+ return getInput("PrimaryEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ case "SecondEmailCheckbox":
+ return getInput("SecondEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ }
+
+ return null;
+}
+
+function getFields(entryName, addIfNeeded = false, count) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let fieldsSelector;
+ let addButtonId;
+ let expectFocusSelector;
+ switch (entryName) {
+ case "email":
+ fieldsSelector = `#vcard-email tr`;
+ addButtonId = "vcard-add-email";
+ expectFocusSelector = "tr:last-of-type .vcard-type-selection";
+ break;
+ case "impp":
+ fieldsSelector = "vcard-impp";
+ addButtonId = "vcard-add-impp";
+ expectFocusSelector = "vcard-impp:last-of-type select";
+ break;
+ case "url":
+ fieldsSelector = "vcard-url";
+ addButtonId = "vcard-add-url";
+ expectFocusSelector = "vcard-url:last-of-type .vcard-type-selection";
+ break;
+ case "tel":
+ fieldsSelector = "vcard-tel";
+ addButtonId = "vcard-add-tel";
+ expectFocusSelector = "vcard-tel:last-of-type .vcard-type-selection";
+ break;
+ case "note":
+ fieldsSelector = "vcard-note";
+ addButtonId = "vcard-add-note";
+ expectFocusSelector = "vcard-note:last-of-type textarea";
+ break;
+ case "title":
+ fieldsSelector = "vcard-title";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "vcard-title:last-of-type input";
+ break;
+ case "custom":
+ fieldsSelector = "vcard-custom";
+ addButtonId = "vcard-add-custom";
+ expectFocusSelector = "vcard-custom:last-of-type input";
+ break;
+ case "specialDate":
+ fieldsSelector = "vcard-special-date";
+ addButtonId = "vcard-add-bday-anniversary";
+ expectFocusSelector =
+ "vcard-special-date:last-of-type .vcard-type-selection";
+ break;
+ case "adr":
+ fieldsSelector = "vcard-adr";
+ addButtonId = "vcard-add-adr";
+ expectFocusSelector = "vcard-adr:last-of-type .vcard-type-selection";
+ break;
+ case "tz":
+ fieldsSelector = "vcard-tz";
+ addButtonId = "vcard-add-tz";
+ expectFocusSelector = "vcard-tz:last-of-type select";
+ break;
+ case "org":
+ fieldsSelector = "vcard-org";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ case "role":
+ fieldsSelector = "vcard-role";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ default:
+ throw new Error("entryName not found: " + entryName);
+ }
+ let fields = abDocument.querySelectorAll(fieldsSelector).length;
+ if (addIfNeeded && fields < count) {
+ let addButton = abDocument.getElementById(addButtonId);
+ for (let clickTimes = fields; clickTimes < count; clickTimes++) {
+ addButton.focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ let expectFocus = abDocument.querySelector(expectFocusSelector);
+ Assert.ok(
+ expectFocus,
+ `Expected focus element should now exist for ${entryName}`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(expectFocus),
+ `Expected focus element for ${entryName} should be visible`
+ );
+ Assert.equal(
+ expectFocus,
+ abDocument.activeElement,
+ `Expected focus element for ${entryName} should be active`
+ );
+ }
+ }
+ return abDocument.querySelectorAll(fieldsSelector);
+}
+
+function checkToolbarState(shouldBeEnabled) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ for (let id of [
+ "toolbarCreateBook",
+ "toolbarCreateContact",
+ "toolbarCreateList",
+ "toolbarImport",
+ ]) {
+ Assert.equal(
+ abDocument.getElementById(id).disabled,
+ !shouldBeEnabled,
+ id + (!shouldBeEnabled ? " should not" : " should") + " be disabled"
+ );
+ }
+}
+
+function checkDisplayValues(expected) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, values] of Object.entries(expected)) {
+ let section = abWindow.document.getElementById(key);
+ let items = Array.from(
+ section.querySelectorAll("li .entry-value"),
+ li => li.textContent
+ );
+ Assert.deepEqual(items, values);
+ }
+}
+
+function checkInputValues(expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ Assert.ok(BrowserTestUtils.is_visible(input));
+ if (input.type == "checkbox") {
+ Assert.equal(input.checked, value, `${key} checked`);
+ } else {
+ Assert.equal(input.value, value, `${key} value`);
+ }
+ }
+}
+
+function checkVCardInputValues(expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let fields = getFields(key, false, expectedEntries.length);
+
+ Assert.equal(
+ fields.length,
+ expectedEntries.length,
+ `${key} occurred ${fields.length} time(s) and ${expectedEntries.length} time(s) is expected.`
+ );
+
+ for (let [index, field] of fields.entries()) {
+ let expectedEntry = expectedEntries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "specialDate":
+ Assert.equal(
+ expectedEntry.value[0],
+ field.year.value,
+ `Year value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[1],
+ field.month.value,
+ `Month value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[2],
+ field.day.value,
+ `Day value of ${key} at position ${index}`
+ );
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+ let addressValue = [
+ field.streetEl.value,
+ field.localityEl.value,
+ field.regionEl.value,
+ field.codeEl.value,
+ field.countryEl.value,
+ ];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ addressValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "tz":
+ valueField = field.selectEl;
+ break;
+ case "org":
+ let orgValue = [field.orgEl.value];
+ if (field.unitEl.value) {
+ orgValue.push(field.unitEl.value);
+ }
+ Assert.deepEqual(
+ expectedEntry.value,
+ orgValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ }
+
+ // Check the input value of the field.
+ if (valueField) {
+ Assert.equal(
+ expectedEntry.value,
+ valueField.value,
+ `Value of ${key} at position ${index}`
+ );
+ }
+
+ // Check the type of the field.
+ if (expectedEntry.type || typeField) {
+ Assert.equal(
+ expectedEntry.type || "",
+ typeField.value,
+ `Type of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function checkCardValues(card, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ if (value) {
+ Assert.equal(
+ card.getProperty(key, "WRONG!"),
+ value,
+ `${key} has the right value`
+ );
+ } else {
+ Assert.equal(
+ card.getProperty(key, "RIGHT!"),
+ "RIGHT!",
+ `${key} has no value`
+ );
+ }
+ }
+}
+
+function checkVCardValues(card, expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let cardValues = card.vCardProperties.getAllEntries(key);
+
+ Assert.equal(
+ expectedEntries.length,
+ cardValues.length,
+ `${key} is expected to occur ${expectedEntries.length} time(s) and ${cardValues.length} time(s) is found.`
+ );
+
+ for (let [index, entry] of cardValues.entries()) {
+ let expectedEntry = expectedEntries[index];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ entry.value,
+ `Value of ${key} at position ${index}`
+ );
+
+ if (entry.params.type || expectedEntry.type) {
+ Assert.equal(
+ expectedEntry.type,
+ entry.params.type,
+ `Type of ${key} at position ${index}`
+ );
+ }
+
+ if (entry.params.pref || expectedEntry.pref) {
+ Assert.equal(
+ expectedEntry.pref,
+ entry.params.pref,
+ `Pref of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ if (input.type == "checkbox") {
+ EventUtils.synthesizeMouseAtCenter(input, {}, abWindow);
+ Assert.equal(
+ input.checked,
+ value,
+ `${key} ${value ? "checked" : "unchecked"}`
+ );
+ } else {
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Uses EventUtils.synthesizeMouseAtCenter and XULPopup.activateItem to
+ * activate optionValue from the select element typeField.
+ *
+ * @param {HTMLSelectElement} typeField Select element.
+ * @param {string} optionValue The value attribute of the option element from
+ * typeField.
+ */
+async function activateTypeSelect(typeField, optionValue) {
+ let abWindow = getAddressBookWindow();
+ // Ensure that the select field is inside the viewport.
+ typeField.scrollIntoView({ block: "nearest" });
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ EventUtils.synthesizeMouseAtCenter(typeField, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ // Get the index of the optionValue from typeField
+ let index = Array.from(typeField.children).findIndex(
+ child => child.value === optionValue
+ );
+ Assert.ok(index >= 0, "Type in select field found");
+
+ // No change event is fired if the same option is activated.
+ if (index === typeField.selectedIndex) {
+ let popupHidden = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
+ selectPopup.hidePopup();
+ await popupHidden;
+ return;
+ }
+
+ // The change event saves the vCard value.
+ let changeEvent = BrowserTestUtils.waitForEvent(typeField, "change");
+ selectPopup.activateItem(selectPopup.children[index]);
+ await changeEvent;
+}
+
+async function setVCardInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, entries] of Object.entries(changes)) {
+ let fields = getFields(key, true, entries.length);
+ // Somehow prevents an error on macOS when using <select> widgets that
+ // have just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ for (let [index, field] of fields.entries()) {
+ let changeEntry = entries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+
+ if (
+ (field.checkboxEl.checked && changeEntry && !changeEntry.pref) ||
+ (!field.checkboxEl.checked &&
+ changeEntry &&
+ changeEntry.pref == "1")
+ ) {
+ field.checkboxEl.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(field.checkboxEl, {}, abWindow);
+ }
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "specialDate":
+ if (changeEntry && changeEntry.value) {
+ field.month.value = changeEntry.value[1];
+ field.day.value = changeEntry.value[2];
+ field.year.value = changeEntry.value[0];
+ } else {
+ field.month.value = "";
+ field.day.value = "";
+ field.year.value = "";
+ }
+
+ if (changeEntry && changeEntry.key === "bday") {
+ field.selectEl.value = "bday";
+ } else {
+ field.selectEl.value = "anniversary";
+ }
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+
+ for (let [index, input] of [
+ field.streetEl,
+ field.localityEl,
+ field.regionEl,
+ field.codeEl,
+ field.countryEl,
+ ].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "tz":
+ if (changeEntry && changeEntry.value) {
+ field.selectEl.value = changeEntry.value;
+ } else {
+ field.selectEl.value = "";
+ }
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "org":
+ for (let [index, input] of [field.orgEl, field.unitEl].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ case "custom":
+ valueField = field.querySelector("vcard-custom:last-of-type input");
+ break;
+ }
+
+ if (valueField) {
+ valueField.select();
+ if (changeEntry && changeEntry.value) {
+ EventUtils.sendString(changeEntry.value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+
+ if (typeField && changeEntry && changeEntry.type) {
+ await activateTypeSelect(typeField, changeEntry.type);
+ } else if (typeField) {
+ await activateTypeSelect(typeField, "");
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Open the contact at the given index in the #cards element.
+ *
+ * @param {number} index - The index of the contact to edit.
+ * @param {object} options - Options for how the contact is selected for
+ * editing.
+ * @param {boolean} options.useMouse - Whether to use mouse events to select the
+ * contact. Otherwise uses keyboard events.
+ * @param {boolean} options.useActivate - Whether to activate the contact for
+ * editing directly from the #cards list using "Enter" or double click.
+ * Otherwise uses the "Edit" button in the contact display.
+ */
+async function editContactAtIndex(index, options) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ if (!options.useMouse) {
+ cardsList.table.body.focus();
+ if (cardsList.currentIndex != index) {
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey("KEY_Home", {}, abWindow);
+ for (let i = 0; i < index; i++) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ }
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ }
+ }
+
+ if (options.useActivate) {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 2 },
+ abWindow
+ );
+ } else {
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ }
+ } else {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ }
+
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ } else {
+ while (abDocument.activeElement != editButton) {
+ EventUtils.synthesizeKey("KEY_Tab", {}, abWindow);
+ }
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ }
+ }
+
+ await inEditingMode();
+}
+
+add_task(async function test_basic_edit() {
+ let book = createAddressBook("Test Book");
+ book.addCard(createContact("contact", "1"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let booksList = abDocument.getElementById("books");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewContactNickName = abDocument.getElementById("viewContactNickName");
+ let viewContactEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editContactName = abDocument.getElementById("editContactHeadingName");
+ let editContactNickName = abDocument.getElementById(
+ "editContactHeadingNickName"
+ );
+ let editContactEmail = abDocument.getElementById("editContactHeadingEmail");
+
+ /**
+ * Assert that the heading has the expected text content and visibility.
+ *
+ * @param {Element} headingEl - The heading to test.
+ * @param {string} expect - The expected text content. If this is "", the
+ * heading is expected to be hidden as well.
+ */
+ function assertHeading(headingEl, expect) {
+ Assert.equal(
+ headingEl.textContent,
+ expect,
+ `Heading ${headingEl.id} content should match`
+ );
+ if (expect) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ }
+ }
+
+ /**
+ * Assert the headings shown in the contact view page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertViewHeadings(name, nickname, email) {
+ assertHeading(viewContactName, name);
+ assertHeading(viewContactNickName, nickname);
+ assertHeading(viewContactEmail, email);
+ }
+
+ /**
+ * Assert the headings shown in the contact edit page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertEditHeadings(name, nickname, email) {
+ assertHeading(editContactName, name);
+ assertHeading(editContactNickName, nickname);
+ assertHeading(editContactEmail, email);
+ }
+
+ Assert.ok(detailsPane.hidden);
+ Assert.ok(!document.querySelector("vcard-n"));
+ Assert.ok(!abDocument.getElementById("vcard-email").children.length);
+
+ // Select a card in the list. Check the display in view mode.
+
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Try to trigger the creation of a new contact while in edit mode.
+ EventUtils.synthesizeKey("n", { ctrlKey: true }, abWindow);
+
+ // Headings reflect initial values and shouldn't have changed.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Check that pressing Tab can't get us stuck on an element that shouldn't
+ // have focus.
+
+ abDocument.documentElement.focus();
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+ Assert.ok(
+ abDocument
+ .getElementById("editContactForm")
+ .contains(abDocument.activeElement),
+ "focus should be on the editing form"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element again"
+ );
+
+ // Check that clicking outside the form doesn't steal focus.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element"
+ );
+ EventUtils.synthesizeMouseAtCenter(cardsList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element still"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make sure the header values reflect the fields values.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Make some changes but cancel them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ PrimaryEmail: "contact.1.edited@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Headings reflect new values.
+ assertEditHeadings(
+ "contact one",
+ "contact nickname",
+ "contact.1.edited@invalid"
+ );
+
+ // Change the preferred email to the secondary.
+ setInputValues({
+ SecondEmailCheckbox: true,
+ });
+ // The new email value should be reflected in the heading.
+ assertEditHeadings("contact one", "contact nickname", "i@roman.invalid");
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Heading reflects initial values.
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ });
+
+ // Click to edit again. The changes should have been reversed.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ // Headings are restored.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make some changes again, and this time save them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ assertEditHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Headings show new values
+ assertViewHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid", "i@roman.invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Click to edit again. The new values should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Click to edit again. This time make some changes.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Cancel the edit by pressing the Escape key and cancel the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.ok(
+ abWindow.detailsPane.isEditing,
+ "still editing after cancelling prompt"
+ );
+
+ // Cancel the edit by pressing the Escape key and accept the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ LastName: "11",
+ DisplayName: "person 11",
+ SecondEmail: "xi@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key and discard the changes.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Make some changes again, and this time save them by pressing Enter.
+
+ setInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ SecondEmail: null,
+ });
+
+ getInput("SecondEmail").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_fields() {
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "true");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The order of the FirstName and LastName fields can be reversed by L10n.
+ // This means they can be broken by L10n. Check that they're alright in the
+ // default configuration. We need to find a more robust way of doing this,
+ // but it is what it is for now.
+
+ let firstName = abDocument.getElementById("FirstName");
+ let lastName = abDocument.getElementById("LastName");
+ Assert.equal(
+ firstName.compareDocumentPosition(lastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "LastName follows FirstName"
+ );
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ let phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ let phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_visible(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_visible(phoneticLastName));
+ Assert.equal(
+ phoneticFirstName.compareDocumentPosition(phoneticLastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "PhoneticLastName follows PhoneticFirstName"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "false");
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticLastName));
+
+ await closeAddressBookWindow();
+
+ Services.prefs.clearUserPref("mail.addr_book.show_phonetic_fields");
+}).skip(); // Phonetic fields not implemented.
+
+/**
+ * Test that the display name field is populated when it should be, and not
+ * when it shouldn't be.
+ */
+add_task(async function test_generate_display_name() {
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Try saving an empty contact.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // First name, no last name.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first" });
+
+ // Last name, no first name.
+ setInputValues({ FirstName: "", LastName: "last" });
+ checkInputValues({ DisplayName: "last" });
+
+ // Both names.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first last" });
+
+ // Modify the display name, it should not be overwritten.
+ setInputValues({ DisplayName: "don't touch me" });
+ setInputValues({ FirstName: "second" });
+ checkInputValues({ DisplayName: "don't touch me" });
+
+ // Clear the modified display name, it should still not be overwritten.
+ setInputValues({ DisplayName: "" });
+ setInputValues({ FirstName: "third" });
+ checkInputValues({ DisplayName: "" });
+
+ // Flip the order.
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "true"
+ );
+ setInputValues({ FirstName: "fourth" });
+ checkInputValues({ DisplayName: "" });
+
+ // Turn off generation.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.displayName.autoGeneration",
+ false
+ );
+ setInputValues({ FirstName: "fifth" });
+ checkInputValues({ DisplayName: "" });
+
+ setInputValues({ DisplayName: "last, fourth" });
+
+ // Save the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ checkCardValues(personalBook.childCards[0], {
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+ Assert.ok(!abWindow.detailsPane.isDirty, "dirty flag is cleared");
+
+ // Reset the order and turn generation back on.
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ // Reload the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Clear all required values.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ });
+
+ // Try saving the empty contact.
+ promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // Close the edit without saving.
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ // Enter edit mode again. The values shouldn't have changed.
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Check the saved name isn't overwritten.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "last, fourth" });
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ Services.prefs.clearUserPref("mail.addr_book.displayName.autoGeneration");
+ Services.prefs.clearUserPref("mail.addr_book.displayName.lastnamefirst");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Test that the "prefer display name" checkbox is visible when it should be
+ * (in edit mode and only if there is a display name).
+ */
+add_task(async function test_prefer_display_name() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Make a new card. Check the default value is true.
+ // The display name shouldn't be affected by first and last name if the field
+ // is not empty.
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+
+ checkInputValues({ DisplayName: "", PreferDisplayName: true });
+
+ setInputValues({ DisplayName: "test" });
+ setInputValues({ FirstName: "first" });
+
+ checkInputValues({ DisplayName: "test" });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "1",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({ DisplayName: "test" });
+ checkInputValues({ FirstName: "first" });
+
+ // Change the card value.
+
+ let preferDisplayName = abDocument.querySelector(
+ "vcard-fn #vCardPreferDisplayName"
+ );
+ EventUtils.synthesizeMouseAtCenter(preferDisplayName, {}, abWindow);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "0",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+
+ preferDisplayName.checked = true; // Ensure it gets set.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Clear the display name. The first and last name shouldn't affect it.
+ setInputValues({ DisplayName: "" });
+ checkInputValues({ FirstName: "first" });
+
+ setInputValues({ LastName: "last" });
+ checkInputValues({ DisplayName: "" });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Checks the state of the toolbar buttons is restored after editing.
+ */
+add_task(async function test_toolbar_state() {
+ personalBook.addCard(createContact("contact", "2"));
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // In All Address Books, the "create card" and "create list" buttons should
+ // be disabled.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // In other directories, all buttons should be enabled.
+
+ await openDirectory(personalBook);
+ checkToolbarState(true);
+
+ // Back to All Address Books.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // Select a card, no change.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ checkToolbarState(true);
+
+ // Edit a card, all buttons disabled.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Edit a card again, all buttons disabled.
+
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_delete_button() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let searchInput = abDocument.getElementById("searchInput");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane), "details pane is hidden");
+
+ // Create a new card. The delete button shouldn't be visible at this point.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ setInputValues({
+ FirstName: "delete",
+ LastName: "me",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+ let contact = personalBook.childCards[0];
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, cancel the deletion.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(abWindow.detailsPane.isEditing, "still in editing mode");
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+
+ // Click to delete, accept the deletion.
+
+ let deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ let [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, contact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(
+ cardsList.view.directory.UID,
+ personalBook.UID,
+ "view didn't change"
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ // Now let's delete a contact while viewing a list.
+
+ let listContact = createContact("delete", "me too");
+ let list = personalBook.addMailList(createMailingList("a list"));
+ list.addCard(listContact);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ openDirectory(list);
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, accept the deletion.
+ deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, listContact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(cardsList.view.directory.UID, list.UID, "view didn't change");
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ personalBook.deleteDirectory(list);
+ await closeAddressBookWindow();
+});
+
+function checkNFieldState({ prefix, middlename, suffix }) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ Assert.equal(abDocument.querySelectorAll("vcard-n").length, 1);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-firstname")),
+ "Firstname is always shown."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-lastname")),
+ "Lastname is always shown."
+ );
+
+ for (let [subValueName, inputId, buttonSelector, inputVisible] of [
+ ["prefix", "vcard-n-prefix", "#n-list-component-prefix button", prefix],
+ [
+ "middlename",
+ "vcard-n-middlename",
+ "#n-list-component-middlename button",
+ middlename,
+ ],
+ ["suffix", "vcard-n-suffix", "#n-list-component-suffix button", suffix],
+ ]) {
+ let inputEl = abDocument.getElementById(inputId);
+ Assert.ok(inputEl);
+ let buttonEl = abDocument.querySelector(buttonSelector);
+ Assert.ok(buttonEl);
+
+ if (inputVisible) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(inputEl),
+ `${subValueName} input is shown with an initial value or a click on the button.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(buttonEl),
+ `${subValueName} button is hidden when the input is shown.`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(inputEl),
+ `${subValueName} input is not shown initially.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(buttonEl),
+ `${subValueName} button is shown when the input is hidden.`
+ );
+ }
+ }
+}
+
+/**
+ * Save repeatedly names of two contacts and ensure that no fields are leaking
+ * to another card.
+ */
+add_task(async function test_name_fields() {
+ let book = createAddressBook("Test Book N Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, {});
+
+ // Check for the original values of contact1.
+ checkInputValues({ FirstName: "contact1", LastName: "lastname1" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "", "", ""] }],
+ });
+
+ // Edit contact1 set all n values.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, {});
+
+ // Check for the original values of contact2 after saving contact1.
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact1 and change the values to only firstname and lastname values
+ // to see that the button/input handling of the field is correct.
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ setInputValues({
+ Prefix: "",
+ FirstName: "contact1 changed",
+ MiddleName: "",
+ LastName: "lastname1 changed",
+ Suffix: "",
+ });
+
+ // Fields are still visible until the contact is saved and edited again.
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1 changed", "contact1 changed", "", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Check in contact1 that prefix, middlename and suffix inputs are hidden
+ // again. Then remove the N last values and save.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact1 changed",
+ LastName: "lastname1 changed",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ // Let firstname and lastname empty for contact1.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // If useActivate is called, expect the focus to return to the cards list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({ FirstName: "", LastName: "" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ setInputValues({
+ Prefix: "prefix 2",
+ FirstName: "contact2",
+ MiddleName: "middle name",
+ LastName: "lastname2",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that prefix, middlename and lastname are correctly shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, {});
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ checkInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Checks if the default choice is visible or hidden.
+ * If the default choice is expected checks that at maximum one
+ * default email is ticked.
+ *
+ * @param {boolean} expectedDefaultChoiceVisible
+ * @param {number} expectedDefaultIndex
+ */
+async function checkDefaultEmailChoice(
+ expectedDefaultChoiceVisible,
+ expectedDefaultIndex
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let emailFields = abDocument.querySelectorAll(`#vcard-email tr`);
+
+ for (let [index, emailField] of emailFields.entries()) {
+ if (expectedDefaultChoiceVisible) {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(emailField.checkboxEl),
+ `Email at index ${index} has a visible default email choice.`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_hidden(emailField.checkboxEl),
+ `Email at index ${index} has a hidden default email choice.`
+ );
+ }
+
+ // Default email checking of the field.
+ Assert.equal(
+ expectedDefaultIndex === index,
+ emailField.checkboxEl.checked,
+ `Pref of email at position ${index}`
+ );
+ }
+
+ // Check that at max one checkbox is ticked.
+ if (expectedDefaultChoiceVisible) {
+ let checked = Array.from(emailFields).filter(
+ emailField => emailField.checkboxEl.checked
+ );
+ Assert.ok(
+ checked.length <= 1,
+ "At maximum one email is ticked for the default email."
+ );
+ }
+}
+
+add_task(async function test_email_fields() {
+ let book = createAddressBook("Test Book Email Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useActivate: true });
+
+ // Check for the original values of contact1.
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // Focus moves to cards list if we activate the edit directly from the list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 set type.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ // Check for the original values of contact2.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 and add another email to see that the default email
+ // choosing is visible.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Choose another default email in contact1.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Remove the first email from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [{}, { value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Add multiple emails to contact2 and click each as the default email.
+ // The last default clicked email should be set as default email and
+ // only one should be selected.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ // Remove 3 emails from contact2.
+ await editContactAtIndex(1, { useActivate: true, useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ await setVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ // For this case the default email is left on an empty field which will be
+ // removed.
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that the default email choosing is not shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [{ value: "another.contact1@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_fields() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book VCard Fields");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+ let contact2 = createContact("contact2", "lastname");
+ book.addCard(contact2);
+
+ openDirectory(book);
+
+ let cardsList = abDocument.getElementById("cards");
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Check that no field is initially shown with a new contact.
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ for (let [selector, label] of [
+ ["vcard-impp", "Chat accounts"],
+ ["vcard-url", "Websites"],
+ ["vcard-tel", "Phone numbers"],
+ ["vcard-note", "Notes"],
+ ["vcard-special-dates", "Special dates"],
+ ["vcard-adr", "Addresses"],
+ ["vcard-tz", "Time Zone"],
+ ["vcard-role", "Organizational properties"],
+ ["vcard-title", "Organizational properties"],
+ ["vcard-org", "Organizational properties"],
+ ]) {
+ Assert.equal(
+ abDocument.querySelectorAll(selector).length,
+ 0,
+ `${label} are not initially shown.`
+ );
+ }
+
+ // Cancel the new contact creation.
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(searchInput);
+
+ // Set values for contact1 with one entry for each field.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [{ value: "1980-12-15" }],
+ adr: [
+ {
+ value: [
+ "",
+ "",
+ "123 Main Street",
+ "Any Town",
+ "CA",
+ "91921-1234",
+ "U.S.A",
+ ],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Edit the same contact and set multiple fields.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Switch from contact1 to contact2 and set some entries.
+ // Ensure that no fields from contact1 are leaked.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: ["Organization contact 2"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Ensure that no fields from contact2 are leaked to contact1.
+ // Check and remove all values from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [{}, {}, {}],
+ url: [{}, {}, {}],
+ tel: [{}, {}, {}],
+ note: [{}],
+ specialDate: [{}, {}, {}, {}],
+ adr: [{}, {}, {}],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check contact2 make changes and cancel.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "" }],
+ url: [
+ { value: "https://www.thunderbird.net" },
+ { value: "www.another.url", type: "work" },
+ ],
+ tel: [{ value: "650-903-0800" }, { value: "+123 456 789", type: "home" }],
+ note: [],
+ specialDate: [{}, { value: [1980, 12, 15], key: "anniversary" }],
+ adr: [],
+ tz: [],
+ role: [{ value: "Some Role contact 2" }],
+ title: [],
+ org: [{ value: "Some Organization" }],
+ });
+
+ // Cancel the changes.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that the cancel for contact2 worked cancel afterwards.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that no values from contact2 are leaked to contact1 when cancelling.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_minimal() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ let addOrgButton = abDocument.getElementById("vcard-add-org");
+ addOrgButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addOrgButton, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-title")),
+ "Title should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-role")),
+ "Role should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-org")),
+ "Organization should be visible"
+ );
+
+ abDocument.querySelector("vcard-org input").value = "FBI";
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+
+ // Should allow to save with only Organization filled.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(personalBook.childCards[0], {
+ org: [{ value: "FBI" }],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Switches to different types to verify that all works accordingly.
+ */
+add_task(async function test_type_selection() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Type Selection");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ await editContactAtIndex(0, {});
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid", type: "work" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", type: "work", pref: "1" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Other vCard contacts are using uppercase types for the predefined spec
+ * labels. This tests our support for them for the edit of a contact.
+ */
+add_task(async function test_support_types_uppercase() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Uppercase Type Support");
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Add a card with uppercase types.
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:contact 1
+ TEL:+123456 789
+ TEL;TYPE=HOME:809 HOME 77 666 8
+ TEL;TYPE=WORK:+111 WORK 3456789
+ TEL;TYPE=CELL:+123 CELL 456 789
+ TEL;TYPE=FAX:809 FAX 77 666 8
+ TEL;TYPE=PAGER:+111 PAGER 3456789
+ END:VCARD
+`)
+ );
+
+ openDirectory(book);
+
+ // First open the edit and check that the values are shown.
+ // Do not change anything.
+ await editContactAtIndex(0, {});
+
+ // The UI uses lowercase types but only changes them when the type is
+ // touched.
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // We haven't touched these values so they are not changed to lower case.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "HOME" },
+ { value: "+111 WORK 3456789", type: "WORK" },
+ { value: "+123 CELL 456 789", type: "CELL" },
+ { value: "809 FAX 77 666 8", type: "FAX" },
+ { value: "+111 PAGER 3456789", type: "PAGER" },
+ ],
+ });
+
+ // Now make changes to the types.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // As we touched the type values they are now saved in lowercase.
+ // At this point it is up to the other vCard implementation to handle this.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_date_field() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Add data to the default values to allow saving.
+ setInputValues({
+ FirstName: "contact",
+ PrimaryEmail: "contact.1.edited@invalid",
+ });
+
+ let addSpecialDate = abDocument.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addSpecialDate, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-special-date")),
+ "The special date field is visible."
+ );
+ // Somehow prevents an error on macOS when using <select> widgets that have
+ // just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ let firstYear = abDocument.querySelector(
+ `vcard-special-date input[type="number"]`
+ );
+ Assert.ok(!firstYear.value, "year empty");
+ let firstMonth = abDocument.querySelector(
+ `vcard-special-date .vcard-month-select`
+ );
+ Assert.equal(firstMonth.value, "", "month should be on placeholder");
+ let firstDay = abDocument.querySelector(
+ `vcard-special-date .vcard-day-select`
+ );
+ Assert.equal(firstDay.value, "", "day should be on placeholder");
+ Assert.equal(firstDay.childNodes.length, 32, "all days should be possible");
+
+ // Set date to a leap year.
+ firstYear.value = 2004;
+
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ firstMonth.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(firstMonth, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ let changePromise = BrowserTestUtils.waitForEvent(firstMonth, "change");
+ selectPopup.activateItem(selectPopup.children[2]);
+ await changePromise;
+
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 30, // 29 days + empty option 0.
+ "day options filled with leap year"
+ );
+
+ // No leap year.
+ firstYear.select();
+ EventUtils.sendString("2003");
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 29, // 28 days + empty option 0.
+ "day options filled without leap year"
+ );
+
+ // Remove the field.
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(`vcard-special-date .remove-property-button`),
+ {},
+ abWindow
+ );
+
+ Assert.ok(
+ !abDocument.querySelector("vcard-special-date"),
+ "The special date field was removed."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are editable.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ let customField = getFields("custom")[0];
+ let inputs = customField.querySelectorAll("input");
+ Assert.equal(inputs.length, 4);
+ Assert.equal(inputs[0].value, "");
+ Assert.equal(inputs[1].value, "custom two");
+ Assert.equal(inputs[2].value, "x-custom three");
+ Assert.equal(inputs[3].value, "x-custom four");
+
+ inputs[0].value = "x-custom one";
+ inputs[1].value = "x-custom two";
+ inputs[3].value = "";
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ card = personalBook.childCards.find(c => c.UID == card.UID);
+ checkCardValues(card, {
+ Custom2: null,
+ Custom4: null,
+ });
+ checkVCardValues(card, {
+ "x-custom1": [{ value: "x-custom one" }],
+ "x-custom2": [{ value: "x-custom two" }],
+ "x-custom3": [{ value: "x-custom three" }],
+ "x-custom4": [],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ EMAIL:test\\\\test@invalid
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "en\\c:oding",
+ LastName: "test",
+ DisplayName: "en\\c:oding test",
+ });
+
+ checkVCardInputValues({
+ title: [
+ { value: "title:title;title,title\\title\\:title\\;title\\,title\\\\" },
+ ],
+ tel: [{ value: "tel:01234567" }],
+ email: [{ value: "test\\test@invalid" }],
+ note: [{ value: "notes:\nnotes;\nnotes,\nnotes\\" }],
+ url: [{ value: "https://host/url:url;url,url\\url" }],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+/**
+ * Tests that contacts with nickname can be edited.
+ */
+add_task(async function testNickname() {
+ let book = createAddressBook("Nick");
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:jsmith@example.org
+ NICKNAME:Johnny
+ N:SMITH;JOHN;;;
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(book);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "JOHN",
+ LastName: "SMITH",
+ NickName: "Johnny",
+ PrimaryEmail: "jsmith@example.org",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_remove_button() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let book = createAddressBook("Test Book VCard Fields");
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ await editContactAtIndex(0, {});
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let removeButtons = detailsPane.querySelectorAll(".remove-property-button");
+ Assert.equal(
+ removeButtons.length,
+ 2,
+ "Email and Organization Properties remove button is present."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument
+ .getElementById("addr-book-edit-email")
+ .querySelector(".remove-property-button")
+ ),
+ "Email is present and remove button is visible."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument
+ .getElementById("addr-book-edit-org")
+ .querySelector(".remove-property-button")
+ ),
+ "Organization Properties are not filled and the remove button is not visible."
+ );
+
+ // Set a value for each field.
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [{ value: [1966, 12, 15], key: "bday" }],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ custom: [{ value: "foo" }],
+ });
+
+ let vCardEdit = detailsPane.querySelector("vcard-edit");
+
+ // Click the remove buttons and check that the properties are removed.
+
+ for (let [propertyName, fieldsetId, propertySelector, addButton] of [
+ ["adr", "addr-book-edit-address", "vcard-adr"],
+ ["impp", "addr-book-edit-impp", "vcard-impp"],
+ ["tel", "addr-book-edit-tel", "vcard-tel"],
+ ["url", "addr-book-edit-url", "vcard-url"],
+ ["email", "addr-book-edit-email", "#vcard-email tr"],
+ ["bday", "addr-book-edit-bday-anniversary", "vcard-special-date"],
+ ["tz", "addr-book-edit-tz", "vcard-tz", "vcard-add-tz"],
+ ["note", "addr-book-edit-note", "vcard-note", "vcard-add-note"],
+ ["org", "addr-book-edit-org", "vcard-org", "vcard-add-org"],
+ ["x-custom1", "addr-book-edit-custom", "vcard-custom", "vcard-add-custom"],
+ ]) {
+ Assert.ok(
+ vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is present.`
+ );
+ let removeButton = abDocument
+ .getElementById(fieldsetId)
+ .querySelector(".remove-property-button");
+
+ removeButton.scrollIntoView({ block: "nearest" });
+ let removeEvent = BrowserTestUtils.waitForEvent(
+ vCardEdit,
+ "vcard-remove-property"
+ );
+ EventUtils.synthesizeMouseAtCenter(removeButton, {}, abWindow);
+ await removeEvent;
+
+ await Assert.ok(
+ !vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is removed.`
+ );
+ Assert.equal(
+ vCardEdit.querySelectorAll(propertySelector).length,
+ 0,
+ `All elements representing ${propertyName} are removed.`
+ );
+
+ // For single entries the add button have to be visible again.
+ // Time Zone, Notes, Organizational Properties, Custom Properties
+ if (addButton) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById(addButton)),
+ `Add button for ${propertyName} is visible after remove.`
+ );
+ Assert.equal(
+ abDocument.activeElement.id,
+ addButton,
+ `The focus for ${propertyName} was moved to the add button.`
+ );
+ }
+ }
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_photo.js b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
new file mode 100644
index 0000000000..0b0da4771d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
@@ -0,0 +1,866 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+async function waitForDialogOpenState(state) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ await TestUtils.waitForCondition(
+ () => dialog.open == state,
+ "waiting for photo dialog to change state"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+}
+
+async function waitForPreviewChange() {
+ let abWindow = getAddressBookWindow();
+ let preview = abWindow.document.querySelector("#photoDialog svg > image");
+ let oldValue = preview.getAttribute("href");
+ await BrowserTestUtils.waitForEvent(
+ preview,
+ "load",
+ false,
+ () => preview.getAttribute("href") != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+}
+
+async function waitForPhotoChange() {
+ let abWindow = getAddressBookWindow();
+ let photo = abWindow.document.querySelector("#photoButton .contact-photo");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let oldValue = photo.src;
+ await BrowserTestUtils.waitForMutationCondition(
+ photo,
+ { attributes: true },
+ () => photo.src != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+ Assert.ok(!dialog.open, "dialog was closed when photo changed");
+}
+
+function dropFile(target, path) {
+ let abWindow = getAddressBookWindow();
+ let file = new FileUtils.File(getTestFilePath(path));
+
+ let dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = "copy";
+ dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_COPY);
+ dragService.getCurrentSession().dataTransfer = dataTransfer;
+
+ EventUtils.synthesizeDragOver(
+ target,
+ target,
+ [{ type: "application/x-moz-file", data: file }],
+ "copy",
+ abWindow
+ );
+
+ // This make sure that the fake dataTransfer has still the expected drop
+ // effect after the synthesizeDragOver call.
+ dataTransfer.dropEffect = "copy";
+
+ EventUtils.synthesizeDropAfterDragOver(null, dataTransfer, target, abWindow, {
+ _domDispatchOnly: true,
+ });
+
+ dragService.endDragSession(true);
+}
+
+function checkDialogElements({
+ dropTargetClass = "",
+ svgVisible = false,
+ saveButtonVisible = false,
+ saveButtonDisabled = false,
+ discardButtonVisible = false,
+}) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, discardButton } = dialog;
+ let dropTarget = dialog.querySelector("#photoDropTarget");
+ let svg = dialog.querySelector("svg");
+ Assert.equal(
+ BrowserTestUtils.is_visible(dropTarget),
+ !!dropTargetClass,
+ "drop target visibility"
+ );
+ if (dropTargetClass) {
+ Assert.stringContains(
+ dropTarget.className,
+ dropTargetClass,
+ "drop target message"
+ );
+ }
+ Assert.equal(BrowserTestUtils.is_visible(svg), svgVisible, "SVG visibility");
+ Assert.equal(
+ BrowserTestUtils.is_visible(saveButton),
+ saveButtonVisible,
+ "save button visibility"
+ );
+ Assert.equal(
+ saveButton.disabled,
+ saveButtonDisabled,
+ "save button disabled state"
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(discardButton),
+ discardButtonVisible,
+ "discard button visibility"
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ }
+
+ return null;
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+add_setup(async function () {
+ await openAddressBookWindow();
+ openDirectory(personalBook);
+});
+
+registerCleanupFunction(async function cleanUp() {
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+ await CardDAVServer.close();
+});
+
+/** Create a new contact. We'll add a photo to this contact. */
+async function subtest_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Save the contact.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 1",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ // Photo shown in view.
+ Assert.notEqual(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Create another new contact. This time we'll add a photo, but discard it. */
+async function subtest_dont_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, cancelButton, discardButton } = dialog;
+ let svg = dialog.querySelector("svg");
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Drop a file on the photo.
+
+ dropFile(photoButton, "data/photo2.jpg");
+ await waitForDialogOpenState(true);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_visible(svg));
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Cancel the photo dialog.
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Drop another file on the photo dialog.
+
+ previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo2.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Save the contact and check the photo was NOT saved.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 2",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Go back to the first contact and discard the photo. */
+async function subtest_discard_photo(book, checkPhotoCallback) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { discardButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(
+ checkPhotoCallback(viewPhoto.src),
+ "saved photo shown in contact view"
+ );
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.ok(
+ checkPhotoCallback(editPhoto.src),
+ "saved photo shown in edit view"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Save the contact and check the photo was removed.
+
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "photo no longer shown in contact view"
+ );
+
+ let [card, uid] = await updatedPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Check that pasting URLs on photo widgets works. */
+async function subtest_paste_url() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let dropTarget = abDocument.getElementById("photoDropTarget");
+
+ // Start a new contact and focus on the photo button.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ // Focus is on name prefix button.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ photoButton,
+ "photo button is focused"
+ );
+
+ // Paste a URL.
+
+ let previewChangePromise = waitForPreviewChange();
+
+ let wrapper1 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper1.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo1.jpg";
+ let transfer1 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer1.init(null);
+ transfer1.addDataFlavor("text/plain");
+ transfer1.setTransferData("text/plain", wrapper1);
+ Services.clipboard.setData(transfer1, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await waitForDialogOpenState(true);
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste a URL.
+
+ previewChangePromise = waitForPreviewChange();
+
+ let wrapper2 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper2.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo2.jpg";
+ let transfer2 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer2.init(null);
+ transfer2.addDataFlavor("text/plain");
+ transfer2.setTransferData("text/plain", wrapper2);
+ Services.clipboard.setData(transfer2, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste an invalid URL.
+
+ let wrapper3 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper3.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/fake.jpg";
+ let transfer3 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer3.init(null);
+ transfer3.addDataFlavor("text/plain");
+ transfer3.setTransferData("text/plain", wrapper3);
+ Services.clipboard.setData(transfer3, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await TestUtils.waitForCondition(() =>
+ dropTarget.classList.contains("drop-error")
+ );
+
+ checkDialogElements({
+ dropTargetClass: "drop-error",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+}
+
+/** Test photo operations with a local address book. */
+add_task(async function test_local() {
+ // Create a new contact. We'll add a photo to this contact.
+
+ let card1 = await subtest_add_photo(personalBook);
+ let photo1Name = card1.getProperty("PhotoName", "");
+ Assert.ok(photo1Name, "PhotoName property saved on card");
+
+ let photo1Path = PathUtils.join(profileDir, "Photos", photo1Name);
+ let photo1File = new FileUtils.File(photo1Path);
+ Assert.ok(photo1File.exists(), "photo saved to disk");
+
+ let image = new Image();
+ let loadedPromise = BrowserTestUtils.waitForEvent(image, "load");
+ image.src = Services.io.newFileURI(photo1File).spec;
+ await loadedPromise;
+
+ Assert.equal(image.naturalWidth, 300, "photo saved at correct width");
+ Assert.equal(image.naturalHeight, 300, "photo saved at correct height");
+
+ // Create another new contact. This time we'll add a photo, but discard it.
+
+ let card2 = await subtest_dont_add_photo(personalBook);
+ Assert.equal(
+ card2.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property not saved on card"
+ );
+
+ // Go back to the first contact and discard the photo.
+
+ let card3 = await subtest_discard_photo(personalBook, src =>
+ src.endsWith(photo1Name)
+ );
+ Assert.equal(
+ card3.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property removed from card"
+ );
+ Assert.ok(
+ !new FileUtils.File(photo1Path).exists(),
+ "photo removed from disk"
+ );
+
+ // Check that pasting URLs on photo widgets works.
+
+ await subtest_paste_url(personalBook);
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that only
+ * speaks vCard 3, i.e. Google.
+ */
+add_task(async function test_add_photo_carddav3() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("alice", "alice");
+ CardDAVServer.mimicGoogle = true;
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+ book.setBoolValue("carddav.vcard3", true);
+ book.wrappedJSObject._isGoogleCardDAV = true;
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card we received from the server. If the server didn't like it,
+ // the photo will be removed and this will fail.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO;ENCODING=B:/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.mimicGoogle = false;
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that can
+ * handle vCard 4.
+ */
+add_task(async function test_add_photo_carddav4() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("bob", "bob");
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "bob");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card we received from the server.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO:data:image/jpeg;base64\\,/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ console.log(serverCard.vCard);
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_ldap_search.js b/comm/mail/components/addrbook/test/browser/browser_ldap_search.js
new file mode 100644
index 0000000000..6eb7322bb4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_ldap_search.js
@@ -0,0 +1,180 @@
+/* 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 { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+const jsonFile =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/ldap_contacts.json";
+
+add_task(async () => {
+ function waitForCountChange(expectedCount) {
+ return new Promise(resolve => {
+ cardsList.addEventListener("rowcountchange", function onRowCountChange() {
+ console.log(cardsList.view.rowCount, expectedCount);
+ if (cardsList.view.rowCount == expectedCount) {
+ cardsList.removeEventListener("rowcountchange", onRowCountChange);
+ resolve();
+ }
+ });
+ });
+ }
+
+ // Set up some local people.
+
+ let cardsToRemove = [];
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cardsToRemove.push(card);
+ }
+
+ // Set up the LDAP server.
+
+ LDAPServer.open();
+ let response = await fetch(jsonFile);
+ let ldapContacts = await response.json();
+
+ let bookPref = MailServices.ab.newAddressBook(
+ "Mochitest",
+ `ldap://localhost:${LDAPServer.port}/`,
+ 0
+ );
+ let book = MailServices.ab.getDirectoryFromId(bookPref);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+ let noSearchResults = abDocument.getElementById("placeholderNoSearchResults");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // Search for some people in the LDAP directory.
+
+ openDirectory(book);
+ checkPlaceholders(["placeholderSearchOnly"]);
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.sendString("holmes", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.mycroft);
+ LDAPServer.writeSearchResultEntry(ldapContacts.sherlock);
+ LDAPServer.writeSearchResultDone();
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await waitForCountChange(2);
+ checkNamesListed("Mycroft Holmes", "Sherlock Holmes");
+ checkPlaceholders();
+
+ // Check that displaying an LDAP card works without error.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("john", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ // Now move back to the "All Address Books" view and search again.
+ // The search string is retained when switching books.
+
+ openAllAddressBooks();
+ checkNamesListed();
+ Assert.equal(searchBox.value, "john");
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("irene", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.irene);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("Irene Adler");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("jo", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed("jonathan");
+ checkPlaceholders();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(2);
+ checkNamesListed("John Watson", "jonathan");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("mark", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultDone();
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(noSearchResults)
+ );
+ checkNamesListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(cardsToRemove);
+ await promiseDirectoryRemoved(book.URI);
+ LDAPServer.close();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
new file mode 100644
index 0000000000..64d679ec13
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
@@ -0,0 +1,474 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MailServices, MailUtils */
+
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const inputs = {
+ abName: "Mochitest Address Book",
+ mlName: "Mochitest Mailing List",
+ nickName: "Nicky",
+ description: "Just a test mailing list.",
+ addresses: [
+ "alan@example.com",
+ "betty@example.com",
+ "clyde@example.com",
+ "deb@example.com",
+ ],
+ modification: " (modified)",
+};
+
+const getDisplayedAddress = address => `${address} <${address}>`;
+
+let global = {};
+
+/**
+ * Set up: create a new address book to hold the mailing list.
+ */
+add_task(async () => {
+ let bookPrefName = MailServices.ab.newAddressBook(
+ inputs.abName,
+ null,
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let addressBook = MailServices.ab.getDirectoryFromId(bookPrefName);
+
+ let abWindow = await openAddressBookWindow();
+
+ global = {
+ abWindow,
+ addressBook,
+ booksList: abWindow.booksList,
+ mailListUID: undefined,
+ };
+});
+
+/**
+ * Create a new mailing list with some addresses, in the new address book.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ let listName = mlDocument.getElementById("ListName");
+ if (mlDocument.activeElement != listName) {
+ await BrowserTestUtils.waitForEvent(listName, "focus");
+ }
+
+ let abPopup = mlDocument.getElementById("abPopup");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInputsCount = mlDocument
+ .getElementById("addressingWidget")
+ .querySelectorAll("input").length;
+
+ Assert.equal(
+ abPopup.label,
+ global.addressBook.dirName,
+ "the correct address book is selected in the menu"
+ );
+ Assert.equal(
+ abPopup.value,
+ global.addressBook.URI,
+ "the address book selected in the menu has the correct address book URI"
+ );
+ Assert.equal(listName.value, "", "no text in the list name field");
+ Assert.equal(listNickName.value, "", "no text in the list nickname field");
+ Assert.equal(listDescription.value, "", "no text in the description field");
+ Assert.equal(addressInput1.value, "", "no text in the addresses list");
+ Assert.equal(addressInputsCount, 1, "only one address list input exists");
+
+ EventUtils.sendString(inputs.mlName, mlWindow);
+
+ // Tab to nickname input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.nickName, mlWindow);
+
+ // Tab to description input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.description, mlWindow);
+
+ // Tab to address input and add addresses zero and one by entering
+ // both of them there.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.addresses.slice(0, 2).join(", "), mlWindow);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Select the address book.
+ openDirectory(global.addressBook);
+
+ // Open the new mailing list dialog, the callback above interacts with it.
+ EventUtils.synthesizeMouseAtCenter(
+ global.abWindow.document.getElementById("toolbarCreateList"),
+ { clickCount: 1 },
+ global.abWindow
+ );
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[0]),
+ "address zero was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[1]),
+ "address one was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[0]),
+ "address zero was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[1]),
+ "address one was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(inputs.mlName);
+
+ // Save the mailing list UID so we can confirm it is the same later.
+ global.mailListUID = mailList.UID;
+
+ Assert.ok(mailList, "mailing list was created");
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName),
+ "mailing list was created in the correct address book"
+ );
+ Assert.equal(mailList.dirName, inputs.mlName, "mailing list name was saved");
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName,
+ "mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description,
+ "mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+ Assert.equal(listCards.length, 2, "two cards exist in the mailing list");
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[1]),
+ "address one was saved in the mailing list"
+ );
+});
+
+/**
+ * Open the mailing list dialog and modify the mailing list.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#3")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#3"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#3") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#3"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName,
+ "list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName,
+ "list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description,
+ "list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[1]),
+ "address one is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 3, "no extraneous addresses are displayed");
+
+ // Add addresses two and three.
+ EventUtils.sendString(inputs.addresses.slice(2, 4).join(", "), mlWindow);
+ EventUtils.sendKey("RETURN", mlWindow);
+ await new Promise(resolve => mlWindow.setTimeout(resolve));
+
+ // Delete the address in the second row (address one).
+ EventUtils.synthesizeMouseAtCenter(
+ addressInput2,
+ { clickCount: 1 },
+ mlWindow
+ );
+ EventUtils.synthesizeKey("a", { accelKey: true }, mlWindow);
+ EventUtils.sendKey("BACK_SPACE", mlWindow);
+
+ // Modify the list's name, nick name, and description fields.
+ let modifyField = id => {
+ id.focus();
+ EventUtils.sendKey("DOWN", mlWindow);
+ EventUtils.sendString(inputs.modification, mlWindow);
+ };
+ modifyField(listName);
+ modifyField(listNickName);
+ modifyField(listDescription);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is displayed in the address book list`
+ );
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[2]),
+ "address two was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[3]),
+ "address three was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[2]),
+ "address two was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[3]),
+ "address three was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(
+ inputs.mlName + inputs.modification
+ );
+
+ Assert.equal(
+ mailList && mailList.UID,
+ global.mailListUID,
+ "mailing list still exists"
+ );
+
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName + inputs.modification),
+ "mailing list is still in the correct address book"
+ );
+ Assert.equal(
+ mailList.dirName,
+ inputs.mlName + inputs.modification,
+ "modified mailing list name was saved"
+ );
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName + inputs.modification,
+ "modified mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description + inputs.modification,
+ "modified mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+
+ Assert.equal(listCards.length, 3, "three cards exist in the mailing list");
+
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list (is still there)"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[2]),
+ "address two was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[2].hasEmailAddress(inputs.addresses[3]),
+ "address three was saved in the mailing list"
+ );
+
+ let hasAddressOne = listCards.find(card =>
+ card.hasEmailAddress(inputs.addresses[1])
+ );
+
+ Assert.ok(!hasAddressOne, "address one was deleted from the mailing list");
+});
+
+/**
+ * Open the mailing list dialog and confirm the changes are displayed.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mailingListWindow) {
+ let mlDocument = mailingListWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#4")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#4"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#4") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#4"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+ let addressInput3 = mlDocument.getElementById("addressCol1#3");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName + inputs.modification,
+ "modified list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName + inputs.modification,
+ "modified list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description + inputs.modification,
+ "modified list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly (is still there)"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[2]),
+ "address two is displayed correctly"
+ );
+ Assert.equal(
+ addressInput3 && addressInput3.value,
+ getDisplayedAddress(inputs.addresses[3]),
+ "address three is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 4, "no extraneous addresses are displayed");
+
+ mlDocElement.getButton("cancel").click();
+ });
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is still displayed in the address book list`
+ );
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+});
+
+/**
+ * Tear down: delete the address book and close the address book window.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ let deletePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(2).querySelector("span").textContent,
+ inputs.abName,
+ `address book ("${inputs.abName}") is displayed in the address book list`
+ );
+
+ global.booksList.focus();
+ global.booksList.selectedIndex = 2;
+ EventUtils.sendKey("DELETE", global.abWindow);
+
+ await Promise.all([mailingListWindowPromise, deletePromise]);
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == inputs.abName
+ );
+
+ Assert.ok(!addressBook, "address book was deleted");
+
+ closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_open_actions.js b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
new file mode 100644
index 0000000000..cb6f681ec8
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
@@ -0,0 +1,157 @@
+/* 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/. */
+
+let tabmail = document.getElementById("tabmail");
+let writableBook, writableCard, readOnlyBook, readOnlyCard;
+
+add_setup(function () {
+ writableBook = createAddressBook("writable book");
+ writableCard = writableBook.addCard(createContact("writable", "card"));
+
+ readOnlyBook = createAddressBook("read-only book");
+ readOnlyCard = readOnlyBook.addCard(createContact("read-only", "card"));
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+ });
+});
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+/**
+ * Tests than a `toAddressBook` call with no argument opens the Address Book.
+ * Then call it again with the tab open and check that it doesn't reload.
+ */
+add_task(async function testNoAction() {
+ let abWindow1 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ await notInEditingMode();
+
+ let abWindow2 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow2.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ tabmail.selectTabByIndex(undefined, 1);
+ let abWindow3 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow3.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ await closeAddressBookWindow();
+ Assert.equal(tabmail.tabInfo.length, 1);
+});
+
+/**
+ * Tests than a call to toAddressBook with only a create action opens the
+ * Address Book. A new blank card should open in edit mode.
+ */
+add_task(async function testCreateBlank() {
+ await window.toAddressBook({ action: "create" });
+ await inEditingMode();
+ // TODO check blank
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and an email
+ * address opens the Address Book. A new card with the email address should
+ * open in edit mode.
+ */
+add_task(async function testCreateWithAddress() {
+ await window.toAddressBook({ action: "create", address: "test@invalid" });
+ await inEditingMode();
+ // TODO check address matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and a vCard opens
+ * the Address Book. A new card should open in edit mode.
+ */
+add_task(async function testCreateWithVCard() {
+ await window.toAddressBook({
+ action: "create",
+ vCard:
+ "BEGIN:VCARD\r\nFN:a test person\r\nN:person;test;;a;\r\nEND:VCARD\r\n",
+ });
+ await inEditingMode();
+ // TODO check card matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a display action opens the Address
+ * Book. The card should be displayed.
+ */
+add_task(async function testDisplayCard() {
+ await window.toAddressBook({ action: "display", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a writable card
+ * opens the Address Book. The card should open in edit mode.
+ */
+add_task(async function testEditCardWritable() {
+ await window.toAddressBook({ action: "edit", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await inEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a read-only card
+ * opens the Address Book. The card should open in display mode.
+ */
+add_task(async function testEditCardReadOnly() {
+ await window.toAddressBook({ action: "edit", card: readOnlyCard });
+ checkDirectoryDisplayed(readOnlyBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "read-only contact");
+
+ await closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_search.js b/comm/mail/components/addrbook/test/browser/browser_search.js
new file mode 100644
index 0000000000..ab4f7a221f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_search.js
@@ -0,0 +1,139 @@
+/* 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/. */
+
+add_task(async () => {
+ async function doSearch(searchString, ...expectedCards) {
+ let viewChangePromise = BrowserTestUtils.waitForEvent(
+ cardsList,
+ "viewchange"
+ );
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ if (searchString) {
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString(searchString, abWindow);
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ } else {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ }
+
+ await viewChangePromise;
+ checkCardsListed(...expectedCards);
+ checkPlaceholders(
+ expectedCards.length ? [] : ["placeholderNoSearchResults"]
+ );
+ }
+
+ let cards = {};
+ let cardsToRemove = {
+ personal: [],
+ history: [],
+ };
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.personal.push(card);
+ }
+ for (let name of ["danielle", "katherine", "natalie", "susanah"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = historyBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.history.push(card);
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ registerCleanupFunction(() => {
+ abWindow.close();
+ personalBook.deleteCards(cardsToRemove.personal);
+ historyBook.deleteCards(cardsToRemove.history);
+ });
+
+ let abDocument = abWindow.document;
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ Assert.equal(
+ abDocument.activeElement,
+ searchBox,
+ "search box was focused when the page loaded"
+ );
+
+ // All address books.
+
+ checkCardsListed(
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+ checkPlaceholders();
+
+ // Personal address book.
+
+ openDirectory(personalBook);
+ checkCardsListed(cards.daniel, cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch("daniel", cards.daniel);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+
+ // History address book.
+
+ openDirectory(historyBook);
+ checkCardsListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await doSearch(
+ null,
+ cards.danielle,
+ cards.katherine,
+ cards.natalie,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.danielle);
+ await doSearch("nathan");
+
+ // All address books.
+
+ openAllAddressBooks();
+ checkCardsListed(cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.daniel, cards.danielle);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_telemetry.js b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
new file mode 100644
index 0000000000..36b73207c2
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to address book.
+ */
+
+let { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Test we're counting address books and contacts.
+ */
+add_task(async function test_address_book_count() {
+ Services.telemetry.clearScalars();
+
+ // Adding some address books and contracts.
+ let addrBook1 = createAddressBook("AB 1");
+ let addrBook2 = createAddressBook("AB 2");
+ let ldapBook = createAddressBook(
+ "LDAP Book",
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ let contact1 = createContact("test1", "example");
+ let contact2 = createContact("test2", "example");
+ let contact3 = createContact("test3", "example");
+ addrBook1.addCard(contact1);
+ addrBook2.addCard(contact2);
+ addrBook2.addCard(contact3);
+
+ // Run the probe.
+ MailTelemetryForTests.reportAddressBookTypes();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"]["moz-abldapdirectory"],
+ 1,
+ "LDAP address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"].jsaddrbook,
+ 4,
+ "JS address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.contact_count"].jsaddrbook,
+ 3,
+ "Contact count must be correct"
+ );
+
+ await promiseDirectoryRemoved(addrBook1.URI);
+ await promiseDirectoryRemoved(addrBook2.URI);
+ await promiseDirectoryRemoved(ldapBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbook.sjs b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
new file mode 100644
index 0000000000..bd28437261
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
@@ -0,0 +1,47 @@
+/* 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 handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <getetag/>
+ // <getctag/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:"
+ xmlns:card="urn:ietf:params:xml:ns:carddav"
+ xmlns:cs="http://calendarserver.org/ns/">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <cs:getctag>0</cs:getctag>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <getetag/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
new file mode 100644
index 0000000000..0380dee3ab
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
@@ -0,0 +1,62 @@
+/* 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 handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <displayname>Things found by DNS</displayname>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <displayname>You found me!</displayname>
+ <current-user-privilege-set>
+ <privilege>
+ <all/>
+ </privilege>
+ </current-user-privilege-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
new file mode 100644
index 0000000000..640d2acc54
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Echoes request headers as JSON so a test can check what was sent.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ let headers = {};
+ let enumerator = request.headers;
+ while (enumerator.hasMoreElements()) {
+ let header = enumerator.getNext().data;
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/dns.sjs b/comm/mail/components/addrbook/test/browser/data/dns.sjs
new file mode 100644
index 0000000000..11121cce7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/dns.sjs
@@ -0,0 +1,48 @@
+/* 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 handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-principal/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <current-user-principal>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ </current-user-principal>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-principal/>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/photo1.jpg b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
new file mode 100644
index 0000000000..35608787bf
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/photo2.jpg b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
new file mode 100644
index 0000000000..41fd1e90fc
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/principal.sjs b/comm/mail/components/addrbook/test/browser/data/principal.sjs
new file mode 100644
index 0000000000..659cd3cd91
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/principal.sjs
@@ -0,0 +1,38 @@
+/* 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 handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <addressbook-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <principal/>
+ </resourcetype>
+ <card:addressbook-home-set>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ </card:addressbook-home-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
new file mode 100644
index 0000000000..a9285c21d0
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the authorisation endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+ let params = new URLSearchParams(request.queryString);
+
+ if (request.method == "POST") {
+ response.setStatusLine(request.httpVersion, 303, "Redirected");
+ } else {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ }
+
+ let url = new URL(params.get("redirect_uri"));
+ url.searchParams.set("code", "success");
+ response.setHeader("Location", url.href);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/token.sjs b/comm/mail/components/addrbook/test/browser/data/token.sjs
new file mode 100644
index 0000000000..e070f8d55f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/token.sjs
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the token endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(request.bodyInputStream);
+
+ let input = stream.readBytes(request.bodyInputStream.available());
+ let params = new URLSearchParams(input);
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ if (params.get("refresh_token") == "expired_token") {
+ response.setStatusLine("1.1", 400, "Bad Request");
+ response.write(JSON.stringify({ error: "invalid_grant" }));
+ return;
+ }
+
+ let data = { access_token: "bobs_access_token" };
+
+ if (params.get("code") == "success") {
+ // Authorisation just happened, set a different access token so the test
+ // can detect it, and provide a refresh token.
+ data.access_token = "new_access_token";
+ data.refresh_token = "new_refresh_token";
+ }
+
+ response.write(JSON.stringify(data));
+}
diff --git a/comm/mail/components/addrbook/test/browser/head.js b/comm/mail/components/addrbook/test/browser/head.js
new file mode 100644
index 0000000000..37fc445410
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/head.js
@@ -0,0 +1,445 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const personalBook = MailServices.ab.getDirectoryFromId("ldap_2.servers.pab");
+const historyBook = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.history"
+);
+
+add_setup(async () => {
+ // Force the window to be full screen to avoid issues with buttons not being
+ // reachable. This is a temporary solution while we update the details pane
+ // UI to be properly responsive and wrap elements correctly.
+ window.fullScreen = true;
+});
+
+// We want to check that everything has been removed/reset, but if we register
+// a cleanup function here, it will run before any other cleanup function has
+// had a chance to run. Instead, when it runs register another cleanup
+// function which will run last.
+registerCleanupFunction(function () {
+ registerCleanupFunction(async function () {
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "Only Personal ab and Collected Addresses should be left."
+ );
+ for (let directory of MailServices.ab.directories) {
+ if (
+ directory.dirPrefId == "ldap_2.servers.history" ||
+ directory.dirPrefId == "ldap_2.servers.pab"
+ ) {
+ Assert.equal(
+ directory.childCardCount,
+ 0,
+ `All contacts should have been removed from ${directory.dirName}`
+ );
+ if (directory.childCardCount) {
+ directory.deleteCards(directory.childCards);
+ }
+ } else {
+ await promiseDirectoryRemoved(directory.URI);
+ }
+ }
+ closeAddressBookWindow();
+
+ // TODO: convert this to UID.
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURIisDefault");
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ // Focus an element in the main window, then blur it again to avoid it
+ // hijacking keypresses.
+ let mainWindowElement = document.getElementById("button-appmenu");
+ mainWindowElement.focus();
+ mainWindowElement.blur();
+ // Reset the window to its default size.
+ window.fullScreen = false;
+ });
+});
+
+async function openAddressBookWindow() {
+ return new Promise(resolve => {
+ window.openTab("addressBookTab", {
+ onLoad(event, browser) {
+ resolve(browser.contentWindow);
+ },
+ });
+ });
+}
+
+function closeAddressBookWindow() {
+ let abTab = getAddressBookTab();
+ if (abTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(abTab);
+ }
+}
+
+function getAddressBookTab() {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+}
+
+function getAddressBookWindow() {
+ let tab = getAddressBookTab();
+ return tab?.browser.contentWindow;
+}
+
+async function openAllAddressBooks() {
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.querySelector("#books > li"),
+ {},
+ abWindow
+ );
+ await new Promise(r => abWindow.setTimeout(r));
+}
+
+function openDirectory(directory) {
+ let abWindow = getAddressBookWindow();
+ let row = abWindow.booksList.getRowForUID(directory.UID);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow);
+}
+
+function createAddressBook(dirName, type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ let prefName = MailServices.ab.newAddressBook(dirName, null, type);
+ return MailServices.ab.getDirectoryFromId(prefName);
+}
+
+async function createAddressBookWithUI(abName) {
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateBook"),
+ {},
+ abWindow
+ );
+
+ let abNameDialog = await newAddressBookPromise;
+ EventUtils.sendString(abName, abNameDialog);
+ abNameDialog.document.querySelector("dialog").getButton("accept").click();
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == abName
+ );
+
+ Assert.ok(addressBook, "a new address book was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return addressBook;
+}
+
+function createContact(firstName, lastName, displayName, primaryEmail) {
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = displayName ?? `${firstName} ${lastName}`;
+ contact.firstName = firstName;
+ contact.lastName = lastName;
+ contact.primaryEmail =
+ primaryEmail ?? `${firstName}.${lastName}@invalid`.toLowerCase();
+ return contact;
+}
+
+function createMailingList(name) {
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = name;
+ return list;
+}
+
+async function createMailingListWithUI(mlParent, mlName) {
+ openDirectory(mlParent);
+
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateList"),
+ {},
+ abWindow
+ );
+
+ let abListDialog = await newAddressBookPromise;
+ let abListDocument = abListDialog.document;
+ await new Promise(resolve => abListDialog.setTimeout(resolve));
+
+ abListDocument.getElementById("abPopup").value = mlParent.URI;
+ abListDocument.getElementById("ListName").value = mlName;
+ abListDocument.querySelector("dialog").getButton("accept").click();
+
+ let list = mlParent.childNodes.find(list => list.dirName == mlName);
+
+ Assert.ok(list, "a new list was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return list;
+}
+
+function checkDirectoryDisplayed(directory) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ if (directory) {
+ Assert.equal(
+ booksList.selectedIndex,
+ booksList.getIndexForUID(directory.UID)
+ );
+ Assert.equal(cardsList.view.directory?.UID, directory.UID);
+ } else {
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.ok(!cardsList.view.directory);
+ }
+}
+
+function checkCardsListed(...expectedCards) {
+ checkNamesListed(
+ ...expectedCards.map(card =>
+ card.isMailList ? card.dirName : card.displayName
+ )
+ );
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ for (let i = 0; i < expectedCards.length; i++) {
+ let row = cardsList.getRowAtIndex(i);
+ Assert.equal(
+ row.classList.contains("MailList"),
+ expectedCards[i].isMailList,
+ `row ${
+ expectedCards[i].isMailList ? "should" : "should not"
+ } be a mailing list row`
+ );
+ Assert.equal(
+ row.address.textContent,
+ expectedCards[i].primaryEmail ?? "",
+ "correct address should be displayed"
+ );
+ Assert.equal(
+ row.avatar.childElementCount,
+ 1,
+ "only one avatar image should be displayed"
+ );
+ }
+}
+
+function checkNamesListed(...expectedNames) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ let expectedCount = expectedNames.length;
+
+ Assert.equal(
+ cardsList.view.rowCount,
+ expectedCount,
+ "Tree view has the right number of rows"
+ );
+
+ for (let i = 0; i < expectedCount; i++) {
+ Assert.equal(
+ cardsList.view.getCellText(i, { id: "GeneratedName" }),
+ expectedNames[i],
+ "view should give the correct name"
+ );
+ Assert.equal(
+ cardsList.getRowAtIndex(i).querySelector(".generatedname-column, .name")
+ .textContent,
+ expectedNames[i],
+ "correct name should be displayed"
+ );
+ }
+}
+
+function checkPlaceholders(expectedVisible = []) {
+ let abWindow = getAddressBookWindow();
+ let placeholder = abWindow.cardsPane.cardsList.placeholder;
+
+ if (!expectedVisible.length) {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(placeholder),
+ "placeholders are hidden"
+ );
+ return;
+ }
+
+ for (let element of placeholder.children) {
+ let id = element.id;
+ if (expectedVisible.includes(id)) {
+ Assert.ok(BrowserTestUtils.is_visible(element), `${id} is visible`);
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(element), `${id} is hidden`);
+ }
+ }
+}
+
+async function showSortMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(
+ sortContext.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ sortContext.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function showPickerMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let pickerButton = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let menupopup = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(pickerButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.activateItem(
+ menupopup.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ menupopup.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function toggleLayout() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(abDocument.getElementById("sortContextTableLayout"));
+ await hiddenPromise;
+}
+
+async function checkComposeWindow(composeWindow, ...expectedAddresses) {
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ let composeDocument = composeWindow.document;
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedAddresses.length);
+ for (let i = 0; i < expectedAddresses.length; i++) {
+ Assert.equal(pills[i].label, expectedAddresses[i]);
+ }
+
+ await Promise.all([
+ BrowserTestUtils.closeWindow(composeWindow),
+ BrowserTestUtils.waitForEvent(window, "activate"),
+ ]);
+}
+
+function promiseDirectoryRemoved(uri) {
+ let removePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(uri);
+ return removePromise;
+}
+
+function promiseLoadSubDialog(url) {
+ let abWindow = getAddressBookWindow();
+
+ return new Promise((resolve, reject) => {
+ abWindow.SubDialog._dialogStack.addEventListener(
+ "dialogopen",
+ function dialogopen(aEvent) {
+ if (
+ aEvent.detail.dialog._frame.contentWindow.location == "about:blank"
+ ) {
+ return;
+ }
+ abWindow.SubDialog._dialogStack.removeEventListener(
+ "dialogopen",
+ dialogopen
+ );
+
+ Assert.equal(
+ aEvent.detail.dialog._frame.contentWindow.location.toString(),
+ url,
+ "Check the proper URL is loaded"
+ );
+
+ // Check visibility
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ aEvent.detail.dialog._overlay,
+ "Overlay is visible"
+ )
+ );
+
+ // Check that stylesheets were injected
+ let expectedStyleSheetURLs =
+ aEvent.detail.dialog._injectedStyleSheets.slice(0);
+ for (let styleSheet of aEvent.detail.dialog._frame.contentDocument
+ .styleSheets) {
+ let i = expectedStyleSheetURLs.indexOf(styleSheet.href);
+ if (i >= 0) {
+ info("found " + styleSheet.href);
+ expectedStyleSheetURLs.splice(i, 1);
+ }
+ }
+ Assert.equal(
+ expectedStyleSheetURLs.length,
+ 0,
+ "All expectedStyleSheetURLs should have been found"
+ );
+
+ // Wait for the next event tick to make sure the remaining part of the
+ // testcase runs after the dialog gets ready for input.
+ executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow));
+ }
+ );
+ });
+}
+
+function formatVCard(strings, ...values) {
+ let arr = [];
+ for (let str of strings) {
+ arr.push(str);
+ arr.push(values.shift());
+ }
+ let lines = arr.join("").split("\n");
+ let indent = lines[1].length - lines[1].trimLeft().length;
+ let outLines = [];
+ for (let line of lines) {
+ if (line.length > 0) {
+ outLines.push(line.substring(indent) + "\r\n");
+ }
+ }
+ return outLines.join("");
+}