summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/multimessageview.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/multimessageview.js')
-rw-r--r--comm/mail/base/content/multimessageview.js844
1 files changed, 844 insertions, 0 deletions
diff --git a/comm/mail/base/content/multimessageview.js b/comm/mail/base/content/multimessageview.js
new file mode 100644
index 0000000000..9b4c593fde
--- /dev/null
+++ b/comm/mail/base/content/multimessageview.js
@@ -0,0 +1,844 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DisplayNameUtils: "resource:///modules/DisplayNameUtils.jsm",
+ Gloda: "resource:///modules/gloda/Gloda.jsm",
+ makeFriendlyDateAgo: "resource:///modules/TemplateUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ mimeMsgToContentSnippetAndMeta: "resource:///modules/gloda/GlodaContent.jsm",
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+ PluralStringFormatter: "resource:///modules/TemplateUtils.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+var gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+// Set up our string formatter for localizing strings.
+XPCOMUtils.defineLazyGetter(this, "formatString", function () {
+ let formatter = new PluralStringFormatter(
+ "chrome://messenger/locale/multimessageview.properties"
+ );
+ return function (...args) {
+ return formatter.get(...args);
+ };
+});
+
+/**
+ * A LimitIterator is a utility class that allows limiting the maximum number
+ * of items to iterate over.
+ *
+ * @param aArray The array to iterate over (can be anything with a .length
+ * property and a subscript operator.
+ * @param aMaxLength The maximum number of items to iterate over.
+ */
+function LimitIterator(aArray, aMaxLength) {
+ this._array = aArray;
+ this._maxLength = aMaxLength;
+}
+
+LimitIterator.prototype = {
+ /**
+ * Returns true if the iterator won't actually iterate over everything in the
+ * array.
+ */
+ get limited() {
+ return this._array.length > this._maxLength;
+ },
+
+ /**
+ * Returns the number of elements that will actually be iterated over.
+ */
+ get length() {
+ return Math.min(this._array.length, this._maxLength);
+ },
+
+ /**
+ * Returns the real number of elements in the array.
+ */
+ get trueLength() {
+ return this._array.length;
+ },
+};
+
+var JS_HAS_SYMBOLS = typeof Symbol === "function";
+var ITERATOR_SYMBOL = JS_HAS_SYMBOLS ? Symbol.iterator : "@@iterator";
+
+/**
+ * Iterate over the array until we hit the end or the maximum length,
+ * whichever comes first.
+ */
+LimitIterator.prototype[ITERATOR_SYMBOL] = function* () {
+ let length = this.length;
+ for (let i = 0; i < length; i++) {
+ yield this._array[i];
+ }
+};
+
+/**
+ * The MultiMessageSummary class is responsible for populating the message pane
+ * with a reasonable summary of a set of messages.
+ */
+function MultiMessageSummary() {
+ this._summarizers = {};
+}
+
+MultiMessageSummary.prototype = {
+ /**
+ * The maximum number of messages to examine in any way.
+ */
+ kMaxMessages: 10000,
+
+ /**
+ * Register a summarizer for a particular type of message summary.
+ *
+ * @param aSummarizer The summarizer object.
+ */
+ registerSummarizer(aSummarizer) {
+ this._summarizers[aSummarizer.name] = aSummarizer;
+ aSummarizer.onregistered(this);
+ },
+
+ /**
+ * Store a mapping from a message header to the summary node in the DOM. We
+ * use this to update things when Gloda tells us to.
+ *
+ * @param aMsgHdr The nsIMsgDBHdr.
+ * @param aNode The related DOM node.
+ */
+ mapMsgToNode(aMsgHdr, aNode) {
+ let key = aMsgHdr.messageKey + aMsgHdr.folder.URI;
+ this._msgNodes[key] = aNode;
+ },
+
+ /**
+ * Clear all the content from the summary.
+ */
+ clear() {
+ this._selectCallback = null;
+ this._listener = null;
+ this._glodaQuery = null;
+ this._msgNodes = {};
+
+ // Clear the messages list.
+ let messageList = document.getElementById("message_list");
+ while (messageList.hasChildNodes()) {
+ messageList.lastChild.remove();
+ }
+
+ // Clear the notice.
+ document.getElementById("notice").textContent = "";
+ },
+
+ /**
+ * Fill in the summary pane describing the selected messages.
+ *
+ * @param aType The type of summary to perform (e.g. 'multimessage').
+ * @param aMessages The messages to summarize.
+ * @param aDBView The current DB view.
+ * @param aSelectCallback Called with an array of nsIMsgHdrs when one of
+ * a summarized message is clicked on.
+ * @param [aListener] A listener to be notified when the summary starts and
+ * finishes.
+ */
+ summarize(aType, aMessages, aDBView, aSelectCallback, aListener) {
+ this.clear();
+
+ this._selectCallback = aSelectCallback;
+ this._listener = aListener;
+ if (this._listener) {
+ this._listener.onLoadStarted();
+ }
+
+ // Enable/disable the archive button as appropriate.
+ let archiveBtn = document.getElementById("hdrArchiveButton");
+ archiveBtn.hidden = !MessageArchiver.canArchive(aMessages);
+
+ // Set archive and delete button listeners.
+ let topChromeWindow = window.browsingContext.topChromeWindow;
+ archiveBtn.onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_archive");
+ }
+ };
+ document.getElementById("hdrTrashButton").onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_delete");
+ }
+ };
+
+ headerToolbarNavigation.init();
+
+ let summarizer = this._summarizers[aType];
+ if (!summarizer) {
+ throw new Error('Unknown summarizer "' + aType + '"');
+ }
+
+ let messages = new LimitIterator(aMessages, this.kMaxMessages);
+ let summarizedMessages = summarizer.summarize(messages, aDBView);
+
+ // Stash somewhere so it doesn't get GC'ed.
+ this._glodaQuery = Gloda.getMessageCollectionForHeaders(
+ summarizedMessages,
+ this
+ );
+ this._computeSize(messages);
+ },
+
+ /**
+ * Set the heading for the summary.
+ *
+ * @param title The title for the heading.
+ * @param subtitle A smaller subtitle for the heading.
+ */
+ setHeading(title, subtitle) {
+ let titleNode = document.getElementById("summary_title");
+ let subtitleNode = document.getElementById("summary_subtitle");
+ titleNode.textContent = title || "";
+ subtitleNode.textContent = subtitle || "";
+ },
+
+ /**
+ * Create a summary item for a message or thread.
+ *
+ * @param aMsgOrThread An nsIMsgDBHdr or an array thereof
+ * @param [aOptions] An optional object to customize the output:
+ * showSubject: true if the subject of the message
+ * should be shown; defaults to false
+ * snippetLength: the length in bytes of the message
+ * snippet; defaults to undefined (let Gloda decide)
+ * @returns A DOM node for the summary item.
+ */
+ makeSummaryItem(aMsgOrThread, aOptions) {
+ let message, thread, numUnread, isStarred, tags;
+ if (aMsgOrThread instanceof Ci.nsIMsgDBHdr) {
+ thread = null;
+ message = aMsgOrThread;
+
+ numUnread = message.isRead ? 0 : 1;
+ isStarred = message.isFlagged;
+
+ tags = this._getTagsForMsg(message);
+ } else {
+ thread = aMsgOrThread;
+ message = thread[0];
+
+ numUnread = thread.reduce(function (x, hdr) {
+ return x + (hdr.isRead ? 0 : 1);
+ }, 0);
+ isStarred = thread.some(function (hdr) {
+ return hdr.isFlagged;
+ });
+
+ tags = new Set();
+ for (let message of thread) {
+ for (let tag of this._getTagsForMsg(message)) {
+ tags.add(tag);
+ }
+ }
+ }
+
+ let row = document.createElement("li");
+ row.dataset.messageId = message.messageId;
+ row.classList.toggle("thread", thread && thread.length > 1);
+ row.classList.toggle("unread", numUnread > 0);
+ row.classList.toggle("starred", isStarred);
+
+ row.appendChild(document.createElement("div")).classList.add("star");
+
+ let summary = document.createElement("div");
+ summary.classList.add("item_summary");
+ summary
+ .appendChild(document.createElement("div"))
+ .classList.add("item_header");
+ summary.appendChild(document.createElement("div")).classList.add("snippet");
+ row.appendChild(summary);
+
+ let itemHeaderNode = row.querySelector(".item_header");
+
+ let authorNode = document.createElement("span");
+ authorNode.classList.add("author");
+ authorNode.textContent = DisplayNameUtils.formatDisplayNameList(
+ message.mime2DecodedAuthor,
+ "from"
+ );
+
+ if (aOptions && aOptions.showSubject) {
+ authorNode.classList.add("right");
+ itemHeaderNode.appendChild(authorNode);
+
+ let subjectNode = document.createElement("span");
+ subjectNode.classList.add("subject", "primary_header", "link");
+ subjectNode.textContent =
+ message.mime2DecodedSubject || formatString("noSubject");
+ subjectNode.addEventListener("click", () => this._selectCallback(thread));
+ itemHeaderNode.appendChild(subjectNode);
+
+ if (thread && thread.length > 1) {
+ let numUnreadStr = "";
+ if (numUnread) {
+ numUnreadStr = formatString(
+ "numUnread",
+ [numUnread.toLocaleString()],
+ numUnread
+ );
+ }
+ let countStr =
+ "(" +
+ formatString(
+ "numMessages",
+ [thread.length.toLocaleString()],
+ thread.length
+ ) +
+ numUnreadStr +
+ ")";
+
+ let countNode = document.createElement("span");
+ countNode.classList.add("count");
+ countNode.textContent = countStr;
+ itemHeaderNode.appendChild(countNode);
+ }
+ } else {
+ let dateNode = document.createElement("span");
+ dateNode.classList.add("date", "right");
+ dateNode.textContent = makeFriendlyDateAgo(new Date(message.date / 1000));
+ itemHeaderNode.appendChild(dateNode);
+
+ authorNode.classList.add("primary_header", "link");
+ authorNode.addEventListener("click", () => {
+ this._selectCallback([message]);
+ });
+ itemHeaderNode.appendChild(authorNode);
+ }
+
+ let tagNode = document.createElement("span");
+ tagNode.classList.add("tags");
+ this._addTagNodes(tags, tagNode);
+ itemHeaderNode.appendChild(tagNode);
+
+ let snippetNode = row.querySelector(".snippet");
+ try {
+ const kSnippetLength = aOptions && aOptions.snippetLength;
+ MsgHdrToMimeMessage(
+ message,
+ null,
+ function (aMsgHdr, aMimeMsg) {
+ if (aMimeMsg == null) {
+ // Shouldn't happen, but sometimes does?
+ return;
+ }
+ let [text, meta] = mimeMsgToContentSnippetAndMeta(
+ aMimeMsg,
+ aMsgHdr.folder,
+ kSnippetLength
+ );
+ snippetNode.textContent = text;
+ if (meta.author) {
+ authorNode.textContent = meta.author;
+ }
+ },
+ false,
+ { saneBodySize: true }
+ );
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FAILURE) {
+ // Offline messages generate exceptions, which is unfortunate. When
+ // that's fixed, this code should adapt. XXX
+ snippetNode.textContent = "...";
+ } else {
+ throw e;
+ }
+ }
+ return row;
+ },
+
+ /**
+ * Show an informative notice about the summarized messages (e.g. if we only
+ * summarized some of them).
+ *
+ * @param aNoticeText The text to show in the notice.
+ */
+ showNotice(aNoticeText) {
+ let notice = document.getElementById("notice");
+ notice.textContent = aNoticeText;
+ },
+
+ /**
+ * Given a msgHdr, return a list of tag objects. This function just does the
+ * messy work of understanding how tags are stored in nsIMsgDBHdrs. It would
+ * be a good candidate for a utility library.
+ *
+ * @param aMsgHdr The msgHdr whose tags we want.
+ * @returns An array of nsIMsgTag objects.
+ */
+ _getTagsForMsg(aMsgHdr) {
+ let keywords = new Set(aMsgHdr.getStringProperty("keywords").split(" "));
+ let allTags = MailServices.tags.getAllTags();
+
+ return allTags.filter(function (tag) {
+ return keywords.has(tag.key);
+ });
+ },
+
+ /**
+ * Add a list of tags to a DOM node.
+ *
+ * @param aTags An array (or any iterable) of nsIMsgTag objects.
+ * @param aTagsNode The DOM node to contain the list of tags.
+ */
+ _addTagNodes(aTags, aTagsNode) {
+ // Make sure the tags are sorted in their natural order.
+ let sortedTags = [...aTags];
+ sortedTags.sort(function (a, b) {
+ return a.key.localeCompare(b.key) || a.ordinal.localeCompare(b.ordinal);
+ });
+
+ for (let tag of sortedTags) {
+ let tagNode = document.createElement("span");
+
+ tagNode.className = "tag";
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.dataset.tag = tag.tag;
+ tagNode.textContent = tag.tag;
+ aTagsNode.appendChild(tagNode);
+ }
+ },
+
+ /**
+ * Compute the size of the messages in the selection and display it in the
+ * element of id "size".
+ *
+ * @param aMessages A LimitIterator of the messages to calculate the size of.
+ */
+ _computeSize(aMessages) {
+ let numBytes = 0;
+ for (let msgHdr of aMessages) {
+ numBytes += msgHdr.messageSize;
+ // XXX do something about news?
+ }
+
+ let format = aMessages.limited
+ ? "messagesTotalSizeMoreThan"
+ : "messagesTotalSize";
+ document.getElementById("size").textContent = formatString(format, [
+ gMessenger.formatFileSize(numBytes),
+ ]);
+ },
+
+ // These are listeners for the gloda collections.
+ onItemsAdded(aItems) {},
+ onItemsModified(aItems) {
+ this._processItems(aItems);
+ },
+ onItemsRemoved(aItems) {},
+
+ /**
+ * Given a set of items from a gloda collection, process them and update
+ * the display accordingly.
+ *
+ * @param aItems Contents of a gloda collection.
+ */
+ _processItems(aItems) {
+ let knownMessageNodes = new Map();
+
+ for (let glodaMsg of aItems) {
+ // Unread and starred will get set if any of the messages in a collapsed
+ // thread qualify. The trick here is that we may get multiple items
+ // corresponding to the same thread (and hence DOM node), so we need to
+ // detect when we get the first item for a particular DOM node, stash the
+ // preexisting status of that DOM node, an only do transitions if the
+ // items warrant it.
+ let key = glodaMsg.messageKey + glodaMsg.folder.uri;
+ let headerNode = this._msgNodes[key];
+ if (!headerNode) {
+ continue;
+ }
+ if (!knownMessageNodes.has(headerNode)) {
+ knownMessageNodes.set(headerNode, {
+ read: true,
+ starred: false,
+ tags: new Set(),
+ });
+ }
+
+ let flags = knownMessageNodes.get(headerNode);
+
+ // Count as read if *all* the messages are read.
+ flags.read &= glodaMsg.read;
+ // Count as starred if *any* of the messages are starred.
+ flags.starred |= glodaMsg.starred;
+ // Count as tagged with a tag if *any* of the messages have that tag.
+ for (let tag of this._getTagsForMsg(glodaMsg.folderMessage)) {
+ flags.tags.add(tag);
+ }
+ }
+
+ for (let [headerNode, flags] of knownMessageNodes) {
+ headerNode.classList.toggle("unread", !flags.read);
+ headerNode.classList.toggle("starred", flags.starred);
+
+ // Clear out all the tags and start fresh, just to make sure we don't get
+ // out of sync.
+ let tagsNode = headerNode.querySelector(".tags");
+ while (tagsNode.hasChildNodes()) {
+ tagsNode.lastChild.remove();
+ }
+ this._addTagNodes(flags.tags, tagsNode);
+ }
+ },
+
+ onQueryCompleted(aCollection) {
+ // If we need something that's just available from GlodaMessages, this is
+ // where we'll get it initially.
+ if (this._listener) {
+ this._listener.onLoadCompleted();
+ }
+ },
+};
+
+/**
+ * A summarizer to use for a single thread.
+ */
+function ThreadSummarizer() {}
+
+ThreadSummarizer.prototype = {
+ /**
+ * The maximum number of messages to summarize.
+ */
+ kMaxSummarizedMessages: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "thread";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages A LimitIterator of the messages to summarize.
+ * @returns An array of the messages actually summarized.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ // Remove all ignored messages from summarization.
+ let summarizedMessages = [];
+ for (let message of aMessages) {
+ if (!message.isKilled) {
+ summarizedMessages.push(message);
+ }
+ }
+ let ignoredCount = aMessages.trueLength - summarizedMessages.length;
+
+ // Summarize the selected messages.
+ let subject = null;
+ let maxCountExceeded = false;
+ for (let [i, msgHdr] of summarizedMessages.entries()) {
+ if (i == this.kMaxSummarizedMessages) {
+ summarizedMessages.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ if (subject == null) {
+ subject = msgHdr.mime2DecodedSubject;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgHdr, {
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+
+ // Set the heading based on the subject and number of messages.
+ let countInfo = formatString(
+ "numMessages",
+ [aMessages.length.toLocaleString()],
+ aMessages.length
+ );
+ if (ignoredCount != 0) {
+ let format = aMessages.limited ? "atLeastNumIgnored" : "numIgnored";
+ countInfo += formatString(
+ format,
+ [ignoredCount.toLocaleString()],
+ ignoredCount
+ );
+ }
+
+ this.context.setHeading(subject || formatString("noSubject"), countInfo);
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxCountExceeded", [
+ aMessages.trueLength.toLocaleString(),
+ this.kMaxSummarizedMessages.toLocaleString(),
+ ])
+ );
+ }
+ return summarizedMessages;
+ },
+};
+
+/**
+ * A summarizer to use when multiple threads are selected.
+ */
+function MultipleSelectionSummarizer() {}
+
+MultipleSelectionSummarizer.prototype = {
+ /**
+ * The maximum number of threads to summarize.
+ */
+ kMaxSummarizedThreads: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "multipleselection";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages The messages to summarize.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ let threads = this._buildThreads(aMessages, aDBView);
+ let threadsCount = threads.length;
+
+ // Set the heading based on the number of messages & threads.
+ let format = aMessages.limited
+ ? "atLeastNumConversations"
+ : "numConversations";
+ this.context.setHeading(
+ formatString(format, [threads.length.toLocaleString()], threads.length)
+ );
+
+ // Summarize the selected messages by thread.
+ let maxCountExceeded = false;
+ for (let [i, msgs] of threads.entries()) {
+ if (i == this.kMaxSummarizedThreads) {
+ threads.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgs, {
+ showSubject: true,
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ for (let msgHdr of msgs) {
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+ }
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxThreadCountExceeded", [
+ threadsCount.toLocaleString(),
+ this.kMaxSummarizedThreads.toLocaleString(),
+ ])
+ );
+
+ // Return only the messages for the threads we're actually showing. We
+ // need to collapse our array-of-arrays into a flat array.
+ return threads.reduce(function (accum, curr) {
+ accum.push(...curr);
+ return accum;
+ }, []);
+ }
+
+ // Return everything, since we're showing all the threads. Don't forget to
+ // turn it into an array, though!
+ return [...aMessages];
+ },
+
+ /**
+ * Group all the messages to be summarized into threads.
+ *
+ * @param aMessages The messages to group.
+ * @returns An array of arrays of messages, grouped by thread.
+ */
+ _buildThreads(aMessages, aDBView) {
+ // First, we group the messages in threads and count the threads.
+ let threads = [];
+ let threadMap = {};
+ for (let msgHdr of aMessages) {
+ let viewThreadId = aDBView.getThreadContainingMsgHdr(msgHdr).threadKey;
+ if (!(viewThreadId in threadMap)) {
+ threadMap[viewThreadId] = threads.length;
+ threads.push([msgHdr]);
+ } else {
+ threads[threadMap[viewThreadId]].push(msgHdr);
+ }
+ }
+ return threads;
+ },
+};
+
+var gMessageSummary = new MultiMessageSummary();
+
+gMessageSummary.registerSummarizer(new ThreadSummarizer());
+gMessageSummary.registerSummarizer(new MultipleSelectionSummarizer());
+
+/**
+ * Roving tab navigation for the header buttons.
+ */
+const headerToolbarNavigation = {
+ /**
+ * If the roving tab has already been loaded.
+ *
+ * @type {boolean}
+ */
+ isLoaded: false,
+ /**
+ * Get all currently visible buttons of the message header toolbar.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerButtons() {
+ return this.headerToolbar.querySelectorAll(
+ `toolbarbutton:not([hidden="true"])`
+ );
+ },
+
+ init() {
+ // Bail out if we already initialized this.
+ if (this.isLoaded) {
+ return;
+ }
+ this.headerToolbar = document.getElementById("header-view-toolbar");
+ this.headerToolbar.addEventListener("keypress", event => {
+ this.triggerMessageHeaderRovingTab(event);
+ });
+ this.updateRovingTab();
+ this.isLoaded = true;
+ },
+
+ /**
+ * Update the `tabindex` attribute of the currently visible buttons.
+ */
+ updateRovingTab() {
+ for (const button of this.headerButtons) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons.
+ this.headerButtons[0].setAttribute("tabindex", "0");
+ },
+
+ /**
+ * Handles the keypress event on the message header toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerMessageHeaderRovingTab(event) {
+ // Expected keyboard actions are Left, Right, Home, End, Space, and Enter.
+ if (!["ArrowRight", "ArrowLeft", " ", "Enter"].includes(event.key)) {
+ return;
+ }
+
+ const headerButtons = [...this.headerButtons];
+ const focusableButton = headerButtons.find(b => b.tabIndex != -1);
+ let elementIndex = headerButtons.indexOf(focusableButton);
+
+ // TODO: Remove once the buttons are updated to not be XUL
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events. However, we need to prevent the default behavior and explicitly
+ // trigger the button click because the XUL toolbarbuttons do not work when
+ // the Enter key is pressed. They do work when the Space key is pressed.
+ // However, if the toolbarbutton is a dropdown menu, the Space key
+ // does not open the menu.
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+
+ // Find the adjacent focusable element based on the pressed key.
+ const isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerButtons.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerButtons.length - 1;
+ }
+ }
+
+ // Move the focus to a new toolbar button and update the tabindex attribute.
+ const newFocusableButton = headerButtons[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.setAttribute("tabindex", "0");
+ newFocusableButton.focus();
+ }
+ },
+};