Revision control

Copy as Markdown

Other Tools

/* -*- 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.importESModule(
);
var { DisplayNameUtils } = ChromeUtils.importESModule(
);
// 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 {nsIMsgCompFields} msgCompFields - An object to receive the recipients.
*/
function Recipients2CompFields(msgCompFields) {
if (!msgCompFields) {
throw new Error(
"Message Compose Error: msgCompFields is null (ExtractRecipients)"
);
}
const otherHeaders = Services.prefs
.getCharPref("mail.compose.other.header", "")
.split(",")
.map(h => h.trim())
.filter(Boolean);
for (const row of document.querySelectorAll(".address-row-raw")) {
const recipientType = row.dataset.recipienttype;
const headerValue = row.querySelector(".address-row-input").value.trim();
if (headerValue) {
msgCompFields.setRawHeader(recipientType, headerValue);
} else if (
otherHeaders.includes(recipientType) &&
recipientType.toLowerCase() != "references" &&
recipientType.toLowerCase() != "in-reply-to"
) {
// Normally drop other headers without value. For that, the UI lets you
// add them and fill.
// But if the ohther header is really a standard header with a value,
// that should be kept.
msgCompFields.deleteHeader(recipientType);
}
}
const getRecipientList = recipientType =>
Array.from(
document.querySelectorAll(
`.address-row[data-recipienttype="${recipientType}"] mail-address-pill`
),
pill => {
// Expect each pill to contain exactly one address.
const { 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
) {
const row = document.getElementById(rowId);
addressRowClearPills(row);
const value = multi
? MailServices.headerParser.parseEncodedHeaderW(headerValue).join(", ")
: headerValue;
if (value || forceShow) {
addressRowSetVisibility(row, true);
}
if (value) {
const 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.
const 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.
const 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 (const item of document.querySelectorAll(".news-show-row-menuitem")) {
showAddressRowMenuItemSetPreferButton(item, true);
}
for (const 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.
const 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.
const 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 (const item of document.querySelectorAll(".mail-show-row-menuitem")) {
showAddressRowMenuItemSetPreferButton(item, true);
}
for (const 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.
const recipientsArray = msgCompFields.splitRecipients(recipientsList, false);
// Remove first instance of specified recipients from specified container.
for (const recipientFullAddress of recipientsArray) {
const pill = container.querySelector(
`mail-address-pill[fullAddress="${recipientFullAddress}"]`
);
if (pill) {
pill.remove();
}
}
const 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) {
const addresses = [];
for (const addr of addressArray) {
addresses.push(...MailServices.headerParser.makeFromDisplayAddress(addr));
}
if (row.classList.contains("hidden")) {
showAndFocusAddressRow(row.id, true);
}
const recipientArea = document.getElementById("recipientsContainer");
const input = row.querySelector(".address-row-input");
for (const address of addresses) {
const 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) {
const 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) {
const 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();
const lastPill = input
.closest(".address-container")
.querySelector("mail-address-pill:last-of-type");
const 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.
const lastPillGlobal = mailRecipientsArea.querySelector(
"mail-address-pill:last-of-type"
);
if (lastPillGlobal) {
mailRecipientsArea.selectAllPills();
lastPillGlobal.focus();
}
break;
}
case " ":
case ",": {
const 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();
const 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].
const 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) {
const 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;
}
const addresses = MailServices.headerParser.makeFromDisplayAddress(
input.value
);
const recipientArea = document.getElementById("recipientsContainer");
for (const 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 (const 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;
}
const 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.
const listNames = MimeParser.parseHeaderField(
address,
MimeParser.HEADER_ADDRESS
);
const 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) {
const pill = element.closest("mail-address-pill");
if (pill.isMailList) {
const addresses = [];
for (const currentPill of pill.parentNode.querySelectorAll(
"mail-address-pill"
)) {
if (currentPill == pill) {
const dir = MailServices.ab.getDirectory(pill.listURI);
if (dir) {
for (const card of dir.childCards) {
addresses.push(makeMailboxObjectFromCard(card));
}
}
} else {
addresses.push(currentPill.fullAddress);
}
}
const 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) {
const menu = event.target;
// Reset previously hidden menuitems.
for (const menuitem of menu.querySelectorAll(
".pill-action-move, .pill-action-edit"
)) {
menuitem.hidden = false;
}
const recipientsContainer = document.getElementById("recipientsContainer");
// Check if the pill where the context menu was originated is not selected.
const pill = event.explicitOriginalTarget.closest("mail-address-pill");
if (!pill.hasAttribute("selected")) {
recipientsContainer.deselectAllPills();
pill.setAttribute("selected", "selected");
}
const 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.
const 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.
const 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 (const 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 (const row of recipientsContainer.querySelectorAll(
".address-row:not(.hidden)"
)) {
// Check if there's at least one selected pill in the address row.
const 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) {
const 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) {
const 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) {
const buttonId = menuItem.dataset.buttonId;
const 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) {
const buttonId = menuItem.dataset.buttonId;
if (!buttonId || menuItem.dataset.preferButton == String(preferButton)) {
return;
}
const 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])");
const 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") {
const addressRow = element.closest(".address-row");
// Prevent address row removal when sending (disable-on-send).
if (
addressRow
.querySelector(".address-container")
.classList.contains("disable-container")
) {
return;
}
const pills = addressRow.querySelectorAll("mail-address-pill");
const 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) {
const fieldName = addressRow.querySelector(
".address-label-container > label"
);
const confirmTitle = getComposeBundle().getFormattedString(
"confirmRemoveRecipientRowTitle2",
[fieldName.value]
);
const confirmBody = getComposeBundle().getFormattedString(
"confirmRemoveRecipientRowBody2",
[fieldName.value]
);
const confirmButton = getComposeBundle().getString(
"confirmRemoveRecipientRowButton"
);
const 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 (const pill of pills) {
pill.remove();
}
// Reset the original input.
const 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.
const 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.
const 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() {
const button = document.getElementById("extraAddressRowsMenuButton");
const menu = document.getElementById("extraAddressRowsMenu");
// NOTE: menu handlers handle the aria-expanded state of the button.
menu.openPopup(button, "after_end", 8, 0);
}