diff options
Diffstat (limited to 'comm/mail/components/addrbook/content')
28 files changed, 10568 insertions, 0 deletions
diff --git a/comm/mail/components/addrbook/content/abCommon.js b/comm/mail/components/addrbook/content/abCommon.js new file mode 100644 index 0000000000..36f251206e --- /dev/null +++ b/comm/mail/components/addrbook/content/abCommon.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var gAbView = null; + +var kDefaultAscending = "ascending"; +var kDefaultDescending = "descending"; +var kAllDirectoryRoot = "moz-abdirectory://"; +var kPersonalAddressbookURI = "jsaddrbook://abook.sqlite"; + +async function AbDelete() { + let types = GetSelectedCardTypes(); + if (types == kNothingSelected) { + return; + } + + let cards = GetSelectedAbCards(); + + // Determine strings for smart and context-sensitive user prompts + // for confirming deletion. + let action, name, list; + let selectedDir = gAbView.directory; + + switch (types) { + case kListsAndCards: + action = "delete-mixed"; + break; + case kSingleListOnly: + case kMultipleListsOnly: + action = "delete-lists"; + name = cards[0].displayName; + break; + default: { + let nameFormatFromPref = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst" + ); + name = cards[0].generateName(nameFormatFromPref); + if (selectedDir && selectedDir.isMailList) { + action = "remove-contacts"; + list = selectedDir.dirName; + } else { + action = "delete-contacts"; + } + break; + } + } + + // Adjust strings to match translations. + let actionString; + switch (action) { + case "delete-contacts": + actionString = !cards.length + ? "delete-contacts-single" + : "delete-contacts-multi"; + break; + case "remove-contacts": + actionString = !cards.length + ? "remove-contacts-single" + : "remove-contacts-multi"; + break; + default: + actionString = action; + break; + } + + let [title, message] = await document.l10n.formatValues([ + { + id: `about-addressbook-confirm-${action}-title`, + args: { count: cards.length }, + }, + { + id: `about-addressbook-confirm-${actionString}`, + args: { + count: cards.length, + name, + list, + }, + }, + ]); + + // Finally, show our smart confirmation message, and act upon it! + if (!Services.prompt.confirm(window, title, message)) { + // Deletion cancelled by user. + return; + } + + // Delete cards from address books or mailing lists. + gAbView.deleteSelectedCards(); +} + +function AbNewMessage(address) { + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.type = Ci.nsIMsgCompType.New; + params.format = Ci.nsIMsgCompFormat.Default; + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if (address) { + params.composeFields.to = address; + } else { + params.composeFields.to = GetSelectedAddresses(); + } + MailServices.compose.OpenComposeWindowWithParams(null, params); +} + +/** + * Make a mailbox string from the card, for use in the UI. + * + * @param {nsIAbCard} - The card to use. + * @returns {string} A mailbox representation of the card. + */ +function makeMailboxObjectFromCard(card) { + if (!card) { + return ""; + } + + let email; + if (card.isMailList) { + let directory = GetDirectoryFromURI(card.mailListURI); + email = directory.description || card.displayName; + } else { + email = card.primaryEmail; + } + + return MailServices.headerParser + .makeMailboxObject(card.displayName, email) + .toString(); +} + +function GetDirectoryFromURI(uri) { + if (uri.startsWith("moz-abdirectory://")) { + return null; + } + return MailServices.ab.getDirectory(uri); +} diff --git a/comm/mail/components/addrbook/content/abContactsPanel.js b/comm/mail/components/addrbook/content/abContactsPanel.js new file mode 100644 index 0000000000..c1e3481318 --- /dev/null +++ b/comm/mail/components/addrbook/content/abContactsPanel.js @@ -0,0 +1,374 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../../toolkit/content/editMenuOverlay.js */ +/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */ +/* import-globals-from ../../../base/content/globalOverlay.js */ +/* import-globals-from abCommon.js */ + +var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm"); +var { getSearchTokens, getModelQuery, generateQueryURI } = ChromeUtils.import( + "resource:///modules/ABQueryUtils.jsm" +); + +// A boolean variable determining whether AB column should be shown +// in Contacts Sidebar in compose window. +var gShowAbColumnInComposeSidebar = false; +var gQueryURIFormat = null; + +UIDensity.registerWindow(window); + +function GetAbViewListener() { + // the ab panel doesn't care if the total changes, or if the selection changes + return null; +} + +/** + * Handle the command event on abContextMenuButton (click, Enter, spacebar). + */ +function abContextMenuButtonOnCommand(event) { + showContextMenu("sidebarAbContextMenu", event, [ + event.target, + "after_end", + 0, + 0, + true, + ]); +} + +/** + * Handle the context menu event of results tree (right-click, context menu key + * press, etc.). Show the respective context menu for selected contact(s) or + * results tree blank space (work around for XUL tree bug 1331377). + * + * @param aEvent a context menu event (right-click, context menu key press, etc.) + */ +function contactsListOnContextMenu(aEvent) { + let target = aEvent.target; + let contextMenuID; + let positionArray; + + // For right-click on column header or column picker, don't show context menu. + if (target.localName == "treecol" || target.localName == "treecolpicker") { + return; + } + + // On treechildren, if there's no selection, show "sidebarAbContextMenu". + if (gAbView.selection.count == 0) { + contextMenuID = gAbResultsTree.getAttribute("contextNoSelection"); + // If "sidebarAbContextMenu" menu was activated by keyboard, + // position it in the topleft corner of gAbResultsTree. + if (!aEvent.button) { + positionArray = [gAbResultsTree, "overlap", 0, 0, true]; + } + // If there's a selection, show "cardProperties" context menu. + } else { + contextMenuID = gAbResultsTree.getAttribute("contextSelection"); + updateCardPropertiesMenu(); + } + showContextMenu(contextMenuID, aEvent, positionArray); +} + +/** + * Update the single row card properties context menu to show or hide the "Edit" + * menu item only depending on the selection type. + */ +function updateCardPropertiesMenu() { + let cards = GetSelectedAbCards(); + + let separator = document.getElementById("abContextBeforeEditContact"); + let menuitem = document.getElementById("abContextEditContact"); + + // Only show the Edit item if one item is selected, is not a mailing list, and + // the contact is not part of a readOnly address book. + if ( + cards.length != 1 || + cards.some(c => c.isMailList) || + MailServices.ab.getDirectoryFromUID(cards[0].directoryUID)?.readOnly + ) { + separator.hidden = true; + menuitem.hidden = true; + return; + } + + separator.hidden = false; + menuitem.hidden = false; +} + +/** + * Handle the click event of the results tree (workaround for XUL tree + * bug 1331377). + * + * @param aEvent a click event + */ +function contactsListOnClick(aEvent) { + CommandUpdate_AddressBook(); + + let target = aEvent.target; + + // Left click on column header: Change sort direction. + if (target.localName == "treecol" && aEvent.button == 0) { + let sortDirection = + target.getAttribute("sortDirection") == kDefaultDescending + ? kDefaultAscending + : kDefaultDescending; + SortAndUpdateIndicators(target.id, sortDirection); + return; + } + // Any click on gAbResultsTree view (rows or blank space). + if (target.localName == "treechildren") { + let row = gAbResultsTree.getRowAt(aEvent.clientX, aEvent.clientY); + if (row < 0 || row >= gAbResultsTree.view.rowCount) { + // Any click on results tree whitespace. + if ((aEvent.detail == 1 && aEvent.button == 0) || aEvent.button == 2) { + // Single left click or any right click on results tree blank space: + // Clear selection. This also triggers on the first click of any + // double-click, but that's ok. MAC OS X doesn't return event.detail==1 + // for single right click, so we also let this trigger for the second + // click of right double-click. + gAbView.selection.clearSelection(); + } + } else if (aEvent.button == 0 && aEvent.detail == 2) { + // Any click on results tree rows. + // Double-click on a row: Go ahead and add the entry. + addSelectedAddresses("addr_to"); + } + } +} + +/** + * Appends the currently selected cards as new recipients in the composed message. + * + * @param recipientType Type of recipient, e.g. "addr_to". + */ +function addSelectedAddresses(recipientType) { + var cards = GetSelectedAbCards(); + + // Turn each card into a properly formatted address. + let addresses = cards.map(makeMailboxObjectFromCard).filter(addr => addr); + parent.addressRowAddRecipientsArray( + parent.document.querySelector( + `.address-row[data-recipienttype="${recipientType}"]` + ), + addresses + ); +} + +/** + * Open the address book tab and trigger the edit of the selected contact. + */ +function editSelectedAddress() { + let cards = GetSelectedAbCards(); + window.top.toAddressBook({ action: "edit", card: cards[0] }); +} + +function AddressBookMenuListChange(aValue) { + let searchInput = document.getElementById("peopleSearchInput"); + if (searchInput.value && !searchInput.showingSearchCriteria) { + onEnterInSearchBar(); + } else { + ChangeDirectoryByURI(aValue); + } + + // Hide the addressbook column if the selected addressbook isn't + // "All address books". Since the column is redundant in all other cases. + let abList = document.getElementById("addressbookList"); + let addrbookColumn = document.getElementById("addrbook"); + if (abList.value.startsWith(kAllDirectoryRoot + "?")) { + addrbookColumn.hidden = !gShowAbColumnInComposeSidebar; + addrbookColumn.removeAttribute("ignoreincolumnpicker"); + } else { + addrbookColumn.hidden = true; + addrbookColumn.setAttribute("ignoreincolumnpicker", "true"); + } + + CommandUpdate_AddressBook(); +} + +var mutationObs = null; + +function AbPanelLoad() { + if (location.search == "?focus") { + document.getElementById("peopleSearchInput").focus(); + } + + document.title = parent.document.getElementById("contactsTitle").value; + + // Get the URI of the directory to display. + let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI"); + // If the URI is a mailing list, use the parent directory instead, since + // mailing lists are not displayed here. + startupURI = startupURI.replace(/^(jsaddrbook:\/\/[\w\.-]*)\/.*$/, "$1"); + + let abPopup = document.getElementById("addressbookList"); + abPopup.value = startupURI; + + // If provided directory is not on abPopup, fall back to All Address Books. + if (!abPopup.selectedItem) { + abPopup.selectedIndex = 0; + } + + // Postpone the slow contacts load so that the sidebar document + // gets a chance to display quickly. + setTimeout(ChangeDirectoryByURI, 0, abPopup.value); + + mutationObs = new MutationObserver(function (aMutations) { + aMutations.forEach(function (mutation) { + if ( + getSelectedDirectoryURI() == kAllDirectoryRoot + "?" && + mutation.type == "attributes" && + mutation.attributeName == "hidden" + ) { + let curState = document.getElementById("addrbook").hidden; + gShowAbColumnInComposeSidebar = !curState; + } + }); + }); + + document.getElementById("addrbook").hidden = !gShowAbColumnInComposeSidebar; + + mutationObs.observe(document.getElementById("addrbook"), { + attributes: true, + childList: true, + }); +} + +function AbPanelUnload() { + mutationObs.disconnect(); + + // If there's no default startupURI, save the last used URI as new startupURI. + if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) { + Services.prefs.setCharPref( + "mail.addr_book.view.startupURI", + getSelectedDirectoryURI() + ); + } + + CloseAbView(); +} + +function AbResultsPaneDoubleClick(card) { + // double click for ab panel means "send mail to this person / list" + AbNewMessage(); +} + +function CommandUpdate_AddressBook() { + // Toggle disable state of to,cc,bcc buttons. + let disabled = GetNumSelectedCards() == 0 ? "true" : "false"; + document.getElementById("cmd_addrTo").setAttribute("disabled", disabled); + document.getElementById("cmd_addrCc").setAttribute("disabled", disabled); + document.getElementById("cmd_addrBcc").setAttribute("disabled", disabled); + + goUpdateCommand("cmd_delete"); +} + +/** + * Handle the onpopupshowing event of #sidebarAbContextMenu. + * Update the checkmark of #sidebarAbContext-startupDir menuitem when context + * menu opens, so as to always be in sync with changes from the main AB window. + */ +function onAbContextShowing() { + let startupItem = document.getElementById("sidebarAbContext-startupDir"); + if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) { + let startupURI = Services.prefs.getCharPref( + "mail.addr_book.view.startupURI" + ); + startupItem.setAttribute( + "checked", + startupURI == getSelectedDirectoryURI() + ); + } else { + startupItem.setAttribute("checked", "false"); + } +} + +function onEnterInSearchBar() { + if (!gQueryURIFormat) { + // Get model query from pref. We don't want the query starting with "?" + // as we have to prefix "?and" to this format. + /* eslint-disable no-global-assign */ + gQueryURIFormat = getModelQuery("mail.addr_book.quicksearchquery.format"); + /* eslint-enable no-global-assign */ + } + + let searchURI = getSelectedDirectoryURI(); + let searchQuery; + let searchInput = document.getElementById("peopleSearchInput"); + + // Use helper method to split up search query to multi-word search + // query against multiple fields. + if (searchInput) { + let searchWords = getSearchTokens(searchInput.value); + searchQuery = generateQueryURI(gQueryURIFormat, searchWords); + } + + SetAbView(searchURI, searchQuery, searchInput ? searchInput.value : ""); +} + +/** + * Open a menupopup as a context menu + * + * @param aContextMenuID The ID of a menupopup to be shown as context menu + * @param aEvent The event which triggered this. + * @param positionArray An optional array containing the parameters for openPopup() method; + * if omitted, mouse pointer position will be used. + */ +function showContextMenu(aContextMenuID, aEvent, aPositionArray) { + let theContextMenu = document.getElementById(aContextMenuID); + if (!aPositionArray) { + aPositionArray = [null, "", aEvent.clientX, aEvent.clientY, true]; + } + theContextMenu.openPopup(...aPositionArray); +} + +/** + * Get the URI of the selected directory. + * + * @returns The URI of the currently selected directory + */ +function getSelectedDirectoryURI() { + return document.getElementById("addressbookList").value; +} + +function abToggleSelectedDirStartup() { + let selectedDirURI = getSelectedDirectoryURI(); + if (!selectedDirURI) { + return; + } + + let isDefault = Services.prefs.getBoolPref( + "mail.addr_book.view.startupURIisDefault" + ); + let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI"); + + if (isDefault && startupURI == selectedDirURI) { + // The current directory has been the default startup view directory; + // toggle that off now. So there's no default startup view directory any more. + Services.prefs.setBoolPref( + "mail.addr_book.view.startupURIisDefault", + false + ); + } else { + // The current directory will now be the default view + // when starting up the main AB window. + Services.prefs.setCharPref( + "mail.addr_book.view.startupURI", + selectedDirURI + ); + Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true); + } + + // Update the checkbox in the menuitem. + goUpdateCommand("cmd_abToggleStartupDir"); +} + +function ChangeDirectoryByURI(uri = kPersonalAddressbookURI) { + SetAbView(uri); + + // Actively de-selecting if there are any pre-existing selections + // in the results list. + if (gAbView && gAbView.selection && gAbView.getCardFromRow(0)) { + gAbView.selection.clearSelection(); + } +} diff --git a/comm/mail/components/addrbook/content/abContactsPanel.xhtml b/comm/mail/components/addrbook/content/abContactsPanel.xhtml new file mode 100644 index 0000000000..18163eafda --- /dev/null +++ b/comm/mail/components/addrbook/content/abContactsPanel.xhtml @@ -0,0 +1,234 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/addressbook/abContactsPanel.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd"> +%abResultsPaneDTD; +<!ENTITY % abContactsPanelDTD SYSTEM "chrome://messenger/locale/addressbook/abContactsPanel.dtd" > +%abContactsPanelDTD; +<!ENTITY % abMainWindowDTD SYSTEM "chrome://messenger/locale/addressbook/abMainWindow.dtd" > +%abMainWindowDTD; ]> + +<window + id="abContactsPanel" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="AbPanelLoad();" + onunload="AbPanelUnload();" +> + <html:link + rel="localization" + href="messenger/addressbook/aboutAddressBook.ftl" + /> + + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://communicator/content/utilityOverlay.js" /> + <script src="chrome://messenger/content/addressbook/abDragDrop.js" /> + <script src="chrome://messenger/content/addressbook/abCommon.js" /> + <script src="chrome://messenger/content/addressbook/abResultsPane.js" /> + <script src="chrome://messenger/content/addressbook/abContactsPanel.js" /> + <script src="chrome://messenger/content/jsTreeView.js" /> + <script src="chrome://messenger/content/addressbook/abView.js" /> + + <commandset + id="CommandUpdate_AddressBook" + commandupdater="true" + events="focus,addrbook-select" + oncommandupdate="CommandUpdate_AddressBook()" + > + <command + id="cmd_addrTo" + oncommand="addSelectedAddresses('addr_to')" + disabled="true" + /> + <command + id="cmd_addrCc" + oncommand="addSelectedAddresses('addr_cc')" + disabled="true" + /> + <command + id="cmd_addrBcc" + oncommand="addSelectedAddresses('addr_bcc')" + disabled="true" + /> + <command id="cmd_delete" oncommand="goDoCommand('cmd_delete');" /> + </commandset> + + <keyset id="keyset_abContactsPanel"> + <!-- This key (key_delete) does not trigger any command, but it is used + only to show the hotkey on the corresponding menuitem. --> + <key id="key_delete" keycode="VK_DELETE" internal="true" /> + </keyset> + + <menupopup id="cardProperties"> + <menuitem + label="&addtoToFieldMenu.label;" + accesskey="&addtoToFieldMenu.accesskey;" + command="cmd_addrTo" + /> + <menuitem + label="&addtoCcFieldMenu.label;" + accesskey="&addtoCcFieldMenu.accesskey;" + command="cmd_addrCc" + /> + <menuitem + label="&addtoBccFieldMenu.label;" + accesskey="&addtoBccFieldMenu.accesskey;" + command="cmd_addrBcc" + /> + <menuseparator /> + <menuitem + label="&deleteAddrBookCard.label;" + accesskey="&deleteAddrBookCard.accesskey;" + key="key_delete" + command="cmd_delete" + /> + <menuseparator id="abContextBeforeEditContact" hidden="true" /> + <menuitem + id="abContextEditContact" + label="&editContactContext.label;" + accesskey="&editContactContext.accesskey;" + oncommand="editSelectedAddress();" + hidden="true" + /> + </menupopup> + + <menupopup + id="sidebarAbContextMenu" + class="no-accel-menupopup" + onpopupshowing="onAbContextShowing();" + > + <menuitem + id="sidebarAbContext-startupDir" + label="&showAsDefault.label;" + accesskey="&showAsDefault.accesskey;" + type="checkbox" + checked="false" + oncommand="abToggleSelectedDirStartup();" + /> + </menupopup> + + <vbox id="results_box" flex="1"> + <separator class="thin" /> + <hbox id="AbPickerHeader" class="themeable-full"> + <label + value="&addressbookPicker.label;" + accesskey="&addressbookPicker.accesskey;" + control="addressbookList" + /> + <spacer flex="1" /> + <button + id="abContextMenuButton" + tooltiptext="&abContextMenuButton.tooltip;" + oncommand="abContextMenuButtonOnCommand(event);" + /> + </hbox> + <hbox id="panel-bar" class="themeable-full" align="center"> + <menulist + is="menulist-addrbooks" + id="addressbookList" + alladdressbooks="true" + oncommand="AddressBookMenuListChange(this.value);" + flex="1" + /> + </hbox> + + <separator class="thin" /> + + <vbox> + <label + value="&searchContacts.label;" + accesskey="&searchContacts.accesskey;" + control="peopleSearchInput" + /> + <search-textbox + id="peopleSearchInput" + class="searchBox" + flex="1" + timeout="800" + placeholder="&SearchNameOrEmail.label;" + oncommand="onEnterInSearchBar();" + /> + </vbox> + + <separator class="thin" /> + + <tree + id="abResultsTree" + flex="1" + class="plain" + sortCol="GeneratedName" + persist="sortCol" + contextSelection="cardProperties" + contextNoSelection="sidebarAbContextMenu" + oncontextmenu="contactsListOnContextMenu(event);" + onclick="contactsListOnClick(event);" + onselect="this.view.selectionChanged(); document.commandDispatcher.updateCommands('addrbook-select');" + > + <treecols> + <!-- these column ids must match up to the mork column names, see nsIAddrDatabase.idl --> + <treecol + id="GeneratedName" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&GeneratedName.label;" + primary="true" + /> + <splitter class="tree-splitter" /> + <treecol + id="addrbook" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&Addrbook.label;" + /> + <splitter class="tree-splitter" /> + <treecol + id="PrimaryEmail" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&PrimaryEmail.label;" + /> + </treecols> + <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);" /> + </tree> + + <separator class="thin" /> + + <hbox pack="center"> + <vbox> + <button + id="toButton" + label="&toButton.label;" + accesskey="&toButton.accesskey;" + command="cmd_addrTo" + /> + <button + id="ccButton" + label="&ccButton.label;" + accesskey="&ccButton.accesskey;" + command="cmd_addrCc" + /> + <button + id="bccButton" + label="&bccButton.label;" + accesskey="&bccButton.accesskey;" + command="cmd_addrBcc" + /> + </vbox> + </hbox> + + <separator class="thin" /> + </vbox> +</window> diff --git a/comm/mail/components/addrbook/content/abEditListDialog.xhtml b/comm/mail/components/addrbook/content/abEditListDialog.xhtml new file mode 100644 index 0000000000..bf775c274b --- /dev/null +++ b/comm/mail/components/addrbook/content/abEditListDialog.xhtml @@ -0,0 +1,99 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd"> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="&mailListWindowAdd.title;" + onload="OnLoadEditList();" + ondragover="DragOverAddressListTree(event);" + ondrop="DropOnAddressListTree(event);" +> + <dialog id="ablistWindow"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- move needed functions into a single js file --> + <script src="chrome://messenger/content/addressbook/abCommon.js" /> + <script src="chrome://messenger/content/addressbook/abMailListDialog.js" /> + + <vbox id="editlist"> + <html:div class="grid-two-column-fr grid-items-center"> + <label + control="ListName" + value="&ListName.label;" + accesskey="&ListName.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListName" type="text" class="input-inline" /> + </hbox> + <label + control="ListNickName" + value="&ListNickName.label;" + accesskey="&ListNickName.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListNickName" type="text" class="input-inline" /> + </hbox> + <label + control="ListDescription" + value="&ListDescription.label;" + accesskey="&ListDescription.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListDescription" type="text" class="input-inline" /> + </hbox> + </html:div> + + <spacer style="height: 1em" /> + <label + control="addressCol1#1" + value="&AddressTitle.label;" + accesskey="&AddressTitle.accesskey;" + /> + <spacer style="height: 0.1em" /> + + <richlistbox + id="addressingWidget" + onclick="awClickEmptySpace(event.target, true)" + > + <richlistitem class="addressingWidgetItem" allowevents="true"> + <hbox + class="addressingWidgetCell input-container" + flex="1" + role="combobox" + > + <html:label for="addressCol1#1" class="person-icon"></html:label> + <html:input + is="autocomplete-input" + id="addressCol1#1" + class="plain textbox-addressingWidget uri-element" + aria-labelledby="addressCol1#1" + autocompletesearch="addrbook ldap" + autocompletesearchparam="{}" + timeout="300" + maxrows="4" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="3" + onkeypress="awAbRecipientKeyPress(event, this);" + onkeydown="awRecipientKeyDown(event, this);" + /> + </hbox> + </richlistitem> + </richlistbox> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/addrbook/content/abMailListDialog.xhtml b/comm/mail/components/addrbook/content/abMailListDialog.xhtml new file mode 100644 index 0000000000..5b0cf11dda --- /dev/null +++ b/comm/mail/components/addrbook/content/abMailListDialog.xhtml @@ -0,0 +1,116 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd"> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="&mailListWindowAdd.title;" + onload="OnLoadNewMailList();" + ondragover="DragOverAddressListTree(event);" + ondrop="DropOnAddressListTree(event);" +> + <dialog id="ablistWindow"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- move needed functions into a single js file --> + <script src="chrome://messenger/content/addressbook/abCommon.js" /> + <script src="chrome://messenger/content/addressbook/abMailListDialog.js" /> + + <hbox align="center"> + <label + control="abPopup" + value="&addToAddressBook.label;" + accesskey="&addToAddressBook.accesskey;" + /> + <menulist + is="menulist-addrbooks" + id="abPopup" + supportsmaillists="true" + flex="1" + writable="true" + /> + </hbox> + + <spacer style="height: 1em" /> + + <vbox id="editlist"> + <html:div class="grid-two-column-fr grid-items-center"> + <label + control="ListName" + value="&ListName.label;" + accesskey="&ListName.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListName" type="text" class="input-inline" /> + </hbox> + <label + control="ListNickName" + value="&ListNickName.label;" + accesskey="&ListNickName.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListNickName" type="text" class="input-inline" /> + </hbox> + <label + control="ListDescription" + value="&ListDescription.label;" + accesskey="&ListDescription.accesskey;" + class="CardEditLabel" + /> + <hbox class="CardEditWidth input-container"> + <html:input id="ListDescription" type="text" class="input-inline" /> + </hbox> + </html:div> + + <spacer style="height: 1em" /> + <label + control="addressCol1#1" + value="&AddressTitle.label;" + accesskey="&AddressTitle.accesskey;" + /> + <spacer style="height: 0.1em" /> + + <richlistbox + id="addressingWidget" + onclick="awClickEmptySpace(event.target, true)" + > + <richlistitem class="addressingWidgetItem" allowevents="true"> + <hbox + class="addressingWidgetCell input-container" + flex="1" + role="combobox" + > + <html:label for="addressCol1#1" class="person-icon"></html:label> + <html:input + is="autocomplete-input" + id="addressCol1#1" + class="plain textbox-addressingWidget uri-element" + aria-labelledby="addressCol1#1" + autocompletesearch="addrbook ldap" + autocompletesearchparam="{}" + timeout="300" + maxrows="4" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="3" + onkeypress="awAbRecipientKeyPress(event, this);" + onkeydown="awRecipientKeyDown(event, this);" + /> + </hbox> + </richlistitem> + </richlistbox> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/addrbook/content/abSearchDialog.js b/comm/mail/components/addrbook/content/abSearchDialog.js new file mode 100644 index 0000000000..694d17c12b --- /dev/null +++ b/comm/mail/components/addrbook/content/abSearchDialog.js @@ -0,0 +1,408 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */ +/* import-globals-from ../../../../mailnews/base/content/dateFormat.js */ +/* import-globals-from ../../../../mailnews/search/content/searchTerm.js */ +/* import-globals-from ../../../base/content/globalOverlay.js */ +/* import-globals-from abCommon.js */ + +var { encodeABTermValue } = ChromeUtils.import( + "resource:///modules/ABQueryUtils.jsm" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); + +var searchSessionContractID = "@mozilla.org/messenger/searchSession;1"; +var gSearchSession; + +var nsMsgSearchScope = Ci.nsMsgSearchScope; +var nsMsgSearchOp = Ci.nsMsgSearchOp; +var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib; + +var gStatusText; +var gSearchBundle; +var gAddressBookBundle; + +var gSearchStopButton; +var gPropertiesCmd; +var gComposeCmd; +var gDeleteCmd; +var gSearchPhoneticName = "false"; + +var gSearchAbViewListener = { + onSelectionChanged() { + UpdateCardView(); + }, + onCountChanged(aTotal) { + let statusText; + if (aTotal == 0) { + statusText = gAddressBookBundle.GetStringFromName("noMatchFound"); + } else { + statusText = PluralForm.get( + aTotal, + gAddressBookBundle.GetStringFromName("matchesFound1") + ).replace("#1", aTotal); + } + + gStatusText.setAttribute("value", statusText); + }, +}; + +function searchOnLoad() { + initializeSearchWidgets(); + initializeSearchWindowWidgets(); + + gSearchBundle = Services.strings.createBundle( + "chrome://messenger/locale/search.properties" + ); + gSearchStopButton.setAttribute( + "label", + gSearchBundle.GetStringFromName("labelForSearchButton") + ); + gSearchStopButton.setAttribute( + "accesskey", + gSearchBundle.GetStringFromName("labelForSearchButton.accesskey") + ); + gAddressBookBundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + gSearchSession = Cc[searchSessionContractID].createInstance( + Ci.nsIMsgSearchSession + ); + + // initialize a flag for phonetic name search + gSearchPhoneticName = Services.prefs.getComplexValue( + "mail.addr_book.show_phonetic_fields", + Ci.nsIPrefLocalizedString + ).data; + + if (window.arguments && window.arguments[0]) { + SelectDirectory(window.arguments[0].directory); + } else { + SelectDirectory( + document.getElementById("abPopup-menupopup").firstElementChild.value + ); + } + + onMore(null); +} + +function searchOnUnload() { + CloseAbView(); +} + +function disableCommands() { + gPropertiesCmd.setAttribute("disabled", "true"); + gComposeCmd.setAttribute("disabled", "true"); + gDeleteCmd.setAttribute("disabled", "true"); +} + +function initializeSearchWindowWidgets() { + gSearchStopButton = document.getElementById("search-button"); + gPropertiesCmd = document.getElementById("cmd_properties"); + gComposeCmd = document.getElementById("cmd_compose"); + gDeleteCmd = document.getElementById("cmd_deleteCard"); + gStatusText = document.getElementById("statusText"); + disableCommands(); + // matchAll doesn't make sense for address book search + hideMatchAllItem(); +} + +function onSearchStop() {} + +function onAbSearchReset(event) { + disableCommands(); + CloseAbView(); + + onReset(event); + gStatusText.setAttribute("value", ""); +} + +function SelectDirectory(aURI) { + // set popup with address book names + let abPopup = document.getElementById("abPopup"); + if (abPopup) { + if (aURI) { + abPopup.value = aURI; + } else { + abPopup.selectedIndex = 0; + } + } + + setSearchScope(GetScopeForDirectoryURI(aURI)); +} + +function GetScopeForDirectoryURI(aURI) { + let directory; + if (aURI && aURI != "moz-abdirectory://?") { + directory = MailServices.ab.getDirectory(aURI); + } + let booleanAnd = gSearchBooleanRadiogroup.selectedItem.value == "and"; + + if (directory?.isRemote) { + if (booleanAnd) { + return nsMsgSearchScope.LDAPAnd; + } + return nsMsgSearchScope.LDAP; + } + + if (booleanAnd) { + return nsMsgSearchScope.LocalABAnd; + } + return nsMsgSearchScope.LocalAB; +} + +function onEnterInSearchTerm() { + // on enter + // if not searching, start the search + // if searching, stop and then start again + if ( + gSearchStopButton.getAttribute("label") == + gSearchBundle.GetStringFromName("labelForSearchButton") + ) { + onSearch(); + } else { + onSearchStop(); + onSearch(); + } +} + +function onSearch() { + gStatusText.setAttribute("value", ""); + disableCommands(); + + gSearchSession.clearScopes(); + + var currentAbURI = document.getElementById("abPopup").getAttribute("value"); + + gSearchSession.addDirectoryScopeTerm(GetScopeForDirectoryURI(currentAbURI)); + gSearchSession.searchTerms = saveSearchTerms( + gSearchSession.searchTerms, + gSearchSession + ); + + let searchUri = "?("; + for (let i = 0; i < gSearchSession.searchTerms.length; i++) { + let searchTerm = gSearchSession.searchTerms[i]; + if (!searchTerm.value.str) { + continue; + } + // get the "and" / "or" value from the first term + if (i == 0) { + if (searchTerm.booleanAnd) { + searchUri += "and"; + } else { + searchUri += "or"; + } + } + + var attrs; + + switch (searchTerm.attrib) { + case nsMsgSearchAttrib.Name: + if (gSearchPhoneticName != "true") { + attrs = [ + "DisplayName", + "FirstName", + "LastName", + "NickName", + "_AimScreenName", + ]; + } else { + attrs = [ + "DisplayName", + "FirstName", + "LastName", + "NickName", + "_AimScreenName", + "PhoneticFirstName", + "PhoneticLastName", + ]; + } + break; + case nsMsgSearchAttrib.DisplayName: + attrs = ["DisplayName"]; + break; + case nsMsgSearchAttrib.Email: + attrs = ["PrimaryEmail"]; + break; + case nsMsgSearchAttrib.PhoneNumber: + attrs = [ + "HomePhone", + "WorkPhone", + "FaxNumber", + "PagerNumber", + "CellularNumber", + ]; + break; + case nsMsgSearchAttrib.Organization: + attrs = ["Company"]; + break; + case nsMsgSearchAttrib.Department: + attrs = ["Department"]; + break; + case nsMsgSearchAttrib.City: + attrs = ["WorkCity"]; + break; + case nsMsgSearchAttrib.Street: + attrs = ["WorkAddress"]; + break; + case nsMsgSearchAttrib.Nickname: + attrs = ["NickName"]; + break; + case nsMsgSearchAttrib.WorkPhone: + attrs = ["WorkPhone"]; + break; + case nsMsgSearchAttrib.HomePhone: + attrs = ["HomePhone"]; + break; + case nsMsgSearchAttrib.Fax: + attrs = ["FaxNumber"]; + break; + case nsMsgSearchAttrib.Pager: + attrs = ["PagerNumber"]; + break; + case nsMsgSearchAttrib.Mobile: + attrs = ["CellularNumber"]; + break; + case nsMsgSearchAttrib.Title: + attrs = ["JobTitle"]; + break; + case nsMsgSearchAttrib.AdditionalEmail: + attrs = ["SecondEmail"]; + break; + case nsMsgSearchAttrib.ScreenName: + attrs = ["_AimScreenName"]; + break; + default: + dump("XXX " + searchTerm.attrib + " not a supported search attr!\n"); + attrs = ["DisplayName"]; + break; + } + + var opStr; + + switch (searchTerm.op) { + case nsMsgSearchOp.Contains: + opStr = "c"; + break; + case nsMsgSearchOp.DoesntContain: + opStr = "!c"; + break; + case nsMsgSearchOp.Is: + opStr = "="; + break; + case nsMsgSearchOp.Isnt: + opStr = "!="; + break; + case nsMsgSearchOp.BeginsWith: + opStr = "bw"; + break; + case nsMsgSearchOp.EndsWith: + opStr = "ew"; + break; + case nsMsgSearchOp.SoundsLike: + opStr = "~="; + break; + default: + opStr = "c"; + break; + } + + // currently, we can't do "and" and "or" searches at the same time + // (it's either all "and"s or all "or"s) + var max_attrs = attrs.length; + + for (var j = 0; j < max_attrs; j++) { + // append the term(s) to the searchUri + searchUri += + "(" + + attrs[j] + + "," + + opStr + + "," + + encodeABTermValue(searchTerm.value.str) + + ")"; + } + } + + searchUri += ")"; + if (searchUri == "?()") { + // Empty search. + searchUri = ""; + } + SetAbView(currentAbURI, searchUri, ""); +} + +// used to toggle functionality for Search/Stop button. +function onSearchButton(event) { + if ( + event.target.label == + gSearchBundle.GetStringFromName("labelForSearchButton") + ) { + onSearch(); + } else { + onSearchStop(); + } +} + +function GetAbViewListener() { + return gSearchAbViewListener; +} + +function onProperties() { + if (!gPropertiesCmd.hasAttribute("disabled")) { + window.opener.toAddressBook({ action: "display", card: GetSelectedCard() }); + } +} + +function onCompose() { + if (!gComposeCmd.hasAttribute("disabled")) { + AbNewMessage(); + } +} + +function onDelete() { + if (!gDeleteCmd.hasAttribute("disabled")) { + AbDelete(); + } +} + +function AbResultsPaneKeyPress(event) { + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: + onProperties(); + break; + case KeyEvent.DOM_VK_DELETE: + case KeyEvent.DOM_VK_BACK_SPACE: + onDelete(); + } +} + +function AbResultsPaneDoubleClick(card) { + // Kept for abResultsPane.js. +} + +function UpdateCardView() { + disableCommands(); + let numSelected = GetNumSelectedCards(); + + if (!numSelected) { + return; + } + + if (MailServices.accounts.allIdentities.length > 0) { + gComposeCmd.removeAttribute("disabled"); + } + + gDeleteCmd.removeAttribute("disabled"); + if (numSelected == 1) { + gPropertiesCmd.removeAttribute("disabled"); + } +} diff --git a/comm/mail/components/addrbook/content/abSearchDialog.xhtml b/comm/mail/components/addrbook/content/abSearchDialog.xhtml new file mode 100644 index 0000000000..75a40df839 --- /dev/null +++ b/comm/mail/components/addrbook/content/abSearchDialog.xhtml @@ -0,0 +1,200 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/addressbook/abResultsPane.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/abSearchDialog.css" type="text/css"?> + +<!DOCTYPE window [ + <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd"> + %abResultsPaneDTD; + <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd"> + %SearchDialogDTD; + <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd"> + %searchTermDTD; +]> +<window id="searchAddressBookWindow" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="searchOnLoad();" + onunload="searchOnUnload();" + onclose="onSearchStop();" + windowtype="mailnews:absearch" + title="&abSearchDialogTitle.label;" + style="min-width: 52em; min-height: 34em;" + lightweightthemes="true" + persist="screenX screenY width height sizemode"> + <html:link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" /> + + <script src="chrome://messenger/content/globalOverlay.js"/> + <script src="chrome://messenger/content/addressbook/abSearchDialog.js"/> + <script src="chrome://messenger/content/addressbook/abResultsPane.js"/> + <script src="chrome://messenger/content/addressbook/abCommon.js"/> + <script src="chrome://messenger/content/searchTerm.js"/> + <script src="chrome://messenger/content/searchWidgets.js"/> + <script src="chrome://messenger/content/dateFormat.js"/> + <script src="chrome://messenger/content/jsTreeView.js"/> + <script src="chrome://messenger/content/addressbook/abView.js"/> + + <keyset id="mailKeys"> + <key key="&closeCmd.key;" modifiers="accel" oncommand="onSearchStop(); window.close();"/> + <key keycode="VK_ESCAPE" oncommand="onSearchStop(); window.close();"/> + </keyset> + + <commandset id="AbCommands"> + <command id="cmd_properties" oncommand="onProperties();"/> + <command id="cmd_compose" oncommand="onCompose();"/> + <command id="cmd_deleteCard" oncommand="onDelete();"/> + </commandset> + + <vbox id="searchTerms" class="themeable-brighttext" persist="height"> + <vbox> + <hbox align="center"> + <label value="&abSearchHeading.label;" accesskey="&abSearchHeading.accesskey;" control="abPopup"/> + <menulist is="menulist-addrbooks" id="abPopup" + oncommand="SelectDirectory(this.value);" + alladdressbooks="true" + flex="1"/> + <spacer style="flex: 3 3;"/> + <button id="search-button" oncommand="onSearchButton(event);" default="true"/> + </hbox> + <hbox align="center"> + <spacer flex="1"/> + <button label="&resetButton.label;" oncommand="onAbSearchReset(event);" accesskey="&resetButton.accesskey;"/> + </hbox> + </vbox> + + <hbox flex="1"> + <vbox id="searchTermListBox" flex="1"> +#include ../../../../mailnews/search/content/searchTerm.inc.xhtml + </hbox> + </vbox> + + <splitter id="gray_horizontal_splitter" orient="vertical"/> + + <vbox id="searchResults" persist="height"> + <vbox id="searchResultListBox"> + <tree id="abResultsTree" flex="1" enableColumnDrag="true" class="plain" + onclick="AbResultsPaneOnClick(event);" + onkeypress="AbResultsPaneKeyPress(event);" + onselect="this.view.selectionChanged();" + sortCol="GeneratedName" + persist="sortCol"> + + <treecols id="abResultsTreeCols"> + <!-- these column ids must match up to the mork column names, except for GeneratedName and ChatName, see nsIAddrDatabase.idl --> + <treecol id="GeneratedName" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&GeneratedName.label;" + primary="true"/> + <splitter class="tree-splitter"/> + <treecol id="PrimaryEmail" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&PrimaryEmail.label;"/> + <splitter class="tree-splitter"/> + <treecol id="ChatName" + hidden="true" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&ChatName.label;"/> + <splitter class="tree-splitter"/> + <treecol id="Company" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&Company.label;"/> + <splitter class="tree-splitter"/> + <treecol id="NickName" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&NickName.label;"/> + <splitter class="tree-splitter"/> + <treecol id="SecondEmail" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&SecondEmail.label;"/> + <splitter class="tree-splitter"/> + <treecol id="Department" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&Department.label;"/> + <splitter class="tree-splitter"/> + <treecol id="JobTitle" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&JobTitle.label;"/> + <splitter class="tree-splitter"/> + <treecol id="CellularNumber" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&CellularNumber.label;"/> + <splitter class="tree-splitter"/> + <treecol id="PagerNumber" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&PagerNumber.label;"/> + <splitter class="tree-splitter"/> + <treecol id="FaxNumber" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&FaxNumber.label;"/> + <splitter class="tree-splitter"/> + <treecol id="HomePhone" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&HomePhone.label;"/> + <splitter class="tree-splitter"/> + <treecol id="WorkPhone" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&WorkPhone.label;"/> + <splitter class="tree-splitter"/> + <treecol id="Addrbook" + persist="hidden ordinal width sortDirection" + style="flex: 1 auto" + label="&Addrbook.label;"/> + <!-- LOCALIZATION NOTE: _PhoneticName may be enabled for Japanese builds. --> + <!-- + <treecol id="_PhoneticName" + persist="hidden ordinal width sortDirection" + hidden="true" + style="flex: 1 auto" + label="&_PhoneticName.label;"/> + <splitter class="tree-splitter"/> + --> + + </treecols> + <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);"/> + </tree> + </vbox> + <hbox align="start"> + <button label="&propertiesButton.label;" + accesskey="&propertiesButton.accesskey;" + command="cmd_properties"/> + <button label="&composeButton.label;" + accesskey="&composeButton.accesskey;" + command="cmd_compose"/> + <button label="&deleteCardButton.label;" + accesskey="&deleteCardButton.accesskey;" + command="cmd_deleteCard"/> + </hbox> + </vbox> + + <hbox id="status-bar" class="statusbar chromeclass-status" role="status"> + <label id="statusText" class="statusbarpanel" crop="end" flex="1"/> + </hbox> + +</window> diff --git a/comm/mail/components/addrbook/content/abView-new.js b/comm/mail/components/addrbook/content/abView-new.js new file mode 100644 index 0000000000..cb3eca969c --- /dev/null +++ b/comm/mail/components/addrbook/content/abView-new.js @@ -0,0 +1,577 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals PROTO_TREE_VIEW */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +function ABView( + directory, + searchQuery, + searchString, + sortColumn, + sortDirection +) { + this.__proto__.__proto__ = new PROTO_TREE_VIEW(); + this.directory = directory; + this.searchString = searchString; + + let directories = directory ? [directory] : MailServices.ab.directories; + if (searchQuery) { + this._searchesInProgress = directories.length; + searchQuery = searchQuery.replace(/^\?+/, ""); + for (let dir of directories) { + dir.search(searchQuery, searchString, this); + } + } else { + for (let dir of directories) { + for (let card of dir.childCards) { + this._rowMap.push(new abViewCard(card, dir)); + } + } + } + this.sortBy(sortColumn, sortDirection); +} +ABView.nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 +); +ABView.NOT_SEARCHING = 0; +ABView.SEARCHING = 1; +ABView.SEARCH_COMPLETE = 2; +ABView.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsITreeView", + "nsIAbDirSearchListener", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + directory: null, + _notifications: [ + "addrbook-directory-deleted", + "addrbook-directory-invalidated", + "addrbook-contact-created", + "addrbook-contact-updated", + "addrbook-contact-deleted", + "addrbook-list-created", + "addrbook-list-updated", + "addrbook-list-deleted", + "addrbook-list-member-added", + "addrbook-list-member-removed", + ], + + sortColumn: "", + sortDirection: "", + collator: new Intl.Collator(undefined, { numeric: true }), + + deleteSelectedCards() { + let directoryMap = new Map(); + for (let i of this._tree.selectedIndices) { + let card = this.getCardFromRow(i); + let cardSet = directoryMap.get(card.directoryUID); + if (!cardSet) { + cardSet = new Set(); + directoryMap.set(card.directoryUID, cardSet); + } + cardSet.add(card); + } + + for (let [directoryUID, cardSet] of directoryMap) { + let directory; + if (this.directory && this.directory.isMailList) { + // Removes cards from the list instead of deleting them. + directory = this.directory; + } else { + directory = MailServices.ab.getDirectoryFromUID(directoryUID); + } + + cardSet = [...cardSet]; + directory.deleteCards(cardSet.filter(card => !card.isMailList)); + for (let card of cardSet.filter(card => card.isMailList)) { + MailServices.ab.deleteAddressBook(card.mailListURI); + } + } + }, + getCardFromRow(row) { + return this._rowMap[row] ? this._rowMap[row].card : null; + }, + getDirectoryFromRow(row) { + return this._rowMap[row] ? this._rowMap[row].directory : null; + }, + getIndexForUID(uid) { + return this._rowMap.findIndex(row => row.id == uid); + }, + sortBy(sortColumn, sortDirection, resort) { + let selectionExists = false; + if (this._tree) { + let { selectedIndices, currentIndex } = this._tree; + selectionExists = selectedIndices.length; + // Remember what was selected. + for (let i = 0; i < this._rowMap.length; i++) { + this._rowMap[i].wasSelected = selectedIndices.includes(i); + this._rowMap[i].wasCurrent = currentIndex == i; + } + } + + // Do the sort. + if (sortColumn == this.sortColumn && !resort) { + if (sortDirection == this.sortDirection) { + return; + } + this._rowMap.reverse(); + } else { + this._rowMap.sort((a, b) => { + let aText = a.getText(sortColumn); + let bText = b.getText(sortColumn); + if (sortDirection == "descending") { + return this.collator.compare(bText, aText); + } + return this.collator.compare(aText, bText); + }); + } + + // Restore what was selected. + if (this._tree) { + this._tree.reset(); + if (selectionExists) { + for (let i = 0; i < this._rowMap.length; i++) { + this._tree.toggleSelectionAtIndex( + i, + this._rowMap[i].wasSelected, + true + ); + } + // Can't do this until updating the selection is finished. + for (let i = 0; i < this._rowMap.length; i++) { + if (this._rowMap[i].wasCurrent) { + this._tree.currentIndex = i; + break; + } + } + this.selectionChanged(); + } + } + this.sortColumn = sortColumn; + this.sortDirection = sortDirection; + }, + get searchState() { + if (this._searchesInProgress === undefined) { + return ABView.NOT_SEARCHING; + } + return this._searchesInProgress ? ABView.SEARCHING : ABView.SEARCH_COMPLETE; + }, + + // nsITreeView + + selectionChanged() {}, + setTree(tree) { + this._tree = tree; + for (let topic of this._notifications) { + if (tree) { + Services.obs.addObserver(this, topic, true); + } else { + try { + Services.obs.removeObserver(this, topic); + } catch (ex) { + // `this` might not be a valid observer. + } + } + } + Services.prefs.addObserver("mail.addr_book.lastnamefirst", this, true); + }, + + // nsIAbDirSearchListener + + onSearchFoundCard(card) { + // Instead of duplicating the insertion code below, just call it. + this.observe(card, "addrbook-contact-created", this.directory?.UID); + }, + onSearchFinished(status, complete, secInfo, location) { + // Special handling for Bad Cert errors. + let offerCertException = false; + try { + // If code is not an NSS error, getErrorClass() will fail. + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + let errorClass = nssErrorsService.getErrorClass(status); + if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + offerCertException = true; + } + } catch (ex) {} + + if (offerCertException) { + // Give the user the option of adding an exception for the bad cert. + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location, + }; + window.browsingContext.topChromeWindow.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // params.exceptionAdded will be set if the user added an exception. + } + + this._searchesInProgress--; + if (!this._searchesInProgress && this._tree) { + this._tree.dispatchEvent(new CustomEvent("searchstatechange")); + } + }, + + // nsIObserver + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + ABView.nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 + ); + for (let card of this._rowMap) { + delete card._getTextCache.GeneratedName; + } + if (this._tree) { + if (this.sortColumn == "GeneratedName") { + this.sortBy(this.sortColumn, this.sortDirection, true); + } else { + // Remember what was selected. + let { selectedIndices, currentIndex } = this._tree; + for (let i = 0; i < this._rowMap.length; i++) { + this._rowMap[i].wasSelected = selectedIndices.includes(i); + this._rowMap[i].wasCurrent = currentIndex == i; + } + + this._tree.reset(); + for (let i = 0; i < this._rowMap.length; i++) { + this._tree.toggleSelectionAtIndex( + i, + this._rowMap[i].wasSelected, + true + ); + } + // Can't do this until updating the selection is finished. + for (let i = 0; i < this._rowMap.length; i++) { + if (this._rowMap[i].wasCurrent) { + this._tree.currentIndex = i; + break; + } + } + } + } + return; + } + + if (this.directory && data && this.directory.UID != data) { + return; + } + + // If we make it here, we're in the root directory, or the right directory. + + switch (topic) { + case "addrbook-directory-deleted": { + if (this.directory) { + break; + } + + subject.QueryInterface(Ci.nsIAbDirectory); + let scrollPosition = this._tree?.getFirstVisibleIndex(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if (this._rowMap[i].directory.UID == subject.UID) { + this._rowMap.splice(i, 1); + if (this._tree) { + this._tree.rowCountChanged(i, -1); + } + } + } + if (this._tree && scrollPosition !== null) { + this._tree.scrollToIndex(scrollPosition); + } + break; + } + case "addrbook-directory-invalidated": + subject.QueryInterface(Ci.nsIAbDirectory); + if (subject == this.directory) { + this._rowMap.length = 0; + for (let card of this.directory.childCards) { + this._rowMap.push(new abViewCard(card, this.directory)); + } + this.sortBy(this.sortColumn, this.sortDirection, true); + } + break; + case "addrbook-list-created": { + let parentDir = MailServices.ab.getDirectoryFromUID(data); + // `subject` is an nsIAbDirectory, make it the matching card instead. + subject.QueryInterface(Ci.nsIAbDirectory); + for (let card of parentDir.childCards) { + if (card.UID == subject.UID) { + subject = card; + break; + } + } + } + // Falls through. + case "addrbook-list-member-added": + case "addrbook-contact-created": + if (topic == "addrbook-list-member-added" && !this.directory) { + break; + } + + subject.QueryInterface(Ci.nsIAbCard); + let viewCard = new abViewCard(subject); + let sortText = viewCard.getText(this.sortColumn); + let addIndex = null; + for (let i = 0; addIndex === null && i < this._rowMap.length; i++) { + let comparison = this.collator.compare( + sortText, + this._rowMap[i].getText(this.sortColumn) + ); + if ( + (comparison < 0 && this.sortDirection == "ascending") || + (comparison >= 0 && this.sortDirection == "descending") + ) { + addIndex = i; + } + } + if (addIndex === null) { + addIndex = this._rowMap.length; + } + this._rowMap.splice(addIndex, 0, viewCard); + if (this._tree) { + this._tree.rowCountChanged(addIndex, 1); + } + break; + + case "addrbook-list-updated": { + let parentDir = this.directory; + if (!parentDir) { + parentDir = MailServices.ab.getDirectoryFromUID(data); + } + // `subject` is an nsIAbDirectory, make it the matching card instead. + subject.QueryInterface(Ci.nsIAbDirectory); + for (let card of parentDir.childCards) { + if (card.UID == subject.UID) { + subject = card; + break; + } + } + } + // Falls through. + case "addrbook-contact-updated": { + subject.QueryInterface(Ci.nsIAbCard); + let needsSort = false; + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if ( + this._rowMap[i].card.equals(subject) && + this._rowMap[i].card.directoryUID == subject.directoryUID + ) { + this._rowMap.splice(i, 1, new abViewCard(subject)); + needsSort = true; + } + } + if (needsSort) { + this.sortBy(this.sortColumn, this.sortDirection, true); + } + break; + } + + case "addrbook-list-deleted": { + subject.QueryInterface(Ci.nsIAbDirectory); + let scrollPosition = this._tree?.getFirstVisibleIndex(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if (this._rowMap[i].card.UID == subject.UID) { + this._rowMap.splice(i, 1); + if (this._tree) { + this._tree.rowCountChanged(i, -1); + } + } + } + if (this._tree && scrollPosition !== null) { + this._tree.scrollToIndex(scrollPosition); + } + break; + } + case "addrbook-list-member-removed": + if (!this.directory) { + break; + } + // Falls through. + case "addrbook-contact-deleted": { + subject.QueryInterface(Ci.nsIAbCard); + let scrollPosition = this._tree?.getFirstVisibleIndex(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if ( + this._rowMap[i].card.equals(subject) && + this._rowMap[i].card.directoryUID == subject.directoryUID + ) { + this._rowMap.splice(i, 1); + if (this._tree) { + this._tree.rowCountChanged(i, -1); + } + } + } + if (this._tree && scrollPosition !== null) { + this._tree.scrollToIndex(scrollPosition); + } + break; + } + } + }, +}; + +/** + * Representation of a card, used as a table row in ABView. + * + * @param {nsIAbCard} card - contact or mailing list card for this row. + * @param {nsIAbDirectory} [directoryHint] - the directory containing card, + * if available (this is a performance optimization only). + */ +function abViewCard(card, directoryHint) { + this.card = card; + this._getTextCache = {}; + if (directoryHint) { + this._directory = directoryHint; + } else { + this._directory = MailServices.ab.getDirectoryFromUID( + this.card.directoryUID + ); + } +} +abViewCard.listFormatter = new Services.intl.ListFormat( + Services.appinfo.name == "xpcshell" ? "en-US" : undefined, + { type: "unit" } +); +abViewCard.prototype = { + _getText(columnID) { + try { + let { getProperty, supportsVCard, vCardProperties } = this.card; + + if (this.card.isMailList) { + if (columnID == "GeneratedName") { + return this.card.displayName; + } + if (["NickName", "Notes"].includes(columnID)) { + return getProperty(columnID, ""); + } + if (columnID == "addrbook") { + return MailServices.ab.getDirectoryFromUID(this.card.directoryUID) + .dirName; + } + return ""; + } + + switch (columnID) { + case "addrbook": + return this._directory.dirName; + case "GeneratedName": + return this.card.generateName(ABView.nameFormat); + case "EmailAddresses": + return abViewCard.listFormatter.format(this.card.emailAddresses); + case "PhoneNumbers": { + let phoneNumbers; + if (supportsVCard) { + phoneNumbers = vCardProperties.getAllValues("tel"); + } else { + phoneNumbers = [ + getProperty("WorkPhone", ""), + getProperty("HomePhone", ""), + getProperty("CellularNumber", ""), + getProperty("FaxNumber", ""), + getProperty("PagerNumber", ""), + ]; + } + return abViewCard.listFormatter.format(phoneNumbers.filter(Boolean)); + } + case "Addresses": { + let addresses; + if (supportsVCard) { + addresses = vCardProperties + .getAllValues("adr") + .map(v => v.join(" ").trim()); + } else { + addresses = [ + this.formatAddress("Work"), + this.formatAddress("Home"), + ]; + } + return abViewCard.listFormatter.format(addresses.filter(Boolean)); + } + case "JobTitle": + case "Title": + if (supportsVCard) { + return vCardProperties.getFirstValue("title"); + } + return getProperty("JobTitle", ""); + case "Department": + if (supportsVCard) { + let vCardValue = vCardProperties.getFirstValue("org"); + if (Array.isArray(vCardValue)) { + return vCardValue[1] || ""; + } + return ""; + } + return getProperty(columnID, ""); + case "Company": + case "Organization": + if (supportsVCard) { + let vCardValue = vCardProperties.getFirstValue("org"); + if (Array.isArray(vCardValue)) { + return vCardValue[0] || ""; + } + return vCardValue; + } + return getProperty("Company", ""); + default: + return getProperty(columnID, ""); + } + } catch (ex) { + return ""; + } + }, + getText(columnID) { + if (!(columnID in this._getTextCache)) { + this._getTextCache[columnID] = this._getText(columnID)?.trim() ?? ""; + } + return this._getTextCache[columnID]; + }, + get id() { + return this.card.UID; + }, + get open() { + return false; + }, + get level() { + return 0; + }, + get children() { + return []; + }, + getProperties() { + return ""; + }, + get directory() { + return this._directory; + }, + + /** + * Creates a string representation of an address from card properties. + * + * @param {"Work"|"Home"} prefix + * @returns {string} + */ + formatAddress(prefix) { + return Array.from( + ["Address", "Address2", "City", "State", "ZipCode", "Country"], + field => this.card.getProperty(`${prefix}${field}`, "") + ) + .join(" ") + .trim(); + }, +}; diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.js b/comm/mail/components/addrbook/content/aboutAddressBook.js new file mode 100644 index 0000000000..8f0eeca693 --- /dev/null +++ b/comm/mail/components/addrbook/content/aboutAddressBook.js @@ -0,0 +1,4445 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals ABView */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm"); +var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm"); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyGetter(this, "ABQueryUtils", function () { + return ChromeUtils.import("resource:///modules/ABQueryUtils.jsm"); +}); +XPCOMUtils.defineLazyGetter(this, "AddrBookUtils", function () { + return ChromeUtils.import("resource:///modules/AddrBookUtils.jsm"); +}); + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddrBookCard: "resource:///modules/AddrBookCard.jsm", + AddrBookUtils: "resource:///modules/AddrBookUtils.jsm", + cal: "resource:///modules/calendar/calUtils.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalMetronome: "resource:///modules/CalMetronome.jsm", + CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm", + GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm", + ICAL: "resource:///modules/calendar/Ical.jsm", + MailE10SUtils: "resource:///modules/MailE10SUtils.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardPropertyEntry: "resource:///modules/VCardUtils.jsm", +}); +XPCOMUtils.defineLazyGetter(this, "SubDialog", function () { + const { SubDialogManager } = ChromeUtils.importESModule( + "resource://gre/modules/SubDialog.sys.mjs" + ); + return new SubDialogManager({ + dialogStack: document.getElementById("dialogStack"), + dialogTemplate: document.getElementById("dialogTemplate"), + dialogOptions: { + styleSheets: [ + "chrome://messenger/skin/preferences/dialog.css", + "chrome://messenger/skin/shared/preferences/subdialog.css", + "chrome://messenger/skin/abFormFields.css", + ], + resizeCallback: ({ title, frame }) => { + UIFontSize.registerWindow(frame.contentWindow); + + // Resize the dialog to fit the content with edited font size. + requestAnimationFrame(() => { + let dialogs = frame.ownerGlobal.SubDialog._dialogs; + let dialog = dialogs.find( + d => d._frame.contentDocument == frame.contentDocument + ); + if (dialog) { + UIFontSize.resizeSubDialog(dialog); + } + }); + }, + }, + }); +}); + +UIDensity.registerWindow(window); +UIFontSize.registerWindow(window); + +var booksList; + +window.addEventListener("load", () => { + document + .getElementById("toolbarCreateBook") + .addEventListener("command", event => { + let type = event.target.value || "JS_DIRECTORY_TYPE"; + createBook(Ci.nsIAbManager[type]); + }); + document + .getElementById("toolbarCreateContact") + .addEventListener("command", () => createContact()); + document + .getElementById("toolbarCreateList") + .addEventListener("command", () => createList()); + document + .getElementById("toolbarImport") + .addEventListener("command", () => importBook()); + + document.getElementById("bookContext").addEventListener("command", event => { + switch (event.target.id) { + case "bookContextProperties": + booksList.showPropertiesOfSelected(); + break; + case "bookContextSynchronize": + booksList.synchronizeSelected(); + break; + case "bookContextPrint": + booksList.printSelected(); + break; + case "bookContextExport": + booksList.exportSelected(); + break; + case "bookContextDelete": + booksList.deleteSelected(); + break; + case "bookContextRemove": + booksList.deleteSelected(); + break; + case "bookContextStartupDefault": + if (event.target.hasAttribute("checked")) { + booksList.setSelectedAsStartupDefault(); + } else { + booksList.clearStartupDefault(); + } + break; + } + }); + + booksList = document.getElementById("books"); + cardsPane.init(); + detailsPane.init(); + photoDialog.init(); + + setKeyboardShortcuts(); + + // Once the old Address Book has gone away, this should be changed to use + // UIDs instead of URIs. It's just easier to keep as-is for now. + let startupURI = Services.prefs.getStringPref( + "mail.addr_book.view.startupURI", + "" + ); + if (startupURI) { + for (let index = 0; index < booksList.rows.length; index++) { + let row = booksList.rows[index]; + if (row._book?.URI == startupURI || row._list?.URI == startupURI) { + booksList.selectedIndex = index; + break; + } + } + } + + if (booksList.selectedIndex == 0) { + // Index 0 was selected before we started listening. + booksList.dispatchEvent(new CustomEvent("select")); + } + + cardsPane.searchInput.focus(); + + window.dispatchEvent(new CustomEvent("about-addressbook-ready")); +}); + +window.addEventListener("unload", () => { + // Once the old Address Book has gone away, this should be changed to use + // UIDs instead of URIs. It's just easier to keep as-is for now. + if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) { + let pref = "mail.addr_book.view.startupURI"; + if (booksList.selectedIndex === 0) { + Services.prefs.clearUserPref(pref); + } else { + let row = booksList.getRowAtIndex(booksList.selectedIndex); + let directory = row._book || row._list; + Services.prefs.setCharPref(pref, directory.URI); + } + } + + // Disconnect the view (if there is one) and tree, so that the view cleans + // itself up and stops listening for observer service notifications. + cardsPane.cardsList.view = null; + detailsPane.uninit(); +}); + +window.addEventListener("keypress", event => { + // Prevent scrolling of the html tag when space is used. + if ( + event.key == " " && + detailsPane.isEditing && + document.activeElement.tagName == "body" + ) { + event.preventDefault(); + } +}); + +/** + * Add a keydown document event listener for international keyboard shortcuts. + */ +async function setKeyboardShortcuts() { + let [newContactKey] = await document.l10n.formatValues([ + { id: "about-addressbook-new-contact-key" }, + ]); + + document.addEventListener("keydown", event => { + if ( + !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) || + ["Shift", "Control", "Meta"].includes(event.key) + ) { + return; + } + + // Always use lowercase to compare the key and avoid OS inconsistencies: + // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A". + switch (event.key.toLowerCase()) { + // Always prevent the default behavior of the keydown if we intercepted + // the key in order to avoid triggering OS specific shortcuts. + case newContactKey.toLowerCase(): { + // Ctrl/Cmd+n. + event.preventDefault(); + if (!detailsPane.isEditing) { + createContact(); + } + break; + } + } + }); +} + +/** + * Called on load from `toAddressBook` to create, display or edit a card. + * + * @param {"create"|"display"|"edit"|"create_ab_*"} action - What to do with the args given. + * @param {?string} address - Create a new card with this email address. + * @param {?string} vCard - Create a new card from this vCard. + * @param {?nsIAbCard} card - Display or edit this card. + */ +function externalAction({ action, address, card, vCard } = {}) { + if (action == "create") { + if (address) { + detailsPane.editNewContact( + `BEGIN:VCARD\r\nEMAIL:${address}\r\nEND:VCARD\r\n` + ); + } else { + detailsPane.editNewContact(vCard); + } + } else if (action == "display" || action == "edit") { + if (!card || !card.directoryUID) { + return; + } + + let book = MailServices.ab.getDirectoryFromUID(card.directoryUID); + if (!book) { + return; + } + + booksList.selectedIndex = booksList.getIndexForUID(card.directoryUID); + cardsPane.cardsList.selectedIndex = cardsPane.cardsList.view.getIndexForUID( + card.UID + ); + + if (action == "edit" && book && !book.readOnly) { + detailsPane.editCurrentContact(); + } + } else if (action == "print") { + if (document.activeElement == booksList) { + booksList.printSelected(); + } else { + cardsPane.printSelected(); + } + } else if (action == "create_ab_JS") { + createBook(); + } else if (action == "create_ab_CARDDAV") { + createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE); + } else if (action == "create_ab_LDAP") { + createBook(Ci.nsIAbManager.LDAP_DIRECTORY_TYPE); + } +} + +/** + * Show UI to create a new address book of the type specified. + * + * @param {integer} [type=Ci.nsIAbManager.JS_DIRECTORY_TYPE] - One of the + * nsIAbManager directory type constants. + */ +function createBook(type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) { + const typeURLs = { + [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]: + "chrome://messenger/content/addressbook/pref-directory-add.xhtml", + [Ci.nsIAbManager.JS_DIRECTORY_TYPE]: + "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml", + [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]: + "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml", + }; + + let url = typeURLs[type]; + if (!url) { + throw new Components.Exception( + `Unexpected type: ${type}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + let params = {}; + SubDialog.open( + url, + { + features: "resizable=no", + closedCallback: () => { + if (params.newDirectoryUID) { + booksList.selectedIndex = booksList.getIndexForUID( + params.newDirectoryUID + ); + booksList.focus(); + } + }, + }, + params + ); +} + +/** + * Show UI to create a new contact in the current address book. + */ +function createContact() { + let row = booksList.getRowAtIndex(booksList.selectedIndex); + let bookUID = row.dataset.book ?? row.dataset.uid; + + if (bookUID) { + let book = MailServices.ab.getDirectoryFromUID(bookUID); + if (book.readOnly) { + throw new Components.Exception( + "Address book is read-only", + Cr.NS_ERROR_FAILURE + ); + } + } + + detailsPane.editNewContact(); +} + +/** + * Show UI to create a new list in the current address book. + * For now this loads the old list UI, the intention is to replace it. + * + * @param {nsIAbCard[]} cards - The contacts, if any, to add to the list. + */ +function createList(cards) { + let row = booksList.getRowAtIndex(booksList.selectedIndex); + let bookUID = row.dataset.book ?? row.dataset.uid; + + let params = { cards }; + if (bookUID) { + let book = MailServices.ab.getDirectoryFromUID(bookUID); + if (book.readOnly) { + throw new Components.Exception( + "Address book is read-only", + Cr.NS_ERROR_FAILURE + ); + } + if (!book.supportsMailingLists) { + throw new Components.Exception( + "Address book does not support lists", + Cr.NS_ERROR_FAILURE + ); + } + params.selectedAB = book.URI; + } + SubDialog.open( + "chrome://messenger/content/addressbook/abMailListDialog.xhtml", + { + features: "resizable=no", + closedCallback: () => { + if (params.newListUID) { + booksList.selectedIndex = booksList.getIndexForUID(params.newListUID); + booksList.focus(); + } + }, + }, + params + ); +} + +/** + * Import an address book from a file. This shows the generic Thunderbird + * import wizard, which isn't ideal but better than nothing. + */ +function importBook() { + let createdDirectory; + let observer = function (subject) { + // It might be possible for more than one directory to be imported, select + // the first one. + if (!createdDirectory) { + createdDirectory = subject.QueryInterface(Ci.nsIAbDirectory); + } + }; + + Services.obs.addObserver(observer, "addrbook-directory-created"); + window.browsingContext.topChromeWindow.toImport("addressBook"); + Services.obs.removeObserver(observer, "addrbook-directory-created"); + + // Select the directory after the import UI closes, so the user sees the change. + if (createdDirectory) { + booksList.selectedIndex = booksList.getIndexForUID(createdDirectory.UID); + } +} + +/** + * Sets the total count for the current selected address book at the bottom + * of the address book view. + */ +async function updateAddressBookCount() { + let cardCount = document.getElementById("cardCount"); + let { rowCount: count, directory } = cardsPane.cardsList.view; + + if (directory) { + document.l10n.setAttributes(cardCount, "about-addressbook-card-count", { + name: directory.dirName, + count, + }); + } else { + document.l10n.setAttributes(cardCount, "about-addressbook-card-count-all", { + count, + }); + } +} + +/** + * Update the shared splitter between the cardsPane and detailsPane in order to + * properly set its properties to handle the correct pane based on the layout. + * + * @param {boolean} isTableLayout - If the current body layout is a table. + */ +function updateSharedSplitter(isTableLayout) { + let splitter = document.getElementById("sharedSplitter"); + splitter.resizeDirection = isTableLayout ? "vertical" : "horizontal"; + splitter.resizeElement = document.getElementById( + isTableLayout ? "detailsPane" : "cardsPane" + ); + + splitter.isCollapsed = + document.getElementById("detailsPane").hidden && isTableLayout; +} + +// Books + +/** + * The list of address books. + * + * @augments {TreeListbox} + */ +class AbTreeListbox extends customElements.get("tree-listbox") { + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + this.setAttribute("is", "ab-tree-listbox"); + + this.addEventListener("select", this); + this.addEventListener("collapsed", this); + this.addEventListener("expanded", this); + this.addEventListener("keypress", this); + this.addEventListener("contextmenu", this); + this.addEventListener("dragover", this); + this.addEventListener("dragleave", this); + this.addEventListener("drop", this); + + for (let book of MailServices.ab.directories) { + this.appendChild(this._createBookRow(book)); + } + + this._abObserver.observe = this._abObserver.observe.bind(this); + for (let topic of this._abObserver._notifications) { + Services.obs.addObserver(this._abObserver, topic, true); + } + + window.addEventListener("unload", this); + + // Add event listener to update the total count of the selected address + // book. + this.addEventListener("select", e => { + updateAddressBookCount(); + }); + + // Row 0 is the "All Address Books" item. + document.body.classList.toggle("all-ab-selected", this.selectedIndex === 0); + } + + destroy() { + this.removeEventListener("select", this); + this.removeEventListener("collapsed", this); + this.removeEventListener("expanded", this); + this.removeEventListener("keypress", this); + this.removeEventListener("contextmenu", this); + this.removeEventListener("dragover", this); + this.removeEventListener("dragleave", this); + this.removeEventListener("drop", this); + + for (let topic of this._abObserver._notifications) { + Services.obs.removeObserver(this._abObserver, topic); + } + } + + handleEvent(event) { + super.handleEvent(event); + + switch (event.type) { + case "select": + this._onSelect(event); + break; + case "collapsed": + this._onCollapsed(event); + break; + case "expanded": + this._onExpanded(event); + break; + case "keypress": + this._onKeyPress(event); + break; + case "contextmenu": + this._onContextMenu(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "dragleave": + this._clearDropTarget(event); + break; + case "drop": + this._onDrop(event); + break; + case "unload": + this.destroy(); + break; + } + } + + _createBookRow(book) { + let row = document + .getElementById("bookRow") + .content.firstElementChild.cloneNode(true); + row.id = `book-${book.UID}`; + row.setAttribute("aria-label", book.dirName); + row.title = book.dirName; + if ( + Services.xulStore.getValue(cardsPane.URL, row.id, "collapsed") == "true" + ) { + row.classList.add("collapsed"); + } + if (book.isRemote) { + row.classList.add("remote"); + } + if (book.readOnly) { + row.classList.add("readOnly"); + } + if ( + ["ldap_2.servers.history", "ldap_2.servers.pab"].includes(book.dirPrefId) + ) { + row.classList.add("noDelete"); + } + if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) { + row.classList.add("carddav"); + } + row.dataset.uid = book.UID; + row._book = book; + row.querySelector("span").textContent = book.dirName; + + for (let list of book.childNodes) { + row.querySelector("ul").appendChild(this._createListRow(book.UID, list)); + } + return row; + } + + _createListRow(bookUID, list) { + let row = document + .getElementById("listRow") + .content.firstElementChild.cloneNode(true); + row.id = `list-${list.UID}`; + row.setAttribute("aria-label", list.dirName); + row.title = list.dirName; + row.dataset.uid = list.UID; + row.dataset.book = bookUID; + row._list = list; + row.querySelector("span").textContent = list.dirName; + return row; + } + + /** + * Get the index of the row representing a book or list. + * + * @param {string|null} uid - The UID of the book or list to find, or null + * for All Address Books. + * @returns {integer} - Index of the book or list. + */ + getIndexForUID(uid) { + if (!uid) { + return 0; + } + return this.rows.findIndex(r => r.dataset.uid == uid); + } + + /** + * Get the row representing a book or list. + * + * @param {string|null} uid - The UID of the book or list to find, or null + * for All Address Books. + * @returns {HTMLLIElement} - Row of the book or list. + */ + getRowForUID(uid) { + if (!uid) { + return this.firstElementChild; + } + return this.querySelector(`li[data-uid="${uid}"]`); + } + + /** + * Show UI to modify the selected address book or list. + */ + showPropertiesOfSelected() { + if (this.selectedIndex === 0) { + throw new Components.Exception( + "Cannot modify the All Address Books item", + Cr.NS_ERROR_UNEXPECTED + ); + } + + let row = this.rows[this.selectedIndex]; + + if (row.classList.contains("listRow")) { + let book = MailServices.ab.getDirectoryFromUID(row.dataset.book); + let list = book.childNodes.find(l => l.UID == row.dataset.uid); + + SubDialog.open( + "chrome://messenger/content/addressbook/abEditListDialog.xhtml", + { features: "resizable=no" }, + { listURI: list.URI } + ); + return; + } + + let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid); + + SubDialog.open( + book.propertiesChromeURI, + { features: "resizable=no" }, + { selectedDirectory: book } + ); + } + + /** + * Synchronize the selected address book. (CardDAV only.) + */ + synchronizeSelected() { + let row = this.rows[this.selectedIndex]; + if (!row.classList.contains("carddav")) { + throw new Components.Exception( + "Attempting to synchronize a non-CardDAV book.", + Cr.NS_ERROR_UNEXPECTED + ); + } + + let directory = MailServices.ab.getDirectoryFromUID(row.dataset.uid); + directory = CardDAVDirectory.forFile(directory.fileName); + directory.syncWithServer().then(res => { + updateAddressBookCount(); + }); + } + + /** + * Print the selected address book. + */ + printSelected() { + if (this.selectedIndex === 0) { + printHandler.printDirectory(); + return; + } + + let row = this.rows[this.selectedIndex]; + if (row.classList.contains("listRow")) { + let book = MailServices.ab.getDirectoryFromUID(row.dataset.book); + let list = book.childNodes.find(l => l.UID == row.dataset.uid); + printHandler.printDirectory(list); + } else { + let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid); + printHandler.printDirectory(book); + } + } + + /** + * Export the selected address book to a file. + */ + exportSelected() { + if (this.selectedIndex == 0) { + return; + } + + let row = this.getRowAtIndex(this.selectedIndex); + let directory = row._book || row._list; + AddrBookUtils.exportDirectory(directory); + } + + /** + * Prompt the user and delete the selected address book. + */ + async deleteSelected() { + if (this.selectedIndex === 0) { + throw new Components.Exception( + "Cannot delete the All Address Books item", + Cr.NS_ERROR_UNEXPECTED + ); + } + + let row = this.rows[this.selectedIndex]; + if (row.classList.contains("noDelete")) { + throw new Components.Exception( + "Refusing to delete a built-in address book", + Cr.NS_ERROR_UNEXPECTED + ); + } + + let action, name, uri; + if (row.classList.contains("listRow")) { + action = "delete-lists"; + name = row._list.dirName; + uri = row._list.URI; + } else { + if ( + [ + Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE, + Ci.nsIAbManager.LDAP_DIRECTORY_TYPE, + ].includes(row._book.dirType) + ) { + action = "remove-remote-book"; + } else { + action = "delete-book"; + } + + name = row._book.dirName; + uri = row._book.URI; + } + + let [title, message] = await document.l10n.formatValues([ + { id: `about-addressbook-confirm-${action}-title`, args: { count: 1 } }, + { + id: `about-addressbook-confirm-${action}`, + args: { name, count: 1 }, + }, + ]); + + if ( + Services.prompt.confirmEx( + window, + title, + message, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) === 0 + ) { + MailServices.ab.deleteAddressBook(uri); + } + } + + /** + * Set the selected directory to be the one opened when the page opens. + */ + setSelectedAsStartupDefault() { + // Once the old Address Book has gone away, this should be changed to use + // UIDs instead of URIs. It's just easier to keep as-is for now. + Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true); + if (this.selectedIndex === 0) { + Services.prefs.clearUserPref("mail.addr_book.view.startupURI"); + return; + } + + let row = this.rows[this.selectedIndex]; + let directory = row._book || row._list; + Services.prefs.setStringPref( + "mail.addr_book.view.startupURI", + directory.URI + ); + } + + /** + * Clear the directory to be opened when the page opens. Instead, the + * last-selected directory will be opened. + */ + clearStartupDefault() { + Services.prefs.setBoolPref( + "mail.addr_book.view.startupURIisDefault", + false + ); + } + + _onSelect() { + let row = this.rows[this.selectedIndex]; + if (row.classList.contains("listRow")) { + cardsPane.displayList(row.dataset.book, row.dataset.uid); + } else { + cardsPane.displayBook(row.dataset.uid); + } + + // Row 0 is the "All Address Books" item. + if (this.selectedIndex === 0) { + document.getElementById("toolbarCreateContact").disabled = false; + document.getElementById("toolbarCreateList").disabled = false; + document.body.classList.add("all-ab-selected"); + } else { + let bookUID = row.dataset.book ?? row.dataset.uid; + let book = MailServices.ab.getDirectoryFromUID(bookUID); + + document.getElementById("toolbarCreateContact").disabled = book.readOnly; + document.getElementById("toolbarCreateList").disabled = + book.readOnly || !book.supportsMailingLists; + document.body.classList.remove("all-ab-selected"); + } + } + + _onCollapsed(event) { + Services.xulStore.setValue( + cardsPane.URL, + event.target.id, + "collapsed", + "true" + ); + } + + _onExpanded(event) { + Services.xulStore.removeValue(cardsPane.URL, event.target.id, "collapsed"); + } + + _onKeyPress(event) { + if (event.altKey || event.metaKey || event.shiftKey) { + return; + } + + switch (event.key) { + case "Delete": + this.deleteSelected(); + break; + } + } + + _onClick(event) { + super._onClick(event); + + // Only handle left-clicks. Right-clicking on the menu button will cause + // the menu to appear anyway, and other buttons can be ignored. + if ( + event.button !== 0 || + !event.target.closest(".bookRow-menu, .listRow-menu") + ) { + return; + } + + this._showContextMenu(event); + } + + _onContextMenu(event) { + this._showContextMenu(event); + } + + _onDragOver(event) { + let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0); + if (!cards) { + return; + } + if (cards.some(c => c.isMailList)) { + return; + } + + // TODO: Handle dropping a vCard here. + + let row = event.target.closest("li"); + if (!row || row.classList.contains("readOnly")) { + return; + } + + let rowIsList = row.classList.contains("listRow"); + event.dataTransfer.effectAllowed = rowIsList ? "link" : "copyMove"; + + if (rowIsList) { + let bookUID = row.dataset.book; + for (let card of cards) { + if (card.directoryUID != bookUID) { + return; + } + } + event.dataTransfer.dropEffect = "link"; + } else { + let bookUID = row.dataset.uid; + for (let card of cards) { + // Prevent dropping a card where it already is. + if (card.directoryUID == bookUID) { + return; + } + } + event.dataTransfer.dropEffect = event.ctrlKey ? "copy" : "move"; + } + + this._clearDropTarget(); + row.classList.add("drop-target"); + + event.preventDefault(); + } + + _clearDropTarget() { + this.querySelector(".drop-target")?.classList.remove("drop-target"); + } + + _onDrop(event) { + this._clearDropTarget(); + if (event.dataTransfer.dropEffect == "none") { + // Somehow this is possible. It should not be possible. + return; + } + + let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0); + let row = event.target.closest("li"); + + if (row.classList.contains("listRow")) { + for (let card of cards) { + row._list.addCard(card); + } + } else if (event.dataTransfer.dropEffect == "copy") { + for (let card of cards) { + row._book.dropCard(card, true); + } + } else { + let booksMap = new Map(); + let bookUID = row.dataset.uid; + for (let card of cards) { + if (bookUID == card.directoryUID) { + continue; + } + row._book.dropCard(card, false); + let bookSet = booksMap.get(card.directoryUID); + if (!bookSet) { + bookSet = new Set(); + booksMap.set(card.directoryUID, bookSet); + } + bookSet.add(card); + } + for (let [uid, bookSet] of booksMap) { + MailServices.ab.getDirectoryFromUID(uid).deleteCards([...bookSet]); + } + } + + event.preventDefault(); + } + + _showContextMenu(event) { + let row = + event.target == this + ? this.rows[this.selectedIndex] + : event.target.closest("li"); + if (!row) { + return; + } + + let popup = document.getElementById("bookContext"); + let synchronizeItem = document.getElementById("bookContextSynchronize"); + let exportItem = document.getElementById("bookContextExport"); + let deleteItem = document.getElementById("bookContextDelete"); + let removeItem = document.getElementById("bookContextRemove"); + let startupDefaultItem = document.getElementById( + "bookContextStartupDefault" + ); + + let isDefault = Services.prefs.getBoolPref( + "mail.addr_book.view.startupURIisDefault" + ); + + this.selectedIndex = this.rows.indexOf(row); + this.focus(); + if (this.selectedIndex === 0) { + // All Address Books - only the startup default item is relevant. + for (let item of popup.children) { + item.hidden = item != startupDefaultItem; + } + + isDefault = + isDefault && + !Services.prefs.prefHasUserValue("mail.addr_book.view.startupURI"); + } else { + for (let item of popup.children) { + item.hidden = false; + } + + document.l10n.setAttributes( + document.getElementById("bookContextProperties"), + row.classList.contains("listRow") + ? "about-addressbook-books-context-edit-list" + : "about-addressbook-books-context-properties" + ); + + synchronizeItem.hidden = !row.classList.contains("carddav"); + exportItem.hidden = row.classList.contains("remote"); + + deleteItem.disabled = row.classList.contains("noDelete"); + deleteItem.hidden = row.classList.contains("carddav"); + + removeItem.disabled = row.classList.contains("noDelete"); + removeItem.hidden = !row.classList.contains("carddav"); + + let directory = row._book || row._list; + isDefault = + isDefault && + Services.prefs.getStringPref("mail.addr_book.view.startupURI") == + directory.URI; + } + + if (isDefault) { + startupDefaultItem.setAttribute("checked", "true"); + } else { + startupDefaultItem.removeAttribute("checked"); + } + + if (event.type == "contextmenu" && event.button == 2) { + // This is a right-click. Open where it happened. + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + // This is a click on the menu button, or the context menu key was + // pressed. Open near the menu button. + popup.openPopup( + row.querySelector(".bookRow-container, .listRow-container"), + { + triggerEvent: event, + position: "end_before", + x: -26, + y: 30, + } + ); + } + event.preventDefault(); + } + + _abObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _notifications: [ + "addrbook-directory-created", + "addrbook-directory-updated", + "addrbook-directory-deleted", + "addrbook-directory-request-start", + "addrbook-directory-request-end", + "addrbook-list-created", + "addrbook-list-updated", + "addrbook-list-deleted", + ], + + // Bound to `booksList`. + observe(subject, topic, data) { + subject.QueryInterface(Ci.nsIAbDirectory); + + switch (topic) { + case "addrbook-directory-created": { + let row = this._createBookRow(subject); + let next = this.children[1]; + while (next) { + if ( + AddrBookUtils.compareAddressBooks( + subject, + MailServices.ab.getDirectoryFromUID(next.dataset.uid) + ) < 0 + ) { + break; + } + next = next.nextElementSibling; + } + this.insertBefore(row, next); + break; + } + case "addrbook-directory-updated": + case "addrbook-list-updated": { + let row = this.getRowForUID(subject.UID); + row.querySelector(".bookRow-name, .listRow-name").textContent = + subject.dirName; + row.setAttribute("aria-label", subject.dirName); + if (cardsPane.cardsList.view.directory?.UID == subject.UID) { + document.l10n.setAttributes( + cardsPane.searchInput, + "about-addressbook-search", + { name: subject.dirName } + ); + } + break; + } + case "addrbook-directory-deleted": { + this.getRowForUID(subject.UID).remove(); + break; + } + case "addrbook-directory-request-start": + this.getRowForUID(data).classList.add("requesting"); + break; + case "addrbook-directory-request-end": + this.getRowForUID(data).classList.remove("requesting"); + break; + case "addrbook-list-created": { + let row = this.getRowForUID(data); + let childList = row.querySelector("ul"); + if (!childList) { + childList = row.appendChild(document.createElement("ul")); + } + + let listRow = this._createListRow(data, subject); + let next = childList.firstElementChild; + while (next) { + if (AddrBookUtils.compareAddressBooks(subject, next._list) < 0) { + break; + } + next = next.nextElementSibling; + } + childList.insertBefore(listRow, next); + break; + } + case "addrbook-list-deleted": { + let row = this.getRowForUID(data); + let childList = row.querySelector("ul"); + let listRow = childList.querySelector(`[data-uid="${subject.UID}"]`); + listRow.remove(); + if (childList.childElementCount == 0) { + setTimeout(() => childList.remove()); + } + break; + } + } + }, + }; +} +customElements.define("ab-tree-listbox", AbTreeListbox, { extends: "ul" }); + +// Cards + +/** + * Search field for card list. An HTML port of MozSearchTextbox. + */ +class AbCardSearchInput extends HTMLInputElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this._fireCommand = this._fireCommand.bind(this); + + this.addEventListener("input", this); + this.addEventListener("keypress", this); + } + + handleEvent(event) { + switch (event.type) { + case "input": + this._onInput(event); + break; + case "keypress": + this._onKeyPress(event); + break; + } + } + + _onInput() { + if (this._timer) { + clearTimeout(this._timer); + } + this._timer = setTimeout(this._fireCommand, 500, this); + } + + _onKeyPress(event) { + switch (event.key) { + case "Escape": + if (this._clearSearch()) { + event.preventDefault(); + event.stopPropagation(); + } + break; + case "Return": + this._enterSearch(); + event.preventDefault(); + event.stopPropagation(); + break; + } + } + + _fireCommand() { + if (this._timer) { + clearTimeout(this._timer); + } + this._timer = null; + this.dispatchEvent(new CustomEvent("command")); + } + + _enterSearch() { + this._fireCommand(); + } + + _clearSearch() { + if (this.value) { + this.value = ""; + this._fireCommand(); + return true; + } + return false; + } +} +customElements.define("ab-card-search-input", AbCardSearchInput, { + extends: "input", +}); + +customElements.whenDefined("tree-view-table-row").then(() => { + /** + * A row in the list of cards. + * + * @augments {TreeViewTableRow} + */ + class AbCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 46; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + + this.cell = document.createElement("td"); + + let container = this.cell.appendChild(document.createElement("div")); + container.classList.add("card-container"); + + this.avatar = container.appendChild(document.createElement("div")); + this.avatar.classList.add("recipient-avatar"); + let dataContainer = container.appendChild(document.createElement("div")); + dataContainer.classList.add("ab-card-row-data"); + + this.firstLine = dataContainer.appendChild(document.createElement("p")); + this.firstLine.classList.add("ab-card-first-line"); + this.name = this.firstLine.appendChild(document.createElement("span")); + this.name.classList.add("name"); + + let secondLine = dataContainer.appendChild(document.createElement("p")); + secondLine.classList.add("ab-card-second-line"); + this.address = secondLine.appendChild(document.createElement("span")); + this.address.classList.add("address"); + + this.appendChild(this.cell); + } + + get index() { + return super.index; + } + + /** + * Override the row setter to generate the layout. + * + * @note This element could be recycled, make sure you set or clear all + * properties. + */ + set index(index) { + super.index = index; + + let card = this.view.getCardFromRow(index); + this.name.textContent = this.view.getCellText(index, { + id: "GeneratedName", + }); + + // Add the address book name for All Address Books if in the sort Context + // Address Book is checked. This is done for the list view only. + if ( + document.getElementById("books").selectedIndex == "0" && + document + .getElementById("sortContext") + .querySelector(`menuitem[value="addrbook"]`) + .getAttribute("checked") === "true" + ) { + let addressBookName = this.querySelector(".address-book-name"); + if (!addressBookName) { + addressBookName = document.createElement("span"); + addressBookName.classList.add("address-book-name"); + this.firstLine.appendChild(addressBookName); + } + addressBookName.textContent = this.view.getCellText(index, { + id: "addrbook", + }); + } else { + this.querySelector(".address-book-name")?.remove(); + } + + // Don't try to fetch the avatar or show the parent AB if this is a list. + if (!card.isMailList) { + this.classList.remove("MailList"); + let photoURL = card.photoURL; + if (photoURL) { + let img = document.createElement("img"); + img.alt = this.name.textContent; + img.src = photoURL; + this.avatar.replaceChildren(img); + } else { + let letter = document.createElement("span"); + letter.textContent = Array.from( + this.name.textContent + )[0]?.toUpperCase(); + letter.setAttribute("aria-hidden", "true"); + this.avatar.replaceChildren(letter); + } + this.address.textContent = card.primaryEmail; + } else { + this.classList.add("MailList"); + let img = document.createElement("img"); + img.alt = ""; + img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg"; + this.avatar.replaceChildren(img); + this.avatar.classList.add("is-mail-list"); + this.address.textContent = ""; + } + + this.cell.setAttribute("aria-label", this.name.textContent); + } + } + customElements.define("ab-card-row", AbCardRow, { extends: "tr" }); + + /** + * A row in the table list of cards. + * + * @augments {TreeViewTableRow} + */ + class AbTableCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 22; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + + for (let column of cardsPane.COLUMNS) { + this.appendChild(document.createElement("td")).classList.add( + `${column.id.toLowerCase()}-column` + ); + } + } + + get index() { + return super.index; + } + + /** + * Override the row setter to generate the layout. + * + * @note This element could be recycled, make sure you set or clear all + * properties. + */ + set index(index) { + super.index = index; + + let card = this.view.getCardFromRow(index); + this.classList.toggle("MailList", card.isMailList); + + for (let column of cardsPane.COLUMNS) { + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + if (!column.hidden) { + cell.textContent = this.view.getCellText(index, { id: column.id }); + continue; + } + + cell.hidden = true; + } + + this.setAttribute("aria-label", this.firstElementChild.textContent); + } + } + customElements.define("ab-table-card-row", AbTableCardRow, { + extends: "tr", + }); +}); + +var cardsPane = { + /** + * The document URL for saving and retrieving values in the XUL Store. + * + * @type {string} + */ + URL: "about:addressbook", + + /** + * The array of columns for the table layout. + * + * @type {Array} + */ + COLUMNS: [ + { + id: "GeneratedName", + l10n: { + header: "about-addressbook-column-header-generatedname2", + menuitem: "about-addressbook-column-label-generatedname2", + }, + }, + { + id: "EmailAddresses", + l10n: { + header: "about-addressbook-column-header-emailaddresses2", + menuitem: "about-addressbook-column-label-emailaddresses2", + }, + }, + { + id: "NickName", + l10n: { + header: "about-addressbook-column-header-nickname2", + menuitem: "about-addressbook-column-label-nickname2", + }, + hidden: true, + }, + { + id: "PhoneNumbers", + l10n: { + header: "about-addressbook-column-header-phonenumbers2", + menuitem: "about-addressbook-column-label-phonenumbers2", + }, + }, + { + id: "Addresses", + l10n: { + header: "about-addressbook-column-header-addresses2", + menuitem: "about-addressbook-column-label-addresses2", + }, + }, + { + id: "Title", + l10n: { + header: "about-addressbook-column-header-title2", + menuitem: "about-addressbook-column-label-title2", + }, + hidden: true, + }, + { + id: "Department", + l10n: { + header: "about-addressbook-column-header-department2", + menuitem: "about-addressbook-column-label-department2", + }, + hidden: true, + }, + { + id: "Organization", + l10n: { + header: "about-addressbook-column-header-organization2", + menuitem: "about-addressbook-column-label-organization2", + }, + hidden: true, + }, + { + id: "addrbook", + l10n: { + header: "about-addressbook-column-header-addrbook2", + menuitem: "about-addressbook-column-label-addrbook2", + }, + hidden: true, + }, + ], + + /** + * Make the list rows density aware. + */ + densityChange() { + let rowClass = customElements.get("ab-card-row"); + let tableRowClass = customElements.get("ab-table-card-row"); + switch (UIDensity.prefValue) { + case UIDensity.MODE_COMPACT: + rowClass.ROW_HEIGHT = 36; + tableRowClass.ROW_HEIGHT = 18; + break; + case UIDensity.MODE_TOUCH: + rowClass.ROW_HEIGHT = 60; + tableRowClass.ROW_HEIGHT = 32; + break; + default: + rowClass.ROW_HEIGHT = 46; + tableRowClass.ROW_HEIGHT = 22; + break; + } + this.cardsList.reset(); + }, + + searchInput: null, + + cardsList: null, + + init() { + this.searchInput = document.getElementById("searchInput"); + this.displayButton = document.getElementById("displayButton"); + this.sortContext = document.getElementById("sortContext"); + this.cardContext = document.getElementById("cardContext"); + + this.cardsList = document.getElementById("cards"); + this.table = this.cardsList.table; + this.table.editable = true; + this.table.setBodyID("cardsBody"); + this.cardsList.setAttribute("rows", "ab-card-row"); + + if ( + Services.xulStore.getValue(cardsPane.URL, "cardsPane", "layout") == + "table" + ) { + this.toggleLayout(true); + } + + let nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 + ); + this.sortContext + .querySelector(`[name="format"][value="${nameFormat}"]`) + ?.setAttribute("checked", "true"); + + let columns = Services.xulStore.getValue(cardsPane.URL, "cards", "columns"); + if (columns) { + columns = columns.split(","); + for (let column of cardsPane.COLUMNS) { + column.hidden = !columns.includes(column.id); + } + } + + this.table.setColumns(cardsPane.COLUMNS); + this.table.restoreColumnsWidths(cardsPane.URL); + + // Only add the address book toggle to the filter button outside the table + // layout view. All other toggles are only for a table context. + let abColumn = cardsPane.COLUMNS.find(c => c.id == "addrbook"); + let menuitem = this.sortContext.insertBefore( + document.createXULElement("menuitem"), + this.sortContext.querySelector("menuseparator:last-of-type") + ); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("name", "toggle"); + menuitem.setAttribute("value", abColumn.id); + menuitem.setAttribute("closemenu", "none"); + if (abColumn.l10n?.menuitem) { + document.l10n.setAttributes(menuitem, abColumn.l10n.menuitem); + } + if (!abColumn.hidden) { + menuitem.setAttribute("checked", "true"); + } + + menuitem.addEventListener("command", event => + this._onColumnsChanged({ target: menuitem, value: abColumn.id }) + ); + + this.searchInput.addEventListener("command", this); + this.displayButton.addEventListener("click", this); + this.sortContext.addEventListener("command", this); + this.table.addEventListener("columns-changed", this); + this.table.addEventListener("sort-changed", this); + this.table.addEventListener("column-resized", this); + this.cardsList.addEventListener("select", this); + this.cardsList.addEventListener("keydown", this); + this.cardsList.addEventListener("dblclick", this); + this.cardsList.addEventListener("dragstart", this); + this.cardsList.addEventListener("contextmenu", this); + this.cardsList.addEventListener("rowcountchange", () => { + if ( + document.activeElement == this.cardsList && + this.cardsList.view.rowCount == 0 + ) { + this.searchInput.focus(); + } + }); + this.cardsList.addEventListener("searchstatechange", () => + this._updatePlaceholder() + ); + this.cardContext.addEventListener("command", this); + + window.addEventListener("uidensitychange", () => cardsPane.densityChange()); + customElements + .whenDefined("ab-table-card-row") + .then(() => cardsPane.densityChange()); + + document + .getElementById("placeholderCreateContact") + .addEventListener("click", () => createContact()); + }, + + handleEvent(event) { + switch (event.type) { + case "command": + this._onCommand(event); + break; + case "click": + this._onClick(event); + break; + case "select": + this._onSelect(event); + break; + case "keydown": + this._onKeyDown(event); + break; + case "dblclick": + this._onDoubleClick(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "contextmenu": + this._onContextMenu(event); + break; + case "columns-changed": + this._onColumnsChanged(event.detail); + break; + case "sort-changed": + this._onSortChanged(event); + break; + case "column-resized": + this._onColumnResized(event); + break; + } + }, + + /** + * Store the resized column value in the xul store. + * + * @param {DOMEvent} event - The dom event bubbling from the resized action. + */ + _onColumnResized(event) { + this.table.setColumnsWidths(cardsPane.URL, event); + }, + + _onSortChanged(event) { + const { sortColumn, sortDirection } = this.cardsList.view; + const column = event.detail.column; + this.sortRows( + column, + sortColumn == column && sortDirection == "ascending" + ? "descending" + : "ascending" + ); + }, + + _onColumnsChanged(data) { + let column = data.value; + let checked = data.target.hasAttribute("checked"); + + for (let columnDef of cardsPane.COLUMNS) { + if (columnDef.id == column) { + columnDef.hidden = !checked; + break; + } + } + + this.table.updateColumns(cardsPane.COLUMNS); + this.cardsList.reset(); + + Services.xulStore.setValue( + cardsPane.URL, + "cards", + "columns", + cardsPane.COLUMNS.filter(c => !c.hidden) + .map(c => c.id) + .join(",") + ); + }, + + /** + * Switch between list and table layouts. + * + * @param {?boolean} isTableLayout - Use table layout if `true` or list + * layout if `false`. If unspecified, switch layouts. + */ + toggleLayout(isTableLayout) { + isTableLayout = document.body.classList.toggle( + "layout-table", + isTableLayout + ); + + updateSharedSplitter(isTableLayout); + + this.cardsList.setAttribute( + "rows", + isTableLayout ? "ab-table-card-row" : "ab-card-row" + ); + this.cardsList.setSpacersColspan( + isTableLayout ? cardsPane.COLUMNS.filter(c => !c.hidden).length : 0 + ); + if (isTableLayout) { + this.sortContext + .querySelector("#sortContextTableLayout") + .setAttribute("checked", "true"); + } else { + this.sortContext + .querySelector("#sortContextTableLayout") + .removeAttribute("checked"); + } + + if (this.cardsList.selectedIndex > -1) { + this.cardsList.scrollToIndex(this.cardsList.selectedIndex); + } + Services.xulStore.setValue( + cardsPane.URL, + "cardsPane", + "layout", + isTableLayout ? "table" : "list" + ); + }, + + /** + * Gets an address book query string based on the value of the search input. + * + * @returns {string} + */ + getQuery() { + if (!this.searchInput.value) { + return null; + } + + let searchWords = ABQueryUtils.getSearchTokens(this.searchInput.value); + let queryURIFormat = ABQueryUtils.getModelQuery( + "mail.addr_book.quicksearchquery.format" + ); + return ABQueryUtils.generateQueryURI(queryURIFormat, searchWords); + }, + + /** + * Display an address book, or all address books. + * + * @param {string|null} uid - The UID of the book or list to display, or null + * for All Address Books. + */ + displayBook(uid) { + let book = uid ? MailServices.ab.getDirectoryFromUID(uid) : null; + if (book) { + document.l10n.setAttributes( + this.searchInput, + "about-addressbook-search", + { name: book.dirName } + ); + } else { + document.l10n.setAttributes( + this.searchInput, + "about-addressbook-search-all" + ); + } + let sortColumn = + Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") || + "GeneratedName"; + let sortDirection = + Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") || + "ascending"; + this.cardsList.view = new ABView( + book, + this.getQuery(), + this.searchInput.value, + sortColumn, + sortDirection + ); + this.sortRows(sortColumn, sortDirection); + this._updatePlaceholder(); + + detailsPane.displayCards(); + }, + + /** + * Display a list. + * + * @param {bookUID} uid - The UID of the address book containing the list. + * @param {string} uid - The UID of the list to display. + */ + displayList(bookUID, uid) { + let book = MailServices.ab.getDirectoryFromUID(bookUID); + let list = book.childNodes.find(l => l.UID == uid); + document.l10n.setAttributes(this.searchInput, "about-addressbook-search", { + name: list.dirName, + }); + let sortColumn = + Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") || + "GeneratedName"; + let sortDirection = + Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") || + "ascending"; + this.cardsList.view = new ABView( + list, + this.getQuery(), + this.searchInput.value, + sortColumn, + sortDirection + ); + this.sortRows(sortColumn, sortDirection); + this._updatePlaceholder(); + + detailsPane.displayCards(); + }, + + get selectedCards() { + return this.cardsList.selectedIndices.map(i => + this.cardsList.view.getCardFromRow(i) + ); + }, + + /** + * Display the right message in the cards list placeholder. The placeholder + * is only visible if there are no cards in the list, but it's kept + * up-to-date at all times, so we don't have to keep track of the size of + * the list. + */ + _updatePlaceholder() { + let { directory, searchState } = this.cardsList.view; + + let idsToShow; + switch (searchState) { + case ABView.NOT_SEARCHING: + if (directory?.isRemote && !Services.io.offline) { + idsToShow = ["placeholderSearchOnly"]; + } else { + idsToShow = ["placeholderEmptyBook"]; + if (!directory?.readOnly && !directory?.isMailList) { + idsToShow.push("placeholderCreateContact"); + } + } + break; + case ABView.SEARCHING: + idsToShow = ["placeholderSearching"]; + break; + case ABView.SEARCH_COMPLETE: + idsToShow = ["placeholderNoSearchResults"]; + break; + } + + this.cardsList.updatePlaceholders(idsToShow); + }, + + /** + * Set the name format to be displayed. + * + * @param {integer} format - One of the nsIAbCard.GENERATE_* constants. + */ + setNameFormat(event) { + // ABView will detect this change and update automatically. + Services.prefs.setIntPref( + "mail.addr_book.lastnamefirst", + event.target.value + ); + }, + + /** + * Change the sort order of the rows being displayed. If `column` and + * `direction` match the existing values no sorting occurs but the UI items + * are always updated. + * + * @param {string} column + * @param {"ascending"|"descending"} direction + */ + sortRows(column, direction) { + // Uncheck the sort button menu item for the previously sorted column, if + // there is one, then check the sort button menu item for the column to be + // sorted. + this.sortContext + .querySelector(`[name="sort"][checked]`) + ?.removeAttribute("checked"); + this.sortContext + .querySelector(`[name="sort"][value="${column} ${direction}"]`) + ?.setAttribute("checked", "true"); + + // Unmark the header of previously sorted column, then mark the header of + // the column to be sorted. + this.table + .querySelector(".sorting") + ?.classList.remove("sorting", "ascending", "descending"); + this.table + .querySelector(`#${column} button`) + ?.classList.add("sorting", direction); + + if ( + this.cardsList.view.sortColumn == column && + this.cardsList.view.sortDirection == direction + ) { + return; + } + + this.cardsList.view.sortBy(column, direction); + + Services.xulStore.setValue(cardsPane.URL, "cards", "sortColumn", column); + Services.xulStore.setValue( + cardsPane.URL, + "cards", + "sortDirection", + direction + ); + }, + + /** + * Start a new message to the given addresses. + * + * @param {string[]} addresses + */ + writeTo(addresses) { + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.type = Ci.nsIMsgCompType.New; + params.format = Ci.nsIMsgCompFormat.Default; + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + params.composeFields.to = addresses.join(","); + MailServices.compose.OpenComposeWindowWithParams(null, params); + }, + + /** + * Start a new message to the selected contact(s) and/or mailing list(s). + */ + writeToSelected() { + let selectedAddresses = []; + + for (let card of this.selectedCards) { + let email; + if (card.isMailList) { + email = card.getProperty("Notes", "") || card.displayName; + } else { + email = card.emailAddresses[0]; + } + + if (email) { + selectedAddresses.push( + MailServices.headerParser.makeMimeAddress(card.displayName, email) + ); + } + } + + this.writeTo(selectedAddresses); + }, + + /** + * Print delete the selected card(s). + */ + printSelected() { + let selectedCards = this.selectedCards; + if (selectedCards.length) { + // Some cards are selected. Print them. + printHandler.printCards(selectedCards); + } else if (this.cardsList.view.searchString) { + // Nothing's selected, so print everything. But this is a search, so we + // can't just print the selected book/list. + let allCards = []; + for (let i = 0; i < this.cardsList.view.rowCount; i++) { + allCards.push(this.cardsList.view.getCardFromRow(i)); + } + printHandler.printCards(allCards); + } else { + // Nothing's selected, so print the selected book/list. + booksList.printSelected(); + } + }, + + /** + * Export the selected mailing list to a file. + */ + exportSelected() { + let card = this.selectedCards[0]; + if (!card || !card.isMailList) { + return; + } + let row = booksList.getRowForUID(card.UID); + AddrBookUtils.exportDirectory(row._list); + }, + + _canModifySelected() { + if (this.cardsList.view.directory?.readOnly) { + return false; + } + + let seenDirectories = new Set(); + for (let index of this.cardsList.selectedIndices) { + let { directoryUID } = this.cardsList.view.getCardFromRow(index); + if (seenDirectories.has(directoryUID)) { + continue; + } + if (MailServices.ab.getDirectoryFromUID(directoryUID).readOnly) { + return false; + } + seenDirectories.add(directoryUID); + } + return true; + }, + + /** + * Prompt the user and delete the selected card(s). + */ + async deleteSelected() { + if (!this._canModifySelected()) { + return; + } + + let selectedLists = []; + let selectedContacts = []; + + for (let index of this.cardsList.selectedIndices) { + let card = this.cardsList.view.getCardFromRow(index); + if (card.isMailList) { + selectedLists.push(card); + } else { + selectedContacts.push(card); + } + } + + if (selectedLists.length + selectedContacts.length == 0) { + return; + } + + // Determine strings for smart and context-sensitive user prompts + // for confirming deletion. + let action, name, list; + let count = selectedLists.length + selectedContacts.length; + let selectedDir = this.cardsList.view.directory; + + if (selectedLists.length && selectedContacts.length) { + action = "delete-mixed"; + } else if (selectedLists.length) { + action = "delete-lists"; + name = selectedLists[0].displayName; + } else { + let nameFormatFromPref = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst" + ); + name = selectedContacts[0].generateName(nameFormatFromPref); + if (selectedDir && selectedDir.isMailList) { + action = "remove-contacts"; + list = selectedDir.dirName; + } else { + action = "delete-contacts"; + } + } + + // Adjust strings to match translations. + let actionString; + switch (action) { + case "delete-contacts": + actionString = + count > 1 ? "delete-contacts-multi" : "delete-contacts-single"; + break; + case "remove-contacts": + actionString = + count > 1 ? "remove-contacts-multi" : "remove-contacts-single"; + break; + default: + actionString = action; + break; + } + + let [title, message] = await document.l10n.formatValues([ + { id: `about-addressbook-confirm-${action}-title`, args: { count } }, + { + id: `about-addressbook-confirm-${actionString}`, + args: { count, name, list }, + }, + ]); + + // Finally, show our smart confirmation message, and act upon it! + if ( + Services.prompt.confirmEx( + window, + title, + message, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) !== 0 + ) { + // Deletion cancelled by user. + return; + } + + // TODO: Setting the index should be unnecessary. + let indexAfterDelete = this.cardsList.currentIndex; + // Delete cards from address books or mailing lists. + this.cardsList.view.deleteSelectedCards(); + this.cardsList.currentIndex = Math.min( + indexAfterDelete, + this.cardsList.view.rowCount - 1 + ); + }, + + _onContextMenu(event) { + this._showContextMenu(event); + }, + + _showContextMenu(event) { + let row; + if (event.target == this.cardsList.table.body) { + row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex); + } else { + row = event.target.closest( + `tr[is="ab-card-row"], tr[is="ab-table-card-row"]` + ); + } + if (!row) { + return; + } + if (!this.cardsList.selectedIndices.includes(row.index)) { + this.cardsList.selectedIndex = row.index; + // Re-fetch the row in case it was replaced. + row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex); + } + + this.cardsList.table.body.focus(); + + let writeMenuItem = document.getElementById("cardContextWrite"); + let writeMenu = document.getElementById("cardContextWriteMenu"); + let writeMenuSeparator = document.getElementById( + "cardContextWriteSeparator" + ); + let editItem = document.getElementById("cardContextEdit"); + // Always reset the edit item to its default string. + document.l10n.setAttributes( + editItem, + "about-addressbook-books-context-edit" + ); + let exportItem = document.getElementById("cardContextExport"); + if (this.cardsList.selectedIndices.length == 1) { + let card = this.cardsList.view.getCardFromRow( + this.cardsList.selectedIndex + ); + if (card.isMailList) { + writeMenuItem.hidden = writeMenuSeparator.hidden = false; + writeMenu.hidden = true; + editItem.hidden = !this._canModifySelected(); + document.l10n.setAttributes( + editItem, + "about-addressbook-books-context-edit-list" + ); + exportItem.hidden = false; + } else { + let addresses = card.emailAddresses; + + if (addresses.length == 0) { + writeMenuItem.hidden = + writeMenu.hidden = + writeMenuSeparator.hidden = + true; + } else if (addresses.length == 1) { + writeMenuItem.hidden = writeMenuSeparator.hidden = false; + writeMenu.hidden = true; + } else { + while (writeMenu.menupopup.lastChild) { + writeMenu.menupopup.lastChild.remove(); + } + + for (let address of addresses) { + let menuitem = document.createXULElement("menuitem"); + menuitem.label = MailServices.headerParser.makeMimeAddress( + card.displayName, + address + ); + menuitem.addEventListener("command", () => + this.writeTo([menuitem.label]) + ); + writeMenu.menupopup.appendChild(menuitem); + } + + writeMenuItem.hidden = true; + writeMenu.hidden = writeMenuSeparator.hidden = false; + } + + editItem.hidden = !this._canModifySelected(); + exportItem.hidden = true; + } + } else { + writeMenuItem.hidden = false; + writeMenu.hidden = true; + editItem.hidden = true; + exportItem.hidden = true; + } + + let deleteItem = document.getElementById("cardContextDelete"); + let removeItem = document.getElementById("cardContextRemove"); + + let inMailList = this.cardsList.view.directory?.isMailList; + deleteItem.hidden = inMailList; + removeItem.hidden = !inMailList; + deleteItem.disabled = removeItem.disabled = !this._canModifySelected(); + + if (event.type == "contextmenu" && event.button == 2) { + // This is a right-click. Open where it happened. + this.cardContext.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + // This is a context menu key press. Open near the middle of the row. + this.cardContext.openPopup(row, { + triggerEvent: event, + position: "overlap", + x: row.clientWidth / 2, + y: row.clientHeight / 2, + }); + } + event.preventDefault(); + }, + + _onCommand(event) { + if (event.target == this.searchInput) { + this.cardsList.view = new ABView( + this.cardsList.view.directory, + this.getQuery(), + this.searchInput.value, + this.cardsList.view.sortColumn, + this.cardsList.view.sortDirection + ); + this._updatePlaceholder(); + detailsPane.displayCards(); + return; + } + + switch (event.target.id) { + case "sortContextTableLayout": + this.toggleLayout(event.target.getAttribute("checked") === "true"); + break; + case "cardContextWrite": + this.writeToSelected(); + return; + case "cardContextEdit": + detailsPane.editCurrent(); + return; + case "cardContextPrint": + this.printSelected(); + return; + case "cardContextExport": + this.exportSelected(); + return; + case "cardContextDelete": + this.deleteSelected(); + return; + case "cardContextRemove": + this.deleteSelected(); + return; + } + + if (event.target.getAttribute("name") == "format") { + this.setNameFormat(event); + } + if (event.target.getAttribute("name") == "sort") { + let [column, direction] = event.target.value.split(" "); + this.sortRows(column, direction); + } + }, + + _onClick(event) { + if (event.target.closest("button") == this.displayButton) { + this.sortContext.openPopup(this.displayButton, { triggerEvent: event }); + event.preventDefault(); + } + }, + + _onSelect(event) { + detailsPane.displayCards(this.selectedCards); + }, + + _onKeyDown(event) { + if (event.altKey || event.shiftKey) { + return; + } + + let modifier = event.ctrlKey; + let antiModifier = event.metaKey; + if (AppConstants.platform == "macosx") { + [modifier, antiModifier] = [antiModifier, modifier]; + } + if (antiModifier) { + return; + } + + switch (event.key) { + case "a": + if (modifier) { + this.cardsList.view.selection.selectAll(); + this.cardsList.dispatchEvent(new CustomEvent("select")); + event.preventDefault(); + } + break; + case "Delete": + if (!modifier) { + this.deleteSelected(); + event.preventDefault(); + } + break; + case "Enter": + if (!modifier) { + if (this.cardsList.currentIndex >= 0) { + this._activateRow(this.cardsList.currentIndex); + } + event.preventDefault(); + } + break; + } + }, + + _onDoubleClick(event) { + if ( + event.button != 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + let row = event.target.closest( + `tr[is="ab-card-row"], tr[is="ab-table-card-row"]` + ); + if (row) { + this._activateRow(row.index); + } + event.preventDefault(); + }, + + /** + * "Activate" the row by opening the corresponding card for editing. This will + * necessarily change the selection to the given index. + * + * @param {number} index - The index of the row to activate. + */ + _activateRow(index) { + if (detailsPane.isEditing) { + return; + } + // Change selection to just the target. + this.cardsList.selectedIndex = index; + // We expect the selection to change the detailsPane immediately. + detailsPane.editCurrent(); + }, + + _onDragStart(event) { + function makeMimeAddressFromCard(card) { + if (!card) { + return ""; + } + + let email; + if (card.isMailList) { + let directory = MailServices.ab.getDirectory(card.mailListURI); + email = directory.description || card.displayName; + } else { + email = card.emailAddresses[0]; + } + if (!email) { + return ""; + } + return MailServices.headerParser.makeMimeAddress(card.displayName, email); + } + + let row = event.target.closest( + `tr[is="ab-card-row"], tr[is="ab-table-card-row"]` + ); + if (!row) { + event.preventDefault(); + return; + } + + let indices = this.cardsList.selectedIndices; + if (!indices.includes(row.index)) { + indices = [row.index]; + } + let cards = indices.map(index => this.cardsList.view.getCardFromRow(index)); + + let addresses = cards.map(makeMimeAddressFromCard); + event.dataTransfer.mozSetDataAt("moz/abcard-array", cards, 0); + event.dataTransfer.setData("text/x-moz-address", addresses); + event.dataTransfer.setData("text/plain", addresses); + + let card = this.cardsList.view.getCardFromRow(row.index); + if (card && card.displayName && !card.isMailList) { + try { + // A card implementation may throw NS_ERROR_NOT_IMPLEMENTED. + // Don't break drag-and-drop if that happens. + let vCard = card.translateTo("vcard"); + event.dataTransfer.setData("text/vcard", decodeURIComponent(vCard)); + event.dataTransfer.setData( + "application/x-moz-file-promise-dest-filename", + `${card.displayName}.vcf`.replace(/(.{74}).*(.{10})$/u, "$1...$2") + ); + event.dataTransfer.setData( + "application/x-moz-file-promise-url", + "data:text/vcard," + vCard + ); + event.dataTransfer.setData( + "application/x-moz-file-promise", + this._flavorDataProvider + ); + } catch (ex) { + console.error(ex); + } + } + + event.dataTransfer.effectAllowed = "all"; + let bcr = row.getBoundingClientRect(); + event.dataTransfer.setDragImage( + row, + event.clientX - bcr.x, + event.clientY - bcr.y + ); + }, + + _flavorDataProvider: { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(transferable, flavor, data) { + if (flavor == "application/x-moz-file-promise") { + let primitive = {}; + transferable.getTransferData("text/vcard", primitive); + let vCard = primitive.value.QueryInterface(Ci.nsISupportsString).data; + transferable.getTransferData( + "application/x-moz-file-promise-dest-filename", + primitive + ); + let leafName = primitive.value.QueryInterface( + Ci.nsISupportsString + ).data; + transferable.getTransferData( + "application/x-moz-file-promise-dir", + primitive + ); + let localFile = primitive.value.QueryInterface(Ci.nsIFile).clone(); + localFile.append(leafName); + + let ofStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + ofStream.init(localFile, -1, -1, 0); + let converter = Cc[ + "@mozilla.org/intl/converter-output-stream;1" + ].createInstance(Ci.nsIConverterOutputStream); + converter.init(ofStream, null); + converter.writeString(vCard); + converter.close(); + + data.value = localFile; + } + }, + }, +}; + +/** + * Object holding the contact view pane to show all vcard info and handle data + * changes and mutations between the view and edit state of a contact. + */ +var detailsPane = { + currentCard: null, + + dirtyFields: new Set(), + + _notifications: [ + "addrbook-contact-created", + "addrbook-contact-updated", + "addrbook-contact-deleted", + "addrbook-list-updated", + "addrbook-list-deleted", + "addrbook-list-member-removed", + ], + + init() { + let booksSplitter = document.getElementById("booksSplitter"); + let booksSplitterWidth = Services.xulStore.getValue( + cardsPane.URL, + "booksSplitter", + "width" + ); + if (booksSplitterWidth) { + booksSplitter.width = booksSplitterWidth; + } + booksSplitter.addEventListener("splitter-resized", () => + Services.xulStore.setValue( + cardsPane.URL, + "booksSplitter", + "width", + booksSplitter.width + ) + ); + + let isTableLayout = document.body.classList.contains("layout-table"); + updateSharedSplitter(isTableLayout); + + this.splitter = document.getElementById("sharedSplitter"); + let sharedSplitterWidth = Services.xulStore.getValue( + cardsPane.URL, + "sharedSplitter", + "width" + ); + if (sharedSplitterWidth) { + this.splitter.width = sharedSplitterWidth; + } + let sharedSplitterHeight = Services.xulStore.getValue( + cardsPane.URL, + "sharedSplitter", + "height" + ); + if (sharedSplitterHeight) { + this.splitter.height = sharedSplitterHeight; + } + this.splitter.addEventListener("splitter-resized", () => { + if (isTableLayout) { + Services.xulStore.setValue( + cardsPane.URL, + "sharedSplitter", + "height", + this.splitter.height + ); + return; + } + Services.xulStore.setValue( + cardsPane.URL, + "sharedSplitter", + "width", + this.splitter.width + ); + }); + + this.node = document.getElementById("detailsPane"); + this.actions = document.getElementById("detailsActions"); + this.writeButton = document.getElementById("detailsWriteButton"); + this.eventButton = document.getElementById("detailsEventButton"); + this.searchButton = document.getElementById("detailsSearchButton"); + this.newListButton = document.getElementById("detailsNewListButton"); + this.editButton = document.getElementById("editButton"); + this.selectedCardsSection = document.getElementById("selectedCards"); + this.form = document.getElementById("editContactForm"); + this.vCardEdit = this.form.querySelector("vcard-edit"); + this.deleteButton = document.getElementById("detailsDeleteButton"); + this.addContactBookList = document.getElementById("addContactBookList"); + this.cancelEditButton = document.getElementById("cancelEditButton"); + this.saveEditButton = document.getElementById("saveEditButton"); + + this.actions.addEventListener("click", this); + document.getElementById("detailsFooter").addEventListener("click", this); + + let photoImage = document.getElementById("viewContactPhoto"); + photoImage.addEventListener("error", event => { + if (!detailsPane.currentCard) { + return; + } + + let vCard = detailsPane.currentCard.getProperty("_vCard", ""); + let match = /^PHOTO.*/im.exec(vCard); + if (match) { + console.warn( + `Broken contact photo, vCard data starts with: ${match[0]}` + ); + } else { + console.warn(`Broken contact photo, source is: ${photoImage.src}`); + } + }); + + this.form.addEventListener("input", event => { + let { type, checked, value, _originalValue } = event.target; + let changed; + if (type == "checkbox") { + changed = checked != _originalValue; + } else { + changed = value != _originalValue; + } + if (changed) { + this.dirtyFields.add(event.target); + } else { + this.dirtyFields.delete(event.target); + } + + // If there are no dirty fields, clear the flag, otherwise set it. + this.isDirty = this.dirtyFields.size > 0; + }); + this.form.addEventListener("keypress", event => { + // Prevent scrolling of the html tag when space is used on a button or + // checkbox. + if ( + event.key == " " && + ["button", "checkbox"].includes(document.activeElement.type) + ) { + event.preventDefault(); + } + + if (event.key != "Escape") { + return; + } + + event.preventDefault(); + this.form.reset(); + }); + this.form.addEventListener("reset", async event => { + event.preventDefault(); + if (this.isDirty) { + let [title, message] = await document.l10n.formatValues([ + { id: `about-addressbook-unsaved-changes-prompt-title` }, + { id: `about-addressbook-unsaved-changes-prompt` }, + ]); + + let buttonPressed = Services.prompt.confirmEx( + window, + title, + message, + Ci.nsIPrompt.BUTTON_TITLE_SAVE * Ci.nsIPrompt.BUTTON_POS_0 + + Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 + + Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE * Ci.nsIPrompt.BUTTON_POS_2, + null, + null, + null, + null, + {} + ); + if (buttonPressed === 0) { + // Don't call this.form.submit, the submit event won't fire. + this.validateBeforeSaving(); + return; + } else if (buttonPressed === 1) { + return; + } + } + this.isEditing = false; + if (this.currentCard) { + // Refresh the card from the book to get exactly what was saved. + let book = MailServices.ab.getDirectoryFromUID( + this.currentCard.directoryUID + ); + let card = book.childCards.find(c => c.UID == this.currentCard.UID); + this.displayContact(card); + if (this._focusOnCardsList) { + cardsPane.cardsList.table.body.focus(); + } else { + this.editButton.focus(); + } + } else { + this.displayCards(cardsPane.selectedCards); + if (this._focusOnCardsList) { + cardsPane.cardsList.table.body.focus(); + } else { + cardsPane.searchInput.focus(); + } + } + }); + this.form.addEventListener("submit", event => { + event.preventDefault(); + this.validateBeforeSaving(); + }); + + this.photoInput = document.getElementById("photoInput"); + // NOTE: We put the paste handler on the button parent because the + // html:button will not be targeted by the paste event. + this.photoInput.addEventListener("paste", photoDialog); + this.photoInput.addEventListener("dragover", photoDialog); + this.photoInput.addEventListener("drop", photoDialog); + + let photoButton = document.getElementById("photoButton"); + photoButton.addEventListener("click", () => { + if (this._photoDetails.sourceURL) { + photoDialog.showWithURL( + this._photoDetails.sourceURL, + this._photoDetails.cropRect, + true + ); + } else { + photoDialog.showEmpty(); + } + }); + + this.cancelEditButton.addEventListener("keypress", event => { + // Prevent scrolling of the html tag when space is used on this button. + if (event.key == " ") { + event.preventDefault(); + } + }); + this.saveEditButton.addEventListener("keypress", event => { + // Prevent scrolling of the html tag when space is used on this button. + if (event.key == " ") { + event.preventDefault(); + } + }); + + for (let topic of this._notifications) { + Services.obs.addObserver(this, topic); + } + }, + + uninit() { + for (let topic of this._notifications) { + Services.obs.removeObserver(this, topic); + } + }, + + handleEvent(event) { + switch (event.type) { + case "click": + this._onClick(event); + break; + } + }, + + async observe(subject, topic, data) { + let hadFocus = + this.node.contains(document.activeElement) || + document.activeElement == document.body; + + switch (topic) { + case "addrbook-contact-created": + subject.QueryInterface(Ci.nsIAbCard); + updateAddressBookCount(); + if ( + !this.currentCard || + this.currentCard.directoryUID != data || + this.currentCard.UID != subject.getProperty("_originalUID", "") + ) { + break; + } + + // The card being displayed had its UID changed by the server. Select + // the new card to display it. (If we're already editing the new card + // when the server responds, that's just tough luck.) + this.isEditing = false; + cardsPane.cardsList.selectedIndex = + cardsPane.cardsList.view.getIndexForUID(subject.UID); + break; + case "addrbook-contact-updated": + subject.QueryInterface(Ci.nsIAbCard); + if ( + !this.currentCard || + this.currentCard.directoryUID != data || + !this.currentCard.equals(subject) + ) { + break; + } + + // If there's editing in progress, we could attempt to update the + // editing interface with the changes, which is difficult, or alert + // the user. For now, changes will be overwritten if the edit is saved. + + if (!this.isEditing) { + this.displayContact(subject); + } + break; + case "addrbook-contact-deleted": + case "addrbook-list-member-removed": + subject.QueryInterface(Ci.nsIAbCard); + updateAddressBookCount(); + + const directoryUID = + topic == "addrbook-contact-deleted" + ? this.currentCard?.directoryUID + : cardsPane.cardsList.view.directory?.UID; + if (directoryUID == data && this.currentCard?.equals(subject)) { + // The card being displayed was deleted. + this.isEditing = false; + this.displayCards(); + + if (hadFocus) { + // Ensure this happens *after* the view handles this notification. + Services.tm.dispatchToMainThread(() => { + if (cardsPane.cardsList.view.rowCount == 0) { + cardsPane.searchInput.focus(); + } else { + cardsPane.cardsList.table.body.focus(); + } + }); + } + } else if (!this.selectedCardsSection.hidden) { + for (let li of this.selectedCardsSection.querySelectorAll("li")) { + if (li._card.equals(subject)) { + // A selected card was deleted. + this.displayCards(cardsPane.selectedCards); + break; + } + } + } + break; + case "addrbook-list-updated": + subject.QueryInterface(Ci.nsIAbDirectory); + if (this.currentList && this.currentList.mailListURI == subject.URI) { + this.displayList(this.currentList); + } + break; + case "addrbook-list-deleted": + subject.QueryInterface(Ci.nsIAbDirectory); + if (this.currentList && this.currentList.mailListURI == subject.URI) { + // The list being displayed was deleted. + this.displayCards(); + + if (hadFocus) { + if (cardsPane.cardsList.view.rowCount == 0) { + cardsPane.searchInput.focus(); + } else { + cardsPane.cardsList.table.body.focus(); + } + } + } else if (!this.selectedCardsSection.hidden) { + for (let li of this.selectedCardsSection.querySelectorAll("li")) { + if ( + li._card.directoryUID == data && + li._card.mailListURI == subject.URI + ) { + // A selected list was deleted. + this.displayCards(cardsPane.selectedCards); + break; + } + } + } + break; + } + }, + + /** + * Is a card being edited? + * + * @type {boolean} + */ + get isEditing() { + return document.body.classList.contains("is-editing"); + }, + + set isEditing(editing) { + if (editing == this.isEditing) { + return; + } + + document.body.classList.toggle("is-editing", editing); + + // Disable the toolbar buttons when starting to edit. Remember their state + // to restore it when editing stops. + for (let toolbarButton of document.querySelectorAll( + "#toolbox > toolbar > toolbarbutton" + )) { + if (editing) { + toolbarButton._wasDisabled = toolbarButton.disabled; + toolbarButton.disabled = true; + } else { + toolbarButton.disabled = toolbarButton._wasDisabled; + delete toolbarButton._wasDisabled; + } + } + + // Remove these elements from (or add them back to) the tab focus cycle. + for (let id of ["books", "searchInput", "displayButton", "cardsBody"]) { + document.getElementById(id).tabIndex = editing ? -1 : 0; + } + + if (editing) { + this.addContactBookList.hidden = !!this.currentCard; + this.addContactBookList.previousElementSibling.hidden = + !!this.currentCard; + + let book = booksList + .getRowAtIndex(booksList.selectedIndex) + .closest(".bookRow")._book; + if (book) { + // TODO: convert this to UID. + this.addContactBookList.value = book.URI; + } + } else { + this.isDirty = false; + } + }, + + /** + * If a card is being edited, has any field changed? + * + * @type {boolean} + */ + get isDirty() { + return this.isEditing && document.body.classList.contains("is-dirty"); + }, + + set isDirty(dirty) { + if (!dirty) { + this.dirtyFields.clear(); + } + document.body.classList.toggle("is-dirty", this.isEditing && dirty); + }, + + clearDisplay() { + this.currentCard = null; + this.currentList = null; + + for (let section of document.querySelectorAll( + "#viewContact :is(.contact-header, .list-header, .selection-header), #detailsBody > section" + )) { + section.hidden = true; + } + }, + + displayCards(cards = []) { + if (this.isEditing) { + return; + } + + this.clearDisplay(); + + if (cards.length == 0) { + this.node.hidden = true; + this.splitter.isCollapsed = + document.body.classList.contains("layout-table"); + return; + } + if (cards.length == 1) { + if (cards[0].isMailList) { + this.displayList(cards[0]); + } else { + this.displayContact(cards[0]); + } + return; + } + + let contacts = cards.filter(c => !c.isMailList); + let contactsWithAddresses = contacts.filter(c => c.primaryEmail); + let lists = cards.filter(c => c.isMailList); + + document.querySelector("#viewContact .selection-header").hidden = false; + let headerString; + if (contacts.length) { + if (lists.length) { + headerString = "about-addressbook-selection-mixed-header2"; + } else { + headerString = "about-addressbook-selection-contacts-header2"; + } + } else { + headerString = "about-addressbook-selection-lists-header2"; + } + document.l10n.setAttributes( + document.getElementById("viewSelectionCount"), + headerString, + { count: cards.length } + ); + + this.writeButton.hidden = contactsWithAddresses.length + lists.length == 0; + this.eventButton.hidden = + !contactsWithAddresses.length || + !cal.manager + .getCalendars() + .filter(cal.acl.isCalendarWritable) + .filter(cal.acl.userCanAddItemsToCalendar).length; + this.searchButton.hidden = true; + this.newListButton.hidden = contactsWithAddresses.length == 0; + this.editButton.hidden = true; + + this.actions.hidden = this.writeButton.hidden; + + let list = this.selectedCardsSection.querySelector("ul"); + list.replaceChildren(); + let template = + document.getElementById("selectedCard").content.firstElementChild; + for (let card of cards) { + let li = list.appendChild(template.cloneNode(true)); + li._card = card; + let avatar = li.querySelector(".recipient-avatar"); + let name = li.querySelector(".name"); + let address = li.querySelector(".address"); + + if (!card.isMailList) { + name.textContent = card.generateName(ABView.nameFormat); + address.textContent = card.primaryEmail; + + let photoURL = card.photoURL; + if (photoURL) { + let img = document.createElement("img"); + img.alt = name.textContent; + img.src = photoURL; + avatar.appendChild(img); + } else { + let letter = document.createElement("span"); + letter.textContent = Array.from(name.textContent)[0]?.toUpperCase(); + letter.setAttribute("aria-hidden", "true"); + avatar.appendChild(letter); + } + } else { + name.textContent = card.displayName; + + let img = avatar.appendChild(document.createElement("img")); + img.alt = ""; + img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg"; + avatar.classList.add("is-mail-list"); + } + } + this.selectedCardsSection.hidden = false; + + this.node.hidden = this.splitter.isCollapsed = false; + document.getElementById("viewContact").scrollTo(0, 0); + }, + + /** + * Show a read-only representation of a card in the details pane. + * + * @param {nsIAbCard?} card - The card to display. This should not be a + * mailing list card. Pass null to hide the details pane. + */ + displayContact(card) { + if (this.isEditing) { + return; + } + + this.clearDisplay(); + if (!card || card.isMailList) { + return; + } + this.currentCard = card; + + this.fillContactDetails(document.getElementById("viewContact"), card); + document.getElementById("viewContactPhoto").hidden = document.querySelector( + "#viewContact .contact-headings" + ).hidden = false; + document.querySelector("#viewContact .contact-header").hidden = false; + + this.writeButton.hidden = this.searchButton.hidden = !card.primaryEmail; + this.eventButton.hidden = + !card.primaryEmail || + !cal.manager + .getCalendars() + .filter(cal.acl.isCalendarWritable) + .filter(cal.acl.userCanAddItemsToCalendar).length; + this.newListButton.hidden = true; + + let book = MailServices.ab.getDirectoryFromUID(card.directoryUID); + this.editButton.hidden = book.readOnly; + this.actions.hidden = this.writeButton.hidden && this.editButton.hidden; + + this.isEditing = false; + this.node.hidden = this.splitter.isCollapsed = false; + document.getElementById("viewContact").scrollTo(0, 0); + }, + + /** + * Set all the values for displaying a contact. + * + * @param {HTMLElement} element - The element to fill, either the on-screen + * contact display or a clone of the printing template. + * @param {nsIAbCard} card - The card to display. This should not be a + * mailing list card. + */ + fillContactDetails(element, card) { + let vCardProperties = card.supportsVCard + ? card.vCardProperties + : VCardProperties.fromPropertyMap( + new Map(card.properties.map(p => [p.name, p.value])) + ); + + element.querySelector(".contact-photo").src = + card.photoURL || "chrome://messenger/skin/icons/new/compact/user.svg"; + element.querySelector(".contact-heading-name").textContent = + card.generateName(ABView.nameFormat); + let nickname = element.querySelector(".contact-heading-nickname"); + let nicknameValue = vCardProperties.getFirstValue("nickname"); + nickname.hidden = !nicknameValue; + nickname.textContent = nicknameValue; + element.querySelector(".contact-heading-email").textContent = + card.primaryEmail; + + let template = document.getElementById("entryItem"); + let createEntryItem = function (name) { + let li = template.content.firstElementChild.cloneNode(true); + if (name) { + document.l10n.setAttributes( + li.querySelector(".entry-type"), + `about-addressbook-entry-name-${name}` + ); + } + return li; + }; + let setEntryType = function (li, entry, allowed = ["work", "home"]) { + if (!entry.params.type) { + return; + } + let lowerTypes = Array.isArray(entry.params.type) + ? entry.params.type.map(t => t.toLowerCase()) + : [entry.params.type.toLowerCase()]; + let lowerType = lowerTypes.find(t => allowed.includes(t)); + if (!lowerType) { + return; + } + + document.l10n.setAttributes( + li.querySelector(".entry-type"), + `about-addressbook-entry-type-${lowerType}` + ); + }; + + let section = element.querySelector(".details-email-addresses"); + let list = section.querySelector("ul"); + list.replaceChildren(); + for (let entry of vCardProperties.getAllEntries("email")) { + let li = list.appendChild(createEntryItem()); + setEntryType(li, entry); + let addr = MailServices.headerParser.makeMimeAddress( + card.displayName, + entry.value + ); + let a = document.createElement("a"); + a.href = "mailto:" + encodeURIComponent(addr); + a.textContent = entry.value; + li.querySelector(".entry-value").appendChild(a); + } + section.hidden = list.childElementCount == 0; + + section = element.querySelector(".details-phone-numbers"); + list = section.querySelector("ul"); + list.replaceChildren(); + for (let entry of vCardProperties.getAllEntries("tel")) { + let li = list.appendChild(createEntryItem()); + setEntryType(li, entry, ["work", "home", "fax", "cell", "pager"]); + let a = document.createElement("a"); + // Handle tel: uri, some other scheme, or plain text number. + let number = entry.value.replace(/^[a-z\+]{3,}:/, ""); + let scheme = entry.value.split(/([a-z\+]{3,}):/)[1] || "tel"; + a.href = `${scheme}:${number.replaceAll(/[^\d\+]/g, "")}`; + a.textContent = number; + li.querySelector(".entry-value").appendChild(a); + } + section.hidden = list.childElementCount == 0; + + section = element.querySelector(".details-addresses"); + list = section.querySelector("ul"); + list.replaceChildren(); + for (let entry of vCardProperties.getAllEntries("adr")) { + let parts = entry.value.flat(); + // Put extended address after street address. + parts[2] = parts.splice(1, 1, parts[2])[0]; + + let li = list.appendChild(createEntryItem()); + setEntryType(li, entry); + let span = li.querySelector(".entry-value"); + for (let part of parts.filter(Boolean)) { + if (span.firstChild) { + span.appendChild(document.createElement("br")); + } + span.appendChild(document.createTextNode(part)); + } + } + section.hidden = list.childElementCount == 0; + + section = element.querySelector(".details-notes"); + let note = vCardProperties.getFirstValue("note"); + if (note) { + section.querySelector("div").textContent = note; + section.hidden = false; + } else { + section.hidden = true; + } + + section = element.querySelector(".details-websites"); + list = section.querySelector("ul"); + list.replaceChildren(); + + for (let entry of vCardProperties.getAllEntries("url")) { + let value = entry.value; + if (!/https?:\/\//.test(value)) { + continue; + } + + let li = list.appendChild(createEntryItem()); + setEntryType(li, entry); + let a = document.createElement("a"); + a.href = value; + let url = new URL(value); + a.textContent = + url.pathname == "/" && !url.search + ? url.host + : `${url.host}${url.pathname}${url.search}`; + li.querySelector(".entry-value").appendChild(a); + } + section.hidden = list.childElementCount == 0; + + section = element.querySelector(".details-instant-messaging"); + list = section.querySelector("ul"); + list.replaceChildren(); + + this._screenNamesToIMPPs(card); + for (let entry of vCardProperties.getAllEntries("impp")) { + let li = list.appendChild(createEntryItem()); + let url; + try { + url = new URL(entry.value); + } catch (e) { + li.querySelector(".entry-value").textContent = entry.value; + continue; + } + let a = document.createElement("a"); + a.href = entry.value; + a.target = "_blank"; + a.textContent = url.toString(); + li.querySelector(".entry-value").append(a); + } + section.hidden = list.childElementCount == 0; + + section = element.querySelector(".details-other-info"); + list = section.querySelector("ul"); + list.replaceChildren(); + + let formatDate = function (date) { + try { + date = ICAL.VCardTime.fromDateAndOrTimeString(date); + } catch (ex) { + console.error(ex); + return ""; + } + if (date.year && date.month && date.day) { + return new Services.intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date(date.year, date.month - 1, date.day)); + } + if (date.year && date.month) { + return new Services.intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + }).format(new Date(date.year, date.month - 1, 1)); + } + if (date.year) { + return date.year; + } + if (date.month && date.day) { + return new Services.intl.DateTimeFormat(undefined, { + month: "long", + day: "numeric", + }).format(new Date(2024, date.month - 1, date.day)); + } + if (date.month) { + return new Services.intl.DateTimeFormat(undefined, { + month: "long", + }).format(new Date(2024, date.month - 1, 1)); + } + if (date.day) { + return date.day; + } + return ""; + }; + + let bday = vCardProperties.getFirstValue("bday"); + if (bday) { + let value = formatDate(bday); + if (value) { + let li = list.appendChild(createEntryItem("birthday")); + li.querySelector(".entry-value").textContent = value; + } + } + + let anniversary = vCardProperties.getFirstValue("anniversary"); + if (anniversary) { + let value = formatDate(anniversary); + if (value) { + let li = list.appendChild(createEntryItem("anniversary")); + li.querySelector(".entry-value").textContent = value; + } + } + + let title = vCardProperties.getFirstValue("title"); + if (title) { + let li = list.appendChild(createEntryItem("title")); + li.querySelector(".entry-value").textContent = title; + } + + let role = vCardProperties.getFirstValue("role"); + if (role) { + let li = list.appendChild(createEntryItem("role")); + li.querySelector(".entry-value").textContent = role; + } + + let org = vCardProperties.getFirstValue("org"); + if (Array.isArray(org)) { + let li = list.appendChild(createEntryItem("organization")); + let span = li.querySelector(".entry-value"); + for (let part of org.filter(Boolean).reverse()) { + if (span.firstChild) { + span.append(" • "); + } + span.appendChild(document.createTextNode(part)); + } + } else if (org) { + let li = list.appendChild(createEntryItem("organization")); + li.querySelector(".entry-value").textContent = org; + } + + let tz = vCardProperties.getFirstValue("tz"); + if (tz) { + let li = list.appendChild(createEntryItem("time-zone")); + try { + li.querySelector(".entry-value").textContent = + cal.timezoneService.getTimezone(tz).displayName; + } catch { + li.querySelector(".entry-value").textContent = tz; + } + li.querySelector(".entry-value").appendChild( + document.createElement("br") + ); + + let time = document.createElement("span", { is: "active-time" }); + time.setAttribute("tz", tz); + li.querySelector(".entry-value").appendChild(time); + } + + for (let key of ["custom1", "custom2", "custom3", "custom4"]) { + let value = vCardProperties.getFirstValue(`x-${key}`); + if (value) { + let li = list.appendChild(createEntryItem(key)); + li.querySelector(".entry-type").style.setProperty( + "white-space", + "nowrap" + ); + li.querySelector(".entry-value").textContent = value; + } + } + + section.hidden = list.childElementCount == 0; + }, + + /** + * Show this given contact photo in the edit form. + * + * @param {?string} url - The URL of the photo to display, or null to + * display none. + */ + showEditPhoto(url) { + this.photoInput.querySelector(".contact-photo").src = + url || "chrome://messenger/skin/icons/new/compact/user.svg"; + }, + + /** + * Store the given photo details to save later, and display the photo in the + * edit form. + * + * @param {?object} details - The photo details to save, or null to remove the + * photo. + * @param {Blob} details.blob - The image blob of the photo to save. + * @param {string} details.sourceURL - The image basis of the photo, before + * cropping. + * @param {DOMRect} details.cropRect - The cropping rectangle for the photo. + */ + setPhoto(details) { + this._photoChanged = true; + this._photoDetails = details || {}; + this.showEditPhoto( + details?.blob ? URL.createObjectURL(details.blob) : null + ); + this.dirtyFields.add(this.photoInput); + this.isDirty = true; + }, + + /** + * Show controls for editing a new card. + * + * @param {?string} vCard - A vCard containing properties for the new card. + */ + async editNewContact(vCard) { + this.currentCard = null; + this.editCurrentContact(vCard); + if (!vCard) { + this.vCardEdit.contactNameHeading.textContent = + await document.l10n.formatValue("about-addressbook-new-contact-header"); + } + }, + + /** + * Takes old nsIAbCard chat names and put them on the card as IMPP URIs. + * + * @param {nsIAbCard?} card - The card to change. + */ + _screenNamesToIMPPs(card) { + if (!card.supportsVCard) { + return; + } + + let existingIMPPValues = card.vCardProperties.getAllValues("impp"); + for (let key of [ + "_GoogleTalk", + "_AimScreenName", + "_Yahoo", + "_Skype", + "_QQ", + "_MSN", + "_ICQ", + "_JabberId", + "_IRC", + ]) { + let value = card.getProperty(key, ""); + if (!value) { + continue; + } + switch (key) { + case "_GoogleTalk": + value = `gtalk:chat?jid=${value}`; + break; + case "_AimScreenName": + value = `aim:goim?screenname=${value}`; + break; + case "_Yahoo": + value = `ymsgr:sendIM?${value}`; + break; + case "_Skype": + value = `skype:${value}`; + break; + case "_QQ": + value = `mqq://${value}`; + break; + case "_MSN": + value = `msnim:chat?contact=${value}`; + break; + case "_ICQ": + value = `icq:message?uin=${value}`; + break; + case "_JabberId": + value = `xmpp:${value}`; + break; + case "_IRC": + // Guess host, in case we have an irc account configured. + let host = + IMServices.accounts + .getAccounts() + .find(a => a.protocol.normalizedName == "irc") + ?.name.split("@", 2)[1] || "irc.example.org"; + value = `ircs://${host}/${value},isuser`; + break; + } + if (!existingIMPPValues.includes(value)) { + card.vCardProperties.addEntry( + new VCardPropertyEntry(`impp`, {}, "uri", value) + ); + } + } + }, + + /** + * Show controls for editing the currently displayed card. + * + * @param {?string} vCard - A vCard containing properties for a new card. + */ + editCurrentContact(vCard) { + let card = this.currentCard; + this.deleteButton.hidden = !card; + if (card && card.supportsVCard) { + this._screenNamesToIMPPs(card); + + this.vCardEdit.vCardProperties = card.vCardProperties; + // getProperty may return a "1" or "0" string, we want a boolean. + this.vCardEdit.preferDisplayName.checked = + // eslint-disable-next-line mozilla/no-compare-against-boolean-literals + card.getProperty("PreferDisplayName", true) == true; + } else { + this.vCardEdit.vCardString = vCard ?? ""; + card = new AddrBookCard(); + card.setProperty("_vCard", vCard); + } + + this.showEditPhoto(card?.photoURL); + this._photoDetails = { sourceURL: card?.photoURL }; + this._photoChanged = false; + this.isEditing = true; + this.node.hidden = this.splitter.isCollapsed = false; + this.form.querySelector(".contact-details-scroll").scrollTo(0, 0); + // If we enter editing directly from the cards list we want to return to it + // once we are done. + this._focusOnCardsList = + document.activeElement == cardsPane.cardsList.table.body; + this.vCardEdit.setFocus(); + }, + + /** + * Edit the currently displayed contact or list. + */ + editCurrent() { + // The editButton is disabled if the book is readOnly. + if (this.editButton.hidden) { + return; + } + if (this.currentCard) { + this.editCurrentContact(); + } else if (this.currentList) { + SubDialog.open( + "chrome://messenger/content/addressbook/abEditListDialog.xhtml", + { features: "resizable=no" }, + { listURI: this.currentList.mailListURI } + ); + } + }, + + /** + * Properly handle a failed form validation. + */ + handleInvalidForm() { + // FIXME: Drop this in favor of an inline notification with fluent strings. + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + Services.prompt.alert( + window, + bundle.GetStringFromName("cardRequiredDataMissingTitle"), + bundle.GetStringFromName("cardRequiredDataMissingMessage") + ); + }, + + /** + * Make sure the data is valid before saving the contact. + */ + validateBeforeSaving() { + // Make sure the minimum required data is present. + if (!this.vCardEdit.checkMinimumRequirements()) { + this.handleInvalidForm(); + return; + } + + // Make sure the dates are filled properly. + if (!this.vCardEdit.validateDates()) { + // Simply return as the validateDates() will handle focus and visual cue. + return; + } + + // Extra validation for any form field that has validatity requirements + // set on them (through pattern etc.). + if (!this.form.checkValidity()) { + this.form.querySelector("input:invalid").focus(); + return; + } + + this.saveCurrentContact(); + }, + + /** + * Save the currently displayed card. + */ + async saveCurrentContact() { + let card = this.currentCard; + let book; + + if (card) { + book = MailServices.ab.getDirectoryFromUID(card.directoryUID); + } else { + card = new AddrBookCard(); + + // TODO: convert this to UID. + book = MailServices.ab.getDirectory(this.addContactBookList.value); + if (book.getBoolValue("carddav.vcard3", false)) { + // This is a CardDAV book, and the server discards photos unless the + // vCard 3 format is used. Since we know this is a new card, setting + // the version here won't cause a problem. + this.vCardEdit.vCardProperties.addValue("version", "3.0"); + } + } + if (!book || book.readOnly) { + throw new Components.Exception( + "Address book is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + // Tell vcard-edit to read the input fields. Setting the _vCard property + // MUST happen before accessing `card.vCardProperties` or creating new + // cards will fail. + this.vCardEdit.saveVCard(); + card.setProperty("_vCard", this.vCardEdit.vCardString); + card.setProperty( + "PreferDisplayName", + this.vCardEdit.preferDisplayName.checked + ); + + // Old screen names should by now be on the vCard. Delete them. + for (let key of [ + "_GoogleTalk", + "_AimScreenName", + "_Yahoo", + "_Skype", + "_QQ", + "_MSN", + "_ICQ", + "_JabberId", + "_IRC", + ]) { + card.deleteProperty(key); + } + + // No photo or a new photo. Delete the old one. + if (this._photoChanged) { + let oldLeafName = card.getProperty("PhotoName", ""); + if (oldLeafName) { + let oldPath = PathUtils.join( + PathUtils.profileDir, + "Photos", + oldLeafName + ); + await IOUtils.remove(oldPath); + + card.setProperty("PhotoName", ""); + card.setProperty("PhotoType", ""); + card.setProperty("PhotoURI", ""); + } + if (card.supportsVCard) { + for (let entry of card.vCardProperties.getAllEntries("photo")) { + card.vCardProperties.removeEntry(entry); + } + } + } + + // Save the new photo. + if (this._photoChanged && this._photoDetails.blob) { + if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) { + let reader = new FileReader(); + await new Promise(resolve => { + reader.onloadend = resolve; + reader.readAsDataURL(this._photoDetails.blob); + }); + if (card.vCardProperties.getFirstValue("version") == "4.0") { + card.vCardProperties.addEntry( + new VCardPropertyEntry("photo", {}, "uri", reader.result) + ); + } else { + card.vCardProperties.addEntry( + new VCardPropertyEntry( + "photo", + { encoding: "B" }, + "binary", + reader.result.substring(reader.result.indexOf(",") + 1) + ) + ); + } + } else { + let leafName = `${AddrBookUtils.newUID()}.jpg`; + let path = PathUtils.join(PathUtils.profileDir, "Photos", leafName); + let buffer = await this._photoDetails.blob.arrayBuffer(); + await IOUtils.write(path, new Uint8Array(buffer)); + card.setProperty("PhotoName", leafName); + } + } + this._photoChanged = false; + this.isEditing = false; + + if (!card.directoryUID) { + card = book.addCard(card); + cardsPane.cardsList.selectedIndex = + cardsPane.cardsList.view.getIndexForUID(card.UID); + // The selection change will update the UI. + } else { + book.modifyCard(card); + // The addrbook-contact-updated notification will update the UI. + } + + if (this._focusOnCardsList) { + cardsPane.cardsList.table.body.focus(); + } else { + this.editButton.focus(); + } + }, + + /** + * Delete the currently displayed card. + */ + async deleteCurrentContact() { + let card = this.currentCard; + let book = MailServices.ab.getDirectoryFromUID(card.directoryUID); + + if (!book) { + throw new Components.Exception( + "Card doesn't have a book to delete from", + Cr.NS_ERROR_FAILURE + ); + } + + if (book.readOnly) { + throw new Components.Exception( + "Address book is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + let name = card.displayName; + let [title, message] = await document.l10n.formatValues([ + { + id: "about-addressbook-confirm-delete-contacts-title", + args: { count: 1 }, + }, + { + id: "about-addressbook-confirm-delete-contacts-single", + args: { name }, + }, + ]); + + if ( + Services.prompt.confirmEx( + window, + title, + message, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) === 0 + ) { + // TODO: Setting the index should be unnecessary. + let indexAfterDelete = cardsPane.cardsList.currentIndex; + book.deleteCards([card]); + cardsPane.cardsList.currentIndex = Math.min( + indexAfterDelete, + cardsPane.cardsList.view.rowCount - 1 + ); + // The addrbook-contact-deleted notification will update the details pane UI. + } + }, + + displayList(listCard) { + if (this.isEditing) { + return; + } + + this.clearDisplay(); + if (!listCard || !listCard.isMailList) { + return; + } + this.currentList = listCard; + + let listDirectory = MailServices.ab.getDirectory(listCard.mailListURI); + + document.querySelector("#viewContact .list-header").hidden = false; + document.querySelector( + "#viewContact .list-header > h1" + ).textContent = `${listDirectory.dirName}`; + + let cards = Array.from(listDirectory.childCards, card => { + return { + name: card.generateName(ABView.nameFormat), + email: card.primaryEmail, + photoURL: card.photoURL, + }; + }); + let { sortColumn, sortDirection } = cardsPane.cardsList.view; + let key = sortColumn == "EmailAddresses" ? "email" : "name"; + cards.sort((a, b) => { + if (sortDirection == "descending") { + [b, a] = [a, b]; + } + return ABView.prototype.collator.compare(a[key], b[key]); + }); + + let list = this.selectedCardsSection.querySelector("ul"); + list.replaceChildren(); + let template = + document.getElementById("selectedCard").content.firstElementChild; + for (let card of cards) { + let li = list.appendChild(template.cloneNode(true)); + li._card = card; + let avatar = li.querySelector(".recipient-avatar"); + let name = li.querySelector(".name"); + let address = li.querySelector(".address"); + name.textContent = card.name; + address.textContent = card.email; + + let photoURL = card.photoURL; + if (photoURL) { + let img = document.createElement("img"); + img.alt = name.textContent; + img.src = photoURL; + avatar.appendChild(img); + } else { + let letter = document.createElement("span"); + letter.textContent = Array.from(name.textContent)[0]?.toUpperCase(); + letter.setAttribute("aria-hidden", "true"); + avatar.appendChild(letter); + } + } + this.selectedCardsSection.hidden = list.childElementCount == 0; + + let book = MailServices.ab.getDirectoryFromUID(listCard.directoryUID); + this.writeButton.hidden = list.childElementCount == 0; + this.eventButton.hidden = this.writeButton.hidden; + this.searchButton.hidden = true; + this.newListButton.hidden = true; + this.editButton.hidden = book.readOnly; + + this.actions.hidden = this.writeButton.hidden && this.editButton.hidden; + + this.node.hidden = this.splitter.isCollapsed = false; + document.getElementById("viewContact").scrollTo(0, 0); + }, + + _onClick(event) { + let selectedContacts = cardsPane.selectedCards.filter( + card => !card.isMailList && card.primaryEmail + ); + + switch (event.target.id) { + case "detailsWriteButton": + cardsPane.writeToSelected(); + break; + case "detailsEventButton": { + let contacts; + if (this.currentList) { + let directory = MailServices.ab.getDirectory( + this.currentList.mailListURI + ); + contacts = directory.childCards; + } else { + contacts = selectedContacts; + } + let attendees = contacts.map(card => { + let attendee = new CalAttendee(); + attendee.id = `mailto:${card.primaryEmail}`; + attendee.commonName = card.displayName; + return attendee; + }); + if (attendees.length) { + window.browsingContext.topChromeWindow.createEventWithDialog( + null, + null, + null, + null, + null, + false, + attendees + ); + } + break; + } + case "detailsSearchButton": + if (this.currentCard.primaryEmail) { + let searchString = this.currentCard.emailAddresses.join(" "); + window.browsingContext.topChromeWindow.tabmail.openTab("glodaFacet", { + searcher: new GlodaMsgSearcher(null, searchString, false), + }); + } + break; + case "detailsNewListButton": + if (selectedContacts.length) { + createList(selectedContacts); + } + break; + case "editButton": + this.editCurrent(); + break; + case "detailsDeleteButton": + this.deleteCurrentContact(); + break; + } + }, +}; + +var photoDialog = { + /** + * The ratio of pixels in the source image to pixels in the preview. + * + * @type {number} + */ + _scale: null, + + /** + * The square to which the image will be cropped, in preview pixels. + * + * @type {DOMRect} + */ + _cropRect: null, + + /** + * The bounding rectangle of the image in the preview, in preview pixels. + * Cached for efficiency. + * + * @type {DOMRect} + */ + _previewRect: null, + + init() { + this._dialog = document.getElementById("photoDialog"); + this._dialog.saveButton = this._dialog.querySelector(".accept"); + this._dialog.cancelButton = this._dialog.querySelector(".cancel"); + this._dialog.discardButton = this._dialog.querySelector(".extra1"); + + this._dropTarget = this._dialog.querySelector("#photoDropTarget"); + this._svg = this._dialog.querySelector("svg"); + this._preview = this._svg.querySelector("image"); + this._cropMask = this._svg.querySelector("path"); + this._dragRect = this._svg.querySelector("rect"); + this._corners = this._svg.querySelectorAll("rect.corner"); + + this._dialog.addEventListener("dragover", this); + this._dialog.addEventListener("drop", this); + this._dialog.addEventListener("paste", this); + this._dropTarget.addEventListener("click", event => { + if (event.button != 0) { + return; + } + this._showFilePicker(); + }); + this._dropTarget.addEventListener("keydown", event => { + if (event.key != " " && event.key != "Enter") { + return; + } + this._showFilePicker(); + }); + + class Mover { + constructor(element) { + element.addEventListener("mousedown", this); + } + + handleEvent(event) { + if (event.type == "mousedown") { + if (event.buttons != 1) { + return; + } + this.onMouseDown(event); + window.addEventListener("mousemove", this); + window.addEventListener("mouseup", this); + } else if (event.type == "mousemove") { + if (event.buttons != 1) { + // The button was released and we didn't get a mouseup event, or the + // button(s) pressed changed. Either way, stop dragging. + this.onMouseUp(); + return; + } + this.onMouseMove(event); + } else { + this.onMouseUp(event); + } + } + + onMouseUp(event) { + delete this._dragPosition; + window.removeEventListener("mousemove", this); + window.removeEventListener("mouseup", this); + } + } + + new (class extends Mover { + onMouseDown(event) { + this._dragPosition = { + x: event.clientX - photoDialog._cropRect.x, + y: event.clientY - photoDialog._cropRect.y, + }; + } + + onMouseMove(event) { + photoDialog._cropRect.x = Math.min( + Math.max(0, event.clientX - this._dragPosition.x), + photoDialog._previewRect.width - photoDialog._cropRect.width + ); + photoDialog._cropRect.y = Math.min( + Math.max(0, event.clientY - this._dragPosition.y), + photoDialog._previewRect.height - photoDialog._cropRect.height + ); + photoDialog._redrawCropRect(); + } + })(this._dragRect); + + class CornerMover extends Mover { + constructor(element, xEdge, yEdge) { + super(element); + this.xEdge = xEdge; + this.yEdge = yEdge; + } + + onMouseDown(event) { + this._dragPosition = { + x: event.clientX - photoDialog._cropRect[this.xEdge], + y: event.clientY - photoDialog._cropRect[this.yEdge], + }; + } + + onMouseMove(event) { + let { width, height } = photoDialog._previewRect; + let { top, right, bottom, left } = photoDialog._cropRect; + let { x, y } = this._dragPosition; + + // New coordinates of the dragged corner, constrained to the image size. + x = Math.max(0, Math.min(width, event.clientX - x)); + y = Math.max(0, Math.min(height, event.clientY - y)); + + // New size based on the dragged corner and a minimum size of 80px. + let newWidth = this.xEdge == "right" ? x - left : right - x; + let newHeight = this.yEdge == "bottom" ? y - top : bottom - y; + let newSize = Math.max(80, Math.min(newWidth, newHeight)); + + photoDialog._cropRect.width = newSize; + if (this.xEdge == "left") { + photoDialog._cropRect.x = right - photoDialog._cropRect.width; + } + photoDialog._cropRect.height = newSize; + if (this.yEdge == "top") { + photoDialog._cropRect.y = bottom - photoDialog._cropRect.height; + } + photoDialog._redrawCropRect(); + } + } + + new CornerMover(this._corners[0], "left", "top"); + new CornerMover(this._corners[1], "right", "top"); + new CornerMover(this._corners[2], "right", "bottom"); + new CornerMover(this._corners[3], "left", "bottom"); + + this._dialog.saveButton.addEventListener("click", () => this._save()); + this._dialog.cancelButton.addEventListener("click", () => this._cancel()); + this._dialog.discardButton.addEventListener("click", () => this._discard()); + }, + + _setState(state) { + if (state == "preview") { + this._dropTarget.hidden = true; + this._svg.toggleAttribute("hidden", false); + this._dialog.saveButton.disabled = false; + return; + } + + this._dropTarget.classList.toggle("drop-target", state == "target"); + this._dropTarget.classList.toggle("drop-loading", state == "loading"); + this._dropTarget.classList.toggle("drop-error", state == "error"); + document.l10n.setAttributes( + this._dropTarget.querySelector(".label"), + `about-addressbook-photo-drop-${state}` + ); + + this._dropTarget.hidden = false; + this._svg.toggleAttribute("hidden", true); + this._dialog.saveButton.disabled = true; + }, + + /** + * Show the photo dialog, with no displayed image. + */ + showEmpty() { + this._setState("target"); + + if (!this._dialog.open) { + this._dialog.discardButton.hidden = true; + this._dialog.showModal(); + } + }, + + /** + * Show the photo dialog, with `file` as the displayed image. + * + * @param {File} file + */ + showWithFile(file) { + this.showWithURL(URL.createObjectURL(file)); + }, + + /** + * Show the photo dialog, with `URL` as the displayed image and (optionally) + * a pre-set crop rectangle + * + * @param {string} url - The URL of the image. + * @param {?DOMRect} cropRect - The rectangle used to crop the image. + * @param {boolean} [showDiscard=false] - Whether to show a discard button + * when opening the dialog. + */ + showWithURL(url, cropRect, showDiscard = false) { + // Load the image from the URL, to figure out the scale factor. + let img = document.createElement("img"); + img.addEventListener("load", () => { + const PREVIEW_SIZE = 500; + + let { naturalWidth, naturalHeight } = img; + this._scale = Math.max( + 1, + img.naturalWidth / PREVIEW_SIZE, + img.naturalHeight / PREVIEW_SIZE + ); + + let previewWidth = naturalWidth / this._scale; + let previewHeight = naturalHeight / this._scale; + let smallDimension = Math.min(previewWidth, previewHeight); + + this._previewRect = new DOMRect(0, 0, previewWidth, previewHeight); + if (cropRect) { + this._cropRect = DOMRect.fromRect(cropRect); + } else { + this._cropRect = new DOMRect( + (this._previewRect.width - smallDimension) / 2, + (this._previewRect.height - smallDimension) / 2, + smallDimension, + smallDimension + ); + } + + this._preview.setAttribute("href", url); + this._preview.setAttribute("width", previewWidth); + this._preview.setAttribute("height", previewHeight); + + this._svg.setAttribute("width", previewWidth + 20); + this._svg.setAttribute("height", previewHeight + 20); + this._svg.setAttribute( + "viewBox", + `-10 -10 ${previewWidth + 20} ${previewHeight + 20}` + ); + + this._redrawCropRect(); + this._setState("preview"); + this._dialog.saveButton.focus(); + }); + img.addEventListener("error", () => this._setState("error")); + img.src = url; + + this._setState("loading"); + + if (!this._dialog.open) { + this._dialog.discardButton.hidden = !showDiscard; + this._dialog.showModal(); + } + }, + + /** + * Resize the crop controls to match the current _cropRect. + */ + _redrawCropRect() { + let { top, right, bottom, left, width, height } = this._cropRect; + + this._cropMask.setAttribute( + "d", + `M0 0H${this._previewRect.width}V${this._previewRect.height}H0Z M${left} ${top}V${bottom}H${right}V${top}Z` + ); + + this._dragRect.setAttribute("x", left); + this._dragRect.setAttribute("y", top); + this._dragRect.setAttribute("width", width); + this._dragRect.setAttribute("height", height); + + this._corners[0].setAttribute("x", left - 10); + this._corners[0].setAttribute("y", top - 10); + this._corners[1].setAttribute("x", right - 30); + this._corners[1].setAttribute("y", top - 10); + this._corners[2].setAttribute("x", right - 30); + this._corners[2].setAttribute("y", bottom - 30); + this._corners[3].setAttribute("x", left - 10); + this._corners[3].setAttribute("y", bottom - 30); + }, + + /** + * Crop, shrink, convert the image to a JPEG, then assign it to the photo + * element and close the dialog. Doesn't save the JPEG to disk, that happens + * when (if) the contact is saved. + */ + async _save() { + const DOUBLE_SIZE = 600; + const FINAL_SIZE = 300; + + let source = this._preview; + let { x, y, width, height } = this._cropRect; + x *= this._scale; + y *= this._scale; + width *= this._scale; + height *= this._scale; + + // If the image is much larger than our target size, draw an intermediate + // version at twice the size first. This produces better-looking results. + if (width > DOUBLE_SIZE) { + let canvas1 = document.createElement("canvas"); + canvas1.width = canvas1.height = DOUBLE_SIZE; + let context1 = canvas1.getContext("2d"); + context1.drawImage( + source, + x, + y, + width, + height, + 0, + 0, + DOUBLE_SIZE, + DOUBLE_SIZE + ); + + source = canvas1; + x = y = 0; + width = height = DOUBLE_SIZE; + } + + let canvas2 = document.createElement("canvas"); + canvas2.width = canvas2.height = FINAL_SIZE; + let context2 = canvas2.getContext("2d"); + context2.drawImage( + source, + x, + y, + width, + height, + 0, + 0, + FINAL_SIZE, + FINAL_SIZE + ); + + let blob = await new Promise(resolve => + canvas2.toBlob(resolve, "image/jpeg") + ); + + detailsPane.setPhoto({ + blob, + sourceURL: this._preview.getAttribute("href"), + cropRect: DOMRect.fromRect(this._cropRect), + }); + + this._dialog.close(); + }, + + /** + * Just close the dialog. + */ + _cancel() { + this._dialog.close(); + }, + + /** + * Throw away the contact's existing photo, and close the dialog. Doesn't + * remove the existing photo from disk, that happens when (if) the contact + * is saved. + */ + _discard() { + this._dialog.close(); + detailsPane.setPhoto(null); + }, + + handleEvent(event) { + switch (event.type) { + case "dragover": + this._onDragOver(event); + break; + case "drop": + this._onDrop(event); + break; + case "paste": + this._onPaste(event); + break; + } + }, + + /** + * Gets the first image file from a DataTransfer object, or null if there + * are no image files in the object. + * + * @param {DataTransfer} dataTransfer + * @returns {File|null} + */ + _getUseableFile(dataTransfer) { + if ( + dataTransfer.files.length && + dataTransfer.files[0].type.startsWith("image/") + ) { + return dataTransfer.files[0]; + } + return null; + }, + + /** + * Gets the first image file from a DataTransfer object, or null if there + * are no image files in the object. + * + * @param {DataTransfer} dataTransfer + * @returns {string|null} + */ + _getUseableURL(dataTransfer) { + let data = dataTransfer.getData("text/plain"); + + return /^https?:\/\//.test(data) ? data : null; + }, + + _onDragOver(event) { + if ( + this._getUseableFile(event.dataTransfer) || + this._getUseableURL(event.clipboardData) + ) { + event.dataTransfer.dropEffect = "move"; + event.preventDefault(); + } + }, + + _onDrop(event) { + let file = this._getUseableFile(event.dataTransfer); + if (file) { + this.showWithFile(file); + event.preventDefault(); + } else { + let url = this._getUseableURL(event.clipboardData); + if (url) { + this.showWithURL(url); + event.preventDefault(); + } + } + }, + + _onPaste(event) { + let file = this._getUseableFile(event.clipboardData); + if (file) { + this.showWithFile(file); + } else { + let url = this._getUseableURL(event.clipboardData); + if (url) { + this.showWithURL(url); + } + } + event.preventDefault(); + }, + + /** + * Show a file picker to choose an image. + */ + async _showFilePicker() { + let title = await document.l10n.formatValue( + "about-addressbook-photo-filepicker-title" + ); + + let picker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + picker.init( + window.browsingContext.topChromeWindow, + title, + Ci.nsIFilePicker.modeOpen + ); + picker.appendFilters(Ci.nsIFilePicker.filterImages); + let result = await new Promise(resolve => picker.open(resolve)); + + if (result != Ci.nsIFilePicker.returnOK) { + return; + } + + this.showWithFile(await File.createFromNsIFile(picker.file)); + }, +}; + +// Printing + +var printHandler = { + printDirectory(directory) { + let title = directory ? directory.dirName : document.title; + + let cards; + if (directory) { + cards = directory.childCards; + } else { + cards = []; + for (let directory of MailServices.ab.directories) { + cards = cards.concat(directory.childCards); + } + } + + this._printCards(title, cards); + }, + + printCards(cards) { + this._printCards(document.title, cards); + }, + + async _printCards(title, cards) { + let collator = new Intl.Collator(undefined, { numeric: true }); + let nameFormat = Services.prefs.getIntPref( + "mail.addr_book.lastnamefirst", + 0 + ); + + cards.sort((a, b) => { + let aName = a.generateName(nameFormat); + let bName = b.generateName(nameFormat); + return collator.compare(aName, bName); + }); + + let printDocument = document.implementation.createHTMLDocument(); + printDocument.title = title; + printDocument.head + .appendChild(printDocument.createElement("meta")) + .setAttribute("charset", "utf-8"); + let link = printDocument.head.appendChild( + printDocument.createElement("link") + ); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", "chrome://messagebody/skin/abPrint.css"); + + let printTemplate = document.getElementById("printTemplate"); + + for (let card of cards) { + if (card.isMailList) { + continue; + } + + let div = printDocument.createElement("div"); + div.append(printTemplate.content.cloneNode(true)); + detailsPane.fillContactDetails(div, card); + let photo = div.querySelector(".contact-photo"); + if (photo.src.startsWith("chrome:")) { + photo.hidden = true; + } + await document.l10n.translateFragment(div); + printDocument.body.appendChild(div); + } + + let html = new XMLSerializer().serializeToString(printDocument); + this._printURL(URL.createObjectURL(new File([html], "text/html"))); + }, + + async _printURL(url) { + let topWindow = window.browsingContext.topChromeWindow; + await topWindow.PrintUtils.loadPrintBrowser(url); + topWindow.PrintUtils.startPrintWindow( + topWindow.PrintUtils.printBrowser.browsingContext, + {} + ); + }, +}; + +/** + * A span that displays the current time in a given time zone. + * The time is updated every minute. + */ +class ActiveTime extends HTMLSpanElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + + this.hasConnected = true; + this.setAttribute("is", "active-time"); + + try { + this.formatter = new Services.intl.DateTimeFormat(undefined, { + timeZone: this.getAttribute("tz"), + weekday: "long", + hour: "numeric", + minute: "2-digit", + }); + } catch { + // DateTimeFormat will throw if the time zone is unknown. + // If it does this will just be an empty span. + return; + } + this.update = this.update.bind(this); + this.update(); + + CalMetronome.on("minute", this.update); + window.addEventListener("unload", this, { once: true }); + } + + disconnectedCallback() { + CalMetronome.off("minute", this.update); + } + + handleEvent() { + CalMetronome.off("minute", this.update); + } + + update() { + this.textContent = this.formatter.format(new Date()); + } +} +customElements.define("active-time", ActiveTime, { extends: "span" }); diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.xhtml b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml new file mode 100644 index 0000000000..51a689106a --- /dev/null +++ b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml @@ -0,0 +1,460 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, you can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true"> +<head> + <meta charset="utf-8" /> + <title data-l10n-id="about-addressbook-title"></title> + <meta http-equiv="Content-Security-Policy" + content="default-src chrome:; script-src chrome: 'unsafe-inline'; img-src blob: chrome: data: http: https:; style-src chrome: 'unsafe-inline'; object-src 'none'" /> + <meta name="color-scheme" content="light dark" /> + + <link rel="icon" href="chrome://messenger/skin/icons/new/compact/address-book.svg" /> + + <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/primaryToolbar.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/avatars.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/aboutAddressBook.css" /> + + <link rel="localization" href="messenger/treeView.ftl" /> + <link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" /> + <link rel="localization" href="messenger/preferences/preferences.ftl" /> + <link rel="localization" href="messenger/appmenu.ftl" /> + + <script src="chrome://messenger/content/globalOverlay.js"></script> + <script src="chrome://global/content/editMenuOverlay.js"></script> + <script src="chrome://messenger/content/pane-splitter.js"></script> + <script src="chrome://messenger/content/tree-listbox.js"></script> + <script type="module" src="chrome://messenger/content/tree-view.mjs"></script> + <script src="chrome://messenger/content/jsTreeView.js"></script> + <script src="chrome://messenger/content/addressbook/abView-new.js"></script> + <script src="chrome://messenger/content/addressbook/aboutAddressBook.js"></script> +</head> +<body> + <xul:toolbox id="toolbox" class="contentTabToolbox" labelalign="end"> + <xul:toolbar class="chromeclass-toolbar contentTabToolbar themeable-full" mode="full"> + <xul:toolbarbutton id="toolbarCreateBook" is="toolbarbutton-menu-button" type="menu-button" + class="toolbarbutton-1" + data-l10n-id="about-addressbook-toolbar-new-address-book" + tabindex="0"> + <xul:menupopup> + <xul:menuitem data-l10n-id="about-addressbook-toolbar-new-address-book"/> + <xul:menuitem value="CARDDAV_DIRECTORY_TYPE" + data-l10n-id="about-addressbook-toolbar-add-carddav-address-book"/> + <xul:menuitem value="LDAP_DIRECTORY_TYPE" + data-l10n-id="about-addressbook-toolbar-add-ldap-address-book"/> + </xul:menupopup> + </xul:toolbarbutton> + <xul:toolbarbutton id="toolbarCreateContact" + class="toolbarbutton-1" + data-l10n-id="about-addressbook-toolbar-new-contact" + tabindex="0"/> + <xul:toolbarbutton id="toolbarCreateList" + class="toolbarbutton-1" + data-l10n-id="about-addressbook-toolbar-new-list" + tabindex="0"/> + <xul:toolbarbutton id="toolbarImport" + class="toolbarbutton-1" + data-l10n-id="about-addressbook-toolbar-import" + tabindex="0"/> + </xul:toolbar> + </xul:toolbox> + <div id="booksPane" class="no-overscroll"> + <ul is="ab-tree-listbox" id="books" role="tree"> + <li id="allAddressBooks" + class="bookRow noDelete readOnly" + data-l10n-id="all-address-books-row"> + <div class="bookRow-container"> + <div class="twisty"></div> + <div class="bookRow-icon"></div> + <span class="bookRow-name" tabindex="-1" data-l10n-id="all-address-books"></span> + <div class="bookRow-menu"></div> + </div> + </li> + </ul> + <div id="cardCount"></div> + <template id="bookRow"> + <li class="bookRow"> + <div class="bookRow-container"> + <div class="twisty"> + <img class="twisty-icon" src="chrome://messenger/skin/icons/new/nav-down-sm.svg" alt="" /> + </div> + <div class="bookRow-icon"></div> + <span class="bookRow-name" tabindex="-1"></span> + <div class="bookRow-menu"></div> + </div> + <ul></ul> + </li> + </template> + <template id="listRow"> + <li class="listRow"> + <div class="listRow-container"> + <div class="listRow-icon"></div> + <span class="listRow-name" tabindex="-1"></span> + <div class="listRow-menu"></div> + </div> + </li> + </template> + </div> + <hr is="pane-splitter" id="booksSplitter" + resize-direction="horizontal" + resize-id="booksPane"/> + <div id="cardsPane"> + <div id="cardsPaneHeader"> + <input is="ab-card-search-input" id="searchInput" + type="search" + data-l10n-attrs="placeholder" /> + <button id="displayButton" + class="button icon-button icon-only button-flat" + data-l10n-id="about-addressbook-sort-button2"> + </button> + </div> + + <tree-view id="cards"> + <slot name="placeholders"> + <div id="placeholderEmptyBook" + hidden="hidden" + data-l10n-id="about-addressbook-placeholder-empty-book"></div> + <button id="placeholderCreateContact" + class="icon-button" + hidden="hidden" + data-l10n-id="about-addressbook-placeholder-new-contact"></button> + <div id="placeholderSearchOnly" + hidden="hidden" + data-l10n-id="about-addressbook-placeholder-search-only"></div> + <div id="placeholderSearching" + hidden="hidden" + data-l10n-id="about-addressbook-placeholder-searching"></div> + <div id="placeholderNoSearchResults" + hidden="hidden" + data-l10n-id="about-addressbook-placeholder-no-search-results"></div> + </slot> + </tree-view> + </div> + <!-- We will dynamically switch this splitter to be horizontal or vertical and + affect the cardsPane or detailsPane based on the required layout. --> + <hr is="pane-splitter" id="sharedSplitter" /> + <div id="detailsPane" hidden="hidden"> + <article id="viewContact" class="contact-details-scroll"> + <!-- If you're changing this, you probably want to change #printTemplate too. --> + <header> + <div class="contact-header"> + <img id="viewContactPhoto" class="contact-photo" alt="" /> + <div class="contact-headings"> + <h1 id="viewContactName" class="contact-heading-name"></h1> + <p id="viewContactNickName" class="contact-heading-nickname"></p> + <p id="viewPrimaryEmail" class="contact-heading-email"></p> + </div> + </div> + <div class="list-header"> + <div class="recipient-avatar is-mail-list"> + <img alt="" src="chrome://messenger/skin/icons/new/compact/user-list-alt.svg" /> + </div> + <h1 id="viewListName" class="contact-heading-name"></h1> + </div> + <div class="selection-header"> + <h1 id="viewSelectionCount" class="contact-heading-name"></h1> + </div> + </header> + <div id="detailsBody"> + <section id="detailsActions" class="button-block"> + <div> + <button type="button" id="detailsWriteButton" + class="icon-button" + data-l10n-id="about-addressbook-write-action-button"></button> + <button type="button" id="detailsEventButton" + class="icon-button" + data-l10n-id="about-addressbook-event-action-button"></button> + <button type="button" id="detailsSearchButton" + class="icon-button" + data-l10n-id="about-addressbook-search-action-button"></button> + <button type="button" id="detailsNewListButton" + class="icon-button" + data-l10n-id="about-addressbook-new-list-action-button"></button> + </div> + <div class="edit-block"> + <button type="button" id="editButton" + data-l10n-id="about-addressbook-begin-edit-contact-button"></button> + </div> + </section> + <section id="emailAddresses" class="details-email-addresses"> + <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="phoneNumbers" class="details-phone-numbers"> + <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="addresses" class="details-addresses"> + <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="notes" class="details-notes"> + <h2 data-l10n-id="about-addressbook-details-notes-header"></h2> + <div></div> + </section> + <section id="websites" class="details-websites"> + <h2 data-l10n-id="about-addressbook-details-websites-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="instantMessaging" class="details-instant-messaging"> + <h2 data-l10n-id="about-addressbook-details-impp-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="otherInfo" class="details-other-info"> + <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2> + <ul class="entry-list"></ul> + </section> + <section id="selectedCards"> + <ul></ul> + </section> + <template id="entryItem"> + <li class="entry-item"> + <span class="entry-type"></span> + <span class="entry-value"></span> + </li> + </template> + <template id="selectedCard"> + <li class="selected-card"> + <div class="recipient-avatar"></div> + <div class="ab-card-row-data"> + <p class="ab-card-first-line"> + <span class="name"></span> + </p> + <p class="ab-card-second-line"> + <span class="address"></span> + </p> + </div> + </li> + </template> + </div> + </article> + <form id="editContactForm" + autocomplete="off" + aria-labelledby="editContactHeadingName"> + <div class="contact-details-scroll"> + <div class="contact-header"> + <div class="contact-headings"> + <h1 id="editContactHeadingName" class="contact-heading-name"></h1> + <p id="editContactHeadingNickName" class="contact-heading-nickname"> + </p> + <p id="editContactHeadingEmail" class="contact-heading-email"></p> + </div> + <!-- NOTE: We place the photo 'input' after the headings, since it is + - functionally a form control. However, we style the photo to + - appear at the inline-start of the contact-header. --> + <!-- NOTE: We wrap the button with a plain div because the button + - itself will not receive the paste event. --> + <div id="photoInput"> + <button type="button" id="photoButton" + class="plain-button" + data-l10n-id="about-addressbook-details-edit-photo"> + <img class="contact-photo" alt="" /> + <div id="photoOverlay"></div> + </button> + </div> + </div> + #include vcard-edit/vCardTemplates.inc.xhtml + <vcard-edit /> + </div> + <div id="detailsFooter" class="button-block"> + <div> + <button type="button" id="detailsDeleteButton" + class="icon-button" + data-l10n-id="about-addressbook-delete-edit-contact-button"></button> + </div> + <div> + <xul:label control="addContactBookList" + data-l10n-id="about-addressbook-add-contact-to"/> + <xul:menulist is="menulist-addrbooks" id="addContactBookList" + writable="true"/> + <button type="reset" id="cancelEditButton" + data-l10n-id="about-addressbook-cancel-edit-contact-button"></button> + <button type="submit" id="saveEditButton" + class="primary" + data-l10n-id="about-addressbook-save-edit-contact-button"></button> + </div> + </div> + </form> + </div> + <div id="detailsPaneBackdrop"><!-- + When editing a card, this element covers everything except #detailsPane, + preventing change to another card. + --></div> + + <dialog id="photoDialog"> + <div id="photoDialogInner"> + <!-- FIXME: The dialog is not semantic or accessible. + - We use a tabindex and role="alert" as a temporary solution. --> + <div id="photoDropTarget" role="alert" tabindex="0"> + <div class="icon"></div> + <div class="label" data-l10n-id="about-addressbook-photo-drop-target"></div> + </div> + <svg xmlns="http://www.w3.org/2000/svg" width="520" height="520" viewBox="-10 -10 520 520"> + <image/> + <path fill="#000000" fill-opacity="0.5" d="M0 0H500V500H0Z M200 200V300H300V200Z"/> + <rect x="0" y="0" width="500" height="500"/> + <rect class="corner nw" width="40" height="40"/> + <rect class="corner ne" width="40" height="40"/> + <rect class="corner se" width="40" height="40"/> + <rect class="corner sw" width="40" height="40"/> + </svg> + </div> + + <menu class="dialog-menu-container"> + <button class="extra1" data-l10n-id="about-addressbook-photo-discard"></button> + <button class="cancel" data-l10n-id="about-addressbook-photo-cancel"></button> + <button class="accept primary" data-l10n-id="about-addressbook-photo-save"></button> + </menu> + </dialog> + + <!-- In-content dialogs. --> + <xul:stack id="dialogStack" hidden="true"/> + <xul:vbox id="dialogTemplate" + class="dialogOverlay" + align="center" + pack="center" + topmost="true" + hidden="true"> + <xul:vbox class="dialogBox" + pack="end" + role="dialog" + aria-labelledby="dialogTitle"> + <xul:hbox class="dialogTitleBar" align="center"> + <xul:label class="dialogTitle" flex="1"/> + <xul:button class="dialogClose close-icon" data-l10n-id="close-button"/> + </xul:hbox> + <xul:browser class="dialogFrame" + autoscroll="false" + disablehistory="true"/> + </xul:vbox> + </xul:vbox> + + <template id="printTemplate"> + <!-- If you're changing this, you probably want to change #viewContact too. --> + <div class="contact-header"> + <img class="contact-photo" alt="" /> + <div class="contact-headings"> + <h1 class="contact-heading-name"></h1> + <p class="contact-heading-nickname"></p> + <p class="contact-heading-email"></p> + </div> + </div> + <div class="contact-body"> + <section class="details-email-addresses"> + <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2> + <ul class="entry-list"></ul> + </section> + <section class="details-phone-numbers"> + <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2> + <ul class="entry-list"></ul> + </section> + <section class="details-addresses"> + <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2> + <ul class="entry-list"></ul> + </section> + <section class="details-notes"> + <h2 data-l10n-id="about-addressbook-details-notes-header"></h2> + <div></div> + </section> + <section class="details-websites"> + <h2 data-l10n-id="about-addressbook-details-websites-header"></h2> + <ul class="entry-list"></ul> + </section> + <section class="details-instant-messaging"> + <h2 data-l10n-id="about-addressbook-details-impp-header"></h2> + <ul class="entry-list"></ul> + </section> + <section class="details-other-info"> + <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2> + <ul class="entry-list"></ul> + </section> + </div> + </template> +</body> +<xul:menupopup id="bookContext"> + <xul:menuitem id="bookContextProperties"/> + <xul:menuitem id="bookContextSynchronize" + data-l10n-id="about-addressbook-books-context-synchronize"/> + <xul:menuitem id="bookContextPrint" + data-l10n-id="about-addressbook-books-context-print"/> + <xul:menuitem id="bookContextExport" + data-l10n-id="about-addressbook-books-context-export"/> + <xul:menuitem id="bookContextDelete" + data-l10n-id="about-addressbook-books-context-delete"/> + <xul:menuitem id="bookContextRemove" + data-l10n-id="about-addressbook-books-context-remove"/> + <xul:menuseparator/> + <xul:menuitem id="bookContextStartupDefault" type="checkbox" + data-l10n-id="about-addressbook-books-context-startup-default"/> +</xul:menupopup> +<xul:menupopup id="sortContext" + position="bottomleft topleft"> + <xul:menuitem type="radio" + name="format" + value="0" + checked="true" + data-l10n-id="about-addressbook-name-format-display"/> + <xul:menuitem type="radio" + name="format" + value="2" + data-l10n-id="about-addressbook-name-format-firstlast"/> + <xul:menuitem type="radio" + name="format" + value="1" + data-l10n-id="about-addressbook-name-format-lastfirst"/> + <xul:menuseparator/> + <xul:menuitem type="radio" + name="sort" + value="GeneratedName ascending" + checked="true" + data-l10n-id="about-addressbook-sort-name-ascending"/> + <xul:menuitem type="radio" + name="sort" + value="GeneratedName descending" + data-l10n-id="about-addressbook-sort-name-descending"/> + <xul:menuitem type="radio" + name="sort" + value="EmailAddresses ascending" + data-l10n-id="about-addressbook-sort-email-ascending"/> + <xul:menuitem type="radio" + name="sort" + value="EmailAddresses descending" + data-l10n-id="about-addressbook-sort-email-descending"/> + <xul:menuseparator/> + <xul:menuitem id="sortContextTableLayout" + type="checkbox" + data-l10n-id="about-addressbook-table-layout"/> +</xul:menupopup> +<xul:menupopup id="cardContext"> + <xul:menuitem id="cardContextWrite" + data-l10n-id="about-addressbook-cards-context-write"/> + <xul:menu id="cardContextWriteMenu" + data-l10n-id="about-addressbook-cards-context-write"> + <xul:menupopup> + <!-- Filled dynamically. --> + </xul:menupopup> + </xul:menu> + <xul:menuseparator id="cardContextWriteSeparator"/> + <xul:menuitem id="cardContextEdit" + data-l10n-id="about-addressbook-books-context-edit"/> + <xul:menuitem id="cardContextPrint" + data-l10n-id="about-addressbook-books-context-print"/> + <xul:menuitem id="cardContextExport" + data-l10n-id="about-addressbook-books-context-export"/> + <xul:menuitem id="cardContextDelete" + data-l10n-id="about-addressbook-books-context-delete"/> + <xul:menuitem id="cardContextRemove" + data-l10n-id="about-addressbook-books-context-remove"/> +</xul:menupopup> +</html> diff --git a/comm/mail/components/addrbook/content/addressBookTab.js b/comm/mail/components/addrbook/content/addressBookTab.js new file mode 100644 index 0000000000..5605612daf --- /dev/null +++ b/comm/mail/components/addrbook/content/addressBookTab.js @@ -0,0 +1,172 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// mail/base/content/specialTabs.js +/* globals contentTabBaseType, DOMLinkHandler */ + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +/** + * A tab to show the Address Book. + */ +var addressBookTabType = { + __proto__: contentTabBaseType, + name: "addressBookTab", + perTabPanel: "vbox", + lastBrowserId: 0, + bundle: Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ), + protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService( + Ci.nsIExternalProtocolService + ), + + get loadingTabString() { + delete this.loadingTabString; + return (this.loadingTabString = document + .getElementById("bundle_messenger") + .getString("loadingTab")); + }, + + modes: { + addressBookTab: { + type: "addressBookTab", + }, + }, + + shouldSwitchTo(aArgs) { + if (!this.tab) { + return -1; + } + + if ("onLoad" in aArgs) { + if (this.tab.browser.contentDocument.readyState != "complete") { + this.tab.browser.addEventListener( + "about-addressbook-ready", + event => aArgs.onLoad(event, this.tab.browser), + { + capture: true, + once: true, + } + ); + } else { + aArgs.onLoad(null, this.tab.browser); + } + } + return document.getElementById("tabmail").tabInfo.indexOf(this.tab); + }, + + closeTab(aTab) { + this.tab = null; + }, + + openTab(aTab, aArgs) { + aTab.tabNode.setIcon( + "chrome://messenger/skin/icons/new/compact/address-book.svg" + ); + + // First clone the page and set up the basics. + let clone = document + .getElementById("preferencesTab") + .firstElementChild.cloneNode(true); + + clone.setAttribute("id", "addressBookTab" + this.lastBrowserId); + clone.setAttribute("collapsed", false); + + aTab.panel.setAttribute("id", "addressBookTabWrapper" + this.lastBrowserId); + aTab.panel.appendChild(clone); + + // Start setting up the browser. + aTab.browser = aTab.panel.querySelector("browser"); + aTab.browser.setAttribute( + "id", + "addressBookTabBrowser" + this.lastBrowserId + ); + aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete"); + aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler); + + aTab.findbar = document.createXULElement("findbar"); + aTab.findbar.setAttribute( + "browserid", + "addressBookTabBrowser" + this.lastBrowserId + ); + aTab.panel.appendChild(aTab.findbar); + + // Default to reload being disabled. + aTab.reloadEnabled = false; + + aTab.url = "about:addressbook"; + aTab.paneID = aArgs.paneID; + aTab.scrollPaneTo = aArgs.scrollPaneTo; + aTab.otherArgs = aArgs.otherArgs; + + // Now set up the listeners. + this._setUpTitleListener(aTab); + this._setUpCloseWindowListener(aTab); + + // Wait for full loading of the tab and the automatic selecting of last tab. + // Then run the given onload code. + aTab.browser.addEventListener( + "about-addressbook-ready", + function (event) { + aTab.pageLoading = false; + aTab.pageLoaded = true; + + if ("onLoad" in aArgs) { + // Let selection of the initial pane complete before selecting another. + // Otherwise we can end up with two panes selected at once. + aTab.browser.contentWindow.setTimeout(() => { + // By now, the tab could already be closed. Check that it isn't. + if (aTab.panel) { + aArgs.onLoad(event, aTab.browser); + } + }); + } + }, + { + capture: true, + once: true, + } + ); + + // Initialize our unit testing variables. + aTab.pageLoading = true; + aTab.pageLoaded = false; + + // Now start loading the content. + aTab.title = this.loadingTabString; + + ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser); + let params = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + postData: aArgs.postData || null, + }; + aTab.browser.loadURI(Services.io.newURI("about:addressbook"), params); + + this.tab = aTab; + this.lastBrowserId++; + }, + + persistTab(aTab) { + if (aTab.browser.currentURI.spec == "about:blank") { + return null; + } + + return {}; + }, + + restoreTab(aTabmail, aPersistedState) { + aTabmail.openTab("addressBookTab", {}); + }, + + doCommand(aCommand, aTab) { + if (aCommand == "cmd_print") { + aTab.browser.contentWindow.externalAction({ action: "print" }); + return; + } + this.__proto__.doCommand(aCommand, aTab); + }, +}; diff --git a/comm/mail/components/addrbook/content/menulist-addrbooks.js b/comm/mail/components/addrbook/content/menulist-addrbooks.js new file mode 100644 index 0000000000..6d919d98ad --- /dev/null +++ b/comm/mail/components/addrbook/content/menulist-addrbooks.js @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +// The menulist CE is defined lazily. Create one now to get menulist defined, +// allowing us to inherit from it. +if (!customElements.get("menulist")) { + delete document.createXULElement("menulist"); +} + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + /** + * MozMenulistAddrbooks is a menulist widget that is automatically + * populated with the complete address book list. + * + * @augments {MozMenuList} + */ + class MozMenulistAddrbooks extends customElements.get("menulist") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback()) { + return; + } + + if (this.menupopup) { + return; + } + + this._directories = []; + + this._rebuild(); + + // Store as a member of `this` so there's a strong reference. + this._addressBookListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _notifications: [ + "addrbook-directory-created", + "addrbook-directory-updated", + "addrbook-directory-deleted", + "addrbook-reloaded", + ], + + init() { + for (let topic of this._notifications) { + Services.obs.addObserver(this, topic, true); + } + window.addEventListener("unload", this); + }, + + cleanUp() { + for (let topic of this._notifications) { + Services.obs.removeObserver(this, topic); + } + window.removeEventListener("unload", this); + }, + + handleEvent(event) { + this.cleanUp(); + }, + + observe: (subject, topic, data) => { + // Test-only reload of the address book manager. + if (topic == "addrbook-reloaded") { + this._rebuild(); + return; + } + + subject.QueryInterface(Ci.nsIAbDirectory); + + switch (topic) { + case "addrbook-directory-created": { + if (this._matches(subject)) { + this._rebuild(); + } + break; + } + case "addrbook-directory-updated": { + // Find the item in the list to rename. + // We can't use indexOf here because we need loose equality. + let len = this._directories.length; + for (var oldIndex = len - 1; oldIndex >= 0; oldIndex--) { + if (this._directories[oldIndex] == subject) { + break; + } + } + if (oldIndex != -1) { + this._rebuild(); + } + break; + } + case "addrbook-directory-deleted": { + // Find the item in the list to remove. + // We can't use indexOf here because we need loose equality. + let len = this._directories.length; + for (var index = len - 1; index >= 0; index--) { + if (this._directories[index] == subject) { + break; + } + } + if (index != -1) { + this._directories.splice(index, 1); + // Are we removing the selected directory? + if ( + this.selectedItem == + this.menupopup.removeChild(this.menupopup.children[index]) + ) { + // If so, try to select the first directory, if available. + if (this.menupopup.hasChildNodes()) { + this.menupopup.firstElementChild.doCommand(); + } else { + this.selectedItem = null; + } + } + } + break; + } + } + }, + }; + + this._addressBookListener.init(); + } + + /** + * Returns the address book type based on the remoteonly attribute + * of the menulist. + * + * "URI" Local Address Book + * "dirPrefId" Remote LDAP Directory + */ + get _type() { + return this.getAttribute("remoteonly") ? "dirPrefId" : "URI"; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._addressBookListener.cleanUp(); + this._teardown(); + } + + _rebuild() { + // Init the address book cache. + this._directories.length = 0; + + for (let ab of MailServices.ab.directories) { + if (this._matches(ab)) { + this._directories.push(ab); + + if (this.getAttribute("mailinglists") == "true") { + // Also append contained mailinglists. + for (let list of ab.childNodes) { + if (this._matches(list)) { + this._directories.push(list); + } + } + } + } + } + + this._teardown(); + + if (this.hasAttribute("none")) { + // Create a dummy menuitem representing no selection. + this._directories.unshift(null); + let listItem = this.appendItem(this.getAttribute("none"), ""); + listItem.setAttribute("class", "menuitem-iconic abMenuItem"); + } + + if (this.hasAttribute("alladdressbooks")) { + // Insert a menuitem representing All Addressbooks. + let allABLabel = this.getAttribute("alladdressbooks"); + if (allABLabel == "true") { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + allABLabel = bundle.GetStringFromName("allAddressBooks"); + } + + this._directories.unshift(null); + let listItem = this.appendItem(allABLabel, "moz-abdirectory://?"); + listItem.setAttribute("class", "menuitem-iconic abMenuItem"); + listItem.setAttribute( + "image", + "chrome://messenger/skin/icons/new/compact/address-book.svg" + ); + } + + // Now create menuitems for all displayed directories. + let type = this._type; + for (let ab of this._directories) { + if (!ab) { + // Skip the empty members added above. + continue; + } + + let listItem = this.appendItem(ab.dirName, ab[type]); + listItem.setAttribute("class", "menuitem-iconic abMenuItem"); + + // Style the items by type. + if (ab.isMailList) { + listItem.setAttribute( + "image", + "chrome://messenger/skin/icons/new/compact/user-list.svg" + ); + } else if (ab.isRemote && ab.isSecure) { + listItem.setAttribute( + "image", + "chrome://messenger/skin/icons/new/compact/globe-secure.svg" + ); + } else if (ab.isRemote) { + listItem.setAttribute( + "image", + "chrome://messenger/skin/icons/new/compact/globe.svg" + ); + } else { + listItem.setAttribute( + "image", + "chrome://messenger/skin/icons/new/compact/address-book.svg" + ); + } + } + + // Attempt to select the persisted or otherwise first directory. + this.selectedIndex = this._directories.findIndex(d => { + return d && d[type] == this.value; + }); + + if (!this.selectedItem && this.menupopup.hasChildNodes()) { + this.selectedIndex = 0; + } + } + + _teardown() { + // Empty out anything in the list. + while (this.menupopup && this.menupopup.hasChildNodes()) { + this.menupopup.lastChild.remove(); + } + } + + _matches(ab) { + // This condition is used for instance when creating cards + if (this.getAttribute("writable") == "true" && ab.readOnly) { + return false; + } + + // This condition is used for instance when creating mailing lists + if ( + this.getAttribute("supportsmaillists") == "true" && + !ab.supportsMailingLists + ) { + return false; + } + + return ( + this.getAttribute(ab.isRemote ? "localonly" : "remoteonly") != "true" + ); + } + } + + customElements.define("menulist-addrbooks", MozMenulistAddrbooks, { + extends: "menulist", + }); +} diff --git a/comm/mail/components/addrbook/content/vcard-edit/adr.mjs b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs new file mode 100644 index 0000000000..2f395173f3 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ADR + */ +export class VCardAdrComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("adr", {}, "text", [ + "", + "", + "", + "", + "", + "", + "", + ]); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-adr"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.streetEl = this.querySelector('textarea[name="street"]'); + this.assignIds(this.streetEl, this.querySelector('label[for="street"]')); + this.streetEl.addEventListener("input", () => { + this.resizeStreetEl(); + }); + + this.localityEl = this.querySelector('input[name="locality"]'); + this.assignIds( + this.localityEl, + this.querySelector('label[for="locality"]') + ); + + this.regionEl = this.querySelector('input[name="region"]'); + this.assignIds(this.regionEl, this.querySelector('label[for="region"]')); + + this.codeEl = this.querySelector('input[name="code"]'); + this.assignIds(this.regionEl, this.querySelector('label[for="code"]')); + + this.countryEl = this.querySelector('input[name="country"]'); + this.assignIds(this.countryEl, this.querySelector('label[for="country"]')); + + // Create the adr type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + }); + + this.fromVCardPropertyEntryToUI(); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + } + + fromVCardPropertyEntryToUI() { + if (Array.isArray(this.vCardPropertyEntry.value[2])) { + this.streetEl.value = this.vCardPropertyEntry.value[2].join("\n"); + } else { + this.streetEl.value = this.vCardPropertyEntry.value[2] || ""; + } + // Per RFC 6350, post office box and extended address SHOULD be empty. + let pobox = this.vCardPropertyEntry.value[0] || ""; + let extendedAddr = this.vCardPropertyEntry.value[1] || ""; + if (extendedAddr) { + this.streetEl.value = this.streetEl.value + "\n" + extendedAddr.trim(); + delete this.vCardPropertyEntry.value[1]; + } + if (pobox) { + this.streetEl.value = pobox.trim() + "\n" + this.streetEl.value; + delete this.vCardPropertyEntry.value[0]; + } + + this.resizeStreetEl(); + this.localityEl.value = this.vCardPropertyEntry.value[3] || ""; + this.regionEl.value = this.vCardPropertyEntry.value[4] || ""; + this.codeEl.value = this.vCardPropertyEntry.value[5] || ""; + this.countryEl.value = this.vCardPropertyEntry.value[6] || ""; + } + + fromUIToVCardPropertyEntry() { + let streetValue = this.streetEl.value || ""; + streetValue = streetValue.trim(); + if (streetValue.includes("\n")) { + streetValue = streetValue.replaceAll("\r", ""); + streetValue = streetValue.split("\n"); + } + + this.vCardPropertyEntry.value = [ + "", + "", + streetValue, + this.localityEl.value || "", + this.regionEl.value || "", + this.codeEl.value || "", + this.countryEl.value || "", + ]; + } + + valueIsEmpty() { + return [ + this.streetEl, + this.localityEl, + this.regionEl, + this.codeEl, + this.countryEl, + ].every(e => !e.value); + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } + + resizeStreetEl() { + this.streetEl.rows = Math.max(1, this.streetEl.value.split("\n").length); + } +} + +customElements.define("vcard-adr", VCardAdrComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/custom.mjs b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs new file mode 100644 index 0000000000..bcdb1f6531 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +export class VCardCustomComponent extends HTMLElement { + /** @type {VCardPropertyEntry[]} */ + vCardPropertyEntries = null; + /** @type {HTMLInputElement[]} */ + inputEls = null; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-custom"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.inputEls = this.querySelectorAll("input"); + let labelEls = this.querySelectorAll("label"); + for (let i = 0; i < 4; i++) { + let inputId = vCardIdGen.next().value; + document.l10n.setAttributes( + labelEls[i], + `about-addressbook-entry-name-custom${i + 1}` + ); + labelEls[i].htmlFor = inputId; + this.inputEls[i].id = inputId; + } + this.fromVCardPropertyEntryToUI(); + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-custom").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + } + + fromVCardPropertyEntryToUI() { + for (let i = 0; i < 4; i++) { + this.inputEls[i].value = this.vCardPropertyEntries[i].value; + } + } + + fromUIToVCardPropertyEntry() { + for (let i = 0; i < 4; i++) { + this.vCardPropertyEntries[i].value = this.inputEls[i].value; + } + } +} + +customElements.define("vcard-custom", VCardCustomComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/edit.mjs b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs new file mode 100644 index 0000000000..90463e33bb --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs @@ -0,0 +1,1094 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; +import { VCardAdrComponent } from "./adr.mjs"; +import { VCardCustomComponent } from "./custom.mjs"; +import { VCardEmailComponent } from "./email.mjs"; +import { VCardIMPPComponent } from "./impp.mjs"; +import { VCardNComponent } from "./n.mjs"; +import { VCardFNComponent } from "./fn.mjs"; +import { VCardNickNameComponent } from "./nickname.mjs"; +import { VCardNoteComponent } from "./note.mjs"; +import { + VCardOrgComponent, + VCardRoleComponent, + VCardTitleComponent, +} from "./org.mjs"; +import { VCardSpecialDateComponent } from "./special-date.mjs"; +import { VCardTelComponent } from "./tel.mjs"; +import { VCardTZComponent } from "./tz.mjs"; +import { VCardURLComponent } from "./url.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardProperties", + "resource:///modules/VCardUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +class VCardEdit extends HTMLElement { + constructor() { + super(); + + this.contactNameHeading = document.getElementById("editContactHeadingName"); + this.contactNickNameHeading = document.getElementById( + "editContactHeadingNickName" + ); + this.contactEmailHeading = document.getElementById( + "editContactHeadingEmail" + ); + } + + connectedCallback() { + if (this.isConnected) { + this.updateView(); + + this.addEventListener("vcard-remove-property", e => { + if (e.target.vCardPropertyEntries) { + for (let entry of e.target.vCardPropertyEntries) { + this.vCardProperties.removeEntry(entry); + } + } else { + this.vCardProperties.removeEntry(e.target.vCardPropertyEntry); + } + + // Move the focus to the first available valid element of the fieldset. + let sibling = + e.target.nextElementSibling || e.target.previousElementSibling; + // If we got a button, focus it since it's the "add row" button. + if (sibling?.type == "button") { + sibling.focus(); + return; + } + + // Otherwise we have a row field, so try to find a focusable element. + if (sibling && this.moveFocusIntoElement(sibling)) { + return; + } + + // If we reach this point, the markup was unpredictable and we should + // move the focus to a valid element to avoid focus lost. + e.target + .closest("fieldset") + .querySelector(".add-property-button") + .focus(); + }); + } + } + + disconnectedCallback() { + this.replaceChildren(); + } + + get vCardString() { + return this._vCardProperties.toVCard(); + } + + set vCardString(value) { + if (value) { + try { + this.vCardProperties = lazy.VCardProperties.fromVCard(value); + return; + } catch (ex) { + console.error(ex); + } + } + this.vCardProperties = new lazy.VCardProperties("4.0"); + } + + get vCardProperties() { + return this._vCardProperties; + } + + set vCardProperties(value) { + this._vCardProperties = value; + // If no n property is present set one. + if (!this._vCardProperties.getFirstEntry("n")) { + this._vCardProperties.addEntry(VCardNComponent.newVCardPropertyEntry()); + } + // If no fn property is present set one. + if (!this._vCardProperties.getFirstEntry("fn")) { + this._vCardProperties.addEntry(VCardFNComponent.newVCardPropertyEntry()); + } + // If no nickname property is present set one. + if (!this._vCardProperties.getFirstEntry("nickname")) { + this._vCardProperties.addEntry( + VCardNickNameComponent.newVCardPropertyEntry() + ); + } + // If no email property is present set one. + if (!this._vCardProperties.getFirstEntry("email")) { + let emailEntry = VCardEmailComponent.newVCardPropertyEntry(); + emailEntry.params.pref = "1"; // Set as default email. + this._vCardProperties.addEntry(emailEntry); + } + // If one of the organizational properties is present, + // make sure they all are. + let title = this._vCardProperties.getFirstEntry("title"); + let role = this._vCardProperties.getFirstEntry("role"); + let org = this._vCardProperties.getFirstEntry("org"); + if (title || role || org) { + if (!title) { + this._vCardProperties.addEntry( + VCardTitleComponent.newVCardPropertyEntry() + ); + } + if (!role) { + this._vCardProperties.addEntry( + VCardRoleComponent.newVCardPropertyEntry() + ); + } + if (!org) { + this._vCardProperties.addEntry( + VCardOrgComponent.newVCardPropertyEntry() + ); + } + } + + for (let i = 1; i <= 4; i++) { + if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "") + ); + } + } + + this.updateView(); + } + + updateView() { + // Create new DOM and replacing other vCardProperties. + let template = document.getElementById("template-addr-book-edit"); + let clonedTemplate = template.content.cloneNode(true); + // Making the next two calls in one go causes a console error to be logged. + this.replaceChildren(); + this.append(clonedTemplate); + + if (!this.vCardProperties) { + return; + } + + this.addFieldsetActions(); + + // Insert the vCard property entries. + for (let vCardPropertyEntry of this.vCardProperties.entries) { + this.insertVCardElement(vCardPropertyEntry, false); + } + + let customProperties = ["x-custom1", "x-custom2", "x-custom3", "x-custom4"]; + if (customProperties.some(key => this.vCardProperties.getFirstValue(key))) { + // If one of these properties has a value, display all of them. + let customFieldset = this.querySelector("#addr-book-edit-custom"); + let customEl = + customFieldset.querySelector("vcard-custom") || + new VCardCustomComponent(); + customEl.vCardPropertyEntries = customProperties.map(key => + this._vCardProperties.getFirstEntry(key) + ); + let addCustom = document.getElementById("vcard-add-custom"); + customFieldset.insertBefore(customEl, addCustom); + addCustom.hidden = true; + } + + let nameEl = this.querySelector("vcard-n"); + this.firstName = nameEl.firstNameEl.querySelector("input"); + this.lastName = nameEl.lastNameEl.querySelector("input"); + this.prefixName = nameEl.prefixEl.querySelector("input"); + this.middleName = nameEl.middleNameEl.querySelector("input"); + this.suffixName = nameEl.suffixEl.querySelector("input"); + this.displayName = this.querySelector("vcard-fn").displayEl; + + [ + this.firstName, + this.lastName, + this.prefixName, + this.middleName, + this.suffixName, + this.displayName, + ].forEach(element => { + element.addEventListener("input", event => + this.generateContactName(event) + ); + }); + + // Only set the strings and define this selector if we're inside the + // address book edit panel. + if (document.getElementById("detailsPane")) { + this.preferDisplayName = this.querySelector("vcard-fn").preferDisplayEl; + document.l10n.setAttributes( + this.preferDisplayName.closest(".vcard-checkbox").querySelector("span"), + "about-addressbook-prefer-display-name" + ); + } + + this.nickName = this.querySelector("vcard-nickname").nickNameEl; + this.nickName.addEventListener("input", () => this.updateNickName()); + + if (this.vCardProperties) { + this.toggleDefaultEmailView(); + this.checkForBdayOccurrences(); + } + + this.updateNickName(); + this.updateEmailHeading(); + this.generateContactName(); + } + + /** + * Update the contact name to reflect the users' choice. + * + * @param {?Event} event - The DOM event if we have one. + */ + async generateContactName(event = null) { + // Don't generate any preview if the contact name element is not available, + // which it might happen since this component is used in other areas outside + // the address book UI. + if (!this.contactNameHeading) { + return; + } + + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + let result = ""; + let pref = Services.prefs.getIntPref("mail.addr_book.lastnamefirst"); + switch (pref) { + case Ci.nsIAbCard.GENERATE_DISPLAY_NAME: + result = this.buildDefaultName(); + break; + + case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER: + if (this.lastName.value) { + result = bundle.formatStringFromName("lastFirstFormat", [ + this.lastName.value, + [ + this.prefixName.value, + this.firstName.value, + this.middleName.value, + this.suffixName.value, + ] + .filter(Boolean) + .join(" "), + ]); + } else { + // Get the generic name if we don't have a last name. + result = this.buildDefaultName(); + } + break; + + default: + result = bundle.formatStringFromName("firstLastFormat", [ + [this.prefixName.value, this.firstName.value, this.middleName.value] + .filter(Boolean) + .join(" "), + [this.lastName.value, this.suffixName.value] + .filter(Boolean) + .join(" "), + ]); + break; + } + + if (result == "" || result == ", ") { + // We don't have anything to show as a contact name, so let's find the + // default email and show that, if we have it, otherwise pass an empty + // string to remove any leftover data. + let email = this.getDefaultEmail(); + result = email ? email.split("@", 1)[0] : ""; + } + + this.contactNameHeading.textContent = result; + this.fillDisplayName(event); + } + + /** + * Returns the name to show for this contact if the display name is available + * or it generates one from the available N data. + * + * @returns {string} - The name to show for this contact. + */ + buildDefaultName() { + return this.displayName.isDirty + ? this.displayName.value + : [ + this.prefixName.value, + this.firstName.value, + this.middleName.value, + this.lastName.value, + this.suffixName.value, + ] + .filter(Boolean) + .join(" "); + } + + /** + * Update the nickname value of the contact header. + */ + updateNickName() { + // Don't generate any preview if the contact nickname element is not + // available, which it might happen since this component is used in other + // areas outside the address book UI. + if (!this.contactNickNameHeading) { + return; + } + + let value = this.nickName.value.trim(); + this.contactNickNameHeading.hidden = !value; + this.contactNickNameHeading.textContent = value; + } + + /** + * Update the email value of the contact header. + * + * @param {?string} email - The email value the user is currently typing. + */ + updateEmailHeading(email = null) { + // Don't generate any preview if the contact nickname email is not + // available, which it might happen since this component is used in other + // areas outside the address book UI. + if (!this.contactEmailHeading) { + return; + } + + // If no email string was passed, it means this method was called when the + // view or edit pane refreshes, therefore we need to fetch the correct + // default email address. + let value = email ?? this.getDefaultEmail(); + this.contactEmailHeading.hidden = !value; + this.contactEmailHeading.textContent = value; + } + + /** + * Find the default email used for this contact. + * + * @returns {VCardEmailComponent} + */ + getDefaultEmail() { + let emails = document.getElementById("vcard-email").children; + if (emails.length == 1) { + return emails[0].emailEl.value; + } + + let defaultEmail = [...emails].find( + el => el.vCardPropertyEntry.params.pref === "1" + ); + + // If no email is marked as preferred, use the first one. + if (!defaultEmail) { + defaultEmail = emails[0]; + } + + return defaultEmail.emailEl.value; + } + + /** + * Auto fill the display name only if the pref is set, the user is not + * editing the display name field, and the field was never edited. + * The intention is to prefill while entering a new contact. Don't fill + * if we don't have a proper default name to show, but only a placeholder. + * + * @param {?Event} event - The DOM event if we have one. + */ + fillDisplayName(event = null) { + if ( + Services.prefs.getBoolPref("mail.addr_book.displayName.autoGeneration") && + event?.originalTarget.id != "vCardDisplayName" && + !this.displayName.isDirty && + this.buildDefaultName() + ) { + this.displayName.value = this.contactNameHeading.textContent; + } + } + + /** + * Inserts a custom element for a {VCardPropertyEntry} + * + * - Assigns rich data (not bind to a html attribute) and therefore + * the reference. + * - Inserts the element in the form at the correct position. + * + * @param {VCardPropertyEntry} entry + * @param {boolean} addEntry Adds the entry to the vCardProperties. + * @returns {VCardPropertyEntryView | undefined} + */ + insertVCardElement(entry, addEntry) { + // Add the entry to the vCardProperty data. + if (addEntry) { + this.vCardProperties.addEntry(entry); + } + + let fieldset; + let addButton; + switch (entry.name) { + case "n": + let n = new VCardNComponent(); + n.vCardPropertyEntry = entry; + fieldset = document.getElementById("addr-book-edit-n"); + let displayNicknameContainer = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore(n, displayNicknameContainer); + return n; + case "fn": + let fn = new VCardFNComponent(); + fn.vCardPropertyEntry = entry; + fieldset = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore(fn, fieldset.firstElementChild); + return fn; + case "nickname": + let nickname = new VCardNickNameComponent(); + nickname.vCardPropertyEntry = entry; + fieldset = this.querySelector( + "#addr-book-edit-n .addr-book-edit-display-nickname" + ); + fieldset.insertBefore( + nickname, + fieldset.firstElementChild?.nextElementSibling + ); + return nickname; + case "email": + let email = document.createElement("tr", { is: "vcard-email" }); + email.vCardPropertyEntry = entry; + document.getElementById("vcard-email").appendChild(email); + return email; + case "url": + let url = new VCardURLComponent(); + url.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-url"); + addButton = document.getElementById("vcard-add-url"); + fieldset.insertBefore(url, addButton); + return url; + case "tel": + let tel = new VCardTelComponent(); + tel.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-tel"); + addButton = document.getElementById("vcard-add-tel"); + fieldset.insertBefore(tel, addButton); + return tel; + case "tz": + let tz = new VCardTZComponent(); + tz.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-tz"); + addButton = document.getElementById("vcard-add-tz"); + fieldset.insertBefore(tz, addButton); + addButton.hidden = true; + return tz; + case "impp": + let impp = new VCardIMPPComponent(); + impp.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-impp"); + addButton = document.getElementById("vcard-add-impp"); + fieldset.insertBefore(impp, addButton); + return impp; + case "anniversary": + let anniversary = new VCardSpecialDateComponent(); + anniversary.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-bday-anniversary"); + addButton = document.getElementById("vcard-add-bday-anniversary"); + fieldset.insertBefore(anniversary, addButton); + return anniversary; + case "bday": + let bday = new VCardSpecialDateComponent(); + bday.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-bday-anniversary"); + addButton = document.getElementById("vcard-add-bday-anniversary"); + fieldset.insertBefore(bday, addButton); + return bday; + case "adr": + let address = new VCardAdrComponent(); + address.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-address"); + addButton = document.getElementById("vcard-add-adr"); + fieldset.insertBefore(address, addButton); + return address; + case "note": + let note = new VCardNoteComponent(); + note.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-note"); + addButton = document.getElementById("vcard-add-note"); + fieldset.insertBefore(note, addButton); + // Only one note is allowed via UI. + addButton.hidden = true; + return note; + case "title": + let title = new VCardTitleComponent(); + title.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore( + title, + fieldset.querySelector("vcard-role, vcard-org, #vcard-add-org") + ); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one title is allowed via UI. + addButton.hidden = true; + return title; + case "role": + let role = new VCardRoleComponent(); + role.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore( + role, + fieldset.querySelector("vcard-org, #vcard-add-org") + ); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one role is allowed via UI. + addButton.hidden = true; + return role; + case "org": + let org = new VCardOrgComponent(); + org.vCardPropertyEntry = entry; + fieldset = this.querySelector("#addr-book-edit-org"); + addButton = document.getElementById("vcard-add-org"); + fieldset.insertBefore(org, addButton); + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).hidden = false; + // Only one org is allowed via UI. + addButton.hidden = true; + return org; + default: + return undefined; + } + } + + /** + * Creates a VCardPropertyEntry with a matching + * name to the vCard spec. + * + * @param {string} entryName - A name which should be a vCard spec property. + * @returns {VCardPropertyEntry | undefined} + */ + static createVCardProperty(entryName) { + switch (entryName) { + case "n": + return VCardNComponent.newVCardPropertyEntry(); + case "fn": + return VCardFNComponent.newVCardPropertyEntry(); + case "nickname": + return VCardNickNameComponent.newVCardPropertyEntry(); + case "email": + return VCardEmailComponent.newVCardPropertyEntry(); + case "url": + return VCardURLComponent.newVCardPropertyEntry(); + case "tel": + return VCardTelComponent.newVCardPropertyEntry(); + case "tz": + return VCardTZComponent.newVCardPropertyEntry(); + case "impp": + return VCardIMPPComponent.newVCardPropertyEntry(); + case "bday": + return VCardSpecialDateComponent.newBdayVCardPropertyEntry(); + case "anniversary": + return VCardSpecialDateComponent.newAnniversaryVCardPropertyEntry(); + case "adr": + return VCardAdrComponent.newVCardPropertyEntry(); + case "note": + return VCardNoteComponent.newVCardPropertyEntry(); + case "title": + return VCardTitleComponent.newVCardPropertyEntry(); + case "role": + return VCardRoleComponent.newVCardPropertyEntry(); + case "org": + return VCardOrgComponent.newVCardPropertyEntry(); + default: + return undefined; + } + } + + /** + * Mutates the referenced vCardPropertyEntry(s). + * If the value of a VCardPropertyEntry is empty, the entry gets + * removed from the vCardProperty. + */ + saveVCard() { + for (let node of [ + ...this.querySelectorAll("vcard-adr"), + ...this.querySelectorAll("vcard-custom"), + ...document.getElementById("vcard-email").children, + ...this.querySelectorAll("vcard-fn"), + ...this.querySelectorAll("vcard-impp"), + ...this.querySelectorAll("vcard-n"), + ...this.querySelectorAll("vcard-nickname"), + ...this.querySelectorAll("vcard-note"), + ...this.querySelectorAll("vcard-org"), + ...this.querySelectorAll("vcard-role"), + ...this.querySelectorAll("vcard-title"), + ...this.querySelectorAll("vcard-special-date"), + ...this.querySelectorAll("vcard-tel"), + ...this.querySelectorAll("vcard-tz"), + ...this.querySelectorAll("vcard-url"), + ]) { + if (typeof node.fromUIToVCardPropertyEntry === "function") { + node.fromUIToVCardPropertyEntry(); + } + + // Filter out empty fields. + if (typeof node.valueIsEmpty === "function" && node.valueIsEmpty()) { + this.vCardProperties.removeEntry(node.vCardPropertyEntry); + } + } + + // If no email has a pref value of 1, set it to the first email. + let emailEntries = this.vCardProperties.getAllEntries("email"); + if ( + emailEntries.length >= 1 && + emailEntries.every(entry => entry.params.pref !== "1") + ) { + emailEntries[0].params.pref = "1"; + } + + for (let i = 1; i <= 4; i++) { + let entry = this._vCardProperties.getFirstEntry(`x-custom${i}`); + if (entry && !entry.value) { + this._vCardProperties.removeEntry(entry); + } + } + } + + /** + * Move focus into the form. + */ + setFocus() { + this.querySelector("vcard-n input:not([hidden])").focus(); + } + + /** + * Move focus to the first visible form element below the given element. + * + * @param {Element} element - The element to move focus into. + * @returns {boolean} - If the focus was moved into the element. + */ + moveFocusIntoElement(element) { + for (let child of element.querySelectorAll( + "select,input,textarea,button" + )) { + // Make sure it is visible. + if (child.clientWidth != 0 && child.clientHeight != 0) { + child.focus(); + return true; + } + } + return false; + } + + /** + * Add buttons and further actions of the groupings for vCard property + * entries. + */ + addFieldsetActions() { + // Add email button. + let addEmail = document.getElementById("vcard-add-email"); + this.registerAddButton(addEmail, "email", () => { + this.toggleDefaultEmailView(); + }); + + // Add listener to update the email written in the contact header. + this.addEventListener("vcard-email-default-changed", event => { + this.updateEmailHeading( + event.target.querySelector('input[type="email"]').value + ); + }); + + // Add listener to be sure that only one checkbox from the emails is ticked. + this.addEventListener("vcard-email-default-checkbox", event => { + // Show the newly selected default email in the contact header. + this.updateEmailHeading( + event.target.querySelector('input[type="email"]').value + ); + for (let vCardEmailComponent of document.getElementById("vcard-email") + .children) { + if (event.target !== vCardEmailComponent) { + vCardEmailComponent.checkboxEl.checked = false; + } + } + }); + + // Handling the VCardPropertyEntry change with the select. + let specialDatesFieldset = document.getElementById( + "addr-book-edit-bday-anniversary" + ); + specialDatesFieldset.addEventListener( + "vcard-bday-anniversary-change", + event => { + let newVCardPropertyEntry = new lazy.VCardPropertyEntry( + event.detail.name, + event.target.vCardPropertyEntry.params, + event.target.vCardPropertyEntry.type, + event.target.vCardPropertyEntry.value + ); + this.vCardProperties.removeEntry(event.target.vCardPropertyEntry); + event.target.vCardPropertyEntry = newVCardPropertyEntry; + this.vCardProperties.addEntry(newVCardPropertyEntry); + this.checkForBdayOccurrences(); + } + ); + + // Add special date button. + let addSpecialDate = document.getElementById("vcard-add-bday-anniversary"); + addSpecialDate.addEventListener("click", e => { + let newVCardProperty; + if (!this.vCardProperties.getFirstEntry("bday")) { + newVCardProperty = VCardEdit.createVCardProperty("bday"); + } else { + newVCardProperty = VCardEdit.createVCardProperty("anniversary"); + } + let el = this.insertVCardElement(newVCardProperty, true); + this.checkForBdayOccurrences(); + this.moveFocusIntoElement(el); + }); + + // Organizational Properties. + let addOrg = document.getElementById("vcard-add-org"); + addOrg.addEventListener("click", event => { + let title = VCardEdit.createVCardProperty("title"); + let role = VCardEdit.createVCardProperty("role"); + let org = VCardEdit.createVCardProperty("org"); + + let titleEl = this.insertVCardElement(title, true); + this.insertVCardElement(role, true); + this.insertVCardElement(org, true); + + this.moveFocusIntoElement(titleEl); + addOrg.hidden = true; + }); + + let addAddress = document.getElementById("vcard-add-adr"); + this.registerAddButton(addAddress, "adr"); + + let addURL = document.getElementById("vcard-add-url"); + this.registerAddButton(addURL, "url"); + + let addTel = document.getElementById("vcard-add-tel"); + this.registerAddButton(addTel, "tel"); + + let addTZ = document.getElementById("vcard-add-tz"); + this.registerAddButton(addTZ, "tz", () => { + addTZ.hidden = true; + }); + + let addIMPP = document.getElementById("vcard-add-impp"); + this.registerAddButton(addIMPP, "impp"); + + let addNote = document.getElementById("vcard-add-note"); + this.registerAddButton(addNote, "note", () => { + addNote.hidden = true; + }); + + let addCustom = document.getElementById("vcard-add-custom"); + addCustom.addEventListener("click", event => { + let el = new VCardCustomComponent(); + + // When the custom properties are deleted and added again ensure that + // the properties are set. + for (let i = 1; i <= 4; i++) { + if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "") + ); + } + } + + el.vCardPropertyEntries = [ + this._vCardProperties.getFirstEntry("x-custom1"), + this._vCardProperties.getFirstEntry("x-custom2"), + this._vCardProperties.getFirstEntry("x-custom3"), + this._vCardProperties.getFirstEntry("x-custom4"), + ]; + addCustom.parentNode.insertBefore(el, addCustom); + + this.moveFocusIntoElement(el); + addCustom.hidden = true; + }); + + // Delete button for Organization Properties. This property has multiple + // fields, so we should dispatch the remove event only once after everything + // has been removed. + this.querySelector( + "#addr-book-edit-org .remove-property-button" + ).addEventListener("click", event => { + this.querySelector("vcard-title").remove(); + this.querySelector("vcard-role").remove(); + let org = this.querySelector("vcard-org"); + // Reveal the "Add" button so we can focus it. + document.getElementById("vcard-add-org").hidden = false; + // Dispatch the event before removing the element so we can handle focus. + org.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + org.remove(); + event.target.hidden = true; + }); + } + + /** + * Registers a click event for addButton which creates a new vCardProperty + * and inserts it. + * + * @param {HTMLButtonElement} addButton + * @param {string} VCardPropertyName RFC6350 vCard property name. + * @param {(vCardElement) => {}} callback For further refinement. + * Like different focus instead of an input field. + */ + registerAddButton(addButton, VCardPropertyName, callback) { + addButton.addEventListener("click", event => { + let newVCardProperty = VCardEdit.createVCardProperty(VCardPropertyName); + let el = this.insertVCardElement(newVCardProperty, true); + + this.moveFocusIntoElement(el); + if (callback) { + callback(el); + } + }); + } + + /** + * If one BDAY vCardPropertyEntry is present disable + * the option to change an Anniversary to a BDAY. + * + * @see VCardSpecialDateComponent + */ + checkForBdayOccurrences() { + let bdayOccurrence = this.vCardProperties.getFirstEntry("bday"); + this.querySelectorAll("vcard-special-date").forEach(specialDate => { + specialDate.birthdayAvailability({ hasBday: !!bdayOccurrence }); + }); + } + + /** + * Hide the default checkbox if we only have one email field. + */ + toggleDefaultEmailView() { + let hideDefault = + document.getElementById("vcard-email").children.length <= 1; + let defaultColumn = this.querySelector(".default-column"); + if (defaultColumn) { + defaultColumn.hidden = hideDefault; + } + document.getElementById("addr-book-edit-email-default").hidden = + hideDefault; + + // Add class to position legend absolute. + document + .getElementById("addr-book-edit-email") + .classList.toggle("default-table-header", !hideDefault); + } + + /** + * Validate the form with the minimum required data to save or update a + * contact. We can't use the built-in checkValidity() since our fields + * are not handled properly by the form element. + * + * @returns {boolean} - If the form is valid or not. + */ + checkMinimumRequirements() { + let hasEmail = [...document.getElementById("vcard-email").children].find( + s => { + let field = s.querySelector(`input[type="email"]`); + return field.value.trim() && field.checkValidity(); + } + ); + let hasOrg = [...this.querySelectorAll("vcard-org")].find(n => + n.orgEl.value.trim() + ); + + return ( + this.firstName.value.trim() || + this.lastName.value.trim() || + this.displayName.value.trim() || + hasEmail || + hasOrg + ); + } + + /** + * Validate the special date fields making sure that we have a valid + * DATE-AND-OR-TIME. See date, date-noreduc. + * That is, valid if any of the fields are valid, but the combination of + * only year and day is not valid. + * + * @returns {boolean} - True all created special date fields are valid. + * @see https://datatracker.ietf.org/doc/html/rfc6350#section-4.3.4 + */ + validateDates() { + for (let field of document.querySelectorAll("vcard-special-date")) { + let y = field.querySelector(`input[type="number"][name="year"]`); + let m = field.querySelector(`select[name="month"]`); + let d = field.querySelector(`select[name="day"]`); + if (!y.checkValidity()) { + y.focus(); + return false; + } + if (y.value && d.value && !m.value) { + m.required = true; + m.focus(); + return false; + } + } + return true; + } +} +customElements.define("vcard-edit", VCardEdit); + +/** + * Responsible for the type selection of a vCard property. + * + * Couples the given vCardPropertyEntry with a <select> element. + * This is safe because contact editing always creates a new contact, even + * when an existing contact is selected for editing. + * + * @see RFC6350 TYPE + */ +class VCardTypeSelectionComponent extends HTMLElement { + /** + * The select element created by this custom element. + * + * @type {HTMLSelectElement} + */ + selectEl; + + /** + * Initializes the type selector elements to control the given + * vCardPropertyEntry. + * + * @param {VCardPropertyEntry} vCardPropertyEntry - The VCardPropertyEntry + * this element should control. + * @param {boolean} [options.createLabel] - Whether a Type label should be + * created for the selectEl element. If this is not `true`, then the label + * for the selectEl should be provided through some other means, such as the + * labelledBy property. + * @param {string} [options.labelledBy] - Optional `id` of the element that + * should label the selectEl element (through aria-labelledby). + * @param {string} [options.propertyType] - Specifies the set of types that + * should be available and shown for the corresponding property. Set as + * "tel" to use the set of telephone types. Otherwise defaults to only using + * the `home`, `work` and `(None)` types. + */ + createTypeSelection(vCardPropertyEntry, options) { + let template; + let types; + switch (options.propertyType) { + case "tel": + types = ["work", "home", "cell", "fax", "pager"]; + template = document.getElementById("template-vcard-edit-type-tel"); + break; + default: + types = ["work", "home"]; + template = document.getElementById("template-vcard-edit-type"); + break; + } + + let clonedTemplate = template.content.cloneNode(true); + this.replaceChildren(clonedTemplate); + + this.selectEl = this.querySelector("select"); + let selectId = vCardIdGen.next().value; + this.selectEl.id = selectId; + + // Just abandon any values we don't have UI for. We don't have any way to + // know whether to keep them or not, and they're very rarely used. + let paramsType = vCardPropertyEntry.params.type; + // toLowerCase is called because other vCard sources are saving the type + // in upper case. E.g. from Google. + if (Array.isArray(paramsType)) { + let lowerCaseTypes = paramsType.map(type => type.toLowerCase()); + this.selectEl.value = lowerCaseTypes.find(t => types.includes(t)) || ""; + } else if (paramsType && types.includes(paramsType.toLowerCase())) { + this.selectEl.value = paramsType.toLowerCase(); + } + + // Change the value on the vCardPropertyEntry. + this.selectEl.addEventListener("change", e => { + if (this.selectEl.value) { + vCardPropertyEntry.params.type = this.selectEl.value; + } else { + delete vCardPropertyEntry.params.type; + } + }); + + // Set an aria-labelledyby on the select. + if (options.labelledBy) { + if (!document.getElementById(options.labelledBy)) { + throw new Error(`No such label element with id ${options.labelledBy}`); + } + this.querySelector("select").setAttribute( + "aria-labelledby", + options.labelledBy + ); + } + + // Create a label element for the select. + if (options.createLabel) { + let labelEl = document.createElement("label"); + labelEl.htmlFor = selectId; + labelEl.setAttribute("data-l10n-id", "vcard-entry-type-label"); + labelEl.classList.add("screen-reader-only"); + this.insertBefore(labelEl, this.selectEl); + } + } +} + +customElements.define("vcard-type", VCardTypeSelectionComponent); + +/** + * Interface for vCard Fields in the edit view. + * + * @interface VCardPropertyEntryView + */ + +/** + * Getter/Setter for rich data do not use HTMLAttributes for this. + * Keep the reference intact through vCardProperties for proper saving. + * + * @property + * @name VCardPropertyEntryView#vCardPropertyEntry + */ + +/** + * fromUIToVCardPropertyEntry should directly change data with the reference + * through vCardPropertyEntry. + * It's there for an action to read the user input values into the + * vCardPropertyEntry. + * + * @function + * @name VCardPropertyEntryView#fromUIToVCardPropertyEntry + * @returns {void} + */ + +/** + * Updates the UI accordingly to the vCardPropertyEntry. + * + * @function + * @name VCardPropertyEntryView#fromVCardPropertyEntryToUI + * @returns {void} + */ + +/** + * Checks if the value of VCardPropertyEntry is empty. + * + * @function + * @name VCardPropertyEntryView#valueIsEmpty + * @returns {boolean} + */ + +/** + * Creates a new VCardPropertyEntry for usage in the a new Field. + * + * @function + * @name VCardPropertyEntryView#newVCardPropertyEntry + * @static + * @returns {VCardPropertyEntry} + */ diff --git a/comm/mail/components/addrbook/content/vcard-edit/email.mjs b/comm/mail/components/addrbook/content/vcard-edit/email.mjs new file mode 100644 index 0000000000..751399ac6c --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/email.mjs @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 EMAIL + */ +export class VCardEmailComponent extends HTMLTableRowElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + emailEl; + /** @type {HTMLInputElement} */ + checkboxEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("email", {}, "text", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-email"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.emailEl = this.querySelector('input[type="email"]'); + this.checkboxEl = this.querySelector('input[type="checkbox"]'); + + this.emailEl.addEventListener("input", () => { + // Dispatch the event only if this field is the currently selected + // default/preferred email address. + if (this.checkboxEl.checked) { + this.dispatchEvent(VCardEmailComponent.EmailEvent()); + } + }); + + // Uncheck the checkbox of other VCardEmailComponents if this one is + // checked. + this.checkboxEl.addEventListener("change", event => { + if (event.target.checked === true) { + this.dispatchEvent(VCardEmailComponent.CheckboxEvent()); + } + }); + + // Create the email type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + labelledBy: "addr-book-edit-email-type", + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + document.querySelector("vcard-edit").toggleDefaultEmailView(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.emailEl.value = this.vCardPropertyEntry.value; + + let pref = this.vCardPropertyEntry.params.pref; + if (pref === "1") { + this.checkboxEl.checked = true; + } + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.emailEl.value; + + if (this.checkboxEl.checked) { + this.vCardPropertyEntry.params.pref = "1"; + } else if ( + this.vCardPropertyEntry.params.pref && + this.vCardPropertyEntry.params.pref === "1" + ) { + // Only delete the pref if a pref of 1 is set and the checkbox is not + // checked. The pref mechanic is not fully supported yet. Leave all other + // prefs untouched. + delete this.vCardPropertyEntry.params.pref; + } + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + /** + * This event is fired when the checkbox is checked and we need to uncheck the + * other checkboxes from each VCardEmailComponent. + * FIXME: This should be a radio button part of radiogroup. + * + * @returns {CustomEvent} + */ + static CheckboxEvent() { + return new CustomEvent("vcard-email-default-checkbox", { + detail: {}, + bubbles: true, + }); + } + + /** + * This event is fired when the value of an email input field is changed. The + * event is fired only if the current email si set as default/preferred. + * + * @returns {CustomEvent} + */ + static EmailEvent() { + return new CustomEvent("vcard-email-default-changed", { + detail: {}, + bubbles: true, + }); + } +} + +customElements.define("vcard-email", VCardEmailComponent, { extends: "tr" }); diff --git a/comm/mail/components/addrbook/content/vcard-edit/fn.mjs b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs new file mode 100644 index 0000000000..446a262f28 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 FN + */ +export class VCardFNComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLElement} */ + displayEl; + /** @type {HTMLElement} */ + preferDisplayEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("fn", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-fn"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.displayEl = this.querySelector("#vCardDisplayName"); + this.displayEl.addEventListener( + "input", + () => { + this.displayEl.isDirty = true; + }, + { once: true } + ); + this.preferDisplayEl = this.querySelector("#vCardPreferDisplayName"); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.displayEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.displayEl.value = this.vCardPropertyEntry.value; + this.displayEl.isDirty = !!this.displayEl.value.trim(); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.displayEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} +customElements.define("vcard-fn", VCardFNComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs new file mode 100644 index 0000000000..b4ce37bfda --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +function* vCardHtmlIdGen() { + let internalId = 0; + while (true) { + yield `vcard-id-${internalId++}`; + } +} + +export let vCardIdGen = vCardHtmlIdGen(); diff --git a/comm/mail/components/addrbook/content/vcard-edit/impp.mjs b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs new file mode 100644 index 0000000000..232925942e --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 IMPP + */ +export class VCardIMPPComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + imppEl; + /** @type {HTMLSelectElement} */ + protocolEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("impp", {}, "uri", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-impp"); + this.appendChild(template.content.cloneNode(true)); + + this.imppEl = this.querySelector('input[name="impp"]'); + document.l10n + .formatValue("vcard-impp-input-title") + .then(t => (this.imppEl.title = t)); + + this.protocolEl = this.querySelector('select[name="protocol"]'); + this.protocolEl.id = vCardIdGen.next().value; + + let protocolLabel = this.querySelector('label[for="protocol"]'); + protocolLabel.htmlFor = this.protocolEl.id; + + this.protocolEl.addEventListener("change", event => { + let entered = this.imppEl.value.split(":", 1)[0]?.toLowerCase(); + if (entered) { + this.protocolEl.value = + [...this.protocolEl.options].find(o => o.value.startsWith(entered)) + ?.value || ""; + } + this.imppEl.placeholder = this.protocolEl.value; + this.imppEl.pattern = this.protocolEl.selectedOptions[0].dataset.pattern; + }); + + this.imppEl.id = vCardIdGen.next().value; + let imppLabel = this.querySelector('label[for="impp"]'); + imppLabel.htmlFor = this.imppEl.id; + document.l10n.setAttributes(imppLabel, "vcard-impp-label"); + this.imppEl.addEventListener("change", event => { + this.protocolEl.dispatchEvent(new CustomEvent("change")); + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + this.imppEl.dispatchEvent(new CustomEvent("change")); + } + + fromVCardPropertyEntryToUI() { + this.imppEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.imppEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-impp", VCardIMPPComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/n.mjs b/comm/mail/components/addrbook/content/vcard-edit/n.mjs new file mode 100644 index 0000000000..ae5d386d93 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/n.mjs @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 N + */ +export class VCardNComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLElement} */ + prefixEl; + /** @type {HTMLElement} */ + firstNameEl; + /** @type {HTMLElement} */ + middleNameEl; + /** @type {HTMLElement} */ + lastNameEl; + /** @type {HTMLElement} */ + suffixEl; + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-n"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.registerListComponents(); + this.fromVCardPropertyEntryToUI(); + this.sortAsOrder(); + } + } + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("n", {}, "text", ["", "", "", "", ""]); + } + + /** + * Assigns the vCardPropertyEntry values to the individual + * NListComponentText elements. + * + * @TODO sort-as param should be used for the order. + * The use-case is that not every language has the order of + * prefix, firstName, middleName, lastName, suffix. + * Aswell that the user is able to change the sorting as he like + * on a per contact base. + */ + sortAsOrder() { + if (!this.vCardPropertyEntry.params["sort-as"]) { + // eslint-disable-next-line no-useless-return + return; + } + /** + * @TODO + * The sort-as DOM Mutation + */ + } + + fromVCardPropertyEntryToUI() { + let prefixVal = this.vCardPropertyEntry.value[3] || ""; + let prefixInput = this.prefixEl.querySelector("input"); + prefixInput.value = prefixVal; + if (prefixVal) { + this.prefixEl.querySelector("button").hidden = true; + } else { + this.prefixEl.classList.add("hasButton"); + this.prefixEl.querySelector("label").hidden = true; + prefixInput.hidden = true; + } + + // First Name is always shown. + this.firstNameEl.querySelector("input").value = + this.vCardPropertyEntry.value[1] || ""; + + let middleNameVal = this.vCardPropertyEntry.value[2] || ""; + let middleNameInput = this.middleNameEl.querySelector("input"); + middleNameInput.value = middleNameVal; + if (middleNameVal) { + this.middleNameEl.querySelector("button").hidden = true; + } else { + this.middleNameEl.classList.add("hasButton"); + this.middleNameEl.querySelector("label").hidden = true; + middleNameInput.hidden = true; + } + + // Last Name is always shown. + this.lastNameEl.querySelector("input").value = + this.vCardPropertyEntry.value[0] || ""; + + let suffixVal = this.vCardPropertyEntry.value[4] || ""; + let suffixInput = this.suffixEl.querySelector("input"); + suffixInput.value = suffixVal; + if (suffixVal) { + this.suffixEl.querySelector("button").hidden = true; + } else { + this.suffixEl.classList.add("hasButton"); + this.suffixEl.querySelector("label").hidden = true; + suffixInput.hidden = true; + } + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = [ + this.lastNameEl.querySelector("input").value, + this.firstNameEl.querySelector("input").value, + this.middleNameEl.querySelector("input").value, + this.prefixEl.querySelector("input").value, + this.suffixEl.querySelector("input").value, + ]; + } + + valueIsEmpty() { + let noEmptyStrings = [ + this.prefixEl, + this.firstNameEl, + this.middleNameEl, + this.lastNameEl, + this.suffixEl, + ].filter(node => { + return node.querySelector("input").value !== ""; + }); + return noEmptyStrings.length === 0; + } + + registerListComponents() { + this.prefixEl = this.querySelector("#n-list-component-prefix"); + let prefixInput = this.prefixEl.querySelector("input"); + let prefixButton = this.prefixEl.querySelector("button"); + prefixButton.addEventListener("click", e => { + this.prefixEl.querySelector("label").hidden = false; + prefixInput.hidden = false; + prefixButton.hidden = true; + this.prefixEl.classList.remove("hasButton"); + prefixInput.focus(); + }); + + this.firstNameEl = this.querySelector("#n-list-component-firstname"); + + this.middleNameEl = this.querySelector("#n-list-component-middlename"); + let middleNameInput = this.middleNameEl.querySelector("input"); + let middleNameButton = this.middleNameEl.querySelector("button"); + middleNameButton.addEventListener("click", e => { + this.middleNameEl.querySelector("label").hidden = false; + middleNameInput.hidden = false; + middleNameButton.hidden = true; + this.middleNameEl.classList.remove("hasButton"); + middleNameInput.focus(); + }); + + this.lastNameEl = this.querySelector("#n-list-component-lastname"); + + this.suffixEl = this.querySelector("#n-list-component-suffix"); + let suffixInput = this.suffixEl.querySelector("input"); + let suffixButton = this.suffixEl.querySelector("button"); + suffixButton.addEventListener("click", e => { + this.suffixEl.querySelector("label").hidden = false; + suffixInput.hidden = false; + suffixButton.hidden = true; + this.suffixEl.classList.remove("hasButton"); + suffixInput.focus(); + }); + } + + disconnectedCallback() { + if (!this.isConnected) { + this.prefixEl = null; + this.firstNameEl = null; + this.middleNameEl = null; + this.lastNameEl = null; + this.suffixEl = null; + } + } +} +customElements.define("vcard-n", VCardNComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs new file mode 100644 index 0000000000..3622b28997 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 NICKNAME + */ +export class VCardNickNameComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + /** @type {HTMLElement} */ + nickNameEl; + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-nickname"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("nickname", {}, "text", ""); + } + + connectedCallback() { + if (this.isConnected) { + this.nickNameEl = this.querySelector("#vCardNickName"); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.nickNameEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.nickNameEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.nickNameEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} +customElements.define("vcard-nickname", VCardNickNameComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/note.mjs b/comm/mail/components/addrbook/content/vcard-edit/note.mjs new file mode 100644 index 0000000000..f78f4a16d8 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/note.mjs @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 Note + */ +export class VCardNoteComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLTextAreaElement} */ + textAreaEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("note", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-note"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.textAreaEl = this.querySelector("textarea"); + this.textAreaEl.addEventListener("input", () => { + this.resizeTextAreaEl(); + }); + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-note").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.textAreaEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.textAreaEl.value = this.vCardPropertyEntry.value; + this.resizeTextAreaEl(); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.textAreaEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + resizeTextAreaEl() { + this.textAreaEl.rows = Math.min( + 15, + Math.max(5, this.textAreaEl.value.split("\n").length) + ); + } +} + +customElements.define("vcard-note", VCardNoteComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/org.mjs b/comm/mail/components/addrbook/content/vcard-edit/org.mjs new file mode 100644 index 0000000000..fb788c3043 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/org.mjs @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 TITLE + */ +export class VCardTitleComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + titleEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("title", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-title"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.titleEl = this.querySelector('input[name="title"]'); + this.assignIds(this.titleEl, this.querySelector('label[for="title"]')); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.vCardPropertyEntry = null; + this.titleEl = null; + } + } + + fromVCardPropertyEntryToUI() { + this.titleEl.value = this.vCardPropertyEntry.value || ""; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.titleEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } +} +customElements.define("vcard-title", VCardTitleComponent); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ROLE + */ +export class VCardRoleComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + roleEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("role", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-role"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.roleEl = this.querySelector('input[name="role"]'); + this.assignIds(this.roleEl, this.querySelector('label[for="role"]')); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.vCardPropertyEntry = null; + this.roleEl = null; + } + } + + fromVCardPropertyEntryToUI() { + this.roleEl.value = this.vCardPropertyEntry.value || ""; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.roleEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } + + assignIds(inputEl, labelEl) { + let labelInputId = vCardIdGen.next().value; + inputEl.id = labelInputId; + labelEl.htmlFor = labelInputId; + } +} +customElements.define("vcard-role", VCardRoleComponent); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 ORG + */ +export class VCardOrgComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + /** @type {HTMLInputElement} */ + orgEl; + /** @type {HTMLInputElement} */ + unitEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("org", {}, "text", ["", ""]); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-org"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.orgEl = this.querySelector('input[name="org"]'); + this.orgEl.id = vCardIdGen.next().value; + this.querySelector('label[for="org"]').htmlFor = this.orgEl.id; + + this.unitEl = this.querySelector('input[name="orgUnit"]'); + this.unitEl.id = vCardIdGen.next().value; + this.querySelector('label[for="orgUnit"]').htmlFor = this.unitEl.id; + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + let values = this.vCardPropertyEntry.value; + if (!values) { + this.orgEl.value = ""; + this.unitEl.value = ""; + return; + } + if (!Array.isArray(values)) { + values = [values]; + } + this.orgEl.value = values.shift() || ""; + // In case data had more levels of units, just pull them together. + this.unitEl.value = values.join(", "); + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = [this.orgEl.value.trim()]; + if (this.unitEl.value.trim()) { + this.vCardPropertyEntry.value.push(this.unitEl.value.trim()); + } + } + + valueIsEmpty() { + return ( + !this.vCardPropertyEntry.value || + (Array.isArray(this.vCardPropertyEntry.value) && + this.vCardPropertyEntry.value.every(v => v === "")) + ); + } +} +customElements.define("vcard-org", VCardOrgComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs new file mode 100644 index 0000000000..17c7df493b --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs @@ -0,0 +1,269 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +/** + * ANNIVERSARY and BDAY both have a cardinality of + * 1 ("Exactly one instance per vCard MAY be present."). + * + * For Anniversary we changed the cardinality to + * ("One or more instances per vCard MAY be present.")". + * + * @implements {VCardPropertyEntryView} + * @see RFC6350 ANNIVERSARY and BDAY + */ +export class VCardSpecialDateComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLSelectElement} */ + selectEl; + /** @type {HTMLInputElement} */ + year; + /** @type {HTMLSelectElement} */ + month; + /** @type {HTMLSelectElement} */ + day; + + /** + * Object containing the available days for each month. + * + * @type {object} + */ + monthDays = { + 1: 31, + 2: 28, + 3: 31, + 4: 30, + 5: 31, + 6: 30, + 7: 31, + 8: 31, + 9: 30, + 10: 31, + 11: 30, + 12: 31, + }; + + static newAnniversaryVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("anniversary", {}, "date", ""); + } + + static newBdayVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("bday", {}, "date", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById( + "template-vcard-edit-bday-anniversary" + ); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.selectEl = this.querySelector(".vcard-type-selection"); + let selectId = vCardIdGen.next().value; + this.selectEl.id = selectId; + this.querySelector(".vcard-type-label").htmlFor = selectId; + + this.selectEl.addEventListener("change", event => { + this.dispatchEvent( + VCardSpecialDateComponent.ChangeVCardPropertyEntryEvent( + event.target.value + ) + ); + }); + + this.month = this.querySelector("#month"); + let monthId = vCardIdGen.next().value; + this.month.id = monthId; + this.querySelector('label[for="month"]').htmlFor = monthId; + this.month.addEventListener("change", () => { + this.fillDayOptions(); + }); + + this.day = this.querySelector("#day"); + let dayId = vCardIdGen.next().value; + this.day.id = dayId; + this.querySelector('label[for="day"]').htmlFor = dayId; + + this.year = this.querySelector("#year"); + let yearId = vCardIdGen.next().value; + this.year.id = yearId; + this.querySelector('label[for="year"]').htmlFor = yearId; + this.year.addEventListener("input", () => { + this.fillDayOptions(); + }); + + document.l10n.formatValues([{ id: "vcard-date-year" }]).then(yearLabel => { + this.year.placeholder = yearLabel; + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fillMonthOptions(); + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.selectEl.value = this.vCardPropertyEntry.name; + if (this.vCardPropertyEntry.type === "text") { + // TODO: support of text type for special-date + this.hidden = true; + return; + } + // Default value is date-and-or-time. + let dateValue; + try { + dateValue = ICAL.VCardTime.fromDateAndOrTimeString( + this.vCardPropertyEntry.value || "", + "date-and-or-time" + ); + } catch (ex) { + console.error(ex); + } + // Always set the month first since that controls the available days. + this.month.value = dateValue?.month || ""; + this.fillDayOptions(); + this.day.value = dateValue?.day || ""; + this.year.value = dateValue?.year || ""; + } + + fromUIToVCardPropertyEntry() { + if (this.vCardPropertyEntry.type === "text") { + // TODO: support of text type for special-date + return; + } + // Default value is date-and-or-time. + let dateValue = new ICAL.VCardTime({}, null, "date"); + // Set the properties directly instead of using the VCardTime + // constructor argument, which causes null values to become 0. + dateValue.year = this.year.value ? Number(this.year.value) : null; + dateValue.month = this.month.value ? Number(this.month.value) : null; + dateValue.day = this.day.value ? Number(this.day.value) : null; + this.vCardPropertyEntry.value = dateValue.toString(); + } + + valueIsEmpty() { + return !this.year.value && !this.month.value && !this.day.value; + } + + /** + * @param {"bday" | "anniversary"} entryName + * @returns {CustomEvent} + */ + static ChangeVCardPropertyEntryEvent(entryName) { + return new CustomEvent("vcard-bday-anniversary-change", { + detail: { + name: entryName, + }, + bubbles: true, + }); + } + + /** + * Check if the specified year is a leap year in order to add or remove the + * extra day to February. + * + * @returns {boolean} True if the currently specified year is a leap year, + * or if no valid year value is available. + */ + isLeapYear() { + // If the year is empty, we can't know if it's a leap year so must assume + // it is. Otherwise year-less dates can't show Feb 29. + if (!this.year.checkValidity() || this.year.value === "") { + return true; + } + + let year = parseInt(this.year.value); + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + } + + fillMonthOptions() { + let formatter = Intl.DateTimeFormat(undefined, { month: "long" }); + for (let m = 1; m <= 12; m++) { + let option = document.createElement("option"); + option.setAttribute("value", m); + option.setAttribute("label", formatter.format(new Date(2000, m - 1, 2))); + this.month.appendChild(option); + } + } + + /** + * Update the Day select element to reflect the available days of the selected + * month. + */ + fillDayOptions() { + let prevDay = 0; + // Save the previously selected day if we have one. + if (this.day.childNodes.length > 1) { + prevDay = this.day.value; + } + + // Always clear old options. + let defaultOption = document.createElement("option"); + defaultOption.value = ""; + document.l10n + .formatValues([{ id: "vcard-date-day" }]) + .then(([dayLabel]) => { + defaultOption.textContent = dayLabel; + }); + this.day.replaceChildren(defaultOption); + + let monthValue = this.month.value || 1; + // Add a day to February if this is a leap year and we're in February. + if (monthValue === "2") { + this.monthDays["2"] = this.isLeapYear() ? 29 : 28; + } + + let formatter = Intl.DateTimeFormat(undefined, { day: "numeric" }); + for (let d = 1; d <= this.monthDays[monthValue]; d++) { + let option = document.createElement("option"); + option.setAttribute("value", d); + option.setAttribute("label", formatter.format(new Date(2000, 0, d))); + this.day.appendChild(option); + } + // Reset the previously selected day, if it's available in the currently + // selected month. + this.day.value = prevDay <= this.monthDays[monthValue] ? prevDay : ""; + } + + /** + * @param {boolean} options.hasBday + */ + birthdayAvailability(options) { + if (this.vCardPropertyEntry.name === "bday") { + return; + } + Array.from(this.selectEl.options).forEach(option => { + if (option.value === "bday") { + option.disabled = options.hasBday; + } + }); + } +} + +customElements.define("vcard-special-date", VCardSpecialDateComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/tel.mjs b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs new file mode 100644 index 0000000000..a5eb30c6d5 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 TEL + * + * @TODO missing type-param-tel support. + * "text, voice, video, textphone" + */ +export class VCardTelComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + inputElement; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("tel", {}, "text", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-tel"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.inputElement = this.querySelector('input[type="text"]'); + let urlId = vCardIdGen.next().value; + this.inputElement.id = urlId; + let urlLabel = this.querySelector('label[for="text"]'); + urlLabel.htmlFor = urlId; + document.l10n.setAttributes(urlLabel, "vcard-tel-label"); + this.inputElement.type = "tel"; + + // Create the tel type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + propertyType: "tel", + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.inputElement.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.inputElement.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-tel", VCardTelComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/tz.mjs b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs new file mode 100644 index 0000000000..cf77114db6 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "cal", + "resource:///modules/calendar/calUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 URL + */ +export class VCardTZComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLSelectElement} */ + selectEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("tz", {}, "text", ""); + } + + constructor() { + super(); + let template = document.getElementById("template-vcard-edit-tz"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + } + + connectedCallback() { + if (this.isConnected) { + this.selectEl = this.querySelector("select"); + for (let tzid of lazy.cal.timezoneService.timezoneIds) { + let option = this.selectEl.appendChild( + document.createElement("option") + ); + option.value = tzid; + option.textContent = + lazy.cal.timezoneService.getTimezone(tzid).displayName; + } + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + document.getElementById("vcard-add-tz").hidden = false; + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + } + + disconnectedCallback() { + if (!this.isConnected) { + this.selectEl = null; + this.vCardPropertyEntry = null; + } + } + + fromVCardPropertyEntryToUI() { + this.selectEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.selectEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-tz", VCardTZComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/url.mjs b/comm/mail/components/addrbook/content/vcard-edit/url.mjs new file mode 100644 index 0000000000..98a1b42951 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/url.mjs @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { vCardIdGen } from "./id-gen.mjs"; + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "VCardPropertyEntry", + "resource:///modules/VCardUtils.jsm" +); + +/** + * @implements {VCardPropertyEntryView} + * @see RFC6350 URL + */ +export class VCardURLComponent extends HTMLElement { + /** @type {VCardPropertyEntry} */ + vCardPropertyEntry; + + /** @type {HTMLInputElement} */ + urlEl; + + static newVCardPropertyEntry() { + return new lazy.VCardPropertyEntry("url", {}, "uri", ""); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + let template = document.getElementById("template-vcard-edit-type-text"); + let clonedTemplate = template.content.cloneNode(true); + this.appendChild(clonedTemplate); + + this.urlEl = this.querySelector('input[type="text"]'); + let urlId = vCardIdGen.next().value; + this.urlEl.id = urlId; + let urlLabel = this.querySelector('label[for="text"]'); + urlLabel.htmlFor = urlId; + this.urlEl.type = "url"; + document.l10n.setAttributes(urlLabel, "vcard-url-label"); + + this.urlEl.addEventListener("input", () => { + // Auto add https:// if the url is missing scheme. + if ( + this.urlEl.value.length > "https://".length && + !/^https?:\/\//.test(this.urlEl.value) + ) { + this.urlEl.value = "https://" + this.urlEl.value; + } + }); + + // Create the url type selection. + this.vCardType = this.querySelector("vcard-type"); + this.vCardType.createTypeSelection(this.vCardPropertyEntry, { + createLabel: true, + }); + + this.querySelector(".remove-property-button").addEventListener( + "click", + () => { + this.dispatchEvent( + new CustomEvent("vcard-remove-property", { bubbles: true }) + ); + this.remove(); + } + ); + + this.fromVCardPropertyEntryToUI(); + } + + fromVCardPropertyEntryToUI() { + this.urlEl.value = this.vCardPropertyEntry.value; + } + + fromUIToVCardPropertyEntry() { + this.vCardPropertyEntry.value = this.urlEl.value; + } + + valueIsEmpty() { + return this.vCardPropertyEntry.value === ""; + } +} + +customElements.define("vcard-url", VCardURLComponent); diff --git a/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml new file mode 100644 index 0000000000..56d53f57f1 --- /dev/null +++ b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml @@ -0,0 +1,398 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<!-- Styles --> +<link rel="stylesheet" href="chrome://messenger/skin/vcard.css" /> + +<!-- Scripts --> +<script type="module" src="chrome://messenger/content/addressbook/edit/edit.mjs"></script> + +<!-- Localization --> +<link rel="localization" href="messenger/addressbook/vcard.ftl" /> + +<!-- Edit View --> +<template id="template-addr-book-edit"> + <!-- Name --> + <fieldset id="addr-book-edit-n" class="addr-book-edit-fieldset fieldset-reset"> + <legend class="screen-reader-only" data-l10n-id="vcard-name-header"/> + <div class="addr-book-edit-display-nickname"> + </div> + </fieldset> + <fieldset id="addr-book-edit-email" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-email-header"/> + <table> + <thead> + <tr> + <th id="addr-book-edit-email-type" scope="col"> + <!-- NOTE: We use the <span> so we can apply the screen-reader-only + - class to the <span> rather than the <th> element. If we apply + - the class to the <th> element directly it causes problems with + - Orca's "browse mode" table navigation. See bug 1776644. --> + <span class="screen-reader-only" + data-l10n-id="vcard-entry-type-label"> + </span> + </th> + <th id="addr-book-edit-email-label" scope="col"> + <span class="screen-reader-only" + data-l10n-id="vcard-email-label"> + </span> + </th> + <th id="addr-book-edit-email-default" scope="col"> + <span data-l10n-id="vcard-primary-email-label"></span> + </th> + </tr> + </thead> + <tbody id="vcard-email"></tbody> + </table> + <button id="vcard-add-email" + data-l10n-id="vcard-email-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- URL --> + <fieldset id="addr-book-edit-url" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-url-header"/> + <button id="vcard-add-url" + data-l10n-id="vcard-url-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Address --> + <fieldset id="addr-book-edit-address" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-adr-header"/> + <button id="vcard-add-adr" + data-l10n-id="vcard-adr-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Tel --> + <fieldset id="addr-book-edit-tel" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-tel-header"/> + <button id="vcard-add-tel" + data-l10n-id="vcard-tel-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Time Zone --> + <fieldset id="addr-book-edit-tz" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-tz-header"/> + <button id="vcard-add-tz" + data-l10n-id="vcard-tz-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- IMPP (Chat) --> + <fieldset id="addr-book-edit-impp" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-impp2-header"/> + <button id="vcard-add-impp" + data-l10n-id="vcard-impp-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Birthday and Anniversary (Special dates) --> + <fieldset id="addr-book-edit-bday-anniversary" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-bday-anniversary-header"/> + <button id="vcard-add-bday-anniversary" + data-l10n-id="vcard-bday-anniversary-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Notes --> + <fieldset id="addr-book-edit-note" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-note-header"/> + <button id="vcard-add-note" + data-l10n-id="vcard-note-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> + <!-- Organization Info --> + <fieldset id="addr-book-edit-org" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-org-header"/> + <button id="vcard-add-org" + data-l10n-id="vcard-org-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button" + hidden="hidden"></button> + </fieldset> + <!-- Custom --> + <fieldset id="addr-book-edit-custom" class="addr-book-edit-fieldset fieldset-reset"> + <legend data-l10n-id="vcard-custom-header"/> + <button id="vcard-add-custom" + data-l10n-id="vcard-custom-add" + class="addr-book-edit-fieldset-button add-property-button icon-button" + type="button"></button> + </fieldset> +</template> + +<!-- Individual fields --> + +<!-- N field --> +<template id="template-vcard-edit-n"> + <div id="n-list-component-prefix" class="n-list-component"> + <label for="vcard-n-prefix" data-l10n-id="vcard-n-prefix" /> + <input id="vcard-n-prefix" + type="text" + autocomplete="off" /> + <button class="primary" data-l10n-id="vcard-n-add-prefix" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + </div> + <div id="n-list-component-firstname" class="n-list-component"> + <label for="vcard-n-firstname" data-l10n-id="vcard-n-firstname" /> + <input id="vcard-n-firstname" + type="text" + autocomplete="off" /> + </div> + <div id="n-list-component-middlename" class="n-list-component"> + <label for="vcard-n-middlename" data-l10n-id="vcard-n-middlename" /> + <input id="vcard-n-middlename" + type="text" + autocomplete="off" /> + <button class="primary" data-l10n-id="vcard-n-add-middlename" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + </div> + <div id="n-list-component-lastname" class="n-list-component"> + <label for="vcard-n-lastname" data-l10n-id="vcard-n-lastname" /> + <input id="vcard-n-lastname" + type="text" + autocomplete="off" /> + </div> + <div id="n-list-component-suffix" class="n-list-component"> + <label for="vcard-n-suffix" data-l10n-id="vcard-n-suffix" /> + <button class="primary" data-l10n-id="vcard-n-add-suffix" + type="button"> + <img src="chrome://global/skin/icons/add.svg" alt="" /> + </button> + <input id="vcard-n-suffix" + type="text" + autocomplete="off" /> + </div> +</template> + +<!-- FN field. --> +<template id="template-vcard-edit-fn"> + <label for="vCardDisplayName" data-l10n-id="vcard-displayname"></label> + <input id="vCardDisplayName" type="text"/> + <label id="vCardDisplayNameCheckbox" class="vcard-checkbox"> + <!-- There is no l10n ID on this element because the vCard edit form is + also used in other sections that don't use this checkbox and don't have + access to the fluent string. The string is added when needed by the + address book edit.js file. --> + <input type="checkbox" id="vCardPreferDisplayName" checked="checked" /> + <!-- SPAN element needed for fluent string. --> + <span></span> + </label> +</template> + +<!-- NICKNAME field. --> +<template id="template-vcard-edit-nickname"> + <label for="vCardNickName" data-l10n-id="vcard-nickname"></label> + <input id="vCardNickName" type="text"/> +</template> + +<!-- Email --> +<template id="template-vcard-edit-email"> + <td> + <vcard-type></vcard-type> + </td> + <td class="email-column"> + <input type="email" + aria-labelledby="addr-book-edit-email-label" /> + </td> + <td class="default-column"> + <input type="checkbox" + aria-labelledby="addr-book-edit-email-default" /> + </td> + <td> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> + </td> +</template> + +<!-- Phone --> +<template id="template-vcard-edit-tel"> + <vcard-type></vcard-type> + <label class="screen-reader-only" for="text"/> + <input type="text"/> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Field with type and text --> +<template id="template-vcard-edit-type-text"> + <vcard-type></vcard-type> + <label class="screen-reader-only" for="text"/> + <input type="text"/> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Time Zone --> +<template id="template-vcard-edit-tz"> + <select> + <option value=""></option> + </select> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- IMPP --> +<template id="template-vcard-edit-impp"> + <label class="screen-reader-only" for="protocol" data-l10n-id="vcard-impp-select"></label> + <select name="protocol" class="vcard-type-selection"> + <option value="matrix:u/john:example.org" data-pattern="matrix:.+/.+:.+">Matrix</option> + <option value="xmpp:john@example.org" data-pattern="xmpp:.+@.+">XMPP</option> + <option value="ircs://irc.example.org/john,isuser" data-pattern="ircs?://.+/.+,.+">IRC</option> + <option value="sip:1-555-123-4567@voip.example.org" data-pattern="sip:.+@.+">SIP</option> + <option value="skype:johndoe" data-pattern="skype:[A-Za-z\d\-\._]{6,32}">Skype</option> + <option value="" data-l10n-id="vcard-impp-option-other" data-pattern="..+:..+"></option> + </select> + <label class="screen-reader-only" for="impp" data-l10n-id="vcard-impp-input-label"></label> + <input type="text" name="impp" pattern="..+:..+" /> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> +</template> + +<!-- Birthday and Anniversary --> +<template id="template-vcard-edit-bday-anniversary"> + <label class="vcard-type-label screen-reader-only" + data-l10n-id="vcard-entry-type-label"></label> + <select class="vcard-type-selection"> + <option value="bday" data-l10n-id="vcard-bday-label" selected="selected"/> + <option value="anniversary" data-l10n-id="vcard-anniversary-label"/> + </select> + + <div class="vcard-year-month-day-container"> + <label class="screen-reader-only" for="year" data-l10n-id="vcard-date-year"></label> + <input id="year" name="year" type="number" min="1000" max="9999" pattern="[0-9]{4}" class="size5" /> + + <label class="screen-reader-only" for="month" data-l10n-id="vcard-date-month"></label> + <select id="month" name="month" class="vcard-month-select"> + <option value="" data-l10n-id="vcard-date-month" selected="selected"></option> + </select> + + <label class="screen-reader-only" for="day" data-l10n-id="vcard-date-day"></label> + <select id="day" name="day" class="vcard-day-select"> + <option value="" data-l10n-id="vcard-date-day" selected="selected"></option> + </select> + + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button-title"></button> + </div> +</template> + +<!-- Address --> +<template id="template-vcard-edit-adr"> + <fieldset class="fieldset-grid fieldset-reset"> + <legend class="screen-reader-only" data-l10n-id="vcard-adr-label"/> + <vcard-type></vcard-type> + <div class="vcard-adr-inputs"> + <label for="street" data-l10n-id="vcard-adr-street"/> + <textarea name="street"></textarea> + </div> + <div class="vcard-adr-inputs"> + <label for="locality" data-l10n-id="vcard-adr-locality"/> + <input type="text" name="locality"/> + </div> + <div class="vcard-adr-inputs"> + <label for="region" data-l10n-id="vcard-adr-region"/> + <input type="text" name="region"/> + </div> + <div class="vcard-adr-inputs"> + <label for="code" data-l10n-id="vcard-adr-code"/> + <input type="text" name="code"/> + </div> + <div class="vcard-adr-inputs"> + <label for="country" data-l10n-id="vcard-adr-country"/> + <input type="text" name="country"/> + </div> + </fieldset> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- Notes --> +<template id="template-vcard-edit-note"> + <textarea></textarea> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<!-- Organization Info --> +<template id="template-vcard-edit-title"> + <div class="vcard-adr-inputs"> + <label for="title" data-l10n-id="vcard-org-title"/> + <input type="text" data-l10n-id="vcard-org-title-input" name="title" /> + </div> +</template> +<template id="template-vcard-edit-role"> + <div class="vcard-adr-inputs"> + <label for="role" data-l10n-id="vcard-org-role"/> + <input type="text" data-l10n-id="vcard-org-role-input" name="role" /> + </div> +</template> +<template id="template-vcard-edit-org"> + <div class="vcard-adr-inputs"> + <label for="org" data-l10n-id="vcard-org-org" /> + <input type="text" name="org" data-l10n-id="vcard-org-org-input" /> + <label for="orgUnit" data-l10n-id="vcard-org-org-unit" class="screen-reader-only"/> + <input type="text" name="orgUnit" data-l10n-id="vcard-org-org-unit-input" /> + </div> +</template> + +<!-- Custom --> +<template id="template-vcard-edit-custom"> + <div class="vcard-adr-inputs"> + <label for="custom1"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom2"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom3"/> + <input type="text"/> + </div> + <div class="vcard-adr-inputs"> + <label for="custom4"/> + <input type="text"/> + </div> + <button type="button" + class="addr-book-edit-fieldset-button remove-property-button icon-button" + data-l10n-id="vcard-remove-button"></button> +</template> + +<template id="template-vcard-edit-type"> + <select class="vcard-type-selection"> + <option value="work" data-l10n-id="vcard-entry-type-work"/> + <option value="home" data-l10n-id="vcard-entry-type-home"/> + <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/> + </select> +</template> + +<template id="template-vcard-edit-type-tel"> + <select class="vcard-type-selection"> + <option value="work" data-l10n-id="vcard-entry-type-work"/> + <option value="home" data-l10n-id="vcard-entry-type-home"/> + <option value="cell" data-l10n-id="vcard-entry-type-cell"/> + <option value="fax" data-l10n-id="vcard-entry-type-fax"/> + <option value="pager" data-l10n-id="vcard-entry-type-pager"/> + <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/> + </select> +</template> |