diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/addrbook | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
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 Binary files differnew file mode 100644 index 0000000000..35608787bf --- /dev/null +++ b/comm/mail/components/addrbook/test/browser/data/photo1.jpg diff --git a/comm/mail/components/addrbook/test/browser/data/photo2.jpg b/comm/mail/components/addrbook/test/browser/data/photo2.jpg Binary files differnew file mode 100644 index 0000000000..41fd1e90fc --- /dev/null +++ b/comm/mail/components/addrbook/test/browser/data/photo2.jpg 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(""); +} |