Files
binoc-central-mirror/mail/base/content/multimessageview.js
T
2020-05-10 13:52:36 -04:00

690 lines
21 KiB
JavaScript

/* 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/. */
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource:///modules/gloda/gloda.js");
Components.utils.import("resource:///modules/gloda/connotent.js");
Components.utils.import("resource:///modules/gloda/mimemsg.js");
Components.utils.import("resource:///modules/displayNameUtils.js");
Components.utils.import("resource:///modules/mailServices.js");
Components.utils.import("resource:///modules/templateUtils.js");
var gMessenger = Components.classes["@mozilla.org/messenger;1"]
.createInstance(Components.interfaces.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 = {};
// Hook into the resize event on the header to make the #content node shift
// down as it reflows.
window.addEventListener("resize", this._adjustHeadingSize.bind(this));
}
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: function(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: function(aMsgHdr, aNode) {
let key = aMsgHdr.messageKey + aMsgHdr.folder.URI;
this._msgNodes[key] = aNode;
},
/**
* Clear all the content from the summary.
*/
clear: function() {
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.
let notice = document.getElementById("notice");
notice.classList.add("hidden");
},
/**
* 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 [aListener] A listener to be notified when the summary starts and
* finishes.
*/
summarize: function(aType, aMessages, aListener) {
this.clear();
this._listener = aListener;
if (this._listener)
this._listener.onLoadStarted();
// Enable/disable the archive button as appropriate.
let archiveBtn = document.getElementById("hdrArchiveButton");
archiveBtn.collapsed = !window.top.gFolderDisplay
.canArchiveSelectedMessages;
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);
// 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: function(title, subtitle) {
let titleNode = document.getElementById("summary_title");
let subtitleNode = document.getElementById("summary_subtitle");
titleNode.textContent = title || "";
subtitleNode.textContent = subtitle || "";
this._adjustHeadingSize();
},
/**
* 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)
* @return A DOM node for the summary item.
*/
makeSummaryItem: function(aMsgOrThread, aOptions) {
let message, thread, numUnread, isStarred, tags;
if (aMsgOrThread instanceof Components.interfaces.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.classList.toggle("thread", thread && thread.length > 1);
row.classList.toggle("unread", numUnread > 0);
row.classList.toggle("starred", isStarred);
row.innerHTML = '<div class="star"/>' +
'<div class="item_summary">' +
'<div class="item_header"/>' +
'<div class="snippet"/>' +
'</div>';
let itemHeaderNode = row.querySelector(".item_header");
let authorNode = document.createElement("span");
authorNode.classList.add("author");
authorNode.textContent = 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", function() {
window.top.gFolderDisplay.selectMessages(thread);
}, false);
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", function() {
window.top.gFolderDisplay.selectMessage(message);
window.top.document.getElementById("messagepane").focus();
}, false);
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 == Components.results.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: function(aNoticeText) {
let notice = document.getElementById("notice");
notice.textContent = aNoticeText;
notice.classList.remove("hidden");
},
/**
* 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.
* @return An array of nsIMsgTag objects.
*/
_getTagsForMsg: function(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: function(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");
// See tagColors.css.
let color = MailServices.tags.getColorForKey(tag.key);
let colorClass = "blc-" + color.substr(1);
tagNode.classList.add("tag", colorClass);
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: function(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)]
);
},
/**
* Adjust the position of the top of the main content so that it fits below
* the heading.
*/
_adjustHeadingSize: function() {
let content = document.getElementById("content");
let heading = document.getElementById("heading");
let buttonbox = document.getElementById("header-view-toolbox");
content.style.top = Math.max(
buttonbox.getBoundingClientRect().height,
heading.getBoundingClientRect().height
) + "px";
},
// These are listeners for the gloda collections.
onItemsAdded: function(aItems) {},
onItemsModified: function(aItems) {
this._processItems(aItems);
},
onItemsRemoved: function(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: function(aItems) {
let knownMessageNodes = new Map();
for (let [,glodaMsg] in Iterator(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 (!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: function(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();
return;
},
};
/**
* 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: function(aContext) {
this.context = aContext;
},
/**
* Summarize a list of messages.
*
* @param aMessages A LimitIterator of the messages to summarize.
* @return An array of the messages actually summarized.
*/
summarize: function(aMessages) {
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] in Iterator(summarizedMessages)) {
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 messages to summarize.
*/
kMaxSummarizedMessages: 500,
/**
* 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: function(aContext) {
this.context = aContext;
},
/**
* Summarize a list of messages.
*
* @param aMessages The messages to summarize.
*/
summarize: function(aMessages) {
let messageList = document.getElementById("message_list");
let threads = this._buildThreads(aMessages);
// 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;
let messageCount = 0;
for (let [i, msgs] in Iterator(threads)) {
messageCount += msgs.length;
if (messageCount > this.kMaxSummarizedMessages ||
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("maxCountExceeded", [
aMessages.trueLength.toLocaleString(),
this.kMaxSummarizedMessages.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.
* @return An array of arrays of messages, grouped by thread.
*/
_buildThreads: function(aMessages) {
// First, we group the messages in threads and count the threads.
let threads = [];
let threadMap = {};
for (let msgHdr of aMessages) {
let viewThreadId = window.top.gFolderDisplay.view.dbView
.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());