/* -*- 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 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 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); }