From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../components/im/modules/ChatEncryption.sys.mjs | 157 ++++ .../components/im/modules/GlodaIMSearcher.sys.mjs | 352 ++++++++ .../mail/components/im/modules/chatHandler.sys.mjs | 106 +++ comm/mail/components/im/modules/chatIcons.sys.mjs | 106 +++ .../im/modules/chatNotifications.sys.mjs | 262 ++++++ comm/mail/components/im/modules/index_im.sys.mjs | 928 +++++++++++++++++++++ 6 files changed, 1911 insertions(+) create mode 100644 comm/mail/components/im/modules/ChatEncryption.sys.mjs create mode 100644 comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs create mode 100644 comm/mail/components/im/modules/chatHandler.sys.mjs create mode 100644 comm/mail/components/im/modules/chatIcons.sys.mjs create mode 100644 comm/mail/components/im/modules/chatNotifications.sys.mjs create mode 100644 comm/mail/components/im/modules/index_im.sys.mjs (limited to 'comm/mail/components/im/modules') 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( + "" + 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); -- cgit v1.2.3