summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/compose/content/addressingWidgetOverlay.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/compose/content/addressingWidgetOverlay.js')
-rw-r--r--comm/mail/components/compose/content/addressingWidgetOverlay.js1336
1 files changed, 1336 insertions, 0 deletions
diff --git a/comm/mail/components/compose/content/addressingWidgetOverlay.js b/comm/mail/components/compose/content/addressingWidgetOverlay.js
new file mode 100644
index 0000000000..cee4b6889e
--- /dev/null
+++ b/comm/mail/components/compose/content/addressingWidgetOverlay.js
@@ -0,0 +1,1336 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 MsgComposeCommands.js */
+/* import-globals-from ../../addrbook/content/abCommon.js */
+/* globals goDoCommand */ // From globalOverlay.js
+
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+
+// Temporarily prevent repeated deletion key events in address rows or subject.
+// Prevent the keyboard shortcut for removing an empty address row (long
+// Backspace or Delete keypress) from affecting another row. Also, when a long
+// deletion keypress has just removed all text or all visible text from a row
+// input, prevent the ongoing keypress from removing the row.
+var gPreventRowDeletionKeysRepeat = false;
+
+/**
+ * Convert all the written recipients into string and store them into the
+ * msgCompFields array to be printed in the message header.
+ *
+ * @param {object} msgCompFields - An object to receive the recipients.
+ */
+function Recipients2CompFields(msgCompFields) {
+ if (!msgCompFields) {
+ throw new Error(
+ "Message Compose Error: msgCompFields is null (ExtractRecipients)"
+ );
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+ for (let row of document.querySelectorAll(".address-row-raw")) {
+ let recipientType = row.dataset.recipienttype;
+ let headerValue = row.querySelector(".address-row-input").value.trim();
+ if (headerValue) {
+ msgCompFields.setRawHeader(recipientType, headerValue);
+ } else if (otherHeaders.includes(recipientType)) {
+ msgCompFields.deleteHeader(recipientType);
+ }
+ }
+
+ let getRecipientList = recipientType =>
+ Array.from(
+ document.querySelectorAll(
+ `.address-row[data-recipienttype="${recipientType}"] mail-address-pill`
+ ),
+ pill => {
+ // Expect each pill to contain exactly one address.
+ let { name, email } = MailServices.headerParser.makeFromDisplayAddress(
+ pill.fullAddress
+ )[0];
+ return MailServices.headerParser.makeMimeAddress(name, email);
+ }
+ ).join(",");
+
+ msgCompFields.to = getRecipientList("addr_to");
+ msgCompFields.cc = getRecipientList("addr_cc");
+ msgCompFields.bcc = getRecipientList("addr_bcc");
+ msgCompFields.replyTo = getRecipientList("addr_reply");
+ msgCompFields.newsgroups = getRecipientList("addr_newsgroups");
+ msgCompFields.followupTo = getRecipientList("addr_followup");
+}
+
+/**
+ * Replace the specified address row's pills with new ones generated by the
+ * given header value. The address row will be automatically shown if the header
+ * value is non-empty.
+ *
+ * @param {string} rowId - The id of the address row to set.
+ * @param {string} headerValue - The headerValue to create pills from.
+ * @param {boolean} multi - If the headerValue contains potentially multiple
+ * addresses and needs to be parsed to extract them.
+ * @param {boolean} [forceShow=false] - Whether to show the row, even if the
+ * given value is empty.
+ */
+function setAddressRowFromCompField(
+ rowId,
+ headerValue,
+ multi,
+ forceShow = false
+) {
+ let row = document.getElementById(rowId);
+ addressRowClearPills(row);
+
+ let value = multi
+ ? MailServices.headerParser.parseEncodedHeaderW(headerValue).join(", ")
+ : headerValue;
+
+ if (value || forceShow) {
+ addressRowSetVisibility(row, true);
+ }
+ if (value) {
+ let input = row.querySelector(".address-row-input");
+ input.value = value;
+ recipientAddPills(input, true);
+ }
+}
+
+/**
+ * Convert all the recipients coming from a message header into pills.
+ *
+ * @param {object} msgCompFields - An object containing all the recipients. If
+ * any property is not a string, it is ignored.
+ */
+function CompFields2Recipients(msgCompFields) {
+ if (msgCompFields) {
+ // Populate all the recipients with the proper values.
+ if (typeof msgCompFields.replyTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowReply",
+ msgCompFields.replyTo,
+ true
+ );
+ }
+
+ if (typeof msgCompFields.to == "string") {
+ setAddressRowFromCompField("addressRowTo", msgCompFields.to, true);
+ }
+
+ if (typeof msgCompFields.cc == "string") {
+ setAddressRowFromCompField(
+ "addressRowCc",
+ msgCompFields.cc,
+ true,
+ gCurrentIdentity.doCc
+ );
+ }
+
+ if (typeof msgCompFields.bcc == "string") {
+ setAddressRowFromCompField(
+ "addressRowBcc",
+ msgCompFields.bcc,
+ true,
+ gCurrentIdentity.doBcc
+ );
+ }
+
+ if (typeof msgCompFields.newsgroups == "string") {
+ setAddressRowFromCompField(
+ "addressRowNewsgroups",
+ msgCompFields.newsgroups,
+ false
+ );
+ }
+
+ if (typeof msgCompFields.followupTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowFollowup",
+ msgCompFields.followupTo,
+ true
+ );
+ }
+
+ // Add the sender to our spell check ignore list.
+ if (gCurrentIdentity) {
+ addRecipientsToIgnoreList(gCurrentIdentity.fullAddress);
+ }
+
+ // Trigger this method only after all the pills have been created.
+ onRecipientsChanged(true);
+ }
+}
+
+/**
+ * Update the recipients area UI to show News related fields and hide
+ * Mail related fields.
+ */
+function updateUIforNNTPAccount() {
+ // Hide the `mail-primary-input` field row if no pills have been created.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-container");
+ if (mailContainer.querySelectorAll("mail-address-pill").length == 0) {
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button")
+ .click();
+ }
+
+ // Show the closing label.
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button").hidden = false;
+
+ // Show the `news-primary-input` field row if not already visible.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(newsContainer.id);
+
+ // Hide the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Prefer showing the buttons for news-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Update the recipients area UI to show Mail related fields and hide
+ * News related fields. This method is called only if the UI was previously
+ * updated to accommodate a News account type.
+ */
+function updateUIforMailAccount() {
+ // Show the `mail-primary-input` field row if not already visible.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(mailContainer.id);
+
+ // Hide the closing label.
+ mailContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Hide the `news-primary-input` field row if no pills have been created.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ if (newsContainer.querySelectorAll("mail-address-pill").length == 0) {
+ newsContainer.querySelector(".remove-field-button").click();
+ }
+
+ // Show the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = false;
+
+ // Prefer showing the buttons for mail-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Remove recipient pills from a specific addressing field based on full address
+ * matching. This is commonly used to clear previous Auto-CC/BCC recipients when
+ * loading a new identity.
+ *
+ * @param {object} msgCompFields - gMsgCompose.compFields, for helper functions.
+ * @param {string} recipientType - The type of recipients to remove,
+ * e.g. "addr_to" (recipient label id).
+ * @param {string} recipientsList - Comma-separated string containing recipients
+ * to be removed. May contain display names, and other commas therein. We only
+ * remove first exact match (full address).
+ */
+function awRemoveRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!recipientType || !recipientsList) {
+ return;
+ }
+
+ let container;
+ switch (recipientType) {
+ case "addr_cc":
+ container = document.getElementById("ccAddrContainer");
+ break;
+ case "addr_bcc":
+ container = document.getElementById("bccAddrContainer");
+ break;
+ case "addr_reply":
+ container = document.getElementById("replyAddrContainer");
+ break;
+ case "addr_to":
+ container = document.getElementById("toAddrContainer");
+ break;
+ }
+
+ // Convert csv string of recipients to be deleted into full addresses array.
+ let recipientsArray = msgCompFields.splitRecipients(recipientsList, false);
+
+ // Remove first instance of specified recipients from specified container.
+ for (let recipientFullAddress of recipientsArray) {
+ let pill = container.querySelector(
+ `mail-address-pill[fullAddress="${recipientFullAddress}"]`
+ );
+ if (pill) {
+ pill.remove();
+ }
+ }
+
+ let addressRow = container.closest(`.address-row`);
+
+ // Remove entire address row if empty, no user input, and not type "addr_to".
+ if (
+ recipientType != "addr_to" &&
+ !container.querySelector(`mail-address-pill`) &&
+ !container.querySelector(`input[is="autocomplete-input"]`).value
+ ) {
+ addressRowSetVisibility(addressRow, false);
+ }
+
+ updateAriaLabelsOfAddressRow(addressRow);
+}
+
+/**
+ * Adds a batch of new rows matching recipientType and drops in the list of addresses.
+ *
+ * @param msgCompFields A nsIMsgCompFields object that is only used as a helper,
+ * it will not get the addresses appended.
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ * @param recipientList A string of addresses to add.
+ */
+function awAddRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!msgCompFields || !recipientsList) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(
+ document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ msgCompFields.splitRecipients(recipientsList, false)
+ );
+}
+
+/**
+ * Adds a batch of new recipient pill matching recipientType and drops in the
+ * array of addresses.
+ *
+ * @param {Element} row - The row to add the addresses to.
+ * @param {string[]} addressArray - Recipient addresses (strings) to add.
+ * @param {boolean=false} select - If the newly generated pills should be
+ * selected.
+ */
+function addressRowAddRecipientsArray(row, addressArray, select = false) {
+ let addresses = [];
+ for (let addr of addressArray) {
+ addresses.push(...MailServices.headerParser.makeFromDisplayAddress(addr));
+ }
+
+ if (row.classList.contains("hidden")) {
+ showAndFocusAddressRow(row.id, true);
+ }
+
+ let recipientArea = document.getElementById("recipientsContainer");
+ let input = row.querySelector(".address-row-input");
+ for (let address of addresses) {
+ let pill = recipientArea.createRecipientPill(input, address);
+ if (select) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ row
+ .querySelector(".address-container")
+ .classList.add("addressing-field-edited");
+
+ // Add the recipients to our spell check ignore list.
+ addRecipientsToIgnoreList(addressArray.join(", "));
+ updateAriaLabelsOfAddressRow(row);
+
+ if (row.id != "addressRowReply") {
+ onRecipientsChanged();
+ }
+}
+
+/**
+ * Find the autocomplete input when an address is dropped in the compose header.
+ *
+ * @param {XULElement} target - The element where an address was dropped.
+ * @param {string} recipient - The email address dragged by the user.
+ */
+function DropRecipient(target, recipient) {
+ let row;
+ if (target.classList.contains("address-row")) {
+ row = target;
+ } else if (target.dataset.addressRow) {
+ row = document.getElementById(target.dataset.addressRow);
+ } else {
+ row = target.closest(".address-row");
+ }
+ if (!row || row.classList.contains("address-row-raw")) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(row, [recipient]);
+}
+
+// Returns the load context for the current window
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+/**
+ * Focus the next available address row's input. Otherwise, focus the "Subject"
+ * input.
+ *
+ * @param {Element} currentInput - The current input to search from.
+ */
+function focusNextAddressRow(currentInput) {
+ let addressRow = currentInput.closest(".address-row").nextElementSibling;
+ while (addressRow) {
+ if (focusAddressRowInput(addressRow)) {
+ return;
+ }
+ addressRow = addressRow.nextElementSibling;
+ }
+ focusSubjectInput();
+}
+
+/**
+ * Handle keydown events for other header input fields in the compose window.
+ * Only applies to rows created from mail.compose.other.header pref; no pills.
+ * Keep behaviour in sync with addressInputOnBeforeHandleKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function otherHeaderInputOnKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case " ":
+ // If the existing input value is empty string or whitespace only,
+ // prevent entering space and clear whitespace-only input text.
+ if (!input.value.trim()) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+
+ case "Enter":
+ // Break if modifier keys were used, to prevent hijacking unrelated
+ // keyboard shortcuts like Ctrl/Cmd+[Shift]+Enter for sending.
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
+ break;
+ }
+
+ // Enter was pressed: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Backspace":
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated deletion keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated deletion keydown
+ // event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]") ||
+ event.altKey
+ ) {
+ // Break if it is not a long deletion keypress, input still has text,
+ // or cursor selection is not at position 0 while deleting whitespace,
+ // to allow regular text deletion before we remove the row.
+ // Also break for non-removable rows with hidden [x] button, and if Alt
+ // key is pressed, to avoid interfering with undo shortcut Alt+Backspace.
+ break;
+ }
+ // Prevent event and set flag to prevent further unwarranted deletion in
+ // the adjacent row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // deletion keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(
+ input,
+ event.key == "Backspace" ? "previous" : "next"
+ );
+ break;
+ }
+}
+
+/**
+ * Handle keydown events for autocomplete address inputs in the compose window.
+ * Does not apply to rows created from mail.compose.other.header pref, which are
+ * handled with a subset of this function in otherHeaderInputOnKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function addressInputOnBeforeHandleKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case "a":
+ // Break if there's text in the input, if not Ctrl/Cmd+A, or for other
+ // modifiers, to not hijack our own (Ctrl/Cmd+Shift+A) or OS shortcuts.
+ if (
+ input.value ||
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ break;
+ }
+
+ // Ctrl/Cmd+A on empty input: Select all pills of the current row.
+ // Prevent a pill keypress event when the focus moves on it.
+ event.preventDefault();
+
+ let lastPill = input
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type");
+ let mailRecipientsArea = input.closest("mail-recipients-area");
+ if (lastPill) {
+ // Select all pills of current address row.
+ mailRecipientsArea.selectSiblingPills(lastPill);
+ lastPill.focus();
+ break;
+ }
+ // No pills in the current address row, select all pills in all rows.
+ let lastPillGlobal = mailRecipientsArea.querySelector(
+ "mail-address-pill:last-of-type"
+ );
+ if (lastPillGlobal) {
+ mailRecipientsArea.selectAllPills();
+ lastPillGlobal.focus();
+ }
+ break;
+
+ case " ":
+ case ",":
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ break;
+ }
+
+ // Otherwise, comma may trigger pill creation.
+ if (event.key !== ",") {
+ break;
+ }
+
+ let beforeComma;
+ let afterComma;
+ if (input.selectionEnd == input.selectionStart) {
+ // If there is no selected text, we will try to create a pill for the
+ // text prior to the typed comma.
+ // NOTE: This also captures auto complete suggestions that are not
+ // inline. E.g. suggestion popup is shown and the user selects one with
+ // the arrow keys.
+ beforeComma = input.value.substring(0, input.selectionEnd);
+ afterComma = input.value.substring(input.selectionEnd);
+ // Only create a pill for valid addresses.
+ if (!isValidAddress(beforeComma)) {
+ break;
+ }
+ } else if (
+ // There is an auto complete suggestion ...
+ input.controller.searchStatus ==
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH &&
+ input.controller.matchCount &&
+ // that is also shown inline (the end of the input is selected).
+ input.selectionEnd == input.value.length
+ // NOTE: This should exclude cases where no suggestion is selected (user
+ // presses "DownArrow" then "UpArrow" when the suggestion pops up), or
+ // if the suggestions were cancelled with "Esc", or the inline
+ // suggestion was cleared with "Backspace".
+ ) {
+ if (input.value[input.selectionStart] == ",") {
+ // Don't create the pill in the special case where the auto-complete
+ // suggestion starts with a comma.
+ break;
+ }
+ // Complete the suggestion as a pill.
+ beforeComma = input.value;
+ afterComma = "";
+ } else {
+ // If any other part of the text is selected, we treat it as normal.
+ break;
+ }
+
+ event.preventDefault();
+ input.value = beforeComma;
+ input.handleEnter(event);
+ // Keep any left over text in the input.
+ input.value = afterComma;
+ // Keep the cursor at the same position.
+ input.selectionStart = 0;
+ input.selectionEnd = 0;
+ break;
+
+ case "Home":
+ case "ArrowLeft":
+ case "Backspace":
+ if (
+ event.key == "Backspace" &&
+ event.repeat &&
+ gPreventRowDeletionKeysRepeat
+ ) {
+ // Prevent repeated backspace keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion if Home or ArrowLeft were pressed, or if it is
+ // a non-repeated Backspace keydown event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ event.altKey
+ ) {
+ // Break and allow the key's default behavior if the row has content,
+ // or the cursor is not at position 0, or the Alt modifier is pressed.
+ break;
+ }
+ // Navigate into pills if there are any, and if the input is empty or
+ // whitespace-only, and the cursor is at position 0, and the Alt key was
+ // not used (prevent undo via Alt+Backspace from deleting pills).
+ // We'll sanitize whitespace on blur.
+
+ // Prevent a pill keypress event when the focus moves on it, or prevent
+ // deletion in previous row after removing current row via long keydown.
+ event.preventDefault();
+
+ let targetPill = input
+ .closest(".address-container")
+ .querySelector(
+ "mail-address-pill" + (event.key == "Home" ? "" : ":last-of-type")
+ );
+ if (targetPill) {
+ if (event.repeat) {
+ // Prevent navigating into pills for repeated keydown from the middle
+ // of whitespace.
+ break;
+ }
+ input
+ .closest("mail-recipients-area")
+ .checkKeyboardSelected(event, targetPill);
+ // Prevent removing the current row after deleting the last pill with
+ // repeated deletion keydown.
+ gPreventRowDeletionKeysRepeat = true;
+ break;
+ }
+
+ // No pill found, so the address row is empty except whitespace.
+ // Check for long Backspace keyboard shortcut to remove the row.
+ if (
+ event.key != "Backspace" ||
+ !event.repeat ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ break;
+ }
+ // Set flag to prevent further unwarranted deletion in the previous row,
+ // which will receive focus while the key is still down. We have already
+ // prevented the event above.
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // Backspace keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(input, "previous");
+ break;
+
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated Delete keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated Delete keydown event,
+ // or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-container")
+ .querySelector("mail-address-pill") ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ // Break and allow the key's default behaviour if the address row has
+ // content, or the cursor is not at position 0, or the row is not
+ // removable.
+ break;
+ }
+ // Prevent the event and set flag to prevent further unwarranted deletion
+ // in the next row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated Delete
+ // keydown event occurred, cursor is at position 0, and it has an
+ // [x] button for removal.
+ hideAddressRowFromWithin(input, "next");
+ break;
+
+ case "Enter":
+ // Break if unrelated modifier keys are used. The toolkit hack for Mac
+ // will consume metaKey, and we'll exclude shiftKey after that.
+ if (event.ctrlKey || event.altKey) {
+ break;
+ }
+
+ // MacOS-only variation necessary to send messages via Cmd+[Shift]+Enter
+ // since autocomplete input fields prevent that by default (bug 1682147).
+ if (event.metaKey) {
+ // Cmd+[Shift]+Enter: Send message [later].
+ let sendCmd = event.shiftKey ? "cmd_sendLater" : "cmd_sendWithCheck";
+ goDoCommand(sendCmd);
+ break;
+ }
+
+ // Break if there's text in the address input, or if Shift modifier is
+ // used, to prevent hijacking shortcuts like Ctrl+Shift+Enter.
+ if (input.value.trim() || event.shiftKey) {
+ break;
+ }
+
+ // Enter on empty input: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Tab":
+ // Return if the Alt or Cmd modifiers were pressed, meaning the user is
+ // switching between windows and not tabbing out of the address input.
+ if (event.altKey || event.metaKey) {
+ break;
+ }
+ // Trigger the autocomplete controller only if we have a value,
+ // to prevent interfering with the natural change of focus on Tab.
+ if (input.value.trim()) {
+ // Prevent Tab from firing again on address input after pill creation.
+ event.preventDefault();
+
+ // Use the setTimeout only if the input field implements a forced
+ // autocomplete and we don't have any match as we might need to wait for
+ // the autocomplete suggestions to show up.
+ if (input.forceComplete && input.mController.matchCount == 0) {
+ // Prevent fast user input to become an error pill before
+ // autocompletion kicks in with its default timeout.
+ setTimeout(() => {
+ input.handleEnter(event);
+ }, input.timeout);
+ } else {
+ input.handleEnter(event);
+ }
+ }
+
+ // Handle Shift+Tab, but not Ctrl+Shift+Tab, which is handled by
+ // moveFocusToNeighbouringAreas.
+ if (event.shiftKey && !event.ctrlKey) {
+ event.preventDefault();
+ input.closest("mail-recipients-area").moveFocusToPreviousElement(input);
+ }
+ break;
+ }
+}
+
+/**
+ * Handle input events for all types of address inputs in the compose window.
+ *
+ * @param {Event} event - A DOM input event.
+ * @param {boolean} rawInput - A flag for plain text inputs created via
+ * mail.compose.other.header, which do not have autocompletion and pills.
+ */
+function addressInputOnInput(event, rawInput) {
+ let input = event.target;
+
+ if (
+ !input.value ||
+ (!input.value.trim() &&
+ input.selectionStart + input.selectionEnd == 0 &&
+ event.inputType == "deleteContentBackward")
+ ) {
+ // Temporarily disable repeated deletion to prevent premature
+ // removal of the current row if input text has just become empty or
+ // whitespace-only with cursor at position 0 from backwards deletion.
+ gPreventRowDeletionKeysRepeat = true;
+ }
+
+ if (rawInput) {
+ // For raw inputs, we are done.
+ return;
+ }
+ // Now handling only autocomplete inputs.
+
+ // Trigger onRecipientsChanged() for every input text change in order
+ // to properly update the "Send" button and trigger the save as draft
+ // prompt even before the creation of any pill.
+ onRecipientsChanged();
+
+ // Change the min size of the input field on input change only if the
+ // current width is smaller than 80% of its container's width
+ // to prevent overflow.
+ if (
+ input.clientWidth <
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ document
+ .getElementById("recipientsContainer")
+ .resizeInputField(input, input.value.trim().length);
+ }
+}
+
+/**
+ * Add one or more <mail-address-pill> elements to the containing address row.
+ *
+ * @param {Element} input - Address input where "autocomplete-did-enter-text"
+ * was observed, and/or to whose containing address row pill(s) will be added.
+ * @param {boolean} [automatic=false] - Set to true if the change of recipients
+ * was invoked programmatically and should not be considered a change of
+ * message content.
+ */
+function recipientAddPills(input, automatic = false) {
+ if (!input.value.trim()) {
+ return;
+ }
+
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(input.value);
+ let recipientArea = document.getElementById("recipientsContainer");
+
+ for (let address of addresses) {
+ recipientArea.createRecipientPill(input, address);
+ }
+
+ // Add the just added recipient address(es) to the spellcheck ignore list.
+ addRecipientsToIgnoreList(input.value.trim());
+
+ // Reset the input element.
+ input.removeAttribute("nomatch");
+ input.setAttribute("size", 1);
+ input.value = "";
+
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur" event
+ // gets triggered.
+ input.detachController();
+ // If it was detached, attach it again to enable autocomplete.
+ if (!input.controller.input) {
+ input.attachController();
+ }
+
+ // Prevent triggering some methods if the pill creation was done automatically
+ // for example during the move of an existing pill between addressing fields.
+ if (!automatic) {
+ input
+ .closest(".address-container")
+ .classList.add("addressing-field-edited");
+ onRecipientsChanged();
+ }
+
+ updateAriaLabelsOfAddressRow(input.closest(".address-row"));
+}
+
+/**
+ * Remove all <mail-address-pill> elements from the containing address row.
+ *
+ * @param {Element} row - The address row to clear.
+ */
+function addressRowClearPills(row) {
+ for (let pill of row.querySelectorAll(
+ ".address-container mail-address-pill"
+ )) {
+ pill.remove();
+ }
+ updateAriaLabelsOfAddressRow(row);
+}
+
+/**
+ * Handle focus event of address inputs: Force a focused styling on the closest
+ * address container of the currently focused input element.
+ *
+ * @param {Element} input - The address input element receiving focus.
+ */
+function addressInputOnFocus(input) {
+ input.closest(".address-container").setAttribute("focused", "true");
+}
+
+/**
+ * Handle blur event of address inputs: Remove focused styling from the closest
+ * address container and create address pills if valid recipients were written.
+ *
+ * @param {Element} input - The input element losing focus.
+ */
+function addressInputOnBlur(input) {
+ input.closest(".address-container").removeAttribute("focused");
+
+ // If the input is still the active element after blur (when switching to
+ // another window), return to prevent autocompletion and pillification
+ // and let the user continue editing the address later where he left.
+ if (document.activeElement == input) {
+ return;
+ }
+
+ // For other headers aka raw input, trim and we are done.
+ if (input.getAttribute("is") != "autocomplete-input") {
+ input.value = input.value.trim();
+ return;
+ }
+
+ let address = input.value.trim();
+ if (!address) {
+ // If input is empty or whitespace only, clear input to remove any leftover
+ // whitespace, reset the input size, and return.
+ input.value = "";
+ input.setAttribute("size", 1);
+ return;
+ }
+
+ if (input.forceComplete && input.mController.matchCount >= 1) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ input.mController.handleEnter(true);
+ return;
+ }
+
+ // Otherwise, try to parse the input text as comma-separated recipients and
+ // convert them into recipient pills.
+ let listNames = MimeParser.parseHeaderField(
+ address,
+ MimeParser.HEADER_ADDRESS
+ );
+ let isMailingList =
+ listNames.length > 0 &&
+ MailServices.ab.mailListNameExists(listNames[0].name);
+
+ if (
+ address &&
+ (isValidAddress(address) ||
+ isMailingList ||
+ input.classList.contains("news-input"))
+ ) {
+ recipientAddPills(input);
+ }
+
+ // Trim any remaining input for which we didn't create a pill.
+ if (input.value.trim()) {
+ input.value = input.value.trim();
+ }
+}
+
+/**
+ * Trigger the startEditing() method of the mail-address-pill element.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ * @param {Event} event - The DOM event.
+ */
+function editAddressPill(element, event) {
+ document
+ .getElementById("recipientsContainer")
+ .startEditing(element.closest("mail-address-pill"), event);
+}
+
+/**
+ * Expands all the selected mailing list pills into their composite addresses.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ */
+function expandList(element) {
+ let pill = element.closest("mail-address-pill");
+ if (pill.isMailList) {
+ let addresses = [];
+ for (let currentPill of pill.parentNode.querySelectorAll(
+ "mail-address-pill"
+ )) {
+ if (currentPill == pill) {
+ let dir = MailServices.ab.getDirectory(pill.listURI);
+ if (dir) {
+ for (let card of dir.childCards) {
+ addresses.push(makeMailboxObjectFromCard(card));
+ }
+ }
+ } else {
+ addresses.push(currentPill.fullAddress);
+ }
+ }
+ let row = pill.closest(".address-row");
+ addressRowClearPills(row);
+ addressRowAddRecipientsArray(row, addresses, false);
+ }
+}
+
+/**
+ * Handle the disabling of context menu items according to the types and count
+ * of selected pills.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function onPillPopupShowing(event) {
+ let menu = event.target;
+ // Reset previously hidden menuitems.
+ for (let menuitem of menu.querySelectorAll(
+ ".pill-action-move, .pill-action-edit"
+ )) {
+ menuitem.hidden = false;
+ }
+
+ let recipientsContainer = document.getElementById("recipientsContainer");
+
+ // Check if the pill where the context menu was originated is not selected.
+ let pill = event.explicitOriginalTarget.closest("mail-address-pill");
+ if (!pill.hasAttribute("selected")) {
+ recipientsContainer.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+
+ let allSelectedPills = recipientsContainer.getAllSelectedPills();
+ // If more than one pill is selected, hide the editing item.
+ if (recipientsContainer.getAllSelectedPills().length > 1) {
+ menu.querySelector("#editAddressPill").hidden = true;
+ }
+
+ // Update the recipient type in the menu label of #menu_selectAllSiblingPills.
+ let type = pill
+ .closest(".address-row")
+ .querySelector(".address-label-container > label").value;
+ document.l10n.setAttributes(
+ menu.querySelector("#menu_selectAllSiblingPills"),
+ "pill-action-select-all-sibling-pills",
+ { type }
+ );
+
+ // Hide the `Expand List` menuitem and the preceding menuseparator if not all
+ // selected pills are mailing lists.
+ let isNotMailingList = [...allSelectedPills].some(pill => !pill.isMailList);
+ menu.querySelector("#expandList").hidden = isNotMailingList;
+ menu.querySelector("#pillContextBeforeExpandListSeparator").hidden =
+ isNotMailingList;
+
+ // If any Newsgroup or Followup pill is selected, hide all move actions.
+ if (
+ recipientsContainer.querySelector(
+ ":is(#addressRowNewsgroups, #addressRowFollowup) " +
+ "mail-address-pill[selected]"
+ )
+ ) {
+ for (let menuitem of menu.querySelectorAll(".pill-action-move")) {
+ menuitem.hidden = true;
+ }
+ // Hide the menuseparator before the move items, as there's nothing below.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = true;
+ return;
+ }
+ // Show the menuseparator before the move items as no Newsgroup or Followup
+ // pill is selected.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = false;
+
+ let selectedType = "";
+ // Check if all selected pills are in the same address row.
+ for (let row of recipientsContainer.querySelectorAll(
+ ".address-row:not(.hidden)"
+ )) {
+ // Check if there's at least one selected pill in the address row.
+ let selectedPill = row.querySelector("mail-address-pill[selected]");
+ if (!selectedPill) {
+ continue;
+ }
+ // Return if we already have a selectedType: More than one type selected.
+ if (selectedType) {
+ return;
+ }
+ selectedType = row.dataset.recipienttype;
+ }
+
+ // All selected pills are of the same type, hide the type's move action.
+ switch (selectedType) {
+ case "addr_to":
+ menu.querySelector("#moveAddressPillTo").hidden = true;
+ break;
+
+ case "addr_cc":
+ menu.querySelector("#moveAddressPillCc").hidden = true;
+ break;
+
+ case "addr_bcc":
+ menu.querySelector("#moveAddressPillBcc").hidden = true;
+ break;
+ }
+}
+
+/**
+ * Show the specified address row and focus its input. If showing the address
+ * row is disabled, the focus is not changed.
+ *
+ * @param {string} rowId - The id of the row to show.
+ */
+function showAndFocusAddressRow(rowId) {
+ let row = document.getElementById(rowId);
+ if (addressRowSetVisibility(row, true)) {
+ row.querySelector(".address-row-input").focus();
+ }
+}
+
+/**
+ * Set the visibility of an address row (Cc, Bcc, etc.).
+ *
+ * @param {Element} row - The address row.
+ * @param {boolean} [show=true] - Whether to show the row or hide it.
+ *
+ * @returns {boolean} - Whether the visibility was set.
+ */
+function addressRowSetVisibility(row, show) {
+ let menuItem = document.getElementById(row.dataset.showSelfMenuitem);
+ if (show && menuItem.hasAttribute("disabled")) {
+ return false;
+ }
+
+ // Show/hide the row and hide/show the menuitem or button
+ row.classList.toggle("hidden", !show);
+ showAddressRowMenuItemSetVisibility(menuItem, !show);
+ return true;
+}
+
+/**
+ * Set the visibility of a menu item that shows an address row.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} [show=true] - Whether to show the item or hide it.
+ */
+function showAddressRowMenuItemSetVisibility(menuItem, show) {
+ let buttonId = menuItem.dataset.buttonId;
+ let button = buttonId && document.getElementById(buttonId);
+ if (button && menuItem.dataset.preferButton == "true") {
+ button.hidden = !show;
+ // Make sure the menuItem is never shown.
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = !show;
+ if (button) {
+ button.hidden = true;
+ }
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Set whether a menu item that shows an address row should prefer being
+ * displayed as the button specified by its "data-button-id" attribute, if it
+ * has one.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} preferButton - Whether to prefer showing the button rather
+ * than the menu item.
+ */
+function showAddressRowMenuItemSetPreferButton(menuItem, preferButton) {
+ let buttonId = menuItem.dataset.buttonId;
+ if (!buttonId || menuItem.dataset.preferButton == String(preferButton)) {
+ return;
+ }
+ let button = document.getElementById(buttonId);
+
+ menuItem.dataset.preferButton = preferButton;
+ if (preferButton) {
+ button.hidden = menuItem.hidden;
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = button.hidden;
+ button.hidden = true;
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Hide or show the menu button for the extra recipients based on the current
+ * hidden status of menuitems and buttons.
+ */
+function updateRecipientsVisibility() {
+ document.getElementById("extraAddressRowsMenuButton").hidden =
+ !document.querySelector("#extraAddressRowsMenu > :not([hidden])");
+
+ let buttonbox = document.getElementById("extraAddressRowsArea");
+ // Toggle the class to show/hide the pseudo element separator
+ // of the msgIdentity field.
+ buttonbox.classList.toggle(
+ "addressingWidget-separator",
+ !!buttonbox.querySelector("button:not([hidden])")
+ );
+}
+
+/**
+ * Hide the container row of a recipient (Cc, Bcc, etc.).
+ * The container can't be hidden if previously typed addresses are listed.
+ *
+ * @param {Element} element - A descendant element of the row to be hidden (or
+ * the row itself), usually the [x] label when triggered, or an empty address
+ * input upon Backspace or Del keydown.
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * hiding the address row: try to focus the input of an available next sibling
+ * row (for [x] or DEL) or previous sibling row (for BACKSPACE).
+ */
+function hideAddressRowFromWithin(element, focusType = "next") {
+ let addressRow = element.closest(".address-row");
+
+ // Prevent address row removal when sending (disable-on-send).
+ if (
+ addressRow
+ .querySelector(".address-container")
+ .classList.contains("disable-container")
+ ) {
+ return;
+ }
+
+ let pills = addressRow.querySelectorAll("mail-address-pill");
+ let isEdited = addressRow
+ .querySelector(".address-container")
+ .classList.contains("addressing-field-edited");
+
+ // Ask the user to confirm the removal of all the typed addresses if the field
+ // holds addressing pills and has been previously edited.
+ if (isEdited && pills.length) {
+ let fieldName = addressRow.querySelector(
+ ".address-label-container > label"
+ );
+ let confirmTitle = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowTitle2",
+ [fieldName.value]
+ );
+ let confirmBody = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowBody2",
+ [fieldName.value]
+ );
+ let confirmButton = getComposeBundle().getString(
+ "confirmRemoveRecipientRowButton"
+ );
+
+ let result = Services.prompt.confirmEx(
+ window,
+ confirmTitle,
+ confirmBody,
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL,
+ confirmButton,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (result == 1) {
+ return;
+ }
+ }
+
+ for (let pill of pills) {
+ pill.remove();
+ }
+
+ // Reset the original input.
+ let input = addressRow.querySelector(".address-row-input");
+ input.value = "";
+
+ addressRowSetVisibility(addressRow, false);
+
+ // Update the Send button only if the content was previously changed.
+ if (isEdited) {
+ onRecipientsChanged(true);
+ }
+ updateAriaLabelsOfAddressRow(addressRow);
+
+ // Move focus to the next focusable address input field.
+ let addressRowSibling =
+ focusType == "next"
+ ? getNextSibling(addressRow, ".address-row:not(.hidden)")
+ : getPreviousSibling(addressRow, ".address-row:not(.hidden)");
+
+ if (addressRowSibling) {
+ addressRowSibling.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Otherwise move focus to the subject field or to the first available input.
+ let fallbackFocusElement =
+ focusType == "next"
+ ? document.getElementById("msgSubject")
+ : getNextSibling(addressRow, ".address-row:not(.hidden)").querySelector(
+ ".address-row-input"
+ );
+ fallbackFocusElement.focus();
+}
+
+/**
+ * Handle the click event on the close label of an address row.
+ *
+ * @param {Event} event - The DOM click event.
+ */
+function closeLabelOnClick(event) {
+ hideAddressRowFromWithin(event.target);
+}
+
+function extraAddressRowsMenuOpened() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "true");
+}
+
+function extraAddressRowsMenuClosed() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "false");
+}
+
+/**
+ * Show the menu for extra address rows (extraAddressRowsMenu).
+ */
+function openExtraAddressRowsMenu() {
+ let button = document.getElementById("extraAddressRowsMenuButton");
+ let menu = document.getElementById("extraAddressRowsMenu");
+ // NOTE: menu handlers handle the aria-expanded state of the button.
+ menu.openPopup(button, "after_end", 8, 0);
+}