summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/im/modules
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/im/modules')
-rw-r--r--comm/mail/components/im/modules/ChatEncryption.sys.mjs157
-rw-r--r--comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs352
-rw-r--r--comm/mail/components/im/modules/chatHandler.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatIcons.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatNotifications.sys.mjs262
-rw-r--r--comm/mail/components/im/modules/index_im.sys.mjs928
6 files changed, 1911 insertions, 0 deletions
diff --git a/comm/mail/components/im/modules/ChatEncryption.sys.mjs b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
new file mode 100644
index 0000000000..4206b3397d
--- /dev/null
+++ b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
@@ -0,0 +1,157 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/otr/otrUI.ftl"], true)
+);
+
+function _str(id) {
+ return lazy.l10n.formatValueSync(id);
+}
+
+const STATE_STRING = {
+ [Ci.prplIConversation.ENCRYPTION_AVAILABLE]: "not-private",
+ [Ci.prplIConversation.ENCRYPTION_ENABLED]: "unverified",
+ [Ci.prplIConversation.ENCRYPTION_TRUSTED]: "private",
+};
+
+export const ChatEncryption = {
+ /**
+ * If OTR is enabled.
+ *
+ * @type {boolean}
+ */
+ get otrEnabled() {
+ if (!this.hasOwnProperty("_otrEnabled")) {
+ this._otrEnabled = Services.prefs.getBoolPref("chat.otr.enable");
+ }
+ return this._otrEnabled;
+ },
+ /**
+ * Check if the given protocol has encryption settings for accounts.
+ *
+ * @param {prplIProtocol} protocol - Protocol to check against.
+ * @returns {boolean} If encryption can be configured.
+ */
+ canConfigureEncryption(protocol) {
+ if (this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return protocol.canEncrypt;
+ },
+ /**
+ * Check if the conversation should offer encryption settings.
+ *
+ * @param {prplIConversation} conversation
+ * @returns {boolean}
+ */
+ hasEncryptionActions(conversation) {
+ if (!conversation.isChat && this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ );
+ },
+ /**
+ * Show and initialize the encryption selector in the conversation UI for the
+ * given conversation, if encryption is available.
+ *
+ * @param {DOMDocument} document
+ * @param {imIConversation} conversation
+ */
+ updateEncryptionButton(document, conversation) {
+ if (!this.hasEncryptionActions(conversation)) {
+ this.hideEncryptionButton(document);
+ }
+ if (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ // OTR is not available if the conversation can natively encrypt
+ document.querySelector(".otr-start").hidden = true;
+ document.querySelector(".otr-end").hidden = true;
+ document.querySelector(".otr-auth").hidden = true;
+ lazy.OTRUI.hideAllOTRNotifications();
+
+ const actionsAvailable =
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_AVAILABLE;
+
+ document.querySelector(".protocol-encrypt").hidden = false;
+ document.querySelector(".protocol-encrypt").disabled = actionsAvailable;
+ document.querySelector(".encryption-container").hidden = false;
+
+ const trustStringLevel = STATE_STRING[conversation.encryptionState];
+ const otrButton = document.querySelector(".encryption-button");
+ otrButton.setAttribute(
+ "tooltiptext",
+ _str("state-generic-" + trustStringLevel)
+ );
+ otrButton.setAttribute(
+ "label",
+ _str("state-" + trustStringLevel + "-label")
+ );
+ otrButton.className = "encryption-button encryption-" + trustStringLevel;
+ } else if (!conversation.isChat && lazy.OTRUI.enabled) {
+ document.querySelector(".otr-start").hidden = false;
+ document.querySelector(".otr-end").hidden = false;
+ document.querySelector(".otr-auth").hidden = false;
+ lazy.OTRUI.updateOTRButton(conversation);
+ document.querySelector(".protocol-encrypt").hidden = true;
+ } else {
+ this.hideEncryptionButton(document);
+ }
+ },
+ /**
+ * Hide the encryption selector in the converstaion UI.
+ *
+ * @param {DOMDocument} document
+ */
+ hideEncryptionButton(document) {
+ document.querySelector(".encryption-container").hidden = true;
+ if (this.otrEnabled) {
+ lazy.OTRUI.hideOTRButton();
+ }
+ },
+ /**
+ * Verify identity of a participant of buddy.
+ *
+ * @param {DOMWindow} window - Window that the verification dialog attaches to.
+ * @param {prplIAccountBuddy|prplIConvChatBuddy} buddy - Buddy to verify.
+ */
+ verifyIdentity(window, buddy) {
+ if (!buddy.canVerifyIdentity) {
+ Promise.resolve();
+ }
+ buddy
+ .verifyIdentity()
+ .then(sessionVerification => {
+ window.openDialog(
+ "chrome://messenger/content/chat/verify.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ sessionVerification
+ );
+ })
+ .catch(error => {
+ // Only prplIAccountBuddy has a reference to the owner account.
+ if (buddy.account) {
+ buddy.account.prplAccount.wrappedJSObject.ERROR(error);
+ } else {
+ console.error(error);
+ }
+ });
+ },
+};
diff --git a/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
new file mode 100644
index 0000000000..f97519ddea
--- /dev/null
+++ b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
@@ -0,0 +1,352 @@
+/* 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/. */
+
+const { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+
+/**
+ * How much time boost should a 'score point' amount to? The authoritative,
+ * incontrivertible answer, across all time and space, is a week.
+ * Note that gloda stores conversation timestamps in seconds.
+ */
+// var FUZZSCORE_TIMESTAMP_FACTOR = 60 * 60 * 24 * 7;
+
+// var RANK_USAGE =
+// "glodaRank(matchinfo(imConversationsText), 1.0, 2.0, 2.0, 1.5, 1.5)";
+
+var DASCORE = "imConversations.time";
+// "(((" + RANK_USAGE + ") * " +
+// FUZZSCORE_TIMESTAMP_FACTOR +
+// ") + imConversations.time)";
+
+/**
+ * A new optimization decision we are making is that we do not want to carry
+ * around any data in our ephemeral tables that is not used for whittling the
+ * result set. The idea is that the btree page cache or OS cache is going to
+ * save us from the disk seeks and carrying around the extra data is just going
+ * to be CPU/memory churn that slows us down.
+ *
+ * Additionally, we try and avoid row lookups that would have their results
+ * discarded by the LIMIT. Because of limitations in FTS3 (which might
+ * be addressed in FTS4 by a feature request), we can't avoid the 'imConversations'
+ * lookup since that has the message's date and static notability but we can
+ * defer the 'imConversationsText' lookup.
+ *
+ * This is the access pattern we are after here:
+ * 1) Order the matches with minimized lookup and result storage costs.
+ * - The innermost MATCH does the doclist magic and provides us with
+ * matchinfo() support which does not require content row retrieval
+ * from imConversationsText. Unfortunately, this is not enough to whittle anything
+ * because we still need static interestingness, so...
+ * - Based on the match we retrieve the date and notability for that row from
+ * 'imConversations' using this in conjunction with matchinfo() to provide a score
+ * that we can then use to LIMIT our results.
+ * 2) We reissue the MATCH query so that we will be able to use offsets(), but
+ * we intersect the results of this MATCH against our LIMITed results from
+ * step 1.
+ * - We use 'docid IN (phase 1 query)' to accomplish this because it results in
+ * efficient lookup. If we just use a join, we get O(mn) performance because
+ * a cartesian join ends up being performed where either we end up performing
+ * the fulltext query M times and table scan intersect with the results from
+ * phase 1 or we do the fulltext once but traverse the entire result set from
+ * phase 1 N times.
+ * - We believe that the re-execution of the MATCH query should have no disk
+ * costs because it should still be cached by SQLite or the OS. In the case
+ * where memory is so constrained this is not true our behavior is still
+ * probably preferable than the old way because that would have caused lots
+ * of swapping.
+ * - This part of the query otherwise resembles the basic gloda query but with
+ * the inclusion of the offsets() invocation. The imConversations table lookup
+ * should not involve any disk traffic because the pages should still be
+ * cached (SQLite or OS) from phase 1. The imConversationsText lookup is new, and
+ * this is the major disk-seek reduction optimization we are making. (Since
+ * we avoid this lookup for all of the documents that were excluded by the
+ * LIMIT.) Since offsets() also needs to retrieve the row from imConversationsText
+ * there is a nice synergy there.
+ */
+var NUEVO_FULLTEXT_SQL =
+ "SELECT imConversations.*, imConversationsText.*, offsets(imConversationsText) AS osets " +
+ "FROM imConversationsText, imConversations " +
+ "WHERE" +
+ " imConversationsText MATCH ?1 " +
+ " AND imConversationsText.docid IN (" +
+ "SELECT docid " +
+ "FROM imConversationsText JOIN imConversations ON imConversationsText.docid = imConversations.id " +
+ "WHERE imConversationsText MATCH ?1 " +
+ "ORDER BY " +
+ DASCORE +
+ " DESC " +
+ "LIMIT ?2" +
+ " )" +
+ " AND imConversations.id = imConversationsText.docid";
+
+function identityFunc(x) {
+ return x;
+}
+
+function oneLessMaxZero(x) {
+ if (x <= 1) {
+ return 0;
+ }
+ return x - 1;
+}
+
+function reduceSum(accum, curValue) {
+ return accum + curValue;
+}
+
+/*
+ * Columns are: body, subject, attachment names, author, recipients
+ */
+
+/**
+ * Scores if all search terms match in a column. We bias against author
+ * slightly and recipient a bit more in this case because a search that
+ * entirely matches just on a person should give a mention of that person
+ * in the subject or attachment a fighting chance.
+ * Keep in mind that because of our indexing in the face of address book
+ * contacts (namely, we index the name used in the e-mail as well as the
+ * display name on the address book card associated with the e-mail address)
+ * a contact is going to bias towards matching multiple times.
+ */
+var COLUMN_ALL_MATCH_SCORES = [4, 20, 20, 16, 12];
+/**
+ * Score for each distinct term that matches in the column. This is capped
+ * by COLUMN_ALL_SCORES.
+ */
+var COLUMN_PARTIAL_PER_MATCH_SCORES = [1, 4, 4, 4, 3];
+/**
+ * If a term matches multiple times, what is the marginal score for each
+ * additional match. We count the total number of matches beyond the
+ * first match for each term. In other words, if we have 3 terms which
+ * matched 5, 3, and 0 times, then the total from our perspective is
+ * (5 - 1) + (3 - 1) + 0 = 4 + 2 + 0 = 6. We take the minimum of that value
+ * and the value in COLUMN_MULTIPLE_MATCH_LIMIT and multiply by the value in
+ * COLUMN_MULTIPLE_MATCH_SCORES.
+ */
+var COLUMN_MULTIPLE_MATCH_SCORES = [1, 0, 0, 0, 0];
+var COLUMN_MULTIPLE_MATCH_LIMIT = [10, 0, 0, 0, 0];
+
+/**
+ * Score the message on its offsets (from stashedColumns).
+ */
+function scoreOffsets(aMessage, aContext) {
+ let score = 0;
+
+ let termTemplate = aContext.terms.map(_ => 0);
+ // for each column, a list of the incidence of each term
+ let columnTermIncidence = [
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ ];
+
+ // we need a friendlyParseInt because otherwise the radix stuff happens
+ // because of the extra arguments map parses. curse you, map!
+ let offsetNums = aContext.stashedColumns[aMessage.id][0]
+ .split(" ")
+ .map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ let columnIndex = offsetNums[i];
+ let termIndex = offsetNums[i + 1];
+ columnTermIncidence[columnIndex][termIndex]++;
+ }
+
+ for (let iColumn = 0; iColumn < COLUMN_ALL_MATCH_SCORES.length; iColumn++) {
+ let termIncidence = columnTermIncidence[iColumn];
+ if (termIncidence.every(identityFunc)) {
+ // Bestow all match credit.
+ score += COLUMN_ALL_MATCH_SCORES[iColumn];
+ } else if (termIncidence.some(identityFunc)) {
+ // Bestow partial match credit.
+ score += Math.min(
+ COLUMN_ALL_MATCH_SCORES[iColumn],
+ COLUMN_PARTIAL_PER_MATCH_SCORES[iColumn] *
+ termIncidence.filter(identityFunc).length
+ );
+ }
+ // bestow multiple match credit
+ score +=
+ Math.min(
+ termIncidence.map(oneLessMaxZero).reduce(reduceSum, 0),
+ COLUMN_MULTIPLE_MATCH_LIMIT[iColumn]
+ ) * COLUMN_MULTIPLE_MATCH_SCORES[iColumn];
+ }
+
+ return score;
+}
+
+/**
+ * The searcher basically looks like a query, but is specialized for fulltext
+ * search against imConversations. Most of the explicit specialization involves
+ * crafting a SQL query that attempts to order the matches by likelihood that
+ * the user was looking for it. This is based on full-text matches combined
+ * with an explicit (generic) interest score value placed on the message at
+ * indexing time (TODO). This is followed by using the more generic gloda scoring
+ * mechanism to explicitly score the IM conversations given the search context in
+ * addition to the more generic score adjusting rules.
+ */
+export function GlodaIMSearcher(aListener, aSearchString, aAndTerms) {
+ this.listener = aListener;
+
+ this.searchString = aSearchString;
+ this.fulltextTerms = this.parseSearchString(aSearchString);
+ this.andTerms = aAndTerms != null ? aAndTerms : true;
+
+ this.query = null;
+ this.collection = null;
+
+ this.scores = null;
+}
+
+GlodaIMSearcher.prototype = {
+ /**
+ * Number of messages to retrieve initially.
+ */
+ get retrievalLimit() {
+ return Services.prefs.getIntPref(
+ "mailnews.database.global.search.im.limit"
+ );
+ },
+
+ /**
+ * Parse the string into terms/phrases by finding matching double-quotes.
+ */
+ parseSearchString(aSearchString) {
+ aSearchString = aSearchString.trim();
+ let terms = [];
+
+ /*
+ * Add the term as long as the trim on the way in didn't obliterate it.
+ *
+ * In the future this might have other helper logic; it did once before.
+ */
+ function addTerm(aTerm) {
+ if (aTerm) {
+ terms.push(aTerm);
+ }
+ }
+
+ while (aSearchString) {
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf(aSearchString[0], 1);
+ // eat the quote if it has no friend
+ if (endIndex == -1) {
+ aSearchString = aSearchString.substring(1);
+ continue;
+ }
+
+ addTerm(aSearchString.substring(1, endIndex).trim());
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let spaceIndex = aSearchString.indexOf(" ");
+ if (spaceIndex == -1) {
+ addTerm(aSearchString);
+ break;
+ }
+
+ addTerm(aSearchString.substring(0, spaceIndex));
+ aSearchString = aSearchString.substring(spaceIndex + 1);
+ }
+
+ return terms;
+ },
+
+ buildFulltextQuery() {
+ let query = Gloda.newQuery(Gloda.lookupNoun("im-conversation"), {
+ noMagic: true,
+ explicitSQL: NUEVO_FULLTEXT_SQL,
+ limitClauseAlreadyIncluded: true,
+ // osets is 0-based column number 4 (volatile to column changes)
+ // save the offset column for extra analysis
+ stashColumns: [6],
+ });
+
+ let fulltextQueryString = "";
+
+ for (let [iTerm, term] of this.fulltextTerms.entries()) {
+ if (iTerm) {
+ fulltextQueryString += this.andTerms ? " " : " OR ";
+ }
+
+ // Put our term in quotes. This is needed for the tokenizer to be able
+ // to do useful things. The exception is people clever enough to use
+ // NEAR.
+ if (/^NEAR(\/\d+)?$/.test(term)) {
+ fulltextQueryString += term;
+ } else if (term.length == 1 && term.charCodeAt(0) >= 0x2000) {
+ // This is a single-character CJK search query, so add a wildcard.
+ // Our tokenizer treats anything at/above 0x2000 as CJK for now.
+ fulltextQueryString += term + "*";
+ } else if (
+ (term.length == 2 &&
+ term.charCodeAt(0) >= 0x2000 &&
+ term.charCodeAt(1) >= 0x2000) ||
+ term.length >= 3
+ ) {
+ fulltextQueryString += '"' + term + '"';
+ }
+ }
+
+ query.fulltextMatches(fulltextQueryString);
+ query.limit(this.retrievalLimit);
+
+ return query;
+ },
+
+ getCollection(aListenerOverride, aData) {
+ if (aListenerOverride) {
+ this.listener = aListenerOverride;
+ }
+
+ this.query = this.buildFulltextQuery();
+ this.collection = this.query.getCollection(this, aData);
+ this.completed = false;
+
+ return this.collection;
+ },
+
+ sortBy: "-dascore",
+
+ onItemsAdded(aItems, aCollection) {
+ let newScores = Gloda.scoreNounItems(
+ aItems,
+ {
+ terms: this.fulltextTerms,
+ stashedColumns: aCollection.stashedColumns,
+ },
+ [scoreOffsets]
+ );
+ if (this.scores) {
+ this.scores = this.scores.concat(newScores);
+ } else {
+ this.scores = newScores;
+ }
+
+ if (this.listener) {
+ this.listener.onItemsAdded(aItems, aCollection);
+ }
+ },
+ onItemsModified(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsModified(aItems, aCollection);
+ }
+ },
+ onItemsRemoved(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsRemoved(aItems, aCollection);
+ }
+ },
+ onQueryCompleted(aCollection) {
+ this.completed = true;
+ if (this.listener) {
+ this.listener.onQueryCompleted(aCollection);
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatHandler.sys.mjs b/comm/mail/components/im/modules/chatHandler.sys.mjs
new file mode 100644
index 0000000000..4b54535aa5
--- /dev/null
+++ b/comm/mail/components/im/modules/chatHandler.sys.mjs
@@ -0,0 +1,106 @@
+/* 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 { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+export var allContacts = {};
+export var onlineContacts = {};
+
+export var ChatCore = {
+ initialized: false,
+ _initializing: false,
+ init() {
+ if (this._initializing) {
+ return;
+ }
+ this._initializing = true;
+
+ Services.obs.addObserver(this, "browser-request");
+ Services.obs.addObserver(this, "contact-signed-on");
+ Services.obs.addObserver(this, "contact-signed-off");
+ Services.obs.addObserver(this, "contact-added");
+ Services.obs.addObserver(this, "contact-removed");
+ },
+ idleStart() {
+ IMServices.core.init();
+
+ // Find the accounts that exist in the im account service but
+ // not in nsMsgAccountManager. They have probably been lost if
+ // the user has used an older version of Thunderbird on a
+ // profile with IM accounts. See bug 736035.
+ let accountsById = {};
+ for (let account of IMServices.accounts.getAccounts()) {
+ accountsById[account.numericId] = account;
+ }
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ delete accountsById[incomingServer.wrappedJSObject.imAccount.numericId];
+ }
+ // Let's recreate each of them...
+ for (let id in accountsById) {
+ let account = accountsById[id];
+ let inServer = MailServices.accounts.createIncomingServer(
+ account.name,
+ account.protocol.id, // hostname
+ "im"
+ );
+ inServer.wrappedJSObject.imAccount = account;
+ let acc = MailServices.accounts.createAccount();
+ // Avoid new folder notifications.
+ inServer.valid = false;
+ acc.incomingServer = inServer;
+ inServer.valid = true;
+ MailServices.accounts.notifyServerLoaded(inServer);
+ }
+
+ IMServices.tags.getTags().forEach(function (aTag) {
+ aTag.getContacts().forEach(function (aContact) {
+ let name = aContact.preferredBuddy.normalizedName;
+ allContacts[name] = aContact;
+ });
+ });
+
+ ChatCore.initialized = true;
+ Services.obs.notifyObservers(null, "chat-core-initialized");
+ ChatCore._initializing = false;
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "browser-request") {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ aSubject
+ );
+ return;
+ }
+
+ if (aTopic == "contact-signed-on") {
+ onlineContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-signed-off") {
+ delete onlineContacts[aSubject.preferredBuddy.normalizedName];
+ return;
+ }
+
+ if (aTopic == "contact-added") {
+ allContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-removed") {
+ delete allContacts[aSubject.preferredBuddy.normalizedName];
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatIcons.sys.mjs b/comm/mail/components/im/modules/chatIcons.sys.mjs
new file mode 100644
index 0000000000..e965c23183
--- /dev/null
+++ b/comm/mail/components/im/modules/chatIcons.sys.mjs
@@ -0,0 +1,106 @@
+/* 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/. */
+
+export var ChatIcons = {
+ /**
+ * Get the icon URI for the given protocol.
+ *
+ * @param {prplIProtocol} protocol - The protocol to get the icon URI for.
+ * @param {16|32|48} [size=16] - The width and height of the icon.
+ *
+ * @returns {string} - The icon's URI.
+ */
+ getProtocolIconURI(protocol, size = 16) {
+ return `${protocol.iconBaseURI}icon${size === 16 ? "" : size}.png`;
+ },
+
+ /**
+ * Sets the opacity of the given protocol icon depending on the given chat
+ * status (see getStatusIconURI).
+ *
+ * @param {HTMLImageElement} protoIconElement - The protocol icon.
+ * @param {string} statusName - The name for the chat status.
+ */
+ setProtocolIconOpacity(protoIconElement, statusName) {
+ switch (statusName) {
+ case "unknown":
+ case "offline":
+ case "left":
+ protoIconElement.classList.add("protoIconDimmed");
+ break;
+ default:
+ protoIconElement.classList.remove("protoIconDimmed");
+ }
+ },
+
+ fallbackUserIconURI: "chrome://messenger/skin/icons/userIcon.svg",
+
+ /**
+ * Set up the user icon to show the given uri, or a fallback.
+ *
+ * @param {HTMLImageElement} userIconElement - An icon with the "userIcon"
+ * class.
+ * @param {string|null} iconUri - The uri to set, or "" to use a fallback
+ * icon, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIconSrc(userIconElement, iconUri, useFallback) {
+ if (iconUri) {
+ userIconElement.setAttribute("src", iconUri);
+ userIconElement.classList.remove("fillUserIcon");
+ } else if (useFallback) {
+ userIconElement.setAttribute("src", this.fallbackUserIconURI);
+ userIconElement.classList.add("fillUserIcon");
+ } else {
+ userIconElement.removeAttribute("src");
+ userIconElement.classList.remove("fillUserIcon");
+ }
+ },
+
+ /**
+ * Get the icon URI for the given chat status. Often given statusName would be
+ * the return of Status.toAttribute for a given status type. But a few more
+ * terms or aliases are supported.
+ *
+ * @param {string} statusName - The name for the chat status.
+ *
+ * @returns {string|null} - The icon URI for the given status, or null if none
+ * exists.
+ */
+ getStatusIconURI(statusName) {
+ switch (statusName) {
+ case "unknown":
+ return "chrome://chat/skin/unknown.svg";
+ case "available":
+ case "connected":
+ return "chrome://messenger/skin/icons/new/status-online.svg";
+ case "unavailable":
+ case "away":
+ return "chrome://messenger/skin/icons/new/status-away.svg";
+ case "offline":
+ case "disconnected":
+ case "invisible":
+ case "left":
+ return "chrome://messenger/skin/icons/new/status-offline.svg";
+ case "connecting":
+ case "disconnecting":
+ case "joining":
+ return "chrome://global/skin/icons/loading.png";
+ case "idle":
+ return "chrome://messenger/skin/icons/new/status-idle.svg";
+ case "mobile":
+ return "chrome://chat/skin/mobile.svg";
+ case "chat":
+ return "chrome://messenger/skin/icons/new/compact/chat.svg";
+ case "chat-left":
+ return "chrome://chat/skin/chat-left.svg";
+ case "active-typing":
+ return "chrome://chat/skin/typing.svg";
+ case "paused-typing":
+ return "chrome://chat/skin/typed.svg";
+ }
+ return null;
+ },
+};
diff --git a/comm/mail/components/im/modules/chatNotifications.sys.mjs b/comm/mail/components/im/modules/chatNotifications.sys.mjs
new file mode 100644
index 0000000000..664fe4e5ca
--- /dev/null
+++ b/comm/mail/components/im/modules/chatNotifications.sys.mjs
@@ -0,0 +1,262 @@
+/* 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 { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { PluralForm } from "resource://gre/modules/PluralForm.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ChatIcons } from "resource:///modules/chatIcons.sys.mjs";
+
+// Time in seconds: it is the minimum time of inactivity
+// needed to show the bundled notification.
+var kTimeToWaitForMoreMsgs = 3;
+
+export var Notifications = {
+ get ellipsis() {
+ let ellipsis = "[\u2026]";
+
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ return ellipsis;
+ },
+
+ // Holds the first direct message of a bundle while we wait for further
+ // messages from the same sender to arrive.
+ _heldMessage: null,
+ // Number of messages to be bundled in the notification (excluding
+ // _heldMessage).
+ _msgCounter: 0,
+ // Time the last message was received.
+ _lastMessageTime: 0,
+ // Sender of the last message.
+ _lastMessageSender: null,
+ // timeout Id for the set timeout for showing notification.
+ _timeoutId: null,
+
+ _showMessageNotification(aMessage, aCounter = 0) {
+ // We are about to show the notification, so let's play the notification sound.
+ // We play the sound if the user is away from TB window or even away from chat tab.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name != "chat"
+ ) {
+ Services.obs.notifyObservers(aMessage, "play-chat-notification-sound");
+ }
+
+ // If TB window has focus, there's no need to show the notification..
+ if (win && win.document.hasFocus()) {
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ let messageText, icon, name;
+ let notificationContent = Services.prefs.getIntPref(
+ "mail.chat.notification_info"
+ );
+ // 0 - show all the info,
+ // 1 - show only the sender not the message,
+ // 2 - show no details about the message being notified.
+ switch (notificationContent) {
+ case 0:
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(aMessage.displayMessage, "text/html");
+ let body = doc.querySelector("body");
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(doc, "text/plain", 0);
+ encoder.setNode(body);
+ messageText = encoder.encodeToString().replace(/\s+/g, " ");
+
+ // Crop the end of the text if needed.
+ if (messageText.length > 50) {
+ messageText = messageText.substr(0, 50);
+ if (aCounter == 0) {
+ messageText = messageText + this.ellipsis;
+ }
+ }
+
+ // If there are more messages being bundled, add the count string.
+ // ellipsis is a part of bundledMessagePreview so we don't include it here.
+ if (aCounter > 0) {
+ let bundledMessage = bundle.formatStringFromName(
+ "bundledMessagePreview",
+ [messageText]
+ );
+ messageText = PluralForm.get(aCounter, bundledMessage).replace(
+ "#1",
+ aCounter
+ );
+ }
+ // Falls through
+ case 1:
+ // Use the buddy icon if available for the icon of the notification.
+ let conv = aMessage.conversation;
+ icon = conv.convIconFilename;
+ if (!icon && !conv.isChat) {
+ icon = conv.buddy?.buddyIconFilename;
+ }
+
+ // Handle third person messages
+ name = aMessage.alias || aMessage.who;
+ if (messageText && aMessage.action) {
+ messageText = name + " " + messageText;
+ }
+ // Falls through
+ case 2:
+ if (!icon) {
+ icon = ChatIcons.fallbackUserIconURI;
+ }
+
+ if (!messageText) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ messageText = bundle.GetStringFromName("messagePreview");
+ }
+ }
+
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(
+ "", // name
+ icon,
+ name, // title
+ messageText,
+ true // clickable
+ );
+ // Show the notification!
+ Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .showAlert(alert, (subject, topic, data) => {
+ if (topic != "alertclickcallback") {
+ return;
+ }
+
+ // If there is a timeout set, clear it.
+ clearTimeout(this._timeoutId);
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ this._lastMessageTime = 0;
+ this._lastMessageSender = null;
+ // Focus the conversation if the notification is clicked.
+ let uiConv = IMServices.conversations.getUIConversation(
+ aMessage.conversation
+ );
+ let mainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mainWindow) {
+ mainWindow.focus();
+ mainWindow.showChatTab();
+ mainWindow.chatHandler.focusConversation(uiConv);
+ } else {
+ Services.appShell.hiddenDOMWindow.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "chat",
+ tabParams: { convType: "focus", conv: uiConv },
+ }
+ );
+ }
+ if (AppConstants.platform == "macosx") {
+ Cc["@mozilla.org/widget/macdocksupport;1"]
+ .getService(Ci.nsIMacDockSupport)
+ .activateApplication(true);
+ }
+ });
+
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ },
+
+ init() {
+ Services.obs.addObserver(Notifications, "new-otr-verification-request");
+ Services.obs.addObserver(Notifications, "new-directed-incoming-message");
+ Services.obs.addObserver(Notifications, "alertclickcallback");
+ },
+
+ _notificationPrefName: "mail.chat.show_desktop_notifications",
+ observe(aSubject, aTopic, aData) {
+ if (!Services.prefs.getBoolPref(this._notificationPrefName)) {
+ return;
+ }
+
+ switch (aTopic) {
+ case "new-directed-incoming-message":
+ // If this is the first message, we show the notification and
+ // store the sender's name.
+ let sender = aSubject.who || aSubject.alias;
+ if (this._lastMessageSender == null) {
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender != sender ||
+ aSubject.time > this._lastMessageTime + kTimeToWaitForMoreMsgs
+ ) {
+ // If the sender is not the same as the previous sender or the
+ // time elapsed since the last message is greater than kTimeToWaitForMoreMsgs,
+ // we show the held notification and set timeout for the message just arrived.
+ if (this._heldMessage) {
+ // if the time for the current message is greater than _lastMessageTime by
+ // more than kTimeToWaitForMoreMsgs, this will not happen since the notification will
+ // have already been dispatched.
+ clearTimeout(this._timeoutId);
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender == sender &&
+ this._lastMessageTime + kTimeToWaitForMoreMsgs >= aSubject.time
+ ) {
+ // If the sender is same as the previous sender and the time elapsed since the
+ // last held message is less than kTimeToWaitForMoreMsgs, we increase the held messages
+ // counter and update the last message's arrival time.
+ this._lastMessageTime = aSubject.time;
+ if (!this._heldMessage) {
+ this._heldMessage = aSubject;
+ } else {
+ this._msgCounter++;
+ }
+
+ clearTimeout(this._timeoutId);
+ this._timeoutId = setTimeout(() => {
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }, kTimeToWaitForMoreMsgs * 1000);
+ }
+ break;
+
+ case "new-otr-verification-request":
+ // If the Chat tab is not focused, play the sounds and update the icon
+ // counter, and show the counter in the buddy richlistitem.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name !=
+ "chat"
+ ) {
+ Services.obs.notifyObservers(
+ aSubject,
+ "play-chat-notification-sound"
+ );
+ }
+
+ break;
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/index_im.sys.mjs b/comm/mail/components/im/modules/index_im.sys.mjs
new file mode 100644
index 0000000000..bcea54e1ea
--- /dev/null
+++ b/comm/mail/components/im/modules/index_im.sys.mjs
@@ -0,0 +1,928 @@
+/* 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 CC = Components.Constructor;
+
+const { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+const { GlodaAccount } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaDataModel.jsm"
+);
+const { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+const { GlodaIndexer, IndexingJob } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaDatastore",
+ "resource:///modules/gloda/GlodaDatastore.jsm"
+);
+
+var kCacheFileName = "indexedFiles.json";
+
+var FileInputStream = CC(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+var ScriptableInputStream = CC(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+);
+
+// kIndexingDelay is how long we wait from the point of scheduling an indexing
+// job to actually carrying it out.
+var kIndexingDelay = 5000; // in milliseconds
+
+XPCOMUtils.defineLazyGetter(lazy, "MailFolder", () =>
+ Cc["@mozilla.org/mail/folder-factory;1?name=mailbox"].createInstance(
+ Ci.nsIMsgFolder
+ )
+);
+
+var gIMAccounts = {};
+
+function GlodaIMConversation(aTitle, aTime, aPath, aContent) {
+ // grokNounItem from Gloda.jsm puts automatically the values of all
+ // JS properties in the jsonAttributes magic attribute, except if
+ // they start with _, so we put the values in _-prefixed properties,
+ // and have getters in the prototype.
+ this._title = aTitle;
+ this._time = aTime;
+ this._path = aPath;
+ this._content = aContent;
+}
+GlodaIMConversation.prototype = {
+ get title() {
+ return this._title;
+ },
+ get time() {
+ return this._time;
+ },
+ get path() {
+ return this._path;
+ },
+ get content() {
+ return this._content;
+ },
+
+ // for glodaFacetBindings.xml compatibility (pretend we are a message object)
+ get account() {
+ let [protocol, username] = this._path.split("/", 2);
+
+ let cacheName = protocol + "/" + username;
+ if (cacheName in gIMAccounts) {
+ return gIMAccounts[cacheName];
+ }
+
+ // Find the nsIIncomingServer for the current imIAccount.
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ let imAccount = incomingServer.wrappedJSObject.imAccount;
+ if (
+ imAccount.protocol.normalizedName == protocol &&
+ imAccount.normalizedName == username
+ ) {
+ return (gIMAccounts[cacheName] = new GlodaAccount(incomingServer));
+ }
+ }
+ // The IM conversation is probably for an account that no longer exists.
+ return null;
+ },
+ get subject() {
+ return this._title;
+ },
+ get date() {
+ return new Date(this._time * 1000);
+ },
+ get involves() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+ _recipients: null,
+ get recipients() {
+ if (!this._recipients) {
+ this._recipients = [{ contact: { name: this._path.split("/", 2)[1] } }];
+ }
+ return this._recipients;
+ },
+ _from: null,
+ get from() {
+ if (!this._from) {
+ let from = "";
+ let account = this.account;
+ if (account) {
+ from = account.incomingServer.wrappedJSObject.imAccount.protocol.name;
+ }
+ this._from = { value: "", contact: { name: from } };
+ }
+ return this._from;
+ },
+ get tags() {
+ return [];
+ },
+ get starred() {
+ return false;
+ },
+ get attachmentNames() {
+ return null;
+ },
+ get indexedBodyText() {
+ return this._content;
+ },
+ get read() {
+ return true;
+ },
+ get folder() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+
+ // for glodaFacetView.js _removeDupes
+ get headerMessageID() {
+ return this.id;
+ },
+};
+
+// FIXME
+var WidgetProvider = {
+ providerName: "widget",
+ *process() {
+ // XXX What is this supposed to do?
+ yield GlodaConstants.kWorkDone;
+ },
+};
+
+var IMConversationNoun = {
+ name: "im-conversation",
+ clazz: GlodaIMConversation,
+ allowsArbitraryAttrs: true,
+ tableName: "imConversations",
+ schema: {
+ columns: [
+ ["id", "INTEGER PRIMARY KEY"],
+ ["title", "STRING"],
+ ["time", "NUMBER"],
+ ["path", "STRING"],
+ ],
+ fulltextColumns: [["content", "STRING"]],
+ },
+};
+Gloda.defineNoun(IMConversationNoun);
+
+// Needs to be set after calling defineNoun, otherwise it's replaced
+// by GlodaDatabind.jsm' implementation.
+IMConversationNoun.objFromRow = function (aRow) {
+ // Row columns are:
+ // 0 id
+ // 1 title
+ // 2 time
+ // 3 path
+ // 4 jsonAttributes
+ // 5 content
+ // 6 offsets
+ let conv = new GlodaIMConversation(
+ aRow.getString(1),
+ aRow.getInt64(2),
+ aRow.getString(3),
+ aRow.getString(5)
+ );
+ conv.id = aRow.getInt64(0); // handleResult will keep only our first result
+ // if the id property isn't set.
+ return conv;
+};
+
+var EXT_NAME = "im";
+
+// --- special (on-row) attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "time",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ specialColumnName: "time",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "title",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "title",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "path",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "path",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+
+// --- fulltext attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "content",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "content",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+ canQuery: true,
+});
+
+// -- fulltext search helper
+// fulltextMatches. Match over message subject, body, and attachments
+// @testpoint gloda.noun.message.attr.fulltextMatches
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "fulltextMatches",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "imConversationsText",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+});
+// For Facet.jsm DateFaceter
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "date",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ facet: {
+ type: "date",
+ },
+ canQuery: true,
+});
+
+var GlodaIMIndexer = {
+ name: "index_im",
+ cacheVersion: 1,
+ enable() {
+ Services.obs.addObserver(this, "conversation-closed");
+ Services.obs.addObserver(this, "new-ui-conversation");
+ Services.obs.addObserver(this, "conversation-update-type");
+ Services.obs.addObserver(this, "ui-conversation-closed");
+ Services.obs.addObserver(this, "ui-conversation-replaced");
+
+ // The shutdown blocker ensures pending saves happen even if the app
+ // gets shut down before the timer fires.
+ if (this._shutdownBlockerAdded) {
+ return;
+ }
+ this._shutdownBlockerAdded = true;
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "GlodaIMIndexer cache save",
+ () => {
+ if (!this._cacheSaveTimer) {
+ return Promise.resolve();
+ }
+ clearTimeout(this._cacheSaveTimer);
+ return this._saveCacheNow();
+ }
+ );
+
+ this._knownFiles = {};
+
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ return;
+ }
+ let cacheFile = dir.clone();
+ cacheFile.append(kCacheFileName);
+ if (!cacheFile.exists()) {
+ return;
+ }
+
+ const PR_RDONLY = 0x01;
+ let fis = new FileInputStream(
+ cacheFile,
+ PR_RDONLY,
+ parseInt("0444", 8),
+ Ci.nsIFileInputStream.CLOSE_ON_EOF
+ );
+ let sis = new ScriptableInputStream(fis);
+ let text = sis.read(sis.available());
+ sis.close();
+
+ let data = JSON.parse(text);
+
+ // Check to see if the Gloda datastore ID matches the one that we saved
+ // in the cache. If so, we can trust it. If not, that means that the
+ // cache is likely invalid now, so we ignore it (and eventually
+ // overwrite it).
+ if (
+ "datastoreID" in data &&
+ Gloda.datastoreID &&
+ data.datastoreID === Gloda.datastoreID
+ ) {
+ // Ok, the cache's datastoreID matches the one we expected, so it's
+ // still valid.
+ this._knownFiles = data.knownFiles;
+ }
+
+ this.cacheVersion = data.version;
+
+ // If there was no version set on the cache, there is a chance that the index
+ // is affected by bug 1069845. fixEntriesWithAbsolutePaths() sets the version to 1.
+ if (!this.cacheVersion) {
+ this.fixEntriesWithAbsolutePaths();
+ }
+ },
+ disable() {
+ Services.obs.removeObserver(this, "conversation-closed");
+ Services.obs.removeObserver(this, "new-ui-conversation");
+ Services.obs.removeObserver(this, "conversation-update-type");
+ Services.obs.removeObserver(this, "ui-conversation-closed");
+ Services.obs.removeObserver(this, "ui-conversation-replaced");
+ },
+
+ /* _knownFiles is a tree whose leaves are the last modified times of
+ * log files when they were last indexed.
+ * Each level of the tree is stored as an object. The root node is an
+ * object that maps a protocol name to an object representing the subtree
+ * for that protocol. The structure is:
+ * _knownFiles -> protoObj -> accountObj -> convObj
+ * The corresponding keys of the above objects are:
+ * protocol names -> account names -> conv names -> file names -> last modified time
+ * convObj maps ALL previously indexed log files of a chat buddy or MUC to
+ * their last modified times. Note that gloda knows nothing about log grouping
+ * done by logger.js.
+ */
+ _knownFiles: {},
+ _cacheSaveTimer: null,
+ _shutdownBlockerAdded: false,
+ _scheduleCacheSave() {
+ if (this._cacheSaveTimer) {
+ return;
+ }
+ this._cacheSaveTimer = setTimeout(this._saveCacheNow, 5000);
+ },
+ _saveCacheNow() {
+ GlodaIMIndexer._cacheSaveTimer = null;
+
+ let data = {
+ knownFiles: GlodaIMIndexer._knownFiles,
+ datastoreID: Gloda.datastoreID,
+ version: GlodaIMIndexer.cacheVersion,
+ };
+
+ // Asynchronously copy the data to the file.
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs",
+ kCacheFileName
+ );
+ return IOUtils.writeJSON(path, data, {
+ tmpPath: path + ".tmp",
+ }).catch(aError => console.error("Failed to write cache file: " + aError));
+ },
+
+ _knownConversations: {},
+ // Promise queue for indexing jobs. The next indexing job is queued using this
+ // promise's then() to ensure we only load logs for one conv at a time.
+ _indexingJobPromise: null,
+ // Maps a conv id to the function that resolves the promise representing the
+ // ongoing indexing job on it. This is called from indexIMConversation when it
+ // finishes and will trigger the next queued indexing job.
+ _indexingJobCallbacks: new Map(),
+
+ _scheduleIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // If we've already scheduled this conversation to be indexed, let's
+ // not repeat.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ if (!this._knownConversations[convId].scheduledIndex) {
+ // Ok, let's schedule the job.
+ this._knownConversations[convId].scheduledIndex = setTimeout(
+ this._beginIndexingJob.bind(this, aConversation),
+ kIndexingDelay
+ );
+ }
+ },
+
+ _beginIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // In the event that we're triggering this indexing job manually, without
+ // bothering to schedule it (for example, when a conversation is closed),
+ // we give the conversation an entry in _knownConversations, which would
+ // normally have been done in _scheduleIndexingJob.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ let conv = this._knownConversations[convId];
+ (async () => {
+ // We need to get the log files every time, because a new log file might
+ // have been started since we last got them.
+ let logFiles = await IMServices.logs.getLogPathsForConversation(
+ aConversation
+ );
+ if (!logFiles || !logFiles.length) {
+ // No log files exist yet, nothing to do!
+ return;
+ }
+
+ if (conv.logFileCount == undefined) {
+ // We initialize the _knownFiles tree path for the current files below in
+ // case it doesn't already exist.
+ let folder = PathUtils.parent(logFiles[0]);
+ let convName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let accountName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let protoName = PathUtils.filename(folder);
+ if (
+ !Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)
+ ) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+
+ // convObj is the penultimate level of the tree,
+ // maps file name -> last modified time
+ conv.convObj = accountObj[convName];
+ conv.logFileCount = 0;
+ }
+
+ // The last log file in the array is the one currently being written to.
+ // When new log files are started, we want to finish indexing the previous
+ // one as well as index the new ones. The index of the previous one is
+ // conv.logFiles.length - 1, so we slice from there. This gives us all new
+ // log files even if there are multiple new ones.
+ let currentLogFiles =
+ conv.logFileCount > 1
+ ? logFiles.slice(conv.logFileCount - 1)
+ : logFiles;
+ for (let logFile of currentLogFiles) {
+ let fileName = PathUtils.filename(logFile);
+ let lastModifiedTime = (await IOUtils.stat(logFile)).lastModified;
+ if (
+ Object.prototype.hasOwnProperty.call(conv.convObj, fileName) &&
+ conv.convObj[fileName] == lastModifiedTime
+ ) {
+ // The file hasn't changed since we last indexed it, so we're done.
+ continue;
+ }
+
+ if (this._indexingJobPromise) {
+ await this._indexingJobPromise;
+ }
+ this._indexingJobPromise = new Promise(aResolve => {
+ this._indexingJobCallbacks.set(convId, aResolve);
+ });
+
+ let job = new IndexingJob("indexIMConversation", null);
+ job.conversation = conv;
+ job.path = logFile;
+ job.lastModifiedTime = lastModifiedTime;
+ GlodaIndexer.indexJob(job);
+ }
+ conv.logFileCount = logFiles.length;
+ })().catch(console.error);
+
+ // Now clear the job, so we can index in the future.
+ this._knownConversations[convId].scheduledIndex = null;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "new-ui-conversation" ||
+ aTopic == "conversation-update-type"
+ ) {
+ // Add ourselves to the ui-conversation's list of observers for the
+ // unread-message-count-changed notification.
+ // For this notification, aSubject is the ui-conversation that is opened.
+ aSubject.addObserver(this);
+ return;
+ }
+
+ if (
+ aTopic == "ui-conversation-closed" ||
+ aTopic == "ui-conversation-replaced"
+ ) {
+ aSubject.removeObserver(this);
+ return;
+ }
+
+ if (aTopic == "unread-message-count-changed") {
+ // We get this notification by attaching observers to conversations
+ // directly (see the new-ui-conversation handler for when we attach).
+ if (aSubject.unreadIncomingMessageCount == 0) {
+ // The unread message count changed to 0, meaning that a conversation
+ // that had been in the background and receiving messages was suddenly
+ // moved to the foreground and displayed to the user. We schedule an
+ // indexing job on this conversation now, since we want to index messages
+ // that the user has seen.
+ this._scheduleIndexingJob(aSubject.target);
+ }
+ return;
+ }
+
+ if (aTopic == "conversation-closed") {
+ let convId = aSubject.id;
+ // If there's a scheduled indexing job, cancel it, because we're going
+ // to index now.
+ if (
+ convId in this._knownConversations &&
+ this._knownConversations[convId].scheduledIndex != null
+ ) {
+ clearTimeout(this._knownConversations[convId].scheduledIndex);
+ }
+
+ this._beginIndexingJob(aSubject);
+ delete this._knownConversations[convId];
+ return;
+ }
+
+ if (aTopic == "new-text" && !aSubject.noLog) {
+ // Ok, some new text is about to be put into a conversation. For this
+ // notification, aSubject is a prplIMessage.
+ let conv = aSubject.conversation;
+ let uiConv = IMServices.conversations.getUIConversation(conv);
+
+ // We only want to schedule an indexing job if this message is
+ // immediately visible to the user. We figure this out by finding
+ // the unread message count on the associated UIConversation for this
+ // message. If the unread count is 0, we know that the message has been
+ // displayed to the user.
+ if (uiConv.unreadIncomingMessageCount == 0) {
+ this._scheduleIndexingJob(conv);
+ }
+ }
+ },
+
+ /* If there is an existing gloda conversation for the given path,
+ * find its id.
+ */
+ _getIdFromPath(aPath) {
+ let selectStatement = lazy.GlodaDatastore._createAsyncStatement(
+ "SELECT id FROM imConversations WHERE path = ?1"
+ );
+ selectStatement.bindByIndex(0, aPath);
+ let id;
+ return new Promise((resolve, reject) => {
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row = aResultSet.getNextRow();
+ if (!row) {
+ return;
+ }
+ if (id || aResultSet.getNextRow()) {
+ console.error(
+ "Warning: found more than one gloda conv id for " + aPath + "\n"
+ );
+ }
+ id = id || row.getInt64(0); // We use the first found id.
+ },
+ handleError: aError =>
+ console.error("Error finding gloda id from path:\n" + aError),
+ handleCompletion: () => {
+ resolve(id);
+ },
+ });
+ });
+ },
+
+ // Get the path of a log file relative to the logs directory - the last 4
+ // components of the path.
+ _getRelativePath(aLogPath) {
+ return PathUtils.split(aLogPath).slice(-4).join("/");
+ },
+
+ /**
+ * @param {object} aCache - An object mapping file names to their last
+ * modified times at the time they were last indexed. The value for the file
+ * currently being indexed is updated to the aLastModifiedTime parameter's
+ * value once indexing is complete.
+ * @param {GlodaIMConversation} [aGlodaConv] - An optional in-out param that
+ * lets the caller save and reuse the GlodaIMConversation instance created
+ * when the conversation is indexed the first time. After a conversation is
+ * indexed for the first time, the GlodaIMConversation instance has its id
+ * property set to the row id of the conversation in the database. This id
+ * is required to later update the conversation in the database, so the
+ * caller dealing with ongoing conversation has to provide the aGlodaConv
+ * parameter, while the caller dealing with old conversations doesn't care.
+ */
+ async indexIMConversation(
+ aCallbackHandle,
+ aLogPath,
+ aLastModifiedTime,
+ aCache,
+ aGlodaConv
+ ) {
+ let log = await IMServices.logs.getLogFromFile(aLogPath);
+ let logConv = await log.getConversation();
+
+ // Ignore corrupted log files.
+ if (!logConv) {
+ return GlodaConstants.kWorkDone;
+ }
+
+ let fileName = PathUtils.filename(aLogPath);
+ let messages = logConv
+ .getMessages()
+ // Some messages returned, e.g. sessionstart messages,
+ // may have the noLog flag set. Ignore these.
+ .filter(m => !m.noLog);
+ let content = [];
+ while (messages.length > 0) {
+ await new Promise(resolve => {
+ ChromeUtils.idleDispatch(timing => {
+ while (timing.timeRemaining() > 5 && messages.length > 0) {
+ let m = messages.shift();
+ let who = m.alias || m.who;
+ // Messages like topic change notifications may not have a source.
+ let prefix = who ? who + ": " : "";
+ content.push(
+ prefix +
+ lazy.MailFolder.convertMsgSnippetToPlainText(
+ "<!DOCTYPE html>" + m.message
+ )
+ );
+ }
+ resolve();
+ });
+ });
+ }
+ content = content.join("\n\n");
+ let glodaConv;
+ if (aGlodaConv && aGlodaConv.value) {
+ glodaConv = aGlodaConv.value;
+ glodaConv._content = content;
+ } else {
+ let relativePath = this._getRelativePath(aLogPath);
+ glodaConv = new GlodaIMConversation(
+ logConv.title,
+ log.time,
+ relativePath,
+ content
+ );
+ // If we've indexed this file before, we need the id of the existing
+ // gloda conversation so that the existing entry gets updated. This can
+ // happen if the log sweep detects that the last messages in an open
+ // chat were not in fact indexed before that session was shut down.
+ let id = await this._getIdFromPath(relativePath);
+ if (id) {
+ glodaConv.id = id;
+ }
+ if (aGlodaConv) {
+ aGlodaConv.value = glodaConv;
+ }
+ }
+
+ if (!aCache) {
+ throw new Error("indexIMConversation called without aCache parameter.");
+ }
+ let isNew =
+ !Object.prototype.hasOwnProperty.call(aCache, fileName) && !glodaConv.id;
+ let rv = aCallbackHandle.pushAndGo(
+ Gloda.grokNounItem(glodaConv, {}, true, isNew, aCallbackHandle)
+ );
+
+ if (!aLastModifiedTime) {
+ console.error(
+ "indexIMConversation called without lastModifiedTime parameter."
+ );
+ }
+ aCache[fileName] = aLastModifiedTime || 1;
+ this._scheduleCacheSave();
+
+ return rv;
+ },
+
+ *_worker_indexIMConversation(aJob, aCallbackHandle) {
+ let glodaConv = {};
+ let existingGlodaConv = aJob.conversation.glodaConv;
+ if (
+ existingGlodaConv &&
+ existingGlodaConv.path == this._getRelativePath(aJob.path)
+ ) {
+ glodaConv.value = aJob.conversation.glodaConv;
+ }
+
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ aJob.path,
+ aJob.lastModifiedTime,
+ aJob.conversation.convObj,
+ glodaConv
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+
+ // Resolve the promise for this job.
+ this._indexingJobCallbacks.get(aJob.conversation.id)();
+ this._indexingJobCallbacks.delete(aJob.conversation.id);
+ this._indexingJobPromise = null;
+ aJob.conversation.indexPending = false;
+ aJob.conversation.glodaConv = glodaConv.value;
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_logsFolderSweep(aJob) {
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ // If the folder does not exist, then we are done.
+ yield GlodaConstants.kWorkDone;
+ }
+
+ // Sweep the logs directory for log files, adding any new entries to the
+ // _knownFiles tree as we traverse.
+ for (let proto of dir.directoryEntries) {
+ if (!proto.isDirectory()) {
+ continue;
+ }
+ let protoName = proto.leafName;
+ if (!Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ let accounts = proto.directoryEntries;
+ for (let account of accounts) {
+ if (!account.isDirectory()) {
+ continue;
+ }
+ let accountName = account.leafName;
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ for (let conv of account.directoryEntries) {
+ let convName = conv.leafName;
+ if (!conv.isDirectory() || convName == ".system") {
+ continue;
+ }
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+ let job = new IndexingJob("convFolderSweep", null);
+ job.folder = conv;
+ job.convObj = accountObj[convName];
+ GlodaIndexer.indexJob(job);
+ }
+ }
+ }
+
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_convFolderSweep(aJob, aCallbackHandle) {
+ let folder = aJob.folder;
+
+ for (let file of folder.directoryEntries) {
+ let fileName = file.leafName;
+ if (
+ !file.isFile() ||
+ !file.isReadable() ||
+ !fileName.endsWith(".json") ||
+ (Object.prototype.hasOwnProperty.call(aJob.convObj, fileName) &&
+ aJob.convObj[fileName] == file.lastModifiedTime)
+ ) {
+ continue;
+ }
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ file.path,
+ file.lastModifiedTime,
+ aJob.convObj
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+ }
+ yield GlodaConstants.kWorkDone;
+ },
+
+ get workers() {
+ return [
+ ["indexIMConversation", { worker: this._worker_indexIMConversation }],
+ ["logsFolderSweep", { worker: this._worker_logsFolderSweep }],
+ ["convFolderSweep", { worker: this._worker_convFolderSweep }],
+ ];
+ },
+
+ initialSweep() {
+ let job = new IndexingJob("logsFolderSweep", null);
+ GlodaIndexer.indexJob(job);
+ },
+
+ // Due to bug 1069845, some logs were indexed against their full paths instead
+ // of their path relative to the logs directory. These entries are updated to
+ // use relative paths below.
+ fixEntriesWithAbsolutePaths() {
+ let store = lazy.GlodaDatastore;
+ let selectStatement = store._createAsyncStatement(
+ "SELECT id, path FROM imConversations"
+ );
+ let updateStatement = store._createAsyncStatement(
+ "UPDATE imConversations SET path = ?1 WHERE id = ?2"
+ );
+
+ store._beginTransaction();
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row;
+ while ((row = aResultSet.getNextRow())) {
+ // If the path has more than 4 components, it is not relative to
+ // the logs folder. Update it to use only the last 4 components.
+ // The absolute paths were stored as OS-specific paths, so we split
+ // them with PathUtils.split(). It's a safe assumption that nobody
+ // ported their profile folder to a different OS since the regression,
+ // so this should work.
+ let pathComponents = PathUtils.split(row.getString(1));
+ if (pathComponents.length > 4) {
+ updateStatement.bindByIndex(1, row.getInt64(0)); // id
+ updateStatement.bindByIndex(0, pathComponents.slice(-4).join("/")); // Last 4 path components
+ updateStatement.executeAsync({
+ handleResult: () => {},
+ handleError: aError =>
+ console.error("Error updating bad entry:\n" + aError),
+ handleCompletion: () => {},
+ });
+ }
+ }
+ },
+
+ handleError: aError =>
+ console.error("Error looking for bad entries:\n" + aError),
+
+ handleCompletion: () => {
+ store.runPostCommit(() => {
+ this.cacheVersion = 1;
+ this._scheduleCacheSave();
+ });
+ store._commitTransaction();
+ },
+ });
+ },
+};
+
+GlodaIndexer.registerIndexer(GlodaIMIndexer);