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 --- comm/chat/modules/CLib.sys.mjs | 64 + comm/chat/modules/IMServices.sys.mjs | 50 + comm/chat/modules/InteractiveBrowser.sys.mjs | 138 ++ comm/chat/modules/NormalizedMap.sys.mjs | 48 + comm/chat/modules/OTR.sys.mjs | 1506 +++++++++++++++++ comm/chat/modules/OTRLib.sys.mjs | 1151 +++++++++++++ comm/chat/modules/OTRUI.sys.mjs | 998 ++++++++++++ comm/chat/modules/ToLocaleFormat.sys.mjs | 208 +++ comm/chat/modules/imContentSink.sys.mjs | 495 ++++++ comm/chat/modules/imSmileys.sys.mjs | 184 +++ comm/chat/modules/imStatusUtils.sys.mjs | 57 + comm/chat/modules/imTextboxUtils.sys.mjs | 19 + comm/chat/modules/imThemes.sys.mjs | 1333 +++++++++++++++ comm/chat/modules/imXPCOMUtils.sys.mjs | 249 +++ comm/chat/modules/jsProtoHelper.sys.mjs | 1796 +++++++++++++++++++++ comm/chat/modules/moz.build | 25 + comm/chat/modules/socket.sys.mjs | 644 ++++++++ comm/chat/modules/test/test_InteractiveBrowser.js | 280 ++++ comm/chat/modules/test/test_NormalizedMap.js | 80 + comm/chat/modules/test/test_filtering.js | 479 ++++++ comm/chat/modules/test/test_imThemes.js | 342 ++++ comm/chat/modules/test/test_jsProtoHelper.js | 159 ++ comm/chat/modules/test/test_otrlib.js | 21 + comm/chat/modules/test/xpcshell.ini | 10 + 24 files changed, 10336 insertions(+) create mode 100644 comm/chat/modules/CLib.sys.mjs create mode 100644 comm/chat/modules/IMServices.sys.mjs create mode 100644 comm/chat/modules/InteractiveBrowser.sys.mjs create mode 100644 comm/chat/modules/NormalizedMap.sys.mjs create mode 100644 comm/chat/modules/OTR.sys.mjs create mode 100644 comm/chat/modules/OTRLib.sys.mjs create mode 100644 comm/chat/modules/OTRUI.sys.mjs create mode 100644 comm/chat/modules/ToLocaleFormat.sys.mjs create mode 100644 comm/chat/modules/imContentSink.sys.mjs create mode 100644 comm/chat/modules/imSmileys.sys.mjs create mode 100644 comm/chat/modules/imStatusUtils.sys.mjs create mode 100644 comm/chat/modules/imTextboxUtils.sys.mjs create mode 100644 comm/chat/modules/imThemes.sys.mjs create mode 100644 comm/chat/modules/imXPCOMUtils.sys.mjs create mode 100644 comm/chat/modules/jsProtoHelper.sys.mjs create mode 100644 comm/chat/modules/moz.build create mode 100644 comm/chat/modules/socket.sys.mjs create mode 100644 comm/chat/modules/test/test_InteractiveBrowser.js create mode 100644 comm/chat/modules/test/test_NormalizedMap.js create mode 100644 comm/chat/modules/test/test_filtering.js create mode 100644 comm/chat/modules/test/test_imThemes.js create mode 100644 comm/chat/modules/test/test_jsProtoHelper.js create mode 100644 comm/chat/modules/test/test_otrlib.js create mode 100644 comm/chat/modules/test/xpcshell.ini (limited to 'comm/chat/modules') diff --git a/comm/chat/modules/CLib.sys.mjs b/comm/chat/modules/CLib.sys.mjs new file mode 100644 index 0000000000..35226b565b --- /dev/null +++ b/comm/chat/modules/CLib.sys.mjs @@ -0,0 +1,64 @@ +/* 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 { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +var OS = Services.appinfo.OS.toLowerCase(); + +// type defs + +var FILE = ctypes.StructType("FILE"); +var fname_t = ctypes.char.ptr; +var wchar_t = ctypes.char16_t; + +// Set the abi and path to CLib based on the OS. +var libcAbi, libcPath; +var strdup = "strdup"; +var fopen = "fopen"; + +switch (OS) { + case "win32": + case "winnt": + libcAbi = ctypes.winapi_abi; + libcPath = ctypes.libraryName("msvcrt"); + strdup = "_strdup"; + fopen = "_wfopen"; + fname_t = wchar_t.ptr; + break; + case "darwin": + case "dragonfly": + case "netbsd": + case "openbsd": + libcAbi = ctypes.default_abi; + libcPath = ctypes.libraryName("c"); + break; + case "freebsd": + libcAbi = ctypes.default_abi; + libcPath = "libc.so.7"; + break; + case "linux": + libcAbi = ctypes.default_abi; + libcPath = "libc.so.6"; + break; + default: + throw new Error("Unknown OS"); +} + +var libc = ctypes.open(libcPath); + +export var CLib = { + FILE, + memcmp: libc.declare( + "memcmp", + libcAbi, + ctypes.int, + ctypes.void_t.ptr, + ctypes.void_t.ptr, + ctypes.size_t + ), + free: libc.declare("free", libcAbi, ctypes.void_t, ctypes.void_t.ptr), + strdup: libc.declare(strdup, libcAbi, ctypes.char.ptr, ctypes.char.ptr), + fclose: libc.declare("fclose", libcAbi, ctypes.int, FILE.ptr), + fopen: libc.declare(fopen, libcAbi, FILE.ptr, fname_t, fname_t), +}; diff --git a/comm/chat/modules/IMServices.sys.mjs b/comm/chat/modules/IMServices.sys.mjs new file mode 100644 index 0000000000..eb6036b608 --- /dev/null +++ b/comm/chat/modules/IMServices.sys.mjs @@ -0,0 +1,50 @@ +/* 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"; + +export const IMServices = {}; + +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "accounts", + "@mozilla.org/chat/accounts-service;1", + "imIAccountsService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "core", + "@mozilla.org/chat/core-service;1", + "imICoreService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "cmd", + "@mozilla.org/chat/commands-service;1", + "imICommandsService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "contacts", + "@mozilla.org/chat/contacts-service;1", + "imIContactsService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "conversations", + "@mozilla.org/chat/conversations-service;1", + "imIConversationsService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "tags", + "@mozilla.org/chat/tags-service;1", + "imITagsService" +); +XPCOMUtils.defineLazyServiceGetter( + IMServices, + "logs", + "@mozilla.org/chat/logger;1", + "imILogger" +); diff --git a/comm/chat/modules/InteractiveBrowser.sys.mjs b/comm/chat/modules/InteractiveBrowser.sys.mjs new file mode 100644 index 0000000000..700bea8a61 --- /dev/null +++ b/comm/chat/modules/InteractiveBrowser.sys.mjs @@ -0,0 +1,138 @@ +/* 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 class CancelledError extends Error { + constructor() { + super("Interactive browser request was cancelled"); + } +} + +export var InteractiveBrowser = { + /** + * URL to redirect to for completion of the redirect. + * + * @type {string} + */ + COMPLETION_URL: "https://localhost", + + /** + * Open an interactive browser prompt that should be redirected to the completion URL. + * + * @param {string} url - URL to start the interaction from. + * @param {string} promptText - Prompt for the user for context to the interaction. + * @returns {Promise} Resolves when the redirect succeeds, else rejects. + */ + waitForRedirect(url, promptText) { + return this._browserRequest(url).then(({ window, webProgress, signal }) => { + window.document.title = promptText; + return this._listenForRedirect({ + window, + webProgress, + signal, + }); + }); + }, + + /** + * Open a browser window to request an interaction from the user. + * + * @param {string} url - URL to load in the browser window + * @returns {Promise} If the url is loaded, resolves with an object + * containing the |window|, |webRequest| and a |signal|. The |signal| is an + * AbortSignal that gets triggered, when the "request is cancelled", i.e. the + * window is closed. + */ + _browserRequest(url) { + return new Promise((resolve, reject) => { + let browserRequest = { + promptText: "", + iconURI: "", + url, + _active: true, + abortController: new AbortController(), + cancelled() { + if (!this._active) { + return; + } + reject(new CancelledError()); + this.abortController.abort(); + this._active = false; + }, + loaded(window, webProgress) { + if (!this._active) { + return; + } + resolve({ window, webProgress, signal: this.abortController.signal }); + }, + }; + Services.obs.notifyObservers(browserRequest, "browser-request"); + }); + }, + + /** + * Listen for a browser window to redirect to the specified URL. + * + * @param {Window} param0.window - Window to listen in. + * @param {nsIWebProgress} param0.webProgress - Web progress instance. + * @param {AbortSignal} param0.signal - Abort signal indicating that this should no longer listen for redirects. + * @returns {Promise} Resolves with the resulting redirect URL. + */ + _listenForRedirect({ window, webProgress, signal }) { + return new Promise((resolve, reject) => { + let listener = { + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + ]), + _abortListener: () => { + listener._cleanUp(); + reject(new CancelledError()); + }, + _cleanUp() { + signal.removeEventListener("abort", listener._abortListener); + webProgress.removeProgressListener(this); + window.close(); + }, + _checkForRedirect(currentUrl) { + if (!currentUrl.startsWith(InteractiveBrowser.COMPLETION_URL)) { + return; + } + resolve(currentUrl); + + this._cleanUp(); + }, + onStateChange(aWebProgress, request, stateFlags, aStatus) { + const wpl = Ci.nsIWebProgressListener; + if (stateFlags & (wpl.STATE_START | wpl.STATE_IS_NETWORK)) { + try { + this._checkForRedirect(request.name); + } catch (error) { + // Ignore |name| not implemented exception + if (error.result !== Cr.NS_ERROR_NOT_IMPLEMENTED) { + throw error; + } + } + } + }, + onLocationChange(webProgress, request, location) { + this._checkForRedirect(location.spec); + }, + onProgressChange() {}, + onStatusChange() {}, + onSecurityChange() {}, + }; + + if (signal.aborted) { + reject(new CancelledError()); + return; + } + signal.addEventListener("abort", listener._abortListener); + webProgress.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL); + const browser = window.document.getElementById("requestFrame"); + if (browser.currentURI.spec) { + listener._checkForRedirect(browser.currentURI.spec); + } + }); + }, +}; diff --git a/comm/chat/modules/NormalizedMap.sys.mjs b/comm/chat/modules/NormalizedMap.sys.mjs new file mode 100644 index 0000000000..863de6874f --- /dev/null +++ b/comm/chat/modules/NormalizedMap.sys.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +/* + * A Map that automatically normalizes keys before accessing the values. + * + * The constructor takes two parameters: + * aNormalize: A function which takes a string and returns the "normalized" + * version of it. + * aIterable: A iterable to prefill the map with, keys will be normalized. + * + * Returns a Map object that will automatically run aNormalize on any operations + * involving keys. + */ +export class NormalizedMap extends Map { + constructor(aNormalize, aIterable = []) { + if (typeof aNormalize != "function") { + throw new Error("NormalizedMap must have a normalize function!"); + } + // Create the wrapped Map; use the provided iterable after normalizing the + // keys. + let entries = [...aIterable].map(([key, val]) => [aNormalize(key), val]); + super(entries); + // Note: In derived classes, super() must be called before using 'this'. + this._normalize = aNormalize; + } + + // Dummy normalize function. + _normalize(aKey) { + return aKey; + } + + // Anything that accepts a key as an input needs to be manually overridden. + delete(key) { + return super.delete(this._normalize(key)); + } + get(key) { + return super.get(this._normalize(key)); + } + has(key) { + return super.has(this._normalize(key)); + } + set(key, val) { + super.set(this._normalize(key), val); + return this; + } +} diff --git a/comm/chat/modules/OTR.sys.mjs b/comm/chat/modules/OTR.sys.mjs new file mode 100644 index 0000000000..33784c6bd0 --- /dev/null +++ b/comm/chat/modules/OTR.sys.mjs @@ -0,0 +1,1506 @@ +/* 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 { BasePromiseWorker } from "resource://gre/modules/PromiseWorker.sys.mjs"; +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { CLib } from "resource:///modules/CLib.sys.mjs"; +import { OTRLibLoader } from "resource:///modules/OTRLib.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["messenger/otr/otr.ftl"], true) +); + +function _str(id) { + return lazy.l10n.formatValueSync(id); +} + +function _strArgs(id, args) { + return lazy.l10n.formatValueSync(id, args); +} + +// some helpers + +function setInterval(fn, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(fn, delay, Ci.nsITimer.TYPE_REPEATING_SLACK); + return timer; +} + +function clearInterval(timer) { + timer.cancel(); +} + +// See: https://developer.mozilla.org/en-US/docs/Mozilla/js-ctypes/Using_js-ctypes/Working_with_data#Determining_if_two_pointers_are_equal +function comparePointers(p, q) { + p = ctypes.cast(p, ctypes.uintptr_t).value.toString(); + q = ctypes.cast(q, ctypes.uintptr_t).value.toString(); + return p === q; +} + +function trustFingerprint(fingerprint) { + return ( + !fingerprint.isNull() && + !fingerprint.contents.trust.isNull() && + fingerprint.contents.trust.readString().length > 0 + ); +} + +// Report whether you think the given user is online. Return 1 if you think +// they are, 0 if you think they aren't, -1 if you're not sure. +function isOnline(conv) { + let ret = -1; + if (conv.buddy) { + ret = conv.buddy.online ? 1 : 0; + } + return ret; +} + +/** + * + * @param {string} filename - File in the profile. + * @returns {string} Full path to given file in the profile directory. + */ +function profilePath(filename) { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + filename + ); +} + +// OTRLib context wrapper + +function Context(context) { + this._context = context; +} + +Context.prototype = { + constructor: Context, + get username() { + return this._context.contents.username.readString(); + }, + get account() { + return this._context.contents.accountname.readString(); + }, + get protocol() { + return this._context.contents.protocol.readString(); + }, + get msgstate() { + return this._context.contents.msgstate; + }, + get fingerprint() { + return this._context.contents.active_fingerprint; + }, + get trust() { + return trustFingerprint(this.fingerprint); + }, +}; + +// otr module + +var OTRLib; + +export var OTR = { + hasRan: false, + libLoaded: false, + once() { + this.hasRan = true; + try { + OTRLib = OTRLibLoader.init(); + if (!OTRLib) { + return; + } + if (OTRLib && OTRLib.init()) { + this.initUiOps(); + OTR.libLoaded = true; + } + } catch (e) { + console.log(e); + } + }, + + privateKeyPath: profilePath("otr.private_key"), + fingerprintsPath: profilePath("otr.fingerprints"), + instanceTagsPath: profilePath("otr.instance_tags"), + + init(opts) { + opts = opts || {}; + + if (!this.hasRan) { + this.once(); + } + + if (!OTR.libLoaded) { + return; + } + + this.userstate = OTRLib.otrl_userstate_create(); + + // A map of UIConvs, keyed on the target.id + this._convos = new Map(); + this._observers = []; + this._buffer = []; + this._pendingSystemMessages = []; + this._poll_timer = null; + + // Async sending may fail in the transport protocols, so periodically + // drop old messages from the internal buffer. Should be rare. + const pluck_time = 1 * 60 * 1000; + this._pluck_timer = setInterval(() => { + let buf = this._buffer; + let i = 0; + while (i < buf.length) { + if (Date.now() - buf[i].time > pluck_time) { + this.log("dropping an old message: " + buf[i].display); + buf.splice(i, 1); + } else { + i += 1; + } + } + this._pendingSystemMessages = this._pendingSystemMessages.filter( + info => info.time + pluck_time < Date.now() + ); + }, pluck_time); + }, + + close() { + if (this._poll_timer) { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + if (this._pluck_timer) { + clearInterval(this._pluck_timer); + this._pluck_timer = null; + } + this._buffer = null; + }, + + log(msg) { + this.notifyObservers(msg, "otr:log"); + }, + + // load stored files from my profile + loadFiles() { + return Promise.all([ + IOUtils.exists(this.privateKeyPath).then(exists => { + if ( + exists && + OTRLib.otrl_privkey_read(this.userstate, this.privateKeyPath) + ) { + throw new Error("Failed to read private keys."); + } + }), + IOUtils.exists(this.fingerprintsPath).then(exists => { + if ( + exists && + OTRLib.otrl_privkey_read_fingerprints( + this.userstate, + this.fingerprintsPath, + null, + null + ) + ) { + throw new Error("Failed to read fingerprints."); + } + }), + IOUtils.exists(this.instanceTagsPath).then(exists => { + if ( + exists && + OTRLib.otrl_instag_read(this.userstate, this.instanceTagsPath) + ) { + throw new Error("Failed to read instance tags."); + } + }), + ]); + }, + + // generate a private key in a worker + generatePrivateKey(account, protocol) { + let newkey = new ctypes.void_t.ptr(); + let err = OTRLib.otrl_privkey_generate_start( + OTR.userstate, + account, + protocol, + newkey.address() + ); + if (err || newkey.isNull()) { + return Promise.reject("otrl_privkey_generate_start (" + err + ")"); + } + + let keyPtrSrc = newkey.toSource(); + let re = new RegExp( + '^ctypes\\.voidptr_t\\(ctypes\\.UInt64\\("0x([0-9a-fA-F]+)"\\)\\)$' + ); + let address; + let match = re.exec(keyPtrSrc); + if (match) { + address = match[1]; + } + + if (!address) { + OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey); + throw new Error( + "generatePrivateKey failed to parse ptr.toSource(): " + keyPtrSrc + ); + } + + let worker = new BasePromiseWorker("chrome://chat/content/otrWorker.js"); + return worker + .post("generateKey", [OTRLib.path, OTRLib.otrl_version, address]) + .then(function () { + let err = OTRLib.otrl_privkey_generate_finish( + OTR.userstate, + newkey, + OTR.privateKeyPath + ); + if (err) { + throw new Error("otrl_privkey_generate_calculate (" + err + ")"); + } + }) + .catch(function (err) { + if (!newkey.isNull()) { + OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey); + } + throw err; + }); + }, + + generatePrivateKeySync(account, protocol) { + let newkey = new ctypes.void_t.ptr(); + let err = OTRLib.otrl_privkey_generate_start( + OTR.userstate, + account, + protocol, + newkey.address() + ); + if (err || newkey.isNull()) { + return "otrl_privkey_generate_start (" + err + ")"; + } + + err = OTRLib.otrl_privkey_generate_calculate(newkey); + if (!err) { + err = OTRLib.otrl_privkey_generate_finish( + OTR.userstate, + newkey, + OTR.privateKeyPath + ); + } + if (err && !newkey.isNull()) { + OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey); + } + + if (err) { + return "otrl_privkey_generate_calculate (" + err + ")"; + } + return null; + }, + + // write fingerprints to file synchronously + writeFingerprints() { + if ( + OTRLib.otrl_privkey_write_fingerprints( + this.userstate, + this.fingerprintsPath + ) + ) { + throw new Error("Failed to write fingerprints."); + } + }, + + // generate instance tag synchronously + generateInstanceTag(account, protocol) { + if ( + OTRLib.otrl_instag_generate( + this.userstate, + this.instanceTagsPath, + account, + protocol + ) + ) { + throw new Error("Failed to generate instance tag."); + } + }, + + // get my fingerprint + privateKeyFingerprint(account, protocol) { + let fingerprint = OTRLib.otrl_privkey_fingerprint( + this.userstate, + new OTRLib.fingerprint_t(), + account, + protocol + ); + return fingerprint.isNull() ? null : fingerprint.readString(); + }, + + // return a human readable string for a fingerprint + hashToHuman(fingerprint) { + let hash; + try { + hash = fingerprint.contents.fingerprint; + } catch (e) {} + if (!hash || hash.isNull()) { + throw new Error("No fingerprint found."); + } + let human = new OTRLib.fingerprint_t(); + OTRLib.otrl_privkey_hash_to_human(human, hash); + return human.readString(); + }, + + base64encode(data, dataLen) { + // CData objects are initialized with zeroes. The plus one gives us + // our null byte so that readString below is safe. + let buf = ctypes.char.array(Math.floor((dataLen + 2) / 3) * 4 + 1)(); + OTRLib.otrl_base64_encode(buf, data, dataLen); // ignore returned size + return buf.readString(); // str + }, + + base64decode(str) { + let size = str.length; + // +1 here so that we're safe in calling readString on data in the tests. + let data = ctypes.unsigned_char.array(Math.floor((size + 3) / 4) * 3 + 1)(); + OTRLib.otrl_base64_decode(data, str, size); // ignore returned len + // We aren't returning the dataLen since we know the hash length in our + // one use case so far. + return data; + }, + + // Fetch list of known fingerprints, either for the given account, + // or for all accounts, if parameter is null. + knownFingerprints(forAccount) { + let fps = []; + for ( + let context = this.userstate.contents.context_root; + !context.isNull(); + context = context.contents.next + ) { + // skip child contexts + if (!comparePointers(context.contents.m_context, context)) { + continue; + } + let wContext = new Context(context); + + if (forAccount) { + if ( + forAccount.normalizedName != wContext.account || + forAccount.protocol.normalizedName != wContext.protocol + ) { + continue; + } + } + + for ( + let fingerprint = context.contents.fingerprint_root.next; + !fingerprint.isNull(); + fingerprint = fingerprint.contents.next + ) { + let trust = trustFingerprint(fingerprint); + fps.push({ + fpointer: fingerprint.contents.address(), + fingerprint: OTR.hashToHuman(fingerprint), + screenname: wContext.username, + trust, + purge: false, + }); + } + } + return fps; + }, + + /** + * Returns true, if all requested fps were removed. + * Returns false, if at least one fps couldn't get removed, + * because it's currently actively used. + */ + forgetFingerprints(fps) { + let result = true; + let write = false; + fps.forEach(function (obj, i) { + if (!obj.purge) { + return; + } + obj.purge = false; // reset early + let fingerprint = obj.fpointer; + if (fingerprint.isNull()) { + return; + } + // don't remove if fp is active and we're in an encrypted state + let context = fingerprint.contents.context.contents.m_context; + for ( + let context_itr = context; + !context_itr.isNull() && + comparePointers(context_itr.contents.m_context, context); + context_itr = context_itr.contents.next + ) { + if ( + context_itr.contents.msgstate === + OTRLib.messageState.OTRL_MSGSTATE_ENCRYPTED && + comparePointers(context_itr.contents.active_fingerprint, fingerprint) + ) { + result = false; + return; + } + } + write = true; + OTRLib.otrl_context_forget_fingerprint(fingerprint, 1); + fps[i] = null; // null out removed fps + }); + if (write) { + OTR.writeFingerprints(); + } + return result; + }, + + addFingerprint(context, hex) { + let fingerprint = new OTRLib.hash_t(); + if (hex.length != 40) { + throw new Error("Invalid fingerprint value."); + } + let bytes = hex.match(/.{1,2}/g); + for (let i = 0; i < 20; i++) { + fingerprint[i] = parseInt(bytes[i], 16); + } + return OTRLib.otrl_context_find_fingerprint( + context._context, + fingerprint, + 1, + null + ); + }, + + getFingerprintsForRecipient(account, protocol, recipient) { + let fingers = OTR.knownFingerprints(); + return fingers.filter(function (fg) { + return ( + fg.account == account && + fg.protocol == protocol && + fg.screenname == recipient + ); + }); + }, + + isFingerprintTrusted(fingerprint) { + return !!OTRLib.otrl_context_is_fingerprint_trusted(fingerprint); + }, + + // update trust in fingerprint + setTrust(fingerprint, trust, context) { + // ignore if no change in trust + if (context && trust === context.trust) { + return; + } + OTRLib.otrl_context_set_trust(fingerprint, trust ? "verified" : ""); + this.writeFingerprints(); + if (context) { + this.notifyTrust(context); + } + }, + + notifyTrust(context) { + this.notifyObservers(context, "otr:msg-state"); + this.notifyObservers(context, "otr:trust-state"); + }, + + authUpdate(context, progress, success) { + this.notifyObservers( + { + context, + progress, + success, + }, + "otr:auth-update" + ); + }, + + // expose message states + getMessageState() { + return OTRLib.messageState; + }, + + // get context from conv + getContext(conv) { + let context = OTRLib.otrl_context_find( + this.userstate, + conv.normalizedName, + conv.account.normalizedName, + // TODO: check why sometimes normalizedName is undefined, and if + // that's ok. Fallback wasn't necessary in the original code. + conv.account.protocol.normalizedName || "", + OTRLib.instag.OTRL_INSTAG_BEST, + 1, + null, + null, + null + ); + return new Context(context); + }, + + getContextFromRecipient(account, protocol, recipient) { + let context = OTRLib.otrl_context_find( + this.userstate, + recipient, + account, + protocol, + OTRLib.instag.OTRL_INSTAG_BEST, + 1, + null, + null, + null + ); + return new Context(context); + }, + + getUIConvFromContext(context) { + return this.getUIConvForRecipient( + context.account, + context.protocol, + context.username + ); + }, + + getUIConvForRecipient(account, protocol, recipient) { + let uiConvs = this._convos.values(); + let uiConv = uiConvs.next(); + while (!uiConv.done) { + let conv = uiConv.value.target; + if ( + conv.account.normalizedName === account && + conv.account.protocol.normalizedName === protocol && + conv.normalizedName === recipient + ) { + // console.log("=== getUIConvForRecipient found, account: " + account + " protocol: " + protocol + " recip: " + recipient); + return uiConv.value; + } + uiConv = uiConvs.next(); + } + throw new Error("Couldn't find conversation."); + }, + + getUIConvFromConv(conv) { + // return this._convos.get(conv.id); + return IMServices.conversations.getUIConversation(conv); + }, + + disconnect(conv, remove) { + OTRLib.otrl_message_disconnect( + this.userstate, + this.uiOps.address(), + null, + conv.account.normalizedName, + conv.account.protocol.normalizedName, + conv.normalizedName, + OTRLib.instag.OTRL_INSTAG_BEST + ); + if (remove) { + let uiConv = this.getUIConvFromConv(conv); + if (uiConv) { + this.removeConversation(uiConv); + } + } else { + this.notifyObservers(this.getContext(conv), "otr:disconnected"); + } + }, + + getAccountPref(prefName, accountId, defaultVal) { + return Services.prefs.getBoolPref( + "messenger.account." + accountId + ".options." + prefName, + defaultVal + ); + }, + + sendQueryMsg(conv) { + let req = this.getAccountPref( + "otrRequireEncryption", + conv.account.id, + Services.prefs.getBoolPref("chat.otr.default.requireEncryption") + ); + let query = OTRLib.otrl_proto_default_query_msg( + conv.account.normalizedName, + req ? OTRLib.OTRL_POLICY_ALWAYS : OTRLib.OTRL_POLICY_OPPORTUNISTIC + ); + if (query.isNull()) { + console.error(new Error("Sending query message failed.")); + return; + } + // Use the default msg to format the version. + // We don't support v1 of the protocol so this should be fine. + let queryMsg = /^\?OTR.*?\?/.exec(query.readString())[0] + "\n"; + // Avoid sending any numbers in the query message, because receiving + // software could misinterpret it as a protocol version. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1536108 + let noNumbersName = conv.account.normalizedName.replace(/[0-9]/g, "#"); + queryMsg += _strArgs("query-msg", { name: noNumbersName }); + this.sendOTRSystemMessage(conv, queryMsg); + OTRLib.otrl_message_free(query); + }, + + _pendingSystemMessages: null, + /** + * Wrapper for system messages sent by OTR to ensure they are correctly + * handled through the OutgoingMessage event handlers. + * + * @param {prplIConversation} conv + * @param {string} message + */ + sendOTRSystemMessage(conv, message) { + this._pendingSystemMessages.push({ + message, + convId: conv.id, + time: Date.now(), + }); + conv.sendMsg(message, false, false); + }, + + trustState: { + TRUST_NOT_PRIVATE: 0, + TRUST_UNVERIFIED: 1, + TRUST_PRIVATE: 2, + TRUST_FINISHED: 3, + }, + + // Check the attributes of the OTR context, and derive how that maps + // to one of the above trust states, which we'll show to the user. + // If we have an encrypted channel, it depends on the presence of a + // context.trust object, if we treat is as private or unverified. + trust(context) { + let level = this.trustState.TRUST_NOT_PRIVATE; + switch (context.msgstate) { + case OTRLib.messageState.OTRL_MSGSTATE_ENCRYPTED: + level = context.trust + ? this.trustState.TRUST_PRIVATE + : this.trustState.TRUST_UNVERIFIED; + break; + case OTRLib.messageState.OTRL_MSGSTATE_FINISHED: + level = this.trustState.TRUST_FINISHED; + break; + } + return level; + }, + + /** @param {Context} wContext - wrapped context. */ + getAccountPrefBranch(wContext) { + let account = IMServices.accounts + .getAccounts() + .find( + acc => + wContext.account == acc.normalizedName && + wContext.protocol == acc.protocol.normalizedName + ); + if (!account) { + return null; + } + return Services.prefs.getBranch(`messenger.account.${account.id}.`); + }, + + // uiOps callbacks + + /** + * Return the OTR policy for the given context. + */ + policy_cb(opdata, context) { + let wContext = new Context(context); + let pb = OTR.getAccountPrefBranch(wContext); + if (!pb) { + return new ctypes.unsigned_int(0); + } + try { + let conv = OTR.getUIConvFromContext(wContext); + // Ensure we never try to layer OTR on top of protocol native encryption. + if ( + conv.encryptionState !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED + ) { + return new ctypes.unsigned_int(0); + } + } catch (error) { + // No conversation found for the context, fall through to default logic. + } + let prefRequire = pb.getBoolPref( + "options.otrRequireEncryption", + Services.prefs.getBoolPref("chat.otr.default.requireEncryption") + ); + return prefRequire + ? OTRLib.OTRL_POLICY_ALWAYS + : OTRLib.OTRL_POLICY_OPPORTUNISTIC; + }, + + /** + * Create a private key for the given accountname/protocol if desired. + */ + create_privkey_cb(opdata, accountname, protocol) { + let args = { + account: accountname.readString(), + protocol: protocol.readString(), + }; + this.notifyObservers(args, "otr:generate"); + }, + + /** + * Report whether you think the given user is online. Return 1 if you + * think they are, 0 if you think they aren't, -1 if you're not sure. + */ + is_logged_in_cb(opdata, accountname, protocol, recipient) { + let conv = this.getUIConvForRecipient( + accountname.readString(), + protocol.readString(), + recipient.readString() + ).target; + return isOnline(conv); + }, + + /** + * Send the given IM to the given recipient from the given + * accountname/protocol. + */ + inject_message_cb(opdata, accountname, protocol, recipient, message) { + let aMsg = message.readString(); + this.log("inject_message_cb (msglen:" + aMsg.length + "): " + aMsg); + this.sendOTRSystemMessage( + this.getUIConvForRecipient( + accountname.readString(), + protocol.readString(), + recipient.readString() + ).target, + aMsg + ); + }, + + /** + * new fingerprint for the given user has been received. + */ + new_fingerprint_cb(opdata, us, accountname, protocol, username, fingerprint) { + let context = OTRLib.otrl_context_find( + us, + username, + accountname, + protocol, + OTRLib.instag.OTRL_INSTAG_MASTER, + 1, + null, + null, + null + ); + + let seen = false; + let fp = context.contents.fingerprint_root.next; + while (!fp.isNull()) { + if ( + CLib.memcmp(fingerprint, fp.contents.fingerprint, new ctypes.size_t(20)) + ) { + seen = true; + break; + } + fp = fp.contents.next; + } + + let wContext = new Context(context); + let defaultNudge = Services.prefs.getBoolPref( + "chat.otr.default.verifyNudge" + ); + let prefNudge = defaultNudge; + let pb = OTR.getAccountPrefBranch(wContext); + if (pb) { + prefNudge = pb.getBoolPref("options.otrVerifyNudge", defaultNudge); + } + + // Only nudge on new fingerprint, as opposed to always. + if (!prefNudge) { + this.notifyObservers( + wContext, + "otr:unverified", + seen ? "seen" : "unseen" + ); + } + }, + + /** + * The list of known fingerprints has changed. Write them to disk. + */ + write_fingerprint_cb(opdata) { + this.writeFingerprints(); + }, + + /** + * A ConnContext has entered a secure state. + */ + gone_secure_cb(opdata, context) { + let wContext = new Context(context); + let defaultNudge = Services.prefs.getBoolPref( + "chat.otr.default.verifyNudge" + ); + let prefNudge = defaultNudge; + let pb = OTR.getAccountPrefBranch(wContext); + if (pb) { + prefNudge = pb.getBoolPref("options.otrVerifyNudge", defaultNudge); + } + let strid = wContext.trust + ? "context-gone-secure-private" + : "context-gone-secure-unverified"; + this.notifyObservers(wContext, "otr:msg-state"); + this.sendAlert(wContext, _strArgs(strid, { name: wContext.username })); + if (prefNudge && !wContext.trust) { + this.notifyObservers(wContext, "otr:unverified", "unseen"); + } + }, + + /** + * A ConnContext has left a secure state. + */ + gone_insecure_cb(opdata, context) { + // This isn't used. See: https://bugs.otr.im/lib/libotr/issues/48 + }, + + /** + * We have completed an authentication, using the D-H keys we already + * knew. + * + * @param is_reply indicates whether we initiated the AKE. + */ + still_secure_cb(opdata, context, is_reply) { + // Indicate the private conversation was refreshed. + if (!is_reply) { + context = new Context(context); + this.notifyObservers(context, "otr:msg-state"); + this.sendAlert( + context, + _strArgs("context-still-secure", { name: context.username }) + ); + } + }, + + /** + * Find the maximum message size supported by this protocol. + */ + max_message_size_cb(opdata, context) { + context = new Context(context); + // These values are, for the most part, from pidgin-otr's mms_table. + switch (context.protocol) { + case "irc": + case "prpl-irc": + return 417; + case "facebook": + case "gtalk": + case "odnoklassniki": + case "jabber": + case "xmpp": + return 65536; + case "prpl-yahoo": + return 799; + case "prpl-msn": + return 1409; + case "prpl-icq": + return 2346; + case "prpl-gg": + return 1999; + case "prpl-aim": + case "prpl-oscar": + return 2343; + case "prpl-novell": + return 1792; + default: + return 0; + } + }, + + /** + * We received a request from the buddy to use the current "extra" + * symmetric key. + */ + received_symkey_cb(opdata, context, use, usedata, usedatalen, symkey) { + // Ignore until we have a use. + }, + + /** + * Return a string according to the error event. + */ + otr_error_message_cb(opdata, context, err_code) { + context = new Context(context); + let msg; + switch (err_code) { + case OTRLib.errorCode.OTRL_ERRCODE_ENCRYPTION_ERROR: + msg = _str("error-enc"); + break; + case OTRLib.errorCode.OTRL_ERRCODE_MSG_NOT_IN_PRIVATE: + msg = _strArgs("error-not-priv", context.username); + break; + case OTRLib.errorCode.OTRL_ERRCODE_MSG_UNREADABLE: + msg = _str("error-unreadable"); + break; + case OTRLib.errorCode.OTRL_ERRCODE_MSG_MALFORMED: + msg = _str("error-malformed"); + break; + default: + return null; + } + return CLib.strdup(msg); + }, + + /** + * Deallocate a string returned by otr_error_message_cb. + */ + otr_error_message_free_cb(opdata, err_msg) { + if (!err_msg.isNull()) { + CLib.free(err_msg); + } + }, + + /** + * Return a string that will be prefixed to any resent message. + */ + resent_msg_prefix_cb(opdata, context) { + return CLib.strdup(_str("resent")); + }, + + /** + * Deallocate a string returned by resent_msg_prefix. + */ + resent_msg_prefix_free_cb(opdata, prefix) { + if (!prefix.isNull()) { + CLib.free(prefix); + } + }, + + /** + * Update the authentication UI with respect to SMP events. + */ + handle_smp_event_cb(opdata, smp_event, context, progress_percent, question) { + context = new Context(context); + switch (smp_event) { + case OTRLib.smpEvent.OTRL_SMPEVENT_NONE: + break; + case OTRLib.smpEvent.OTRL_SMPEVENT_ASK_FOR_ANSWER: + case OTRLib.smpEvent.OTRL_SMPEVENT_ASK_FOR_SECRET: + this.notifyObservers( + { + context, + progress: progress_percent, + question: question.isNull() ? null : question.readString(), + }, + "otr:auth-ask" + ); + break; + case OTRLib.smpEvent.OTRL_SMPEVENT_CHEATED: + OTR.abortSMP(context); + /* falls through */ + case OTRLib.smpEvent.OTRL_SMPEVENT_IN_PROGRESS: + case OTRLib.smpEvent.OTRL_SMPEVENT_SUCCESS: + case OTRLib.smpEvent.OTRL_SMPEVENT_FAILURE: + case OTRLib.smpEvent.OTRL_SMPEVENT_ABORT: + this.authUpdate( + context, + progress_percent, + smp_event === OTRLib.smpEvent.OTRL_SMPEVENT_SUCCESS + ); + break; + case OTRLib.smpEvent.OTRL_SMPEVENT_ERROR: + OTR.abortSMP(context); + break; + default: + this.log("smp event: " + smp_event); + } + }, + + /** + * Handle and send the appropriate message(s) to the sender/recipient + * depending on the message events. + */ + handle_msg_event_cb(opdata, msg_event, context, message, err) { + context = new Context(context); + switch (msg_event) { + case OTRLib.messageEvent.OTRL_MSGEVENT_NONE: + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_ENCRYPTION_REQUIRED: + this.sendAlert( + context, + _strArgs("msgevent-encryption-required-part1", { + name: context.username, + }) + ); + this.sendAlert(context, _str("msgevent-encryption-required-part2")); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_ENCRYPTION_ERROR: + this.sendAlert(context, _str("msgevent-encryption-error")); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_CONNECTION_ENDED: + this.sendAlert( + context, + _strArgs("msgevent-connection-ended", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_SETUP_ERROR: + this.sendAlert( + context, + _strArgs("msgevent-setup-error", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_MSG_REFLECTED: + this.sendAlert(context, _str("msgevent-msg-reflected")); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_MSG_RESENT: + this.sendAlert( + context, + _strArgs("msgevent-msg-resent", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_NOT_IN_PRIVATE: + this.sendAlert( + context, + _strArgs("msgevent-rcvdmsg-not-private", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNREADABLE: + this.sendAlert( + context, + _strArgs("msgevent-rcvdmsg-unreadable", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_MALFORMED: + this.sendAlert( + context, + _strArgs("msgevent-rcvdmsg-malformed", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_LOG_HEARTBEAT_RCVD: + this.log( + _strArgs("msgevent-log-heartbeat-rcvd", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_LOG_HEARTBEAT_SENT: + this.log( + _strArgs("msgevent-log-heartbeat-sent", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_GENERAL_ERR: + this.sendAlert(context, _str("msgevent-rcvdmsg-general-err")); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNENCRYPTED: + this.sendAlert( + context, + _strArgs("msgevent-rcvdmsg-unencrypted", { + name: context.username, + msg: message.isNull() ? "" : message.readString(), + }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNRECOGNIZED: + this.sendAlert( + context, + _strArgs("msgevent-rcvdmsg-unrecognized", { name: context.username }) + ); + break; + case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_FOR_OTHER_INSTANCE: + this.log( + _strArgs("msgevent-rcvdmsg-for-other-instance", { + name: context.username, + }) + ); + break; + default: + this.log("msg event: " + msg_event); + } + }, + + /** + * Create an instance tag for the given accountname/protocol if + * desired. + */ + create_instag_cb(opdata, accountname, protocol) { + this.generateInstanceTag(accountname.readString(), protocol.readString()); + }, + + /** + * When timer_control is called, turn off any existing periodic timer. + * Additionally, if interval > 0, set a new periodic timer to go off + * every interval seconds. + */ + timer_control_cb(opdata, interval) { + if (this._poll_timer) { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + if (interval > 0) { + this._poll_timer = setInterval(() => { + OTRLib.otrl_message_poll(this.userstate, this.uiOps.address(), null); + }, interval * 1000); + } + }, + + // end of uiOps + + initUiOps() { + this.uiOps = new OTRLib.OtrlMessageAppOps(); + + let methods = [ + "policy", + "create_privkey", + "is_logged_in", + "inject_message", + "update_context_list", // not implemented + "new_fingerprint", + "write_fingerprint", + "gone_secure", + "gone_insecure", + "still_secure", + "max_message_size", + "account_name", // not implemented + "account_name_free", // not implemented + "received_symkey", + "otr_error_message", + "otr_error_message_free", + "resent_msg_prefix", + "resent_msg_prefix_free", + "handle_smp_event", + "handle_msg_event", + "create_instag", + "convert_msg", // not implemented + "convert_free", // not implemented + "timer_control", + ]; + + for (let i = 0; i < methods.length; i++) { + let m = methods[i]; + if (!this[m + "_cb"]) { + this.uiOps[m] = null; + continue; + } + // keep a pointer to this in memory to avoid crashing + this[m + "_cb"] = OTRLib[m + "_cb_t"](this[m + "_cb"].bind(this)); + this.uiOps[m] = this[m + "_cb"]; + } + }, + + sendAlert(context, msg) { + this.getUIConvFromContext(context).systemMessage(msg, false, true); + }, + + observe(aObject, aTopic, aMsg) { + switch (aTopic) { + case "sending-message": + this.onSend(aObject); + break; + case "received-message": + this.onReceive(aObject); + break; + case "new-ui-conversation": + this.addConversation(aObject); + break; + case "conversation-update-type": + if (this._convos.has(aObject.target.id)) { + this._convos.get(aObject.target.id).removeObserver(this); + } + this.addConversation(aObject); + break; + case "update-conv-encryption": { + // Disable OTR encryption when the chat protocol initiates encryption + // for the conversation. + const context = this.getContext(aObject); + const trust = this.trust(context); + if ( + trust === this.trustState.TRUST_NOT_PRIVATE || + trust === this.trustState.TRUST_PRIVATE + ) { + this.disconnect(aObject, false); + } + break; + } + } + }, + + addConversation(uiConv) { + let conv = uiConv.target; + if (conv.isChat) { + return; + } + this._convos.set(conv.id, uiConv); + uiConv.addObserver(this); + }, + + removeConversation(uiConv) { + uiConv.removeObserver(this); + this._convos.delete(uiConv.target.id); + this.clearMsgs(uiConv.target.id); + }, + + sendSecret(context, secret, question) { + let str = ctypes.char.array()(secret); + let strlen = new ctypes.size_t(str.length - 1); + OTRLib.otrl_message_initiate_smp_q( + this.userstate, + this.uiOps.address(), + null, + context._context, + question ? question : null, + str, + strlen + ); + }, + + sendResponse(context, response) { + let str = ctypes.char.array()(response); + let strlen = new ctypes.size_t(str.length - 1); + OTRLib.otrl_message_respond_smp( + this.userstate, + this.uiOps.address(), + null, + context._context, + str, + strlen + ); + }, + + abortSMP(context) { + OTRLib.otrl_message_abort_smp( + this.userstate, + this.uiOps.address(), + null, + context._context + ); + }, + + onSend(om) { + if (om.cancelled) { + return; + } + + let conv = om.conversation; + if (conv.isChat) { + return; + } + + if (om.action) { + // embed /me into the message text for encrypted actions. + let context = this.getContext(conv); + if (context.msgstate != this.trustState.TRUST_NOT_PRIVATE) { + om.cancelled = true; + conv.sendMsg("/me " + om.message, false, false); + } + return; + } + + // Skip if OTR sent this message. + let pendingIndex = this._pendingSystemMessages.findIndex( + info => info.convId == conv.id && info.message == om.message + ); + if (pendingIndex > -1) { + this._pendingSystemMessages.splice(pendingIndex, 1); + return; + } + + let newMessage = new ctypes.char.ptr(); + + this.log("pre sending: " + om.message); + + let err = OTRLib.otrl_message_sending( + this.userstate, + this.uiOps.address(), + null, + conv.account.normalizedName, + conv.account.protocol.normalizedName, + conv.normalizedName, + OTRLib.instag.OTRL_INSTAG_BEST, + om.message, + null, + newMessage.address(), + OTRLib.fragPolicy.OTRL_FRAGMENT_SEND_ALL_BUT_LAST, + null, + null, + null + ); + + let msg = om.message; + + if (err) { + om.cancelled = true; + console.error(new Error("Failed to send message. Returned code: " + err)); + } else if (!newMessage.isNull()) { + msg = newMessage.readString(); + // https://bugs.otr.im/lib/libotr/issues/52 + if (!msg) { + om.cancelled = true; + } + } + + if (!om.cancelled) { + // OTR handshakes only work while both peers are online. + // Sometimes we want to include a special whitespace suffix, + // which the OTR protocol uses to signal that the sender is willing + // to start an OTR session. Don't do that for offline messages. + // See: https://bugs.otr.im/lib/libotr/issues/102 + if (isOnline(conv) === 0) { + let ind = msg.indexOf(OTRLib.OTRL_MESSAGE_TAG_BASE); + if (ind > -1) { + msg = msg.substring(0, ind); + let context = this.getContext(conv); + context._context.contents.otr_offer = OTRLib.otr_offer.OFFER_NOT; + } + } + + this.bufferMsg(conv.id, om.message, msg); + om.message = msg; + } + + this.log("post sending (" + !om.cancelled + "): " + om.message); + OTRLib.otrl_message_free(newMessage); + }, + + /** + * + * @param {imIMessage} im - Incoming message. + */ + onReceive(im) { + if (im.cancelled || im.system) { + return; + } + + let conv = im.conversation; + if (conv.isChat) { + return; + } + + // After outgoing messages have been handled in onSend, + // they are again passed back to us, here in onReceive. + // This is our chance to prevent both outgoing and incoming OTR + // messages from being logged here. + if (im.originalMessage.startsWith("?OTR")) { + im.otrEncrypted = true; + } + + if (im.outgoing) { + this.log("outgoing message to display: " + im.displayMessage); + this.pluckMsg(im); + return; + } + + let newMessage = new ctypes.char.ptr(); + let tlvs = new OTRLib.OtrlTLV.ptr(); + + let err = OTRLib.otrl_message_receiving( + this.userstate, + this.uiOps.address(), + null, + conv.account.normalizedName, + conv.account.protocol.normalizedName, + conv.normalizedName, + im.displayMessage, + newMessage.address(), + tlvs.address(), + null, + null, + null + ); + + // An OTR message was properly decrypted. + if (!newMessage.isNull()) { + im.displayMessage = newMessage.readString(); + // Check if it was an encrypted action message. + if (im.displayMessage.startsWith("/me ")) { + im.action = true; + im.displayMessage = im.displayMessage.slice(4); + } + } + + // search tlvs for a disconnect msg + // https://bugs.otr.im/lib/libotr/issues/54 + let tlv = OTRLib.otrl_tlv_find(tlvs, OTRLib.tlvs.OTRL_TLV_DISCONNECTED); + if (!tlv.isNull()) { + let context = this.getContext(conv); + this.notifyObservers(context, "otr:disconnected"); + this.sendAlert( + context, + _strArgs("tlv-disconnected", { name: conv.normalizedName }) + ); + } + + if (err) { + this.log("error (" + err + ") ignoring: " + im.displayMessage); + im.cancelled = true; // ignore + } + + OTRLib.otrl_tlv_free(tlvs); + OTRLib.otrl_message_free(newMessage); + }, + + // observer interface + + addObserver(observer) { + if (!this._observers.includes(observer)) { + this._observers.push(observer); + } + }, + + removeObserver(observer) { + this._observers = this._observers.filter(o => o !== observer); + }, + + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + observer.observe(aSubject, aTopic, aData); + } + }, + + // buffer messages + + /** + * Remove messages that were making it through the system related to a + * conversation. + * + * @param {number} convId - ID of the conversation to purge all messages for. + */ + clearMsgs(convId) { + this._buffer = this._buffer.filter(msg => msg.convId !== convId); + this._pendingSystemMessages = this._pendingSystemMessages.filter( + info => info.convId !== convId + ); + }, + + /** + * Save unencrypted outgoing message to a buffer so we can restore it later + * on when displaying it. + * + * @param {number} convId - ID of the conversation. + * @param {string} display - Message to display. + * @param {string} sent - Message that was sent. + */ + bufferMsg(convId, display, sent) { + this._buffer.push({ + convId, + display, + sent, + time: Date.now(), + }); + }, + + /** + * Get the unencrypted version of an outgoing OTR encrypted message that we + * are handling in the incoming message path for displaying. Also discards + * magic OTR bytes and such for displaying. + * + * @param {imIMessage} incomingMessage - Message with an outgoing tag. + * @returns + */ + pluckMsg(incomingMessage) { + for (let i = 0; i < this._buffer.length; i++) { + let bufferedInfo = this._buffer[i]; + if ( + bufferedInfo.convId === incomingMessage.conversation.id && + bufferedInfo.sent === incomingMessage.displayMessage + ) { + incomingMessage.displayMessage = bufferedInfo.display; + this._buffer.splice(i, 1); + this.log("displaying: " + bufferedInfo.display); + return; + } + } + // don't display if message wasn't buffered + if (incomingMessage.otrEncrypted) { + incomingMessage.cancelled = true; + this.log("not displaying: " + incomingMessage.displayMessage); + } + }, +}; + +// exports diff --git a/comm/chat/modules/OTRLib.sys.mjs b/comm/chat/modules/OTRLib.sys.mjs new file mode 100644 index 0000000000..b9fddbe89e --- /dev/null +++ b/comm/chat/modules/OTRLib.sys.mjs @@ -0,0 +1,1151 @@ +/* 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 otrl_version = [4, 1, 1]; + +import { CLib } from "resource:///modules/CLib.sys.mjs"; + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +var systemOS = Services.appinfo.OS.toLowerCase(); + +var abi = ctypes.default_abi; + +var libotr, libotrPath; + +function getLibraryFilename(baseName, suffix) { + return ctypes.libraryName(baseName) + suffix; +} + +function getSystemVersionedFilename() { + let baseName; + let suffix; + + switch (systemOS) { + case "winnt": + baseName = "libotr-5"; + suffix = ""; + break; + case "darwin": + baseName = "otr.5"; + suffix = ""; + break; + default: + baseName = "otr"; + suffix = ".5"; + break; + } + + return getLibraryFilename(baseName, suffix); +} + +function getDistributionFilename() { + let baseName; + let suffix; + + if (systemOS === "winnt") { + baseName = "libotr"; + suffix = ""; + } else { + baseName = "otr"; + suffix = ""; + } + + return getLibraryFilename(baseName, suffix); +} + +function getDistributionFullPath() { + let binPath = Services.dirsvc.get("XpcomLib", Ci.nsIFile).path; + let binDir = PathUtils.parent(binPath); + return PathUtils.join(binDir, getDistributionFilename()); +} + +function tryLoadOTR(filename, info) { + libotrPath = filename; + try { + libotr = ctypes.open(filename); + } catch (e) { + return `Tried to load ${filename}${info}`; + } + return ""; +} + +function loadExternalOTRLib() { + const systemInfo = " from system's standard library locations."; + + let info = ""; + // Try to load using an absolute path from our install directory + if (!libotr) { + info += tryLoadOTR(getDistributionFullPath(), ""); + } + + // Try to load using our expected filename from system directories + if (!libotr) { + info += ", " + tryLoadOTR(getDistributionFilename(), systemInfo); + } + + // Try to load using a versioned library name + if (!libotr) { + info += ", " + tryLoadOTR(getSystemVersionedFilename(), systemInfo); + } + + // Try other filenames + + if (!libotr && systemOS == "winnt") { + info += ", " + tryLoadOTR(getLibraryFilename("otr.5", ""), systemInfo); + } + + if (!libotr && systemOS == "winnt") { + info += ", " + tryLoadOTR(getLibraryFilename("otr-5", ""), systemInfo); + } + + if (!libotr) { + info += ", " + tryLoadOTR(getLibraryFilename("otr", ""), systemInfo); + } + + if (!libotr) { + throw new Error("Cannot load required OTR library; " + info); + } +} + +export var OTRLibLoader = { + init() { + loadExternalOTRLib(); + if (libotr) { + enableOTRLibJS(); + } + return OTRLib; + }, +}; + +// Helper function to open files with the path properly encoded. +var callWithFILEp = function () { + // Windows filenames are in UTF-16. + let charType = systemOS === "winnt" ? "jschar" : "char"; + + let args = Array.from(arguments); + let func = args.shift() + "_FILEp"; + let mode = ctypes[charType].array()(args.shift()); + let ind = args.shift(); + let filename = ctypes[charType].array()(args[ind]); + + let file = CLib.fopen(filename, mode); + if (file.isNull()) { + return 1; + } + + // Swap filename with file. + args[ind] = file; + + let ret = OTRLib[func].apply(OTRLib, args); + CLib.fclose(file); + return ret; +}; + +// type defs + +const FILE = CLib.FILE; + +const time_t = ctypes.long; +const gcry_error_t = ctypes.unsigned_int; +const gcry_cipher_hd_t = ctypes.StructType("gcry_cipher_handle").ptr; +const gcry_md_hd_t = ctypes.StructType("gcry_md_handle").ptr; +const gcry_mpi_t = ctypes.StructType("gcry_mpi").ptr; + +const otrl_instag_t = ctypes.unsigned_int; +const OtrlPolicy = ctypes.unsigned_int; +const OtrlTLV = ctypes.StructType("s_OtrlTLV"); +const ConnContext = ctypes.StructType("context"); +const ConnContextPriv = ctypes.StructType("context_priv"); +const OtrlMessageAppOps = ctypes.StructType("s_OtrlMessageAppOps"); +const OtrlAuthInfo = ctypes.StructType("OtrlAuthInfo"); +const Fingerprint = ctypes.StructType("s_fingerprint"); +const s_OtrlUserState = ctypes.StructType("s_OtrlUserState"); +const OtrlUserState = s_OtrlUserState.ptr; +const OtrlSMState = ctypes.StructType("OtrlSMState"); +const DH_keypair = ctypes.StructType("DH_keypair"); +const OtrlPrivKey = ctypes.StructType("s_OtrlPrivKey"); +const OtrlInsTag = ctypes.StructType("s_OtrlInsTag"); +const OtrlPendingPrivKey = ctypes.StructType("s_OtrlPendingPrivKey"); + +const OTRL_PRIVKEY_FPRINT_HUMAN_LEN = 45; +const fingerprint_t = ctypes.char.array(OTRL_PRIVKEY_FPRINT_HUMAN_LEN); +const hash_t = ctypes.unsigned_char.array(20); + +const app_data_free_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, +]).ptr; + +// enums + +const OtrlErrorCode = ctypes.int; +const OtrlSMPEvent = ctypes.int; +const OtrlMessageEvent = ctypes.int; +const OtrlFragmentPolicy = ctypes.int; +const OtrlConvertType = ctypes.int; +const OtrlMessageState = ctypes.int; +const OtrlAuthState = ctypes.int; +const OtrlSessionIdHalf = ctypes.int; +const OtrlSMProgState = ctypes.int; +const NextExpectedSMP = ctypes.int; + +// callback signatures + +const policy_cb_t = ctypes.FunctionType(abi, OtrlPolicy, [ + ctypes.void_t.ptr, + ConnContext.ptr, +]).ptr; + +const create_privkey_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, +]).ptr; + +const is_logged_in_cb_t = ctypes.FunctionType(abi, ctypes.int, [ + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, +]).ptr; + +const inject_message_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, +]).ptr; + +const update_context_list_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, +]).ptr; + +const new_fingerprint_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + OtrlUserState, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.unsigned_char.array(20), +]).ptr; + +const write_fingerprint_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, +]).ptr; + +const gone_secure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, +]).ptr; + +const gone_insecure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, +]).ptr; + +const still_secure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.int, +]).ptr; + +const max_message_size_cb_t = ctypes.FunctionType(abi, ctypes.int, [ + ctypes.void_t.ptr, + ConnContext.ptr, +]).ptr; + +const account_name_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [ + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, +]).ptr; + +const account_name_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, +]).ptr; + +const received_symkey_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.unsigned_int, + ctypes.unsigned_char.ptr, + ctypes.size_t, + ctypes.unsigned_char.ptr, +]).ptr; + +const otr_error_message_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [ + ctypes.void_t.ptr, + ConnContext.ptr, + OtrlErrorCode, +]).ptr; + +const otr_error_message_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, +]).ptr; + +const resent_msg_prefix_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [ + ctypes.void_t.ptr, + ConnContext.ptr, +]).ptr; + +const resent_msg_prefix_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, +]).ptr; + +const handle_smp_event_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + OtrlSMPEvent, + ConnContext.ptr, + ctypes.unsigned_short, + ctypes.char.ptr, +]).ptr; + +const handle_msg_event_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + OtrlMessageEvent, + ConnContext.ptr, + ctypes.char.ptr, + gcry_error_t, +]).ptr; + +const create_instag_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, +]).ptr; + +const convert_msg_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, + OtrlConvertType, + ctypes.char.ptr.ptr, + ctypes.char.ptr, +]).ptr; + +const convert_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.char.ptr, +]).ptr; + +const timer_control_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [ + ctypes.void_t.ptr, + ctypes.unsigned_int, +]).ptr; + +// defines + +s_OtrlUserState.define([ + { context_root: ConnContext.ptr }, + { privkey_root: OtrlPrivKey.ptr }, + { instag_root: OtrlInsTag.ptr }, + { pending_root: OtrlPendingPrivKey.ptr }, + { timer_running: ctypes.int }, +]); + +Fingerprint.define([ + { next: Fingerprint.ptr }, + { tous: Fingerprint.ptr.ptr }, + { fingerprint: ctypes.unsigned_char.ptr }, + { context: ConnContext.ptr }, + { trust: ctypes.char.ptr }, +]); + +DH_keypair.define([ + { groupid: ctypes.unsigned_int }, + { priv: gcry_mpi_t }, + { pub: gcry_mpi_t }, +]); + +OtrlSMState.define([ + { secret: gcry_mpi_t }, + { x2: gcry_mpi_t }, + { x3: gcry_mpi_t }, + { g1: gcry_mpi_t }, + { g2: gcry_mpi_t }, + { g3: gcry_mpi_t }, + { g3o: gcry_mpi_t }, + { p: gcry_mpi_t }, + { q: gcry_mpi_t }, + { pab: gcry_mpi_t }, + { qab: gcry_mpi_t }, + { nextExpected: NextExpectedSMP }, + { received_question: ctypes.int }, + { sm_prog_state: OtrlSMProgState }, +]); + +OtrlAuthInfo.define([ + { authstate: OtrlAuthState }, + { context: ConnContext.ptr }, + { our_dh: DH_keypair }, + { our_keyid: ctypes.unsigned_int }, + { encgx: ctypes.unsigned_char.ptr }, + { encgx_len: ctypes.size_t }, + { r: ctypes.unsigned_char.array(16) }, + { hashgx: ctypes.unsigned_char.array(32) }, + { their_pub: gcry_mpi_t }, + { their_keyid: ctypes.unsigned_int }, + { enc_c: gcry_cipher_hd_t }, + { enc_cp: gcry_cipher_hd_t }, + { mac_m1: gcry_md_hd_t }, + { mac_m1p: gcry_md_hd_t }, + { mac_m2: gcry_md_hd_t }, + { mac_m2p: gcry_md_hd_t }, + { their_fingerprint: ctypes.unsigned_char.array(20) }, + { initiated: ctypes.int }, + { protocol_version: ctypes.unsigned_int }, + { secure_session_id: ctypes.unsigned_char.array(20) }, + { secure_session_id_len: ctypes.size_t }, + { session_id_half: OtrlSessionIdHalf }, + { lastauthmsg: ctypes.char.ptr }, + { commit_sent_time: time_t }, +]); + +ConnContext.define([ + { next: ConnContext.ptr }, + { tous: ConnContext.ptr.ptr }, + { context_priv: ConnContextPriv.ptr }, + { username: ctypes.char.ptr }, + { accountname: ctypes.char.ptr }, + { protocol: ctypes.char.ptr }, + { m_context: ConnContext.ptr }, + { recent_rcvd_child: ConnContext.ptr }, + { recent_sent_child: ConnContext.ptr }, + { recent_child: ConnContext.ptr }, + { our_instance: otrl_instag_t }, + { their_instance: otrl_instag_t }, + { msgstate: OtrlMessageState }, + { auth: OtrlAuthInfo }, + { fingerprint_root: Fingerprint }, + { active_fingerprint: Fingerprint.ptr }, + { sessionid: ctypes.unsigned_char.array(20) }, + { sessionid_len: ctypes.size_t }, + { sessionid_half: OtrlSessionIdHalf }, + { protocol_version: ctypes.unsigned_int }, + { otr_offer: ctypes.int }, + { app_data: ctypes.void_t.ptr }, + { app_data_free: app_data_free_t }, + { smstate: OtrlSMState.ptr }, +]); + +OtrlMessageAppOps.define([ + { policy: policy_cb_t }, + { create_privkey: create_privkey_cb_t }, + { is_logged_in: is_logged_in_cb_t }, + { inject_message: inject_message_cb_t }, + { update_context_list: update_context_list_cb_t }, + { new_fingerprint: new_fingerprint_cb_t }, + { write_fingerprint: write_fingerprint_cb_t }, + { gone_secure: gone_secure_cb_t }, + { gone_insecure: gone_insecure_cb_t }, + { still_secure: still_secure_cb_t }, + { max_message_size: max_message_size_cb_t }, + { account_name: account_name_cb_t }, + { account_name_free: account_name_free_cb_t }, + { received_symkey: received_symkey_cb_t }, + { otr_error_message: otr_error_message_cb_t }, + { otr_error_message_free: otr_error_message_free_cb_t }, + { resent_msg_prefix: resent_msg_prefix_cb_t }, + { resent_msg_prefix_free: resent_msg_prefix_free_cb_t }, + { handle_smp_event: handle_smp_event_cb_t }, + { handle_msg_event: handle_msg_event_cb_t }, + { create_instag: create_instag_cb_t }, + { convert_msg: convert_msg_cb_t }, + { convert_free: convert_free_cb_t }, + { timer_control: timer_control_cb_t }, +]); + +OtrlTLV.define([ + { type: ctypes.unsigned_short }, + { len: ctypes.unsigned_short }, + { data: ctypes.unsigned_char.ptr }, + { next: OtrlTLV.ptr }, +]); + +// policies + +// const OTRL_POLICY_ALLOW_V1 = 0x01; +const OTRL_POLICY_ALLOW_V2 = 0x02; + +// const OTRL_POLICY_ALLOW_V3 = 0x04; +// See https://bugzilla.mozilla.org/show_bug.cgi?id=1550474 re v3. + +const OTRL_POLICY_REQUIRE_ENCRYPTION = 0x08; +const OTRL_POLICY_SEND_WHITESPACE_TAG = 0x10; +const OTRL_POLICY_WHITESPACE_START_AKE = 0x20; + +// const OTRL_POLICY_ERROR_START_AKE = 0x40; +// Disabled to avoid automatic resend and MITM, as explained in +// https://github.com/arlolra/ctypes-otr/issues/55 + +var OTRLib; + +function enableOTRLibJS() { + // this must be delayed until after "libotr" is initialized + + OTRLib = { + path: libotrPath, + + // libotr API version + otrl_version, + + init() { + // apply version array as arguments to the init function + if (this.otrl_init.apply(this, this.otrl_version)) { + throw new Error("Couldn't initialize libotr."); + } + return true; + }, + + // proto.h + + // If we ever see this sequence in a plaintext message, we'll assume the + // other side speaks OTR, and try to establish a connection. + OTRL_MESSAGE_TAG_BASE: " \t \t\t\t\t \t \t \t ", + + OTRL_POLICY_OPPORTUNISTIC: new ctypes.unsigned_int( + OTRL_POLICY_ALLOW_V2 | + // OTRL_POLICY_ALLOW_V3 | + OTRL_POLICY_SEND_WHITESPACE_TAG | + OTRL_POLICY_WHITESPACE_START_AKE | + // OTRL_POLICY_ERROR_START_AKE | + 0 + ), + + OTRL_POLICY_ALWAYS: new ctypes.unsigned_int( + OTRL_POLICY_ALLOW_V2 | + // OTRL_POLICY_ALLOW_V3 | + OTRL_POLICY_REQUIRE_ENCRYPTION | + OTRL_POLICY_WHITESPACE_START_AKE | + // OTRL_POLICY_ERROR_START_AKE | + 0 + ), + + fragPolicy: { + OTRL_FRAGMENT_SEND_SKIP: 0, + OTRL_FRAGMENT_SEND_ALL: 1, + OTRL_FRAGMENT_SEND_ALL_BUT_FIRST: 2, + OTRL_FRAGMENT_SEND_ALL_BUT_LAST: 3, + }, + + // Return a pointer to a newly-allocated OTR query message, customized + // with our name. The caller should free() the result when he's done + // with it. + otrl_proto_default_query_msg: libotr.declare( + "otrl_proto_default_query_msg", + abi, + ctypes.char.ptr, + ctypes.char.ptr, + OtrlPolicy + ), + + // Initialize the OTR library. Pass the version of the API you are using. + otrl_init: libotr.declare( + "otrl_init", + abi, + gcry_error_t, + ctypes.unsigned_int, + ctypes.unsigned_int, + ctypes.unsigned_int + ), + + // instag.h + + instag: { + OTRL_INSTAG_MASTER: new ctypes.unsigned_int(0), + OTRL_INSTAG_BEST: new ctypes.unsigned_int(1), + OTRL_INSTAG_RECENT: new ctypes.unsigned_int(2), + OTRL_INSTAG_RECENT_RECEIVED: new ctypes.unsigned_int(3), + OTRL_INSTAG_RECENT_SENT: new ctypes.unsigned_int(4), + OTRL_MIN_VALID_INSTAG: new ctypes.unsigned_int(0x100), + }, + + // Get a new instance tag for the given account and write to file. The FILE* + // must be open for writing. + otrl_instag_generate: callWithFILEp.bind( + null, + "otrl_instag_generate", + "wb", + 1 + ), + otrl_instag_generate_FILEp: libotr.declare( + "otrl_instag_generate_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr, + ctypes.char.ptr, + ctypes.char.ptr + ), + + // Read our instance tag from a file on disk into the given OtrlUserState. + // The FILE* must be open for reading. + otrl_instag_read: callWithFILEp.bind(null, "otrl_instag_read", "rb", 1), + otrl_instag_read_FILEp: libotr.declare( + "otrl_instag_read_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr + ), + + // Write our instance tags to a file on disk. The FILE* must be open for + // writing. + otrl_instag_write: callWithFILEp.bind(null, "otrl_instag_write", "wb", 1), + otrl_instag_write_FILEp: libotr.declare( + "otrl_instag_write_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr + ), + + // auth.h + + authState: { + OTRL_AUTHSTATE_NONE: 0, + OTRL_AUTHSTATE_AWAITING_DHKEY: 1, + OTRL_AUTHSTATE_AWAITING_REVEALSIG: 2, + OTRL_AUTHSTATE_AWAITING_SIG: 3, + OTRL_AUTHSTATE_V1_SETUP: 4, + }, + + // b64.h + + // base64 encode data. Insert no linebreaks or whitespace. + // The buffer base64data must contain at least ((datalen+2)/3)*4 bytes of + // space. This function will return the number of bytes actually used. + otrl_base64_encode: libotr.declare( + "otrl_base64_encode", + abi, + ctypes.size_t, + ctypes.char.ptr, + ctypes.unsigned_char.ptr, + ctypes.size_t + ), + + // base64 decode data. Skip non-base64 chars, and terminate at the + // first '=', or the end of the buffer. + // The buffer data must contain at least ((base64len+3) / 4) * 3 bytes + // of space. This function will return the number of bytes actually + // used. + otrl_base64_decode: libotr.declare( + "otrl_base64_decode", + abi, + ctypes.size_t, + ctypes.unsigned_char.ptr, + ctypes.char.ptr, + ctypes.size_t + ), + + // context.h + + otr_offer: { + OFFER_NOT: 0, + OFFER_SENT: 1, + OFFER_REJECTED: 2, + OFFER_ACCEPTED: 3, + }, + + messageState: { + OTRL_MSGSTATE_PLAINTEXT: 0, + OTRL_MSGSTATE_ENCRYPTED: 1, + OTRL_MSGSTATE_FINISHED: 2, + }, + + // Look up a connection context by name/account/protocol/instance from the + // given OtrlUserState. + otrl_context_find: libotr.declare( + "otrl_context_find", + abi, + ConnContext.ptr, + OtrlUserState, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + otrl_instag_t, + ctypes.int, + ctypes.int.ptr, + ctypes.void_t.ptr, + ctypes.void_t.ptr + ), + + // Set the trust level for a given fingerprint. + otrl_context_set_trust: libotr.declare( + "otrl_context_set_trust", + abi, + ctypes.void_t, + Fingerprint.ptr, + ctypes.char.ptr + ), + + // Find a fingerprint in a given context, perhaps adding it if not present. + otrl_context_find_fingerprint: libotr.declare( + "otrl_context_find_fingerprint", + abi, + Fingerprint.ptr, + ConnContext.ptr, + hash_t, + ctypes.int, + ctypes.int.ptr + ), + + // Forget a fingerprint (and maybe the whole context). + otrl_context_forget_fingerprint: libotr.declare( + "otrl_context_forget_fingerprint", + abi, + ctypes.void_t, + Fingerprint.ptr, + ctypes.int + ), + + // Return true iff the given fingerprint is marked as trusted. + otrl_context_is_fingerprint_trusted: libotr.declare( + "otrl_context_is_fingerprint_trusted", + abi, + ctypes.int, + Fingerprint.ptr + ), + + // dh.h + + sessionIdHalf: { + OTRL_SESSIONID_FIRST_HALF_BOLD: 0, + OTRL_SESSIONID_SECOND_HALF_BOLD: 1, + }, + + // sm.h + + nextExpectedSMP: { + OTRL_SMP_EXPECT1: 0, + OTRL_SMP_EXPECT2: 1, + OTRL_SMP_EXPECT3: 2, + OTRL_SMP_EXPECT4: 3, + OTRL_SMP_EXPECT5: 4, + }, + + smProgState: { + OTRL_SMP_PROG_OK: 0, + OTRL_SMP_PROG_CHEATED: -2, + OTRL_SMP_PROG_FAILED: -1, + OTRL_SMP_PROG_SUCCEEDED: 1, + }, + + // userstate.h + + // Create a new OtrlUserState. + otrl_userstate_create: libotr.declare( + "otrl_userstate_create", + abi, + OtrlUserState + ), + + // privkey.h + + // Generate a private DSA key for a given account, storing it into a file on + // disk, and loading it into the given OtrlUserState. Overwrite any + // previously generated keys for that account in that OtrlUserState. + otrl_privkey_generate: callWithFILEp.bind( + null, + "otrl_privkey_generate", + "w+b", + 1 + ), + otrl_privkey_generate_FILEp: libotr.declare( + "otrl_privkey_generate_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr, + ctypes.char.ptr, + ctypes.char.ptr + ), + + // Begin a private key generation that will potentially take place in + // a background thread. This routine must be called from the main + // thread. It will set *newkeyp, which you can pass to + // otrl_privkey_generate_calculate in a background thread. If it + // returns gcry_error(GPG_ERR_EEXIST), then a privkey creation for + // this accountname/protocol is already in progress, and *newkeyp will + // be set to NULL. + otrl_privkey_generate_start: libotr.declare( + "otrl_privkey_generate_start", + abi, + gcry_error_t, + OtrlUserState, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.void_t.ptr.ptr + ), + + // Do the private key generation calculation. You may call this from a + // background thread. When it completes, call + // otrl_privkey_generate_finish from the _main_ thread. + otrl_privkey_generate_calculate: libotr.declare( + "otrl_privkey_generate_calculate", + abi, + gcry_error_t, + ctypes.void_t.ptr + ), + + // Call this from the main thread only. It will write the newly created + // private key into the given file and store it in the OtrlUserState. + otrl_privkey_generate_finish: callWithFILEp.bind( + null, + "otrl_privkey_generate_finish", + "w+b", + 2 + ), + otrl_privkey_generate_finish_FILEp: libotr.declare( + "otrl_privkey_generate_finish_FILEp", + abi, + gcry_error_t, + OtrlUserState, + ctypes.void_t.ptr, + FILE.ptr + ), + + // Call this from the main thread only, in the event that the background + // thread generating the key is cancelled. The newkey is deallocated, + // and must not be used further. + otrl_privkey_generate_cancelled: libotr.declare( + "otrl_privkey_generate_cancelled", + abi, + gcry_error_t, + OtrlUserState, + ctypes.void_t.ptr + ), + + // Read a sets of private DSA keys from a file on disk into the given + // OtrlUserState. + otrl_privkey_read: callWithFILEp.bind(null, "otrl_privkey_read", "rb", 1), + otrl_privkey_read_FILEp: libotr.declare( + "otrl_privkey_read_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr + ), + + // Read the fingerprint store from a file on disk into the given + // OtrlUserState. + otrl_privkey_read_fingerprints: callWithFILEp.bind( + null, + "otrl_privkey_read_fingerprints", + "rb", + 1 + ), + otrl_privkey_read_fingerprints_FILEp: libotr.declare( + "otrl_privkey_read_fingerprints_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr, + ctypes.void_t.ptr, + ctypes.void_t.ptr + ), + + // Write the fingerprint store from a given OtrlUserState to a file on disk. + otrl_privkey_write_fingerprints: callWithFILEp.bind( + null, + "otrl_privkey_write_fingerprints", + "wb", + 1 + ), + otrl_privkey_write_fingerprints_FILEp: libotr.declare( + "otrl_privkey_write_fingerprints_FILEp", + abi, + gcry_error_t, + OtrlUserState, + FILE.ptr + ), + + // The length of a string representing a human-readable version of a + // fingerprint (including the trailing NUL). + OTRL_PRIVKEY_FPRINT_HUMAN_LEN, + + // Human readable fingerprint type + fingerprint_t, + + // fingerprint value + hash_t, + + // Calculate a human-readable hash of our DSA public key. Return it in the + // passed fingerprint buffer. Return NULL on error, or a pointer to the given + // buffer on success. + otrl_privkey_fingerprint: libotr.declare( + "otrl_privkey_fingerprint", + abi, + ctypes.char.ptr, + OtrlUserState, + fingerprint_t, + ctypes.char.ptr, + ctypes.char.ptr + ), + + // Convert a 20-byte hash value to a 45-byte human-readable value. + otrl_privkey_hash_to_human: libotr.declare( + "otrl_privkey_hash_to_human", + abi, + ctypes.void_t, + fingerprint_t, + hash_t + ), + + // Calculate a raw hash of our DSA public key. Return it in the passed + // fingerprint buffer. Return NULL on error, or a pointer to the given + // buffer on success. + otrl_privkey_fingerprint_raw: libotr.declare( + "otrl_privkey_fingerprint_raw", + abi, + ctypes.unsigned_char.ptr, + OtrlUserState, + hash_t, + ctypes.char.ptr, + ctypes.char.ptr + ), + + // uiOps callbacks + policy_cb_t, + create_privkey_cb_t, + is_logged_in_cb_t, + inject_message_cb_t, + update_context_list_cb_t, + new_fingerprint_cb_t, + write_fingerprint_cb_t, + gone_secure_cb_t, + gone_insecure_cb_t, + still_secure_cb_t, + max_message_size_cb_t, + account_name_cb_t, + account_name_free_cb_t, + received_symkey_cb_t, + otr_error_message_cb_t, + otr_error_message_free_cb_t, + resent_msg_prefix_cb_t, + resent_msg_prefix_free_cb_t, + handle_smp_event_cb_t, + handle_msg_event_cb_t, + create_instag_cb_t, + convert_msg_cb_t, + convert_free_cb_t, + timer_control_cb_t, + + // message.h + + OtrlMessageAppOps, + + errorCode: { + OTRL_ERRCODE_NONE: 0, + OTRL_ERRCODE_ENCRYPTION_ERROR: 1, + OTRL_ERRCODE_MSG_NOT_IN_PRIVATE: 2, + OTRL_ERRCODE_MSG_UNREADABLE: 3, + OTRL_ERRCODE_MSG_MALFORMED: 4, + }, + + smpEvent: { + OTRL_SMPEVENT_NONE: 0, + OTRL_SMPEVENT_ERROR: 1, + OTRL_SMPEVENT_ABORT: 2, + OTRL_SMPEVENT_CHEATED: 3, + OTRL_SMPEVENT_ASK_FOR_ANSWER: 4, + OTRL_SMPEVENT_ASK_FOR_SECRET: 5, + OTRL_SMPEVENT_IN_PROGRESS: 6, + OTRL_SMPEVENT_SUCCESS: 7, + OTRL_SMPEVENT_FAILURE: 8, + }, + + messageEvent: { + OTRL_MSGEVENT_NONE: 0, + OTRL_MSGEVENT_ENCRYPTION_REQUIRED: 1, + OTRL_MSGEVENT_ENCRYPTION_ERROR: 2, + OTRL_MSGEVENT_CONNECTION_ENDED: 3, + OTRL_MSGEVENT_SETUP_ERROR: 4, + OTRL_MSGEVENT_MSG_REFLECTED: 5, + OTRL_MSGEVENT_MSG_RESENT: 6, + OTRL_MSGEVENT_RCVDMSG_NOT_IN_PRIVATE: 7, + OTRL_MSGEVENT_RCVDMSG_UNREADABLE: 8, + OTRL_MSGEVENT_RCVDMSG_MALFORMED: 9, + OTRL_MSGEVENT_LOG_HEARTBEAT_RCVD: 10, + OTRL_MSGEVENT_LOG_HEARTBEAT_SENT: 11, + OTRL_MSGEVENT_RCVDMSG_GENERAL_ERR: 12, + OTRL_MSGEVENT_RCVDMSG_UNENCRYPTED: 13, + OTRL_MSGEVENT_RCVDMSG_UNRECOGNIZED: 14, + OTRL_MSGEVENT_RCVDMSG_FOR_OTHER_INSTANCE: 15, + }, + + convertType: { + OTRL_CONVERT_SENDING: 0, + OTRL_CONVERT_RECEIVING: 1, + }, + + // Deallocate a message allocated by other otrl_message_* routines. + otrl_message_free: libotr.declare( + "otrl_message_free", + abi, + ctypes.void_t, + ctypes.char.ptr + ), + + // Handle a message about to be sent to the network. + otrl_message_sending: libotr.declare( + "otrl_message_sending", + abi, + gcry_error_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + otrl_instag_t, + ctypes.char.ptr, + OtrlTLV.ptr, + ctypes.char.ptr.ptr, + OtrlFragmentPolicy, + ConnContext.ptr.ptr, + ctypes.void_t.ptr, + ctypes.void_t.ptr + ), + + // Handle a message just received from the network. + otrl_message_receiving: libotr.declare( + "otrl_message_receiving", + abi, + ctypes.int, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr.ptr, + OtrlTLV.ptr.ptr, + ConnContext.ptr.ptr, + ctypes.void_t.ptr, + ctypes.void_t.ptr + ), + + // Put a connection into the PLAINTEXT state, first sending the + // other side a notice that we're doing so if we're currently ENCRYPTED, + // and we think he's logged in. Affects only the specified instance. + otrl_message_disconnect: libotr.declare( + "otrl_message_disconnect", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + otrl_instag_t + ), + + // Call this function every so often, to clean up stale private state that + // may otherwise stick around in memory. + otrl_message_poll: libotr.declare( + "otrl_message_poll", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr + ), + + // Initiate the Socialist Millionaires' Protocol. + otrl_message_initiate_smp: libotr.declare( + "otrl_message_initiate_smp", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.char.ptr, + ctypes.size_t + ), + + // Initiate the Socialist Millionaires' Protocol and send a prompt + // question to the buddy. + otrl_message_initiate_smp_q: libotr.declare( + "otrl_message_initiate_smp_q", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.size_t + ), + + // Respond to a buddy initiating the Socialist Millionaires' Protocol. + otrl_message_respond_smp: libotr.declare( + "otrl_message_respond_smp", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ConnContext.ptr, + ctypes.char.ptr, + ctypes.size_t + ), + + // Abort the SMP. Called when an unexpected SMP message breaks the + // normal flow. + otrl_message_abort_smp: libotr.declare( + "otrl_message_abort_smp", + abi, + ctypes.void_t, + OtrlUserState, + OtrlMessageAppOps.ptr, + ctypes.void_t.ptr, + ConnContext.ptr + ), + + // tlv.h + + tlvs: { + OTRL_TLV_PADDING: new ctypes.unsigned_short(0x0000), + OTRL_TLV_DISCONNECTED: new ctypes.unsigned_short(0x0001), + OTRL_TLV_SMP1: new ctypes.unsigned_short(0x0002), + OTRL_TLV_SMP2: new ctypes.unsigned_short(0x0003), + OTRL_TLV_SMP3: new ctypes.unsigned_short(0x0004), + OTRL_TLV_SMP4: new ctypes.unsigned_short(0x0005), + OTRL_TLV_SMP_ABORT: new ctypes.unsigned_short(0x0006), + OTRL_TLV_SMP1Q: new ctypes.unsigned_short(0x0007), + OTRL_TLV_SYMKEY: new ctypes.unsigned_short(0x0008), + }, + + OtrlTLV, + + // Return the first TLV with the given type in the chain, or NULL if one + // isn't found. + otrl_tlv_find: libotr.declare( + "otrl_tlv_find", + abi, + OtrlTLV.ptr, + OtrlTLV.ptr, + ctypes.unsigned_short + ), + + // Deallocate a chain of TLVs. + otrl_tlv_free: libotr.declare( + "otrl_tlv_free", + abi, + ctypes.void_t, + OtrlTLV.ptr + ), + }; +} + +// exports diff --git a/comm/chat/modules/OTRUI.sys.mjs b/comm/chat/modules/OTRUI.sys.mjs new file mode 100644 index 0000000000..fdf4771607 --- /dev/null +++ b/comm/chat/modules/OTRUI.sys.mjs @@ -0,0 +1,998 @@ +/* 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 { OTR } from "resource:///modules/OTR.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["messenger/otr/otrUI.ftl"], true) +); + +function _str(id) { + return lazy.l10n.formatValueSync(id); +} + +function _strArgs(id, args) { + return lazy.l10n.formatValueSync(id, args); +} + +const OTR_ADD_FINGER_DIALOG_URL = + "chrome://chat/content/otr-add-fingerprint.xhtml"; + +const AUTH_STATUS_UNVERIFIED = "otr-auth-unverified"; +var authLabelMap; +var trustMap; + +function initStrings() { + authLabelMap = new Map([ + ["otr:auth-error", _str("auth-error")], + ["otr:auth-success", _str("auth-success")], + ["otr:auth-success-them", _str("auth-success-them")], + ["otr:auth-fail", _str("auth-fail")], + ["otr:auth-waiting", _str("auth-waiting")], + ]); + + let sl = _str("start-label"); + let al = _str("auth-label"); + let rfl = _str("refresh-label"); + let ral = _str("reauth-label"); + + trustMap = new Map([ + [ + OTR.trustState.TRUST_NOT_PRIVATE, + { + startLabel: sl, + authLabel: al, + disableStart: false, + disableEnd: true, + disableAuth: true, + class: "not-private", + }, + ], + [ + OTR.trustState.TRUST_UNVERIFIED, + { + startLabel: rfl, + authLabel: al, + disableStart: false, + disableEnd: false, + disableAuth: false, + class: "unverified", + }, + ], + [ + OTR.trustState.TRUST_PRIVATE, + { + startLabel: rfl, + authLabel: ral, + disableStart: false, + disableEnd: false, + disableAuth: false, + class: "private", + }, + ], + [ + OTR.trustState.TRUST_FINISHED, + { + startLabel: sl, + authLabel: al, + disableStart: false, + disableEnd: false, + disableAuth: true, + class: "finished", + }, + ], + ]); +} + +var windowRefs = new Map(); + +export var OTRUI = { + enabled: false, + stringsLoaded: false, + globalDoc: null, + visibleConv: null, + + debug: false, + logMsg(msg) { + if (!OTRUI.debug) { + return; + } + Services.console.logStringMessage(msg); + }, + + addMenuObserver() { + for (let win of Services.ww.getWindowEnumerator()) { + OTRUI.addMenus(win); + } + Services.obs.addObserver(OTRUI, "domwindowopened"); + }, + + removeMenuObserver() { + for (let win of Services.ww.getWindowEnumerator()) { + OTRUI.removeMenus(win); + } + Services.obs.removeObserver(OTRUI, "domwindowopened"); + }, + + addMenus(win) { + let doc = win.document; + // Account for unready windows + if (doc.readyState !== "complete") { + let listen = function () { + win.removeEventListener("load", listen); + OTRUI.addMenus(win); + }; + win.addEventListener("load", listen); + } + }, + + removeMenus(win) { + let doc = win.document; + OTRUI.removeBuddyContextMenu(doc); + }, + + addBuddyContextMenu(buddyContextMenu, doc, contact) { + if (!buddyContextMenu || !OTR.libLoaded) { + return; // Not the buddy list context menu + } + + let sep = doc.createXULElement("menuseparator"); + sep.setAttribute("id", "otrsep"); + let menuitem = doc.createXULElement("menuitem"); + menuitem.setAttribute("label", _str("buddycontextmenu-label")); + menuitem.setAttribute("id", "otrcont"); + menuitem.addEventListener("command", () => { + let args = OTRUI.contactWrapper(contact); + args.wrappedJSObject = args; + let features = "chrome,modal,centerscreen,resizable=no,minimizable=no"; + Services.ww.openWindow( + null, + OTR_ADD_FINGER_DIALOG_URL, + "", + features, + args + ); + }); + + buddyContextMenu.addEventListener("popupshowing", e => { + let target = e.target.triggerNode; + if (target.localName == "richlistitem") { + menuitem.hidden = false; + sep.hidden = false; + } else { + /* probably imconv */ + menuitem.hidden = true; + sep.hidden = true; + } + }); + + buddyContextMenu.appendChild(sep); + buddyContextMenu.appendChild(menuitem); + }, + + removeBuddyContextMenu(doc) { + let s = doc.getElementById("otrsep"); + if (s) { + s.remove(); + } + let p = doc.getElementById("otrcont"); + if (p) { + p.remove(); + } + }, + + loopKeyGenSuccess() { + ChromeUtils.idleDispatch(OTRUI.genNextMissingKey); + }, + + loopKeyGenFailure(param) { + ChromeUtils.idleDispatch(OTRUI.genNextMissingKey); + OTRUI.reportKeyGenFailure(param); + }, + + reportKeyGenFailure(param) { + throw new Error(_strArgs("otr-genkey-failed", { error: String(param) })); + }, + + accountsToGenKey: [], + + genNextMissingKey() { + if (OTRUI.accountsToGenKey.length == 0) { + return; + } + + let acc = OTRUI.accountsToGenKey.pop(); + let fp = OTR.privateKeyFingerprint(acc.name, acc.prot); + if (!fp) { + OTR.generatePrivateKey(acc.name, acc.prot).then( + OTRUI.loopKeyGenSuccess, + OTRUI.loopKeyGenFailure + ); + } else { + ChromeUtils.idleDispatch(OTRUI.genNextMissingKey); + } + }, + + genMissingKeys() { + for (let acc of IMServices.accounts.getAccounts()) { + OTRUI.accountsToGenKey.push({ + name: acc.normalizedName, + prot: acc.protocol.normalizedName, + }); + } + ChromeUtils.idleDispatch(OTRUI.genNextMissingKey); + }, + + async init() { + if (!OTRUI.stringsLoaded) { + // HACK: calling initStrings may fail the first time due to synchronous + // loading of the .ftl files. If we load the files and wait for a known + // value asynchronously, no such failure will happen. + // + // If the value "start-label" is removed, this will fail. + // + // Also, we can't reuse this Localization object elsewhere because it + // fails to load values synchronously (even after calling setIsSync). + await new Localization(["messenger/otr/otrUI.ftl"]).formatValue( + "start-label" + ); + + initStrings(); + OTRUI.stringsLoaded = true; + } + + this.debug = Services.prefs.getBoolPref("chat.otr.trace", false); + + OTR.init({}); + if (!OTR.libLoaded) { + return; + } + + this.enabled = true; + this.notificationbox = null; + + OTR.addObserver(OTRUI); + OTR.loadFiles() + .then(function () { + Services.obs.addObserver(OTR, "new-ui-conversation"); + Services.obs.addObserver(OTR, "conversation-update-type"); + // Disabled until #76 is resolved. + // Services.obs.addObserver(OTRUI, "contact-added", false); + Services.obs.addObserver(OTRUI, "account-added"); + // Services.obs.addObserver(OTRUI, "contact-signed-off", false); + Services.obs.addObserver(OTRUI, "conversation-loaded"); + Services.obs.addObserver(OTRUI, "conversation-closed"); + Services.obs.addObserver(OTRUI, "prpl-quit"); + + for (let conv of IMServices.conversations.getConversations()) { + OTRUI.initConv(conv); + } + OTRUI.addMenuObserver(); + + ChromeUtils.idleDispatch(OTRUI.genMissingKeys); + }) + .catch(function (err) { + // console.log("===> " + err + "\n"); + throw err; + }); + }, + + disconnect(aConv) { + if (aConv) { + return OTR.disconnect(aConv, true); + } + let allGood = true; + for (let conv of IMServices.conversations.getConversations()) { + if (conv.isChat) { + continue; + } + if (!OTR.disconnect(conv, true)) { + allGood = false; + } + } + return allGood; + }, + + openAuth(window, name, mode, uiConv, contactInfo) { + let otrAuth = this.globalDoc.querySelector(".otr-auth"); + otrAuth.disabled = true; + let win = window.openDialog( + "chrome://chat/content/otr-auth.xhtml", + "auth=" + name, + "centerscreen,resizable=no,minimizable=no", + mode, + uiConv, + contactInfo + ); + windowRefs.set(name, win); + window.addEventListener("beforeunload", function () { + otrAuth.disabled = false; + windowRefs.delete(name); + }); + }, + + closeAuth(context) { + let win = windowRefs.get(context.username); + if (win) { + win.close(); + } + }, + + /** + * Hide the encryption state container and any pending notifications. + * + * @param {Element} otrContainer + * @param {Context} [context] + */ + noOtrPossible(otrContainer, context) { + otrContainer.hidden = true; + + if (context) { + OTRUI.hideUserNotifications(context); + } else { + OTRUI.hideAllOTRNotifications(); + } + }, + + sendSystemAlert(uiConv, conv, bundleId) { + uiConv.systemMessage( + _strArgs(bundleId, { name: conv.normalizedName }), + false, + true + ); + }, + + setNotificationBox(notificationbox) { + this.globalBox = notificationbox; + }, + + /* + * These states are only relevant if OTR is the only encryption available for + * the conversation. Protocol provided encryption takes priority. + * possible states: + * tab isn't a 1:1, isChat == true + * then OTR isn't possible, hide the button + * tab is a 1:1, isChat == false + * no conversation active, uiConv cannot be found + * then OTR isn't possible YET, hide the button + * conversation active, uiConv found + * disconnected? + * could the other side come back? should we keep the button? + * set the state based on the OTR library state + */ + + /** + * Store a reference to the document, as well as the current conversation. + * + * @param {Element} aObject - conversation-browser instance (most importantly, has a _conv field) + */ + addButton(aObject) { + this.globalDoc = aObject.ownerDocument; + let _conv = aObject._conv; + OTRUI.visibleConv = _conv; + if ( + _conv.encryptionState === Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED + ) { + OTRUI.setMsgState(_conv, null, this.globalDoc, true); + } + }, + + /** + * Hide the encryption state information for the current conversation. + */ + hideOTRButton() { + if (!OTR.libLoaded) { + return; + } + if (!this.globalDoc) { + return; + } + OTRUI.visibleConv = null; + let otrContainer = this.globalDoc.querySelector(".encryption-container"); + OTRUI.noOtrPossible(otrContainer); + }, + + /** + * Sets the visible conversation of the OTR UI state and ensures + * the encryption state button is set up correctly. + * + * @param {prplIConversation} _conv + */ + updateOTRButton(_conv) { + if ( + _conv.encryptionState !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED + ) { + return; + } + if (!OTR.libLoaded) { + return; + } + if (!this.globalDoc) { + return; + } + OTRUI.visibleConv = _conv; + let convBinding; + for (let element of this.globalDoc.getElementById("conversationsBox") + .children) { + if (!element.hidden) { + convBinding = element; + break; + } + } + if (convBinding && convBinding._conv && convBinding._conv.target) { + OTRUI.setMsgState(_conv, null, this.globalDoc, false); + } else { + this.hideOTRButton(); + } + }, + + /** + * Set encryption state on selector for conversation. + * + * @param {prplIConversation} _conv - Must match the visible conversation. + * @param {Context} [context] - The OTR context for the conversation. + * @param {DOMDocument} doc + * @param {boolean} [addSystemMessage] - If a system message with the conversation security. + */ + setMsgState(_conv, context, doc, addSystemMessage) { + if (!this.visibleConv) { + return; + } + if (_conv != null && !(_conv === this.visibleConv)) { + return; + } + + let otrContainer = doc.querySelector(".encryption-container"); + let otrButton = doc.querySelector(".encryption-button"); + if (_conv != null && _conv.isChat) { + OTRUI.noOtrPossible(otrContainer, context); + return; + } + + if (!context && _conv != null) { + context = OTR.getContext(_conv); + if (!context) { + OTRUI.noOtrPossible(otrContainer, null); + } + } + + try { + let uiConv = OTR.getUIConvFromContext(context); + if (uiConv != null && !(uiConv === this.visibleConv)) { + return; + } + if ( + uiConv.encryptionState === Ci.prplIConversation.ENCRYPTION_ENABLED || + uiConv.encryptionState === Ci.prplIConversation.ENCRYPTION_TRUSTED + ) { + return; + } + + if (uiConv.isChat) { + OTRUI.noOtrPossible(otrContainer, context); + return; + } + if (addSystemMessage) { + let trust = OTRUI.getTrustSettings(context); + let id = "state-" + trust.class; + let msg; + if (OTR.trust(context) == OTR.trustState.TRUST_NOT_PRIVATE) { + msg = lazy.l10n.formatValueSync(id); + } else { + msg = lazy.l10n.formatValueSync(id, { name: context.username }); + } + uiConv.systemMessage(msg, false, true); + } + } catch (e) { + OTRUI.noOtrPossible(otrContainer, context); + return; + } + + otrContainer.hidden = false; + let otrStart = doc.querySelector(".otr-start"); + let otrEnd = doc.querySelector(".otr-end"); + let otrAuth = doc.querySelector(".otr-auth"); + let trust = OTRUI.getTrustSettings(context); + otrButton.setAttribute( + "tooltiptext", + _strArgs("state-" + trust.class, { name: context.username }) + ); + otrButton.setAttribute("label", _str("state-" + trust.class + "-label")); + otrButton.className = "encryption-button encryption-" + trust.class; + otrStart.setAttribute("label", trust.startLabel); + otrStart.setAttribute("disabled", trust.disableStart); + otrEnd.setAttribute("disabled", trust.disableEnd); + otrAuth.setAttribute("label", trust.authLabel); + otrAuth.setAttribute("disabled", trust.disableAuth); + OTRUI.hideAllOTRNotifications(); + OTRUI.showUserNotifications(context); + }, + + alertTrust(context) { + let uiConv = OTR.getUIConvFromContext(context); + let trust = OTRUI.getTrustSettings(context); + uiConv.systemMessage( + _strArgs("afterauth-" + trust.class, { name: context.username }), + false, + true + ); + }, + + getTrustSettings(context) { + let result = trustMap.get(OTR.trust(context)); + return result; + }, + + askAuth(aObject) { + let uiConv = OTR.getUIConvFromContext(aObject.context); + if (!uiConv) { + return; + } + + let name = uiConv.target.normalizedName; + let msg = _strArgs("verify-request", { name }); + // Trigger the update of the unread message counter. + uiConv.notifyVerifyOTR(msg); + Services.obs.notifyObservers(uiConv, "new-otr-verification-request"); + + let window = this.globalDoc.defaultView; + let buttons = [ + { + label: _str("finger-verify"), + accessKey: _str("finger-verify-access-key"), + callback() { + OTRUI.openAuth(window, name, "ask", uiConv, aObject); + // prevent closing of notification bar when the button is hit + return true; + }, + }, + { + label: _str("finger-ignore"), + accessKey: _str("finger-ignore-access-key"), + callback() { + let context = OTR.getContext(uiConv.target); + OTR.abortSMP(context); + }, + }, + ]; + + let notification = this.globalBox.appendNotification( + `ask-auth-${name}`, + { + label: msg, + priority: this.globalBox.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + + notification.removeAttribute("dismissable"); + }, + + closeAskAuthNotification(aObject) { + let name = aObject.context.username; + let notification = this.globalBox.getNotificationWithValue( + `ask-auth-${name}` + ); + if (!notification) { + return; + } + + this.globalBox.removeNotification(notification); + }, + + closeUnverified(context) { + let uiConv = OTR.getUIConvFromContext(context); + if (!uiConv) { + return; + } + + for (let notification of this.globalBox.allNotifications) { + if ( + context.username == notification.getAttribute("user") && + notification.getAttribute("value") == AUTH_STATUS_UNVERIFIED + ) { + notification.close(); + } + } + }, + + hideUserNotifications(context) { + for (let notification of this.globalBox.allNotifications) { + if (context.username == notification.getAttribute("user")) { + notification.close(); + } + } + }, + + hideAllOTRNotifications() { + for (let notification of this.globalBox.allNotifications) { + if (notification.getAttribute("protocol") == "otr") { + notification.setAttribute("hidden", "true"); + } + } + }, + + showUserNotifications(context) { + let name = context.username; + for (let notification of this.globalBox.allNotifications) { + if (name == notification.getAttribute("user")) { + notification.removeAttribute("hidden"); + } + } + }, + + notifyUnverified(context, seen) { + let uiConv = OTR.getUIConvFromContext(context); + if (!uiConv) { + return; + } + + let name = context.username; + let window = this.globalDoc.defaultView; + + let buttons = [ + { + label: _str("finger-verify"), + accessKey: _str("finger-verify-access-key"), + callback() { + let name = uiConv.target.normalizedName; + OTRUI.openAuth(window, name, "start", uiConv); + // prevent closing of notification bar when the button is hit + return true; + }, + }, + { + label: _str("finger-ignore"), + accessKey: _str("finger-ignore-access-key"), + callback() { + let context = OTR.getContext(uiConv.target); + OTR.abortSMP(context); + }, + }, + ]; + + let notification = this.globalBox.appendNotification( + name, + { + label: _strArgs(`finger-${seen}`, { name }), + priority: this.globalBox.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + + // Set the user attribute so we can show and hide notifications based on the + // currently viewed conversation. + notification.setAttribute("user", name); + // Set custom attributes for CSS styling. + notification.setAttribute("protocol", "otr"); + notification.setAttribute("status", AUTH_STATUS_UNVERIFIED); + // Prevent users from dismissing this notification. + notification.removeAttribute("dismissable"); + + if (!this.visibleConv) { + return; + } + + if (name !== this.visibleConv.normalizedName) { + this.hideUserNotifications(context); + } + }, + + closeVerification(context) { + let uiConv = OTR.getUIConvFromContext(context); + if (!uiConv) { + return; + } + + let prevNotification = OTRUI.globalBox.getNotificationWithValue( + context.username + ); + if (prevNotification) { + prevNotification.close(); + } + }, + + notifyVerification(context, key, cancelable, verifiable) { + let uiConv = OTR.getUIConvFromContext(context); + if (!uiConv) { + return; + } + + OTRUI.closeVerification(context); + + let buttons = []; + if (cancelable) { + buttons = [ + { + label: _str("auth-cancel"), + accessKey: _str("auth-cancel-access-key"), + callback() { + let context = OTR.getContext(uiConv.target); + OTR.abortSMP(context); + }, + }, + ]; + } + + if (verifiable) { + let window = this.globalDoc.defaultView; + + buttons = [ + { + label: _str("finger-verify"), + accessKey: _str("finger-verify-access-key"), + callback() { + let name = uiConv.target.normalizedName; + OTRUI.openAuth(window, name, "start", uiConv); + // prevent closing of notification bar when the button is hit + return true; + }, + }, + { + label: _str("finger-ignore"), + accessKey: _str("finger-ignore-access-key"), + callback() { + let context = OTR.getContext(uiConv.target); + OTR.abortSMP(context); + }, + }, + ]; + } + + // Change priority type based on the passed key. + let priority = this.globalBox.PRIORITY_WARNING_HIGH; + let dismissable = true; + switch (key) { + case "otr:auth-error": + case "otr:auth-fail": + priority = this.globalBox.PRIORITY_CRITICAL_HIGH; + break; + case "otr:auth-waiting": + priority = this.globalBox.PRIORITY_INFO_MEDIUM; + dismissable = false; + break; + + default: + break; + } + + OTRUI.closeUnverified(context); + let notification = this.globalBox.appendNotification( + context.username, + { + label: authLabelMap.get(key), + priority, + }, + buttons + ); + + // Set the user attribute so we can show and hide notifications based on the + // currently viewed conversation. + notification.setAttribute("user", context.username); + // Set custom attributes for CSS styling. + notification.setAttribute("protocol", "otr"); + notification.setAttribute("status", key); + + // The notification API don't currently support a "success" PRIORITY flag, + // so we need to manually set it if we need to. + if (["otr:auth-success", "otr:auth-success-them"].includes(key)) { + notification.setAttribute("type", "success"); + } + + if (!dismissable) { + // Prevent users from dismissing this notification if something is in + // progress or an action is required. + notification.removeAttribute("dismissable"); + } + }, + + updateAuth(aObj) { + // let uiConv = OTR.getUIConvFromContext(aObj.context); + if (!aObj.progress) { + OTRUI.closeAuth(aObj.context); + OTRUI.notifyVerification(aObj.context, "otr:auth-error", false, false); + } else if (aObj.progress === 100) { + let key; + let verifiable = false; + if (aObj.success) { + if (aObj.context.trust) { + key = "otr:auth-success"; + OTR.notifyTrust(aObj.context); + } else { + key = "otr:auth-success-them"; + verifiable = true; + } + } else { + key = "otr:auth-fail"; + if (!aObj.context.trust) { + OTR.notifyTrust(aObj.context); + } + } + OTRUI.notifyVerification(aObj.context, key, false, verifiable); + } else { + // TODO: show the aObj.progress to the user with a + // + OTRUI.notifyVerification(aObj.context, "otr:auth-waiting", true, false); + } + OTRUI.closeAskAuthNotification(aObj); + }, + + onAccountCreated(acc) { + let account = acc.normalizedName; + let protocol = acc.protocol.normalizedName; + Promise.resolve(); + if (OTR.privateKeyFingerprint(account, protocol) === null) { + OTR.generatePrivateKey(account, protocol).catch( + OTRUI.reportKeyGenFailure + ); + } + }, + + contactWrapper(contact) { + // If the conversation already started. + if (contact.buddy) { + return { + account: contact.buddy.normalizedName, + protocol: contact.buddy.buddy.protocol.normalizedName, + screenname: contact.buddy.userName, + }; + } + + // For online and offline contacts without an open conversation. + return { + account: + contact.preferredBuddy.preferredAccountBuddy.account.normalizedName, + protocol: contact.preferredBuddy.protocol.normalizedName, + screenname: contact.preferredBuddy.preferredAccountBuddy.userName, + }; + }, + + onContactAdded(contact) { + let args = OTRUI.contactWrapper(contact); + if ( + OTR.getFingerprintsForRecipient( + args.account, + args.protocol, + args.screenname + ).length > 0 + ) { + return; + } + args.wrappedJSObject = args; + let features = "chrome,modal,centerscreen,resizable=no,minimizable=no"; + Services.ww.openWindow(null, OTR_ADD_FINGER_DIALOG_URL, "", features, args); + }, + + observe(aObject, aTopic, aMsg) { + let doc; + // console.log("====> observing topic: " + aTopic + " with msg: " + aMsg); + // console.log(aObject); + + switch (aTopic) { + case "nsPref:changed": + break; + case "conversation-loaded": + doc = aObject.ownerDocument; + let windowtype = doc.documentElement.getAttribute("windowtype"); + if (windowtype !== "mail:3pane") { + return; + } + OTRUI.addButton(aObject); + break; + case "conversation-closed": + if (aObject.isChat) { + return; + } + this.globalBox.removeAllNotifications(); + OTRUI.closeAuth(OTR.getContext(aObject)); + OTRUI.disconnect(aObject); + break; + // case "contact-signed-off": + // break; + case "prpl-quit": + OTRUI.disconnect(null); + break; + case "domwindowopened": + OTRUI.addMenus(aObject); + break; + case "otr:generate": { + let result = OTR.generatePrivateKeySync( + aObject.account, + aObject.protocol + ); + if (result != null) { + OTRUI.reportKeyGenFailure(result); + } + break; + } + case "otr:disconnected": + case "otr:msg-state": + if ( + aTopic === "otr:disconnected" || + OTR.trust(aObject) !== OTR.trustState.TRUST_UNVERIFIED + ) { + OTRUI.closeAuth(aObject); + OTRUI.closeUnverified(aObject); + OTRUI.closeVerification(aObject); + } + OTRUI.setMsgState(null, aObject, this.globalDoc, false); + break; + case "otr:unverified": + if (!this.globalDoc) { + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if (!win) { + return; + } + win.focus(); + win.showChatTab(); + this.globalDoc = win.document; + } + OTRUI.notifyUnverified(aObject, aMsg); + break; + case "otr:trust-state": + OTRUI.alertTrust(aObject); + break; + case "otr:log": + OTRUI.logMsg("otr: " + aObject); + break; + case "account-added": + OTRUI.onAccountCreated(aObject); + break; + case "contact-added": + OTRUI.onContactAdded(aObject); + break; + case "otr:auth-ask": + OTRUI.askAuth(aObject); + break; + case "otr:auth-update": + OTRUI.updateAuth(aObject); + break; + case "otr:cancel-ask-auth": + OTRUI.closeAskAuthNotification(aObject); + break; + } + }, + + initConv(binding) { + OTR.addConversation(binding._conv); + OTRUI.addButton(binding); + }, + + /** + * Restore the conversation to a state before OTR knew about it. + * + * @param {Element} binding - conversation-browser instance. + */ + resetConv(binding) { + OTR.removeConversation(binding._conv); + }, + + destroy() { + if (!OTR.libLoaded) { + return; + } + OTRUI.disconnect(null); + Services.obs.removeObserver(OTR, "new-ui-conversation"); + Services.obs.removeObserver(OTR, "conversation-update-type"); + // Services.obs.removeObserver(OTRUI, "contact-added"); + // Services.obs.removeObserver(OTRUI, "contact-signed-off"); + Services.obs.removeObserver(OTRUI, "account-added"); + Services.obs.removeObserver(OTRUI, "conversation-loaded"); + Services.obs.removeObserver(OTRUI, "conversation-closed"); + Services.obs.removeObserver(OTRUI, "prpl-quit"); + + for (let conv of IMServices.conversations.getConversations()) { + OTRUI.resetConv(conv); + } + OTR.removeObserver(OTRUI); + OTR.close(); + OTRUI.removeMenuObserver(); + }, +}; diff --git a/comm/chat/modules/ToLocaleFormat.sys.mjs b/comm/chat/modules/ToLocaleFormat.sys.mjs new file mode 100644 index 0000000000..256a6fb5f0 --- /dev/null +++ b/comm/chat/modules/ToLocaleFormat.sys.mjs @@ -0,0 +1,208 @@ +/* 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/. */ + +/** + * JS implementation of the deprecated Date.toLocaleFormat. + * aFormat follows strftime syntax, + * http://pubs.opengroup.org/onlinepubs/007908799/xsh/strftime.html + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter( + lazy, + "dateTimeFormatter", + () => + new Services.intl.DateTimeFormat(undefined, { + dateStyle: "full", + timeStyle: "long", + }) +); +XPCOMUtils.defineLazyGetter( + lazy, + "dateFormatter", + () => + new Services.intl.DateTimeFormat(undefined, { + dateStyle: "full", + }) +); +XPCOMUtils.defineLazyGetter( + lazy, + "timeFormatter", + () => + new Services.intl.DateTimeFormat(undefined, { + timeStyle: "long", + }) +); + +function Day(t) { + return Math.floor(t.valueOf() / 86400000); +} +function DayFromYear(y) { + return ( + 365 * (y - 1970) + + Math.floor((y - 1969) / 4) - + Math.floor((y - 1901) / 100) + + Math.floor((y - 1601) / 400) + ); +} +function DayWithinYear(t) { + return Day(t) - DayFromYear(t.getFullYear()); +} +function weekday(aDate, option) { + return aDate.toLocaleString(undefined, { weekday: option }); +} +function month(aDate, option) { + return aDate.toLocaleString(undefined, { month: option }); +} +function hourMinSecTwoDigits(aDate) { + return aDate.toLocaleString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} +function dayPeriod(aDate) { + let dtf = Intl.DateTimeFormat(undefined, { hour: "2-digit" }); + let dayPeriodPart = + dtf.resolvedOptions().hour12 && + dtf.formatToParts(aDate).find(part => part.type === "dayPeriod"); + return dayPeriodPart ? dayPeriodPart.value : ""; +} +function weekNumber(aDate, weekStart) { + let day = aDate.getDay(); + if (weekStart) { + day = (day || 7) - weekStart; + } + return Math.max(Math.floor((DayWithinYear(aDate) + 7 - day) / 7), 0); +} +function weekNumberISO(t) { + let thisWeek = weekNumber(1, t); + let firstDayOfYear = (new Date(t.getFullYear(), 0, 1).getDay() || 7) - 1; + if (thisWeek === 0 && firstDayOfYear >= 4) { + return weekNumberISO(new Date(t.getFullYear() - 1, 11, 31)); + } + if (t.getMonth() === 11 && t.getDate() - ((t.getDay() || 7) - 1) >= 29) { + return 1; + } + return thisWeek + (firstDayOfYear > 0 && firstDayOfYear < 4); +} +function weekYearISO(aDate) { + let thisWeek = weekNumber(1, aDate); + let firstDayOfYear = (new Date(aDate.getFullYear(), 0, 1).getDay() || 7) - 1; + if (thisWeek === 0 && firstDayOfYear >= 4) { + return aDate.getFullYear() - 1; + } + if ( + aDate.getMonth() === 11 && + aDate.getDate() - ((aDate.getDay() || 7) - 1) >= 29 + ) { + return aDate.getFullYear() + 1; + } + return aDate.getFullYear(); +} +function timeZoneOffset(aDate) { + let offset = aDate.getTimezoneOffset(); + let tzoff = Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60); + return (offset < 0 ? "+" : "-") + String(tzoff).padStart(4, "0"); +} +function timeZone(aDate) { + let dtf = Intl.DateTimeFormat(undefined, { timeZoneName: "short" }); + let timeZoneNamePart = dtf + .formatToParts(aDate) + .find(part => part.type === "timeZoneName"); + return timeZoneNamePart ? timeZoneNamePart.value : ""; +} + +const formatFunctions = { + a: aDate => weekday(aDate, "short"), + A: aDate => weekday(aDate, "long"), + b: aDate => month(aDate, "short"), + B: aDate => month(aDate, "long"), + c: aDate => lazy.dateTimeFormatter.format(aDate), + C: aDate => String(Math.trunc(aDate.getFullYear() / 100)), + d: aDate => String(aDate.getDate()), + D: aDate => ToLocaleFormat("%m/%d/%y", aDate), + e: aDate => String(aDate.getDate()), + F: aDate => ToLocaleFormat("%Y-%m-%d", aDate), + g: aDate => String(weekYearISO(aDate) % 100), + G: aDate => String(weekYearISO(aDate)), + h: aDate => month(aDate, "short"), + H: aDate => String(aDate.getHours()), + I: aDate => String(aDate.getHours() % 12 || 12), + j: aDate => String(DayWithinYear(aDate) + 1), + k: aDate => String(aDate.getHours()), + l: aDate => String(aDate.getHours() % 12 || 12), + m: aDate => String(aDate.getMonth() + 1), + M: aDate => String(aDate.getMinutes()), + n: () => "\n", + p: aDate => dayPeriod(aDate).toLocaleUpperCase(), + P: aDate => dayPeriod(aDate).toLocaleLowerCase(), + r: aDate => hourMinSecTwoDigits(aDate), + R: aDate => ToLocaleFormat("%H:%M", aDate), + s: aDate => String(Math.trunc(aDate.getTime() / 1000)), + S: aDate => String(aDate.getSeconds()), + t: () => "\t", + T: aDate => ToLocaleFormat("%H:%M:%S", aDate), + u: aDate => String(aDate.getDay() || 7), + U: aDate => String(weekNumber(aDate, 0)), + V: aDate => String(weekNumberISO(aDate)), + w: aDate => String(aDate.getDay()), + W: aDate => String(weekNumber(aDate, 1)), + x: aDate => lazy.dateFormatter.format(aDate), + X: aDate => lazy.timeFormatter.format(aDate), + y: aDate => String(aDate.getFullYear() % 100), + Y: aDate => String(aDate.getFullYear()), + z: aDate => timeZoneOffset(aDate), + Z: aDate => timeZone(aDate), + "%": () => "%", +}; +const padding = { + C: { fill: "0", width: 2 }, + d: { fill: "0", width: 2 }, + e: { fill: " ", width: 2 }, + g: { fill: "0", width: 2 }, + H: { fill: "0", width: 2 }, + I: { fill: "0", width: 2 }, + j: { fill: "0", width: 3 }, + k: { fill: " ", width: 2 }, + l: { fill: " ", width: 2 }, + m: { fill: "0", width: 2 }, + M: { fill: "0", width: 2 }, + S: { fill: "0", width: 2 }, + U: { fill: "0", width: 2 }, + V: { fill: "0", width: 2 }, + W: { fill: "0", width: 2 }, + y: { fill: "0", width: 2 }, +}; + +export function ToLocaleFormat(aFormat, aDate) { + // Modified conversion specifiers E and O are ignored. + let specifiers = Object.keys(formatFunctions).join(""); + let pattern = RegExp(`%#?(\\^)?([0_-]\\d*)?(?:[EO])?([${specifiers}])`, "g"); + + return aFormat.replace( + pattern, + (matched, upperCaseFlag, fillWidthFlags, specifier) => { + let result = formatFunctions[specifier](aDate); + if (upperCaseFlag) { + result = result.toLocaleUpperCase(); + } + let fill = specifier in padding ? padding[specifier].fill : ""; + let width = specifier in padding ? padding[specifier].width : 0; + if (fillWidthFlags) { + let newFill = fillWidthFlags[0]; + let newWidth = fillWidthFlags.match(/\d+/); + if (newFill === "-" && newWidth === null) { + fill = ""; + } else { + fill = newFill === "0" ? "0" : " "; + width = newWidth !== null ? Number(newWidth) : width; + } + } + return result.padStart(width, fill); + } + ); +} diff --git a/comm/chat/modules/imContentSink.sys.mjs b/comm/chat/modules/imContentSink.sys.mjs new file mode 100644 index 0000000000..b3ff617048 --- /dev/null +++ b/comm/chat/modules/imContentSink.sys.mjs @@ -0,0 +1,495 @@ +/* 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 kAllowedURLs = aValue => /^(https?|ftp|mailto|magnet):/.test(aValue); +var kAllowedMozClasses = aClassName => + aClassName == "moz-txt-underscore" || + aClassName == "moz-txt-tag" || + aClassName == "ib-person"; +var kAllowedAnchorClasses = aClassName => aClassName == "ib-person"; + +/* Tags whose content should be fully removed, and reported in the Error Console. */ +var kForbiddenTags = { + script: true, + style: true, +}; + +/** + * In strict mode, remove all formatting. Keep only links and line breaks. + * + * @type {CleanRules} + */ +var kStrictMode = { + attrs: {}, + + tags: { + a: { + title: true, + href: kAllowedURLs, + class: kAllowedAnchorClasses, + }, + br: true, + p: true, + }, + + styles: {}, +}; + +/** + * Standard mode allows basic formattings (bold, italic, underlined). + * + * @type {CleanRules} + */ +var kStandardMode = { + attrs: { + style: true, + }, + + tags: { + div: true, + a: { + title: true, + href: kAllowedURLs, + class: kAllowedAnchorClasses, + }, + em: true, + strong: true, + b: true, + i: true, + u: true, + s: true, + span: { + class: kAllowedMozClasses, + }, + br: true, + code: true, + ul: true, + li: true, + ol: { + start: true, + }, + cite: true, + blockquote: true, + p: true, + del: true, + strike: true, + ins: true, + sub: true, + sup: true, + pre: true, + table: true, + thead: true, + tbody: true, + tr: true, + th: true, + td: true, + caption: true, + details: true, + summary: true, + }, + + styles: { + "font-style": true, + "font-weight": true, + "text-decoration-line": true, + }, +}; + +/** + * Permissive mode allows just about anything that isn't going to mess up the chat window. + * In comparison to normal mode this primarily means elements that can vary font sizes and + * colors. + * + * @type {CleanRules} + */ +var kPermissiveMode = { + attrs: { + style: true, + }, + + tags: { + div: true, + a: { + title: true, + href: kAllowedURLs, + class: kAllowedAnchorClasses, + }, + font: { + face: true, + color: true, + size: true, + }, + em: true, + strong: true, + b: true, + i: true, + u: true, + s: true, + span: { + class: kAllowedMozClasses, + }, + br: true, + hr: true, + code: true, + ul: true, + li: true, + ol: { + start: true, + }, + cite: true, + blockquote: true, + p: true, + del: true, + strike: true, + ins: true, + sub: true, + sup: true, + pre: true, + table: true, + thead: true, + tbody: true, + tr: true, + th: true, + td: true, + caption: true, + details: true, + summary: true, + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + }, + + // FIXME: should be possible to use functions to filter values + styles: { + color: true, + font: true, + "font-family": true, + "font-size": true, + "font-style": true, + "font-weight": true, + "text-decoration-color": true, + "text-decoration-style": true, + "text-decoration-line": true, + }, +}; + +var kModePref = "messenger.options.filterMode"; +var kModes = [kStrictMode, kStandardMode, kPermissiveMode]; + +var gGlobalRuleset = null; + +function initGlobalRuleset() { + gGlobalRuleset = newRuleset(); + + Services.prefs.addObserver(kModePref, styleObserver); +} + +var styleObserver = { + observe(aObject, aTopic, aMsg) { + if (aTopic != "nsPref:changed" || aMsg != kModePref) { + throw new Error("bad notification"); + } + + if (!gGlobalRuleset) { + throw new Error("gGlobalRuleset not initialized"); + } + + setBaseRuleset(getModePref(), gGlobalRuleset); + }, +}; + +function getModePref() { + let baseNum = Services.prefs.getIntPref(kModePref); + if (baseNum < 0 || baseNum > 2) { + baseNum = 1; + } + + return kModes[baseNum]; +} + +function setBaseRuleset(aBase, aResult) { + for (let property in aBase) { + aResult[property] = Object.create(aBase[property], aResult[property]); + } +} + +function newRuleset(aBase) { + let result = { + tags: {}, + attrs: {}, + styles: {}, + }; + setBaseRuleset(aBase || getModePref(), result); + return result; +} + +export function createDerivedRuleset() { + if (!gGlobalRuleset) { + initGlobalRuleset(); + } + return newRuleset(gGlobalRuleset); +} + +export function addGlobalAllowedTag(aTag, aAttrs = true) { + gGlobalRuleset.tags[aTag] = aAttrs; +} + +export function removeGlobalAllowedTag(aTag) { + delete gGlobalRuleset.tags[aTag]; +} + +export function addGlobalAllowedAttribute(aAttr, aRule = true) { + gGlobalRuleset.attrs[aAttr] = aRule; +} + +export function removeGlobalAllowedAttribute(aAttr) { + delete gGlobalRuleset.attrs[aAttr]; +} + +export function addGlobalAllowedStyleRule(aStyle, aRule = true) { + gGlobalRuleset.styles[aStyle] = aRule; +} + +export function removeGlobalAllowedStyleRule(aStyle) { + delete gGlobalRuleset.styles[aStyle]; +} + +/** + * A dynamic rule which decides if an attribute is allowed based on the + * attribute's value. + * + * @callback ValueRule + * @param {string} value - The attribute value. + * @returns {bool} - True if the attribute should be allowed. + * + * @example + * + * aValue => aValue == 'about:blank' + */ + +/** + * An object whose properties are the allowed attributes. + * + * The value of the property should be true to unconditionally accept the + * attribute, or a function which accepts the value of the attribute and + * returns a boolean of whether the attribute should be accepted or not. + * + * @typedef Ruleset + * @type {Object}} + */ + +/** + * A set of rules for which tags, attributes, and styles should be allowed when + * rendering HTML. + * + * See kStrictMode, kStandardMode, kPermissiveMode for examples of Rulesets. + * + * @typedef CleanRules + * @type {object} + * @property {Ruleset} attrs + * An object whose properties are the allowed attributes for any tag. + * @property {Object} tags + * An object whose properties are the allowed tags. + * + * The value can point to a {@link Ruleset} for that tag which augments the + * ones provided by attrs. If either of the {@link Ruleset}s from attrs or + * tags allows an attribute, then it is accepted. + * @property {Object} styles + * An object whose properties are the allowed CSS style rules. + * + * The value of each property is unused. + * + * FIXME: make styles accept functions to filter the CSS values like Ruleset. + * + * @example + * + * { + * attrs: { 'style': true }, + * tags: { + * a: { 'href': true }, + * }, + * styles: { + * 'font-size': true + * } + * } + */ + +/** + * A function to modify text nodes. + * + * @callback TextModifier + * @param {Node} - The text node to modify. + * @returns {int} - The number of nodes added. + * + * -1 if the current textnode was deleted + * 0 if the node count is unchanged + * positive value if nodes were added. + * + * For instance, adding an tag for a smiley adds 2 nodes: + * the img tag + * the new text node after the img tag. + */ + +/** + * Removes nodes, attributes and styles that are not allowed according to the + * given rules. + * + * @param {Node} aNode + * A DOM node to inspect recursively against the rules. + * @param {CleanRules} aRules + * The rules for what tags, attributes, and styles are allowed. + * @param {TextModifier[]} aTextModifiers + * A list of functions to modify text content. + */ +function cleanupNode(aNode, aRules, aTextModifiers) { + // Iterate each node and apply rules for what content is allowed. This has two + // modes: one for element nodes and one for text nodes. + for (let i = 0; i < aNode.childNodes.length; ++i) { + let node = aNode.childNodes[i]; + if ( + node.nodeType == node.ELEMENT_NODE && + node.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + // If the node is an element, check if the node is an allowed tag. + let nodeName = node.localName; + if (!(nodeName in aRules.tags)) { + // If the node is not allowed, either remove it completely (if + // it is forbidden) or replace it with its children. + if (nodeName in kForbiddenTags) { + console.error( + "removing a " + nodeName + " tag from a message before display" + ); + } else { + while (node.hasChildNodes()) { + aNode.insertBefore(node.firstChild, node); + } + } + aNode.removeChild(node); + // We want to process again the node at the index i which is + // now the first child of the node we removed + --i; + continue; + } + + // This node is being kept, cleanup each child node. + cleanupNode(node, aRules, aTextModifiers); + + // Cleanup the attributes of this node. + let attrs = node.attributes; + let acceptFunction = function (aAttrRules, aAttr) { + // An attribute is always accepted if its rule is true, or conditionally + // accepted if its rule is a function that evaluates to true. + // If its rule does not exist, it is removed. + let localName = aAttr.localName; + let rule = localName in aAttrRules && aAttrRules[localName]; + return ( + rule === true || (typeof rule == "function" && rule(aAttr.value)) + ); + }; + for (let j = 0; j < attrs.length; ++j) { + let attr = attrs[j]; + // If either the attribute is accepted for all tags or for this specific + // tag then it is allowed. + if ( + !( + acceptFunction(aRules.attrs, attr) || + (typeof aRules.tags[nodeName] == "object" && + acceptFunction(aRules.tags[nodeName], attr)) + ) + ) { + node.removeAttribute(attr.name); + --j; + } + } + + // Cleanup the style attribute. + let style = node.style; + for (let j = 0; j < style.length; ++j) { + if (!(style[j] in aRules.styles)) { + style.removeProperty(style[j]); + --j; + } + } + + // If the style attribute is now empty or if it contained unsupported or + // unparsable CSS it should be dropped completely. + if (!style.length) { + node.removeAttribute("style"); + } + + // Sort the style attributes for easier checking/comparing later. + if (node.hasAttribute("style")) { + let trailingSemi = false; + let attrs = node.getAttribute("style").trim(); + if (attrs.endsWith(";")) { + attrs = attrs.slice(0, -1); + trailingSemi = true; + } + attrs = attrs.split(";").map(a => a.trim()); + attrs.sort(); + node.setAttribute( + "style", + attrs.join("; ") + (trailingSemi ? ";" : "") + ); + } + } else { + // We are on a text node, we need to apply the functions + // provided in the aTextModifiers array. + + // Each of these function should return the number of nodes added: + // * -1 if the current textnode was deleted + // * 0 if the node count is unchanged + // * positive value if nodes were added. + // For instance, adding an tag for a smiley adds 2 nodes: + // - the img tag + // - the new text node after the img tag. + + // This is the number of nodes we need to process. If new nodes + // are created, the next text modifier functions have more nodes + // to process. + let textNodeCount = 1; + for (let modifier of aTextModifiers) { + for (let n = 0; n < textNodeCount; ++n) { + let textNode = aNode.childNodes[i + n]; + + // If we are processing nodes created by one of the previous + // text modifier function, some of the nodes are likely not + // text node, skip them. + if ( + textNode.nodeType != textNode.TEXT_NODE && + textNode.nodeType != textNode.CDATA_SECTION_NODE + ) { + continue; + } + + let result = modifier(textNode); + textNodeCount += result; + n += result; + } + } + + // newly created nodes should not be filtered, be sure we skip them! + i += textNodeCount - 1; + } + } +} + +export function cleanupImMarkup(aText, aRuleset, aTextModifiers = []) { + if (!gGlobalRuleset) { + initGlobalRuleset(); + } + + let parser = new DOMParser(); + // Wrap the text to be parsed in a to avoid losing leading whitespace. + let doc = parser.parseFromString( + "" + aText + "", + "text/html" + ); + let span = doc.querySelector("span"); + cleanupNode(span, aRuleset || gGlobalRuleset, aTextModifiers); + return span.innerHTML; +} diff --git a/comm/chat/modules/imSmileys.sys.mjs b/comm/chat/modules/imSmileys.sys.mjs new file mode 100644 index 0000000000..1658033786 --- /dev/null +++ b/comm/chat/modules/imSmileys.sys.mjs @@ -0,0 +1,184 @@ +/* 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/. */ + +/** Used to add smileys to the content of a textnode. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "gTextDecoder", () => { + return new TextDecoder(); +}); + +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); + +var kEmoticonsThemePref = "messenger.options.emoticonsTheme"; +var kThemeFile = "theme.json"; + +Object.defineProperty(lazy, "gTheme", { + configurable: true, + enumerable: true, + + get() { + delete this.gTheme; + gPrefObserver.init(); + return (this.gTheme = getTheme()); + }, +}); + +var gPrefObserver = { + init() { + Services.prefs.addObserver(kEmoticonsThemePref, gPrefObserver); + }, + + observe(aObject, aTopic, aMsg) { + if (aTopic != "nsPref:changed" || aMsg != kEmoticonsThemePref) { + throw new Error("bad notification"); + } + + lazy.gTheme = getTheme(); + }, +}; + +function getTheme(aName) { + let name = aName || Services.prefs.getCharPref(kEmoticonsThemePref); + + let theme = { + name, + iconsHash: null, + json: null, + regExp: null, + }; + + if (name == "none") { + return theme; + } + + if (name == "default") { + theme.baseUri = "chrome://instantbird-emoticons/skin/"; + } else { + theme.baseUri = "chrome://" + theme.name + "/skin/"; + } + try { + let channel = Services.io.newChannel( + theme.baseUri + kThemeFile, + null, + null, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_IMAGE + ); + let stream = channel.open(); + let bytes = lazy.NetUtil.readInputStream(stream, stream.available()); + theme.json = JSON.parse(lazy.gTextDecoder.decode(bytes)); + stream.close(); + theme.iconsHash = {}; + for (let smiley of theme.json.smileys) { + for (let textCode of smiley.textCodes) { + theme.iconsHash[textCode] = smiley; + } + } + } catch (e) { + console.error(e); + } + return theme; +} + +function getRegexp() { + if (lazy.gTheme.regExp) { + lazy.gTheme.regExp.lastIndex = 0; + return lazy.gTheme.regExp; + } + + // return null if smileys are disabled + if (!lazy.gTheme.iconsHash) { + return null; + } + + if ("" in lazy.gTheme.iconsHash) { + console.error( + "Emoticon " + + lazy.gTheme.iconsHash[""].filename + + " matches the empty string!" + ); + delete lazy.gTheme.iconsHash[""]; + } + + let emoticonList = []; + for (let emoticon in lazy.gTheme.iconsHash) { + emoticonList.push(emoticon); + } + + let exp = /[[\]{}()*+?.\\^$|]/g; + emoticonList = emoticonList + .sort() + .reverse() + .map(x => x.replace(exp, "\\$&")); + + if (!emoticonList.length) { + // the theme contains no valid emoticon, make sure we will return + // early next time + lazy.gTheme.iconsHash = null; + return null; + } + + lazy.gTheme.regExp = new RegExp(emoticonList.join("|"), "g"); + return lazy.gTheme.regExp; +} + +export function smileTextNode(aNode) { + /* + * Skip text nodes that contain the href in the child text node. + * We must check both the testNode.textContent and the aNode.data since they + * cover different cases: + * textContent: The URL is split over multiple nodes for some reason + * data: The URL is not the only content in the link, skip only the one node + * Check the class name to skip any autolinked nodes from mozTXTToHTMLConv. + */ + let testNode = aNode; + while ((testNode = testNode.parentNode)) { + if ( + testNode.nodeName.toLowerCase() == "a" && + (testNode.getAttribute("href") == testNode.textContent.trim() || + testNode.getAttribute("href") == aNode.data.trim() || + testNode.className.includes("moz-txt-link-")) + ) { + return 0; + } + } + + let result = 0; + let exp = getRegexp(); + if (!exp) { + return result; + } + + let match; + while ((match = exp.exec(aNode.data))) { + let smileNode = aNode.splitText(match.index); + aNode = smileNode.splitText(exp.lastIndex - match.index); + // at this point, smileNode is a text node with only the text + // of the smiley and aNode is a text node with the text after + // the smiley. The text in aNode hasn't been processed yet. + let smile = smileNode.data; + let elt = aNode.ownerDocument.createElement("span"); + elt.appendChild( + aNode.ownerDocument.createTextNode(lazy.gTheme.iconsHash[smile].glyph) + ); + // Add the title attribute (to show the original text in a tooltip) in case + // the replacement was done incorrectly. + elt.setAttribute("title", smile); + smileNode.parentNode.replaceChild(elt, smileNode); + result += 2; + exp.lastIndex = 0; + } + return result; +} diff --git a/comm/chat/modules/imStatusUtils.sys.mjs b/comm/chat/modules/imStatusUtils.sys.mjs new file mode 100644 index 0000000000..58c594b117 --- /dev/null +++ b/comm/chat/modules/imStatusUtils.sys.mjs @@ -0,0 +1,57 @@ +/* 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"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/status.properties") +); + +var imIStatusInfo = Ci.imIStatusInfo; +var statusAttributes = {}; +statusAttributes[imIStatusInfo.STATUS_UNKNOWN] = "unknown"; +statusAttributes[imIStatusInfo.STATUS_OFFLINE] = "offline"; +statusAttributes[imIStatusInfo.STATUS_INVISIBLE] = "invisible"; +statusAttributes[imIStatusInfo.STATUS_MOBILE] = "mobile"; +statusAttributes[imIStatusInfo.STATUS_IDLE] = "idle"; +statusAttributes[imIStatusInfo.STATUS_AWAY] = "away"; +statusAttributes[imIStatusInfo.STATUS_UNAVAILABLE] = "unavailable"; +statusAttributes[imIStatusInfo.STATUS_AVAILABLE] = "available"; + +export var Status = { + toAttribute: aStatusType => + aStatusType in statusAttributes ? statusAttributes[aStatusType] : "unknown", + + _labels: {}, + toLabel(aStatusType, aStatusText) { + // aStatusType may be either one of the (integral) imIStatusInfo status + // constants, or one of the statusAttributes. + if (!(typeof aStatusType == "string")) { + aStatusType = this.toAttribute(aStatusType); + } + + if (!(aStatusType in this._labels)) { + this._labels[aStatusType] = lazy._(aStatusType + "StatusType"); + } + + let label = this._labels[aStatusType]; + if (aStatusText) { + label = lazy._("statusWithStatusMessage", label, aStatusText); + } + + return label; + }, + + toFlag(aAttribute) { + for (let flag in statusAttributes) { + if (statusAttributes[flag] == aAttribute) { + return flag; + } + } + return imIStatusInfo.STATUS_UNKNOWN; + }, +}; diff --git a/comm/chat/modules/imTextboxUtils.sys.mjs b/comm/chat/modules/imTextboxUtils.sys.mjs new file mode 100644 index 0000000000..979abb6f61 --- /dev/null +++ b/comm/chat/modules/imTextboxUtils.sys.mjs @@ -0,0 +1,19 @@ +/* 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 TextboxSize = { + _textboxAutoResizePrefName: "messenger.conversations.textbox.autoResize", + get autoResize() { + delete this.autoResize; + Services.prefs.addObserver(this._textboxAutoResizePrefName, this); + return (this.autoResize = Services.prefs.getBoolPref( + this._textboxAutoResizePrefName + )); + }, + observe(aSubject, aTopic, aMsg) { + if (aTopic == "nsPref:changed" && aMsg == this._textboxAutoResizePrefName) { + this.autoResize = Services.prefs.getBoolPref(aMsg); + } + }, +}; diff --git a/comm/chat/modules/imThemes.sys.mjs b/comm/chat/modules/imThemes.sys.mjs new file mode 100644 index 0000000000..5b7f0ee824 --- /dev/null +++ b/comm/chat/modules/imThemes.sys.mjs @@ -0,0 +1,1333 @@ +/* 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 = {}; + +const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils +); + +var kMessagesStylePrefBranch = "messenger.options.messagesStyle."; +var kThemePref = "theme"; +var kVariantPref = "variant"; +var kCombineConsecutivePref = "combineConsecutive"; +var kCombineConsecutiveIntervalPref = "combineConsecutiveInterval"; + +var DEFAULT_THEME = "bubbles"; +var DEFAULT_THEMES = ["bubbles", "dark", "mail", "papersheets", "simple"]; + +var kLineBreak = "@mozilla.org/windows-registry-key;1" in Cc ? "\r\n" : "\n"; + +XPCOMUtils.defineLazyGetter(lazy, "gPrefBranch", () => + Services.prefs.getBranch(kMessagesStylePrefBranch) +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTXT => cs.scanTXT(aTXT, cs.kEntities); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gTimeFormatter", () => { + return new Services.intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs", +}); + +var gCurrentTheme = null; + +function getChromeFile(aURI) { + try { + let channel = Services.io.newChannel( + aURI, + null, + null, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let stream = channel.open(); + let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sstream.init(stream); + let text = sstream.read(sstream.available()); + sstream.close(); + return text; + } catch (e) { + if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + dump("Getting " + aURI + ": " + e + "\n"); + } + return null; + } +} + +function HTMLTheme(aBaseURI) { + let files = { + footer: "Footer.html", + header: "Header.html", + status: "Status.html", + statusNext: "NextStatus.html", + incomingContent: "Incoming/Content.html", + incomingContext: "Incoming/Context.html", + incomingNextContent: "Incoming/NextContent.html", + incomingNextContext: "Incoming/NextContext.html", + outgoingContent: "Outgoing/Content.html", + outgoingContext: "Outgoing/Context.html", + outgoingNextContent: "Outgoing/NextContent.html", + outgoingNextContext: "Outgoing/NextContext.html", + }; + + for (let id in files) { + let html = getChromeFile(aBaseURI + files[id]); + if (html) { + Object.defineProperty(this, id, { value: html }); + } + } + + if (!("incomingContent" in files)) { + throw new Error("Invalid theme: Incoming/Content.html is missing!"); + } +} + +HTMLTheme.prototype = { + get footer() { + return ""; + }, + get header() { + return ""; + }, + get status() { + return this.incomingContent; + }, + get statusNext() { + return this.status; + }, + get incomingContent() { + throw new Error("Incoming/Content.html is a required file"); + }, + get incomingNextContent() { + return this.incomingContent; + }, + get outgoingContent() { + return this.incomingContent; + }, + get outgoingNextContent() { + return this.incomingNextContent; + }, + get incomingContext() { + return this.incomingContent; + }, + get incomingNextContext() { + return this.incomingNextContent; + }, + get outgoingContext() { + return this.hasOwnProperty("outgoingContent") + ? this.outgoingContent + : this.incomingContext; + }, + get outgoingNextContext() { + return this.hasOwnProperty("outgoingNextContent") + ? this.outgoingNextContent + : this.incomingNextContext; + }, +}; + +function plistToJSON(aElt) { + switch (aElt.localName) { + case "true": + return true; + case "false": + return false; + case "string": + case "data": + return aElt.textContent; + case "real": + return parseFloat(aElt.textContent); + case "integer": + return parseInt(aElt.textContent, 10); + + case "dict": + let res = {}; + let nodes = aElt.children; + for (let i = 0; i < nodes.length; ++i) { + if (nodes[i].nodeName == "key") { + let key = nodes[i].textContent; + ++i; + while (!Element.isInstance(nodes[i])) { + ++i; + } + res[key] = plistToJSON(nodes[i]); + } + } + return res; + + case "array": + let array = []; + nodes = aElt.children; + for (let i = 0; i < nodes.length; ++i) { + if (Element.isInstance(nodes[i])) { + array.push(plistToJSON(nodes[i])); + } + } + return array; + + default: + throw new Error("Unknown tag in plist file"); + } +} + +function getInfoPlistContent(aBaseURI) { + try { + let channel = Services.io.newChannel( + aBaseURI + "Info.plist", + null, + null, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let stream = channel.open(); + let parser = new DOMParser(); + let doc = parser.parseFromStream( + stream, + null, + stream.available(), + "text/xml" + ); + if (doc.documentElement.localName != "plist") { + throw new Error("Invalid Info.plist file"); + } + let node = doc.documentElement.firstElementChild; + while (node && !Element.isInstance(node)) { + node = node.nextElementSibling; + } + if (!node || node.localName != "dict") { + throw new Error("Empty or invalid Info.plist file"); + } + return plistToJSON(node); + } catch (e) { + console.error(e); + return null; + } +} + +function getChromeBaseURI(aThemeName) { + if (DEFAULT_THEMES.includes(aThemeName)) { + return "chrome://messenger-messagestyles/skin/" + aThemeName + "/"; + } + return "chrome://" + aThemeName + "/skin/"; +} + +export function getThemeByName(aName) { + let baseURI = getChromeBaseURI(aName); + let metadata = getInfoPlistContent(baseURI); + if (!metadata) { + throw new Error("Cannot load theme " + aName); + } + + return { + name: aName, + variant: "default", + baseURI, + metadata, + html: new HTMLTheme(baseURI), + combineConsecutive: lazy.gPrefBranch.getBoolPref(kCombineConsecutivePref), + combineConsecutiveInterval: lazy.gPrefBranch.getIntPref( + kCombineConsecutiveIntervalPref + ), + }; +} + +export function getCurrentTheme() { + let name = lazy.gPrefBranch.getCharPref(kThemePref); + let variant = lazy.gPrefBranch.getCharPref(kVariantPref); + if ( + gCurrentTheme && + gCurrentTheme.name == name && + gCurrentTheme.variant == variant + ) { + return gCurrentTheme; + } + + try { + gCurrentTheme = getThemeByName(name); + gCurrentTheme.variant = variant; + } catch (e) { + console.error(e); + gCurrentTheme = getThemeByName(DEFAULT_THEME); + gCurrentTheme.variant = "default"; + } + + return gCurrentTheme; +} + +function getDirectoryEntries(aDir) { + let ios = Services.io; + let uri = ios.newURI(aDir); + let cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIXULChromeRegistry + ); + while (uri.scheme == "chrome") { + uri = cr.convertChromeURL(uri); + } + + // remove any trailing file name added by convertChromeURL + let spec = uri.spec.replace(/[^\/]+$/, ""); + uri = ios.newURI(spec); + + let results = []; + if (uri.scheme == "jar") { + uri.QueryInterface(Ci.nsIJARURI); + let strEntry = uri.JAREntry; + if (!strEntry) { + return []; + } + + let zr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( + Ci.nsIZipReader + ); + let jarFile = uri.JARFile; + if (jarFile instanceof Ci.nsIJARURI) { + let innerZr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( + Ci.nsIZipReader + ); + innerZr.open(jarFile.JARFile.QueryInterface(Ci.nsIFileURL).file); + zr.openInner(innerZr, jarFile.JAREntry); + } else { + zr.open(jarFile.QueryInterface(Ci.nsIFileURL).file); + } + + if (!zr.hasEntry(strEntry) || !zr.getEntry(strEntry).isDirectory) { + zr.close(); + return []; + } + + let escapedEntry = strEntry.replace(/([*?$[\]^~()\\])/g, "\\$1"); + let filter = escapedEntry + "?*~" + escapedEntry + "?*/?*"; + let entries = zr.findEntries(filter); + + let parentLength = strEntry.length; + for (let entry of entries) { + results.push(entry.substring(parentLength)); + } + zr.close(); + } else if (uri.scheme == "file") { + uri.QueryInterface(Ci.nsIFileURL); + let dir = uri.file; + + if (!dir.exists() || !dir.isDirectory()) { + return []; + } + + for (let file of dir.directoryEntries) { + results.push(file.leafName); + } + } + + return results; +} + +export function getThemeVariants(aTheme) { + let variants = getDirectoryEntries(aTheme.baseURI + "Variants/"); + return variants + .filter(v => v.endsWith(".css")) + .map(v => v.substring(0, v.length - 4)); +} + +/* helper function for replacements in messages */ +function getBuddyFromMessage(aMsg) { + if (aMsg.incoming) { + let conv = aMsg.conversation; + if (!conv.isChat) { + return conv.buddy; + } + } + + return null; +} + +function getStatusIconFromBuddy(aBuddy) { + let status = "unknown"; + if (aBuddy) { + if (!aBuddy.online) { + status = "offline"; + } else if (aBuddy.idle) { + status = "idle"; + } else if (!aBuddy.available) { + status = "away"; + } else { + status = "available"; + } + } + + return "chrome://chat/skin/" + status + "-16.png"; +} + +var footerReplacements = { + chatName: aConv => lazy.TXTToHTML(aConv.title), + sourceName: aConv => + lazy.TXTToHTML(aConv.account.alias || aConv.account.name), + destinationName: aConv => lazy.TXTToHTML(aConv.name), + destinationDisplayName: aConv => lazy.TXTToHTML(aConv.title), + incomingIconPath(aConv) { + let buddy; + return ( + (!aConv.isChat && (buddy = aConv.buddy) && buddy.buddyIconFilename) || + "incoming_icon.png" + ); + }, + outgoingIconPath: aConv => "outgoing_icon.png", + timeOpened(aConv, aFormat) { + let date = new Date(aConv.startDate / 1000); + if (aFormat) { + return lazy.ToLocaleFormat(aFormat, date); + } + return lazy.gTimeFormatter.format(date); + }, +}; + +function formatAutoResponce(aTxt) { + return Services.strings + .createBundle("chrome://chat/locale/conversations.properties") + .formatStringFromName("autoReply", [aTxt]); +} + +var statusMessageReplacements = { + message: aMsg => + '' + + (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) + + "", + time(aMsg, aFormat) { + let date = new Date(aMsg.time * 1000); + if (aFormat) { + return lazy.ToLocaleFormat(aFormat, date); + } + return lazy.gTimeFormatter.format(date); + }, + timestamp: aMsg => aMsg.time, + shortTime(aMsg) { + return lazy.gTimeFormatter.format(new Date(aMsg.time * 1000)); + }, + messageClasses(aMsg) { + let msgClass = []; + + if (aMsg.system) { + msgClass.push("event"); + } else { + msgClass.push("message"); + + if (aMsg.incoming) { + msgClass.push("incoming"); + } else if (aMsg.outgoing) { + msgClass.push("outgoing"); + } + + if (aMsg.action) { + msgClass.push("action"); + } + + if (aMsg.autoResponse) { + msgClass.push("autoreply"); + } + } + + if (aMsg.containsNick) { + msgClass.push("nick"); + } + if (aMsg.error) { + msgClass.push("error"); + } + if (aMsg.delayed) { + msgClass.push("delayed"); + } + if (aMsg.notification) { + msgClass.push("notification"); + } + if (aMsg.noFormat) { + msgClass.push("monospaced"); + } + if (aMsg.noCollapse) { + msgClass.push("no-collapse"); + } + + return msgClass.join(" "); + }, +}; + +function formatSender(aName, isEncrypted = false) { + let otr = isEncrypted ? " message-encrypted" : ""; + return `${lazy.TXTToHTML(aName)}`; +} +var messageReplacements = { + userIconPath(aMsg) { + // If the protocol plugin provides an icon for the message, use it. + let iconURL = aMsg.iconURL; + if (iconURL) { + return iconURL; + } + + // For outgoing messages, use the current user icon. + if (aMsg.outgoing) { + iconURL = aMsg.conversation.account.statusInfo.getUserIcon(); + if (iconURL) { + return iconURL.spec; + } + } + + // Fallback to the theme's default icons. + return (aMsg.incoming ? "Incoming" : "Outgoing") + "/buddy_icon.svg"; + }, + senderScreenName: aMsg => formatSender(aMsg.who, aMsg.isEncrypted), + sender: aMsg => formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted), + senderColor: aMsg => aMsg.color, + senderStatusIcon: aMsg => getStatusIconFromBuddy(getBuddyFromMessage(aMsg)), + messageDirection: aMsg => "ltr", + // no theme actually use this, don't bother making sure this is the real + // serverside alias + senderDisplayName: aMsg => + formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted), + service: aMsg => aMsg.conversation.account.protocol.name, + textbackgroundcolor: (aMsg, aFormat) => "transparent", // FIXME? + __proto__: statusMessageReplacements, +}; + +var statusReplacements = { + status: aMsg => "", // FIXME + statusIcon(aMsg) { + let conv = aMsg.conversation; + let buddy = null; + if (!conv.isChat) { + buddy = conv.buddy; + } + return getStatusIconFromBuddy(buddy); + }, + __proto__: statusMessageReplacements, +}; + +var kReplacementRegExp = /%([a-zA-Z]*)(\{([^\}]*)\})?%/g; + +function replaceKeywordsInHTML(aHTML, aReplacements, aReplacementArg) { + kReplacementRegExp.lastIndex = 0; + let previousIndex = 0; + let result = ""; + let match; + while ((match = kReplacementRegExp.exec(aHTML))) { + let content = ""; + if (match[1] in aReplacements) { + content = aReplacements[match[1]](aReplacementArg, match[3]); + } else { + console.error( + "Unknown replacement string %" + match[1] + "% in message styles." + ); + } + result += aHTML.substring(previousIndex, match.index) + content; + previousIndex = kReplacementRegExp.lastIndex; + } + + return result + aHTML.slice(previousIndex); +} + +/** + * Determine if a message should be grouped with a previous message. + * + * @param {object} aTheme - The theme the messages will be displayed in. + * @param {imIMessage} aMsg - The message that is about to be appended. + * @param {imIMessage} aPreviousMsg - The last message that was displayed. + * @returns {boolean} If the message should be grouped with the previous one. + */ +export function isNextMessage(aTheme, aMsg, aPreviousMsg) { + if ( + !aTheme.combineConsecutive || + (hasMetadataKey(aTheme, "DisableCombineConsecutive") && + getMetadata(aTheme, "DisableCombineConsecutive")) + ) { + return false; + } + + if (!aPreviousMsg) { + return false; + } + + if (aMsg.system && aPreviousMsg.system) { + return true; + } + + if ( + aMsg.who != aPreviousMsg.who || + aMsg.outgoing != aPreviousMsg.outgoing || + aMsg.incoming != aPreviousMsg.incoming || + aMsg.system != aPreviousMsg.system + ) { + return false; + } + + let timeDifference = aMsg.time - aPreviousMsg.time; + return ( + timeDifference >= 0 && timeDifference <= aTheme.combineConsecutiveInterval + ); +} + +/** + * Determine whether the message was a next message when it was initially + * inserted. + * + * @param {imIMessage} msg + * @param {DOMDocument} doc + * @returns {boolean} If the message is a next message. Returns false if the + * message doesn't already exist in the conversation. + */ +export function wasNextMessage(msg, doc) { + return Boolean( + doc.querySelector(`#Chat [data-remote-id="${CSS.escape(msg.remoteId)}"]`) + ?.dataset.isNext + ); +} + +/** + * Create an HTML string to insert the message into the conversation. + * + * @param {imIMessage} aMsg + * @param {object} aTheme + * @param {boolean} aIsNext - If this message is immediately following a + * message of the same origin. Used for visual grouping. + * @param {boolean} aIsContext - If this message was already read by the user + * previously and just provided for context. + * @returns {string} Raw HTML for the message. + */ +export function getHTMLForMessage(aMsg, aTheme, aIsNext, aIsContext) { + let html, replacements; + if (aMsg.system) { + html = aIsNext ? aTheme.html.statusNext : aTheme.html.status; + replacements = statusReplacements; + } else { + html = aMsg.incoming ? "incoming" : "outgoing"; + if (aIsNext) { + html += "Next"; + } + html += aIsContext ? "Context" : "Content"; + html = aTheme.html[html]; + replacements = messageReplacements; + if (aMsg.action) { + let actionMessageTemplate = "* %message% *"; + if (hasMetadataKey(aTheme, "ActionMessageTemplate")) { + actionMessageTemplate = getMetadata(aTheme, "ActionMessageTemplate"); + } + html = html.replace(/%message%/g, actionMessageTemplate); + } + } + + return replaceKeywordsInHTML(html, replacements, aMsg); +} + +/** + * + * @param {imIMessage} aMsg + * @param {string} aHTML + * @param {DOMDocument} aDoc + * @param {boolean} aIsNext + * @returns {Element} + */ +export function insertHTMLForMessage(aMsg, aHTML, aDoc, aIsNext) { + let insert = aDoc.getElementById("insert"); + if (insert && !aIsNext) { + insert.remove(); + insert = null; + } + + let parent = insert ? insert.parentNode : aDoc.getElementById("Chat"); + let documentFragment = getDocumentFragmentFromHTML(aDoc, aHTML); + + // If the parent already has a remote ID, we remove it, since it now contains + // multiple different messages. + if (parent.dataset.remoteId) { + for (let child of parent.children) { + child.dataset.remoteId = parent.dataset.remoteId; + child.dataset.isNext = true; + } + delete parent.dataset.remoteId; + } + + let result = documentFragment.firstElementChild; + // store the prplIMessage object in each of the "root" node that + // will be inserted into the document, so that selection code can + // retrieve the message by just looking at the parent node until it + // finds something. + for (let root = result; root; root = root.nextElementSibling) { + // Skip the insert placeholder. + if (root.id === "insert") { + continue; + } + root._originalMsg = aMsg; + // Store remote ID of the message in the DOM for fast retrieval + root.dataset.remoteId = aMsg.remoteId; + if (aIsNext) { + root.dataset.isNext = aIsNext; + } + } + + // make sure the result is an HTMLElement and not some text (whitespace)... + while ( + result && + !( + result.nodeType == result.ELEMENT_NODE && + result.namespaceURI == "http://www.w3.org/1999/xhtml" + ) + ) { + result = result.nextElementSibling; + } + if (insert) { + parent.replaceChild(documentFragment, insert); + } else { + parent.appendChild(documentFragment); + } + return result; +} + +/** + * Replace the HTML of an already displayed message based on the matching + * remote ID. + * + * @param {imIMessage} msg - Message to insert the updated contents of. + * @param {string} html - The HTML contents to insert. + * @param {Document} doc - The HTML document the message should be replaced + * in. + * @param {boolean} isNext - If this message is immediately following a + * message of the same origin. Used for visual grouping. + */ +export function replaceHTMLForMessage(msg, html, doc, isNext) { + // If the updated message has no remote ID, do nothing. + if (!msg.remoteId) { + return; + } + let message = getExistingMessage(msg.remoteId, doc); + + // If we couldn't find a matching message, do nothing. + if (!message.length) { + return; + } + + let documentFragment = getDocumentFragmentFromHTML(doc, html); + // We don't want to add an insert point when replacing a message. + documentFragment.querySelector("#insert")?.remove(); + // store the prplIMessage object in each of the "root" nodes that + // will be inserted into the document, so that the selection code can + // retrieve the message by just looking at the parent node until it + // finds something. + for ( + let root = documentFragment.firstElementChild; + root; + root = root.nextElementSibling + ) { + root._originalMsg = msg; + root.dataset.remoteId = msg.remoteId; + if (isNext) { + root.dataset.isNext = isNext; + } + } + + // Remove all but the first element of the original message + if (message.length > 1) { + let range = doc.createRange(); + range.setStartBefore(message[1]); + range.setEndAfter(message[message.length - 1]); + range.deleteContents(); + } + // Insert the new message into the DOM + message[0].replaceWith(documentFragment); +} + +/** + * Remove all elements belonging to a message from the document, based on the + * remote ID of the message. + * + * @param {string} remoteId + * @param {Document} doc + */ +export function removeMessage(remoteId, doc) { + let message = getExistingMessage(remoteId, doc); + + // If we couldn't find a matching message, do nothing. + if (!message.length) { + return; + } + + // Remove all elements of the original message + let range = doc.createRange(); + range.setStartBefore(message[0]); + range.setEndAfter(message[message.length - 1]); + range.deleteContents(); +} + +function hasMetadataKey(aTheme, aKey) { + return ( + aKey in aTheme.metadata || + (aTheme.variant != "default" && + aKey + ":" + aTheme.variant in aTheme.metadata) || + ("DefaultVariant" in aTheme.metadata && + aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata) + ); +} + +function getMetadata(aTheme, aKey) { + if ( + aTheme.variant != "default" && + aKey + ":" + aTheme.variant in aTheme.metadata + ) { + return aTheme.metadata[aKey + ":" + aTheme.variant]; + } + + if ( + "DefaultVariant" in aTheme.metadata && + aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata + ) { + return aTheme.metadata[aKey + ":" + aTheme.metadata.DefaultVariant]; + } + + return aTheme.metadata[aKey]; +} + +export function initHTMLDocument(aConv, aTheme, aDoc) { + let base = aDoc.createElement("base"); + base.href = aTheme.baseURI; + aDoc.head.appendChild(base); + + // Screen readers may read the title of the document, so provide one + // to avoid an ugly fallback to the URL (see bug 1165). + aDoc.title = aConv.title; + + function addCSS(aHref) { + let link = aDoc.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", aHref); + link.setAttribute("type", "text/css"); + aDoc.head.appendChild(link); + } + addCSS("chrome://chat/skin/conv.css"); + addCSS("chrome://messenger/skin/icons.css"); + + // add css to handle DefaultFontFamily and DefaultFontSize + let cssText = ""; + if (hasMetadataKey(aTheme, "DefaultFontFamily")) { + cssText += "font-family: " + getMetadata(aTheme, "DefaultFontFamily") + ";"; + } + if (hasMetadataKey(aTheme, "DefaultFontSize")) { + cssText += "font-size: " + getMetadata(aTheme, "DefaultFontSize") + ";"; + } + if (cssText) { + addCSS("data:text/css,*{ " + cssText + " }"); + } + + // add the main CSS file of the theme + if (aTheme.metadata.MessageViewVersion >= 3 || aTheme.variant == "default") { + addCSS("main.css"); + } + + // add the CSS file of the variant + if (aTheme.variant != "default") { + addCSS("Variants/" + aTheme.variant + ".css"); + } else if ("DefaultVariant" in aTheme.metadata) { + addCSS("Variants/" + aTheme.metadata.DefaultVariant + ".css"); + } + aDoc.body.id = "ibcontent"; + + // We insert the whole content of body: chat div, footer + let html = '
'; + html += replaceKeywordsInHTML(aTheme.html.footer, footerReplacements, aConv); + + let frag = getDocumentFragmentFromHTML(aDoc, html); + aDoc.body.appendChild(frag); + if (!aTheme.metadata.NoScript) { + const scriptTag = aDoc.createElement("script"); + scriptTag.src = "inline.js"; + aDoc.body.appendChild(scriptTag); + } + aDoc.defaultView.convertTimeUnits = lazy.DownloadUtils.convertTimeUnits; +} + +/* Selection stuff */ +function getEllipsis() { + let ellipsis = "[\u2026]"; + + try { + ellipsis = Services.prefs.getComplexValue( + "messenger.conversations.selections.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + return ellipsis; +} + +function _serializeDOMObject(aDocument, aInitFunction) { + // This shouldn't really be a constant, as we want to support + // text/html too in the future. + const type = "text/plain"; + + let encoder = Cu.createDocumentEncoder(type); + encoder.init(aDocument, type, Ci.nsIDocumentEncoder.OutputPreformatted); + aInitFunction(encoder); + let result = encoder.encodeToString(); + return result; +} + +function serializeRange(aRange) { + return _serializeDOMObject( + aRange.startContainer.ownerDocument, + function (aEncoder) { + aEncoder.setRange(aRange); + } + ); +} + +function serializeNode(aNode) { + return _serializeDOMObject(aNode.ownerDocument, function (aEncoder) { + aEncoder.setNode(aNode); + }); +} + +/* This function is used to pretty print a selection inside a conversation area */ +export function serializeSelection(aSelection) { + // We have two kinds of selection serialization: + // - The short version, used when only a part of message is + // selected, or if nothing interesting is selected + let shortSelection = ""; + + // - The long version, which is used: + // * when both some of the message text and some of the context + // (sender, time, ...) is selected; + // * when several messages are selected at once + // This version uses an array, with each message formatted + // through the theme system. + let longSelection = []; + + // We first assume that we are going to use the short version, but + // while working on creating the short version, we prepare + // everything to be able to switch to the long version if we later + // discover that it is in fact needed. + let shortVersionPossible = true; + + // Sometimes we need to know if a selection range is inside the same + // message as the previous selection range, so we keep track of the + // last message we have processed. + let lastMessage = null; + + for (let i = 0; i < aSelection.rangeCount; ++i) { + let range = aSelection.getRangeAt(i); + let messages = getMessagesForRange(range); + + // If at least one selected message has some of its text selected, + // remove from the selection all the messages that have no text + // selected + let testFunction = msg => msg.isTextSelected(); + if (messages.some(testFunction)) { + messages = messages.filter(testFunction); + } + + if (!messages.length) { + // Do it only if it wouldn't override a better already found selection + if (!shortSelection) { + shortSelection = serializeRange(range); + } + continue; + } + + if ( + shortVersionPossible && + messages.length == 1 && + (!messages[0].isTextSelected() || messages[0].onlyTextSelected()) && + (!lastMessage || + lastMessage.msg == messages[0].msg || + lastMessage.msg.who == messages[0].msg.who) + ) { + if (shortSelection) { + if (lastMessage.msg != messages[0].msg) { + // Add the ellipsis only if the previous message was cut + if (lastMessage.cutEnd) { + shortSelection += " " + getEllipsis(); + } + shortSelection += kLineBreak; + } else { + shortSelection += " " + getEllipsis() + " "; + } + } + shortSelection += serializeRange(range); + longSelection.push(messages[0].getFormattedMessage()); + } else { + shortVersionPossible = false; + for (let m = 0; m < messages.length; ++m) { + let message = messages[m]; + if (m == 0 && lastMessage && lastMessage.msg == message.msg) { + let text = message.getSelectedText(); + if (message.cutEnd) { + text += " " + getEllipsis(); + } + longSelection[longSelection.length - 1] += " " + text; + } else { + longSelection.push(message.getFormattedMessage()); + } + } + } + lastMessage = messages[messages.length - 1]; + } + + if (shortVersionPossible) { + return shortSelection || aSelection.toString(); + } + return longSelection.join(kLineBreak); +} + +function SelectedMessage(aRootNode, aRange) { + this._rootNodes = [aRootNode]; + this._range = aRange; +} + +SelectedMessage.prototype = { + get msg() { + return this._rootNodes[0]._originalMsg; + }, + addRoot(aRootNode) { + this._rootNodes.push(aRootNode); + }, + + // Helper function that returns the first span node of class + // ib-msg-text under the rootNodes of the selected message. + _getSpanNode() { + // first use the cached value if any + if (this._spanNode) { + return this._spanNode; + } + + let spanNode = null; + // If we could use NodeFilter.webidl, we wouldn't have to make up our own + // object. FILTER_REJECT is not used here, but included for completeness. + const NodeFilter = { + SHOW_ELEMENT: 0x1, + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + FILTER_SKIP: 3, + }; + // helper filter function for the tree walker + let filter = function (node) { + return node.className == "ib-msg-txt" + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }; + // walk the DOM subtrees of each root, keep the first correct span node + for (let i = 0; !spanNode && i < this._rootNodes.length; ++i) { + let rootNode = this._rootNodes[i]; + // the TreeWalker doesn't test the root node, special case it first + if (filter(rootNode) == NodeFilter.FILTER_ACCEPT) { + spanNode = rootNode; + break; + } + let treeWalker = rootNode.ownerDocument.createTreeWalker( + rootNode, + NodeFilter.SHOW_ELEMENT, + { acceptNode: filter }, + false + ); + spanNode = treeWalker.nextNode(); + } + + return (this._spanNode = spanNode); + }, + + // Initialize _textSelected and _otherSelected; if _textSelected is true, + // also initialize _selectedText and _cutBegin/End. + _initSelectedText() { + if ("_textSelected" in this) { + // Already initialized. + return; + } + + let spanNode = this._getSpanNode(); + if (!spanNode) { + // can happen if the message text is under a separate root node + // that isn't selected at all + this._textSelected = false; + this._otherSelected = true; + return; + } + let startPoint = this._range.comparePoint(spanNode, 0); + // Note that we are working on the HTML DOM, including text nodes, + // so we need to use childNodes here and below. + let endPoint = this._range.comparePoint( + spanNode, + spanNode.childNodes.length + ); + if (startPoint <= 0 && endPoint >= 0) { + let range = this._range.cloneRange(); + if (startPoint >= 0) { + range.setStart(spanNode, 0); + } + if (endPoint <= 0) { + range.setEnd(spanNode, spanNode.childNodes.length); + } + this._selectedText = serializeRange(range); + + // if the selected text is empty, set _selectedText to false + // this happens if the carret is at the offset 0 in the span node + this._textSelected = this._selectedText != ""; + } else { + this._textSelected = false; + } + if (this._textSelected) { + // to check if the start or end is cut, the result of + // comparePoint is not enough because the selection range may + // start or end in a text node instead of the span node + + if (startPoint == -1) { + let range = spanNode.ownerDocument.createRange(); + range.setStart(spanNode, 0); + range.setEnd(this._range.startContainer, this._range.startOffset); + this._cutBegin = serializeRange(range) != ""; + } else { + this._cutBegin = false; + } + + if (endPoint == 1) { + let range = spanNode.ownerDocument.createRange(); + range.setStart(this._range.endContainer, this._range.endOffset); + range.setEnd(spanNode, spanNode.childNodes.length); + this._cutEnd = !/^(\r?\n)?$/.test(serializeRange(range)); + } else { + this._cutEnd = false; + } + } + this._otherSelected = + (startPoint >= 0 || endPoint <= 0) && // eliminate most negative cases + (!this._textSelected || + serializeRange(this._range).length > this._selectedText.length); + }, + get cutBegin() { + this._initSelectedText(); + return this._textSelected && this._cutBegin; + }, + get cutEnd() { + this._initSelectedText(); + return this._textSelected && this._cutEnd; + }, + isTextSelected() { + this._initSelectedText(); + return this._textSelected; + }, + onlyTextSelected() { + this._initSelectedText(); + return !this._otherSelected; + }, + getSelectedText() { + this._initSelectedText(); + return this._textSelected ? this._selectedText : ""; + }, + getFormattedMessage() { + // First, get the selected text + this._initSelectedText(); + let msg = this.msg; + let text; + if (this._textSelected) { + // Add ellipsis is needed + text = + (this._cutBegin ? getEllipsis() + " " : "") + + this._selectedText + + (this._cutEnd ? " " + getEllipsis() : ""); + } else { + let div = this._rootNodes[0].ownerDocument.createElement("div"); + let divChildren = getDocumentFragmentFromHTML( + div.ownerDocument, + msg.autoResponse ? formatAutoResponce(msg.message) : msg.message + ); + div.appendChild(divChildren); + text = serializeNode(div); + } + + // then get the suitable replacements and templates for this message + let getLocalizedPrefWithDefault = function (aName, aDefault) { + try { + let prefBranch = Services.prefs.getBranch( + "messenger.conversations.selections." + ); + return prefBranch.getComplexValue(aName, Ci.nsIPrefLocalizedString) + .data; + } catch (e) { + return aDefault; + } + }; + let html, replacements; + if (msg.system) { + replacements = statusReplacements; + html = getLocalizedPrefWithDefault( + "systemMessagesTemplate", + "%time% - %message%" + ); + } else { + replacements = messageReplacements; + if (msg.action) { + html = getLocalizedPrefWithDefault( + "actionMessagesTemplate", + "%time% * %sender% %message%" + ); + } else { + html = getLocalizedPrefWithDefault( + "contentMessagesTemplate", + "%time% - %sender%: %message%" + ); + } + } + + // Overrides default replacements so that they don't add a span node. + // Also, this uses directly the text variable so that we don't + // have to change the content of msg.message and revert it + // afterwards. + replacements = { + message: aMsg => text, + sender: aMsg => aMsg.alias || aMsg.who, + __proto__: replacements, + }; + + // Finally, let the theme system do the magic! + return replaceKeywordsInHTML(html, replacements, msg); + }, +}; + +export function getMessagesForRange(aRange) { + let result = []; // will hold the final result + let messages = {}; // used to prevent duplicate messages in the result array + + // cache the range boundaries, they will be used a lot + let endNode = aRange.endContainer; + let startNode = aRange.startContainer; + + // Helper function to recursively look for _originalMsg JS + // properties on DOM nodes, and stop when endNode is reached. + // Found nodes are pushed into the rootNodes array. + let processSubtree = function (aNode) { + if (aNode._originalMsg) { + // store the result + if (!(aNode._originalMsg.id in messages)) { + // we've found a new message! + let newMessage = new SelectedMessage(aNode, aRange); + messages[aNode._originalMsg.id] = newMessage; + result.push(newMessage); + } else { + // we've found another root of an already known message + messages[aNode._originalMsg.id].addRoot(aNode); + } + } + + // check if we have reached the end node + if (aNode == endNode) { + return true; + } + + // recurse through children + if ( + aNode.nodeType == aNode.ELEMENT_NODE && + aNode.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + for (let i = 0; i < aNode.children.length; ++i) { + if (processSubtree(aNode.children[i])) { + return true; + } + } + } + + return false; + }; + + let currentNode = aRange.commonAncestorContainer; + if ( + currentNode.nodeType == currentNode.ELEMENT_NODE && + currentNode.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + // Determine the index of the first and last children of currentNode + // that we should process. + let found = false; + let start = 0; + if (currentNode == startNode) { + // we want to process all children + found = true; + start = aRange.startOffset; + } else { + // startNode needs to be a direct child of currentNode + while (startNode.parentNode != currentNode) { + startNode = startNode.parentNode; + } + } + let end; + if (currentNode == endNode) { + end = aRange.endOffset; + } else { + end = currentNode.children.length; + } + + for (let i = start; i < end; ++i) { + let node = currentNode.children[i]; + + // don't do anything until we find the startNode + found = found || node == startNode; + if (!found) { + continue; + } + + if (processSubtree(node)) { + break; + } + } + } + + // The selection may not include any root node of the first touched + // message, in this case, the DOM traversal of the DOM range + // couldn't give us the first message. Make sure we actually have + // the message in which the range starts. + let firstRoot = aRange.startContainer; + while (firstRoot && !firstRoot._originalMsg) { + firstRoot = firstRoot.parentNode; + } + if (firstRoot && !(firstRoot._originalMsg.id in messages)) { + result.unshift(new SelectedMessage(firstRoot, aRange)); + } + + return result; +} + +/** + * Turns a raw HTML string into a DocumentFragment usable in the provided + * document. + * + * @param {Document} doc - The Document the fragment will belong to. + * @param {string} html - The target HTML to be parsed. + * + * @returns {DocumentFragment} + */ +export function getDocumentFragmentFromHTML(doc, html) { + let uri = Services.io.newURI(doc.baseURI); + let flags = Ci.nsIParserUtils.SanitizerAllowStyle; + let context = doc.createElement("div"); + return ParserUtils.parseFragment(html, flags, false, uri, context); +} + +/** + * Get all nodes that make up the given message if any. + * + * @param {string} remoteId - Remote ID of the message to get + * @param {Document} doc - Document the message is in. + * @returns {NodeList} Node list of all the parts of the message, or an empty + * list if the message is not found. + */ +function getExistingMessage(remoteId, doc) { + let parent = doc.getElementById("Chat"); + return parent.querySelectorAll(`[data-remote-id="${CSS.escape(remoteId)}"]`); +} diff --git a/comm/chat/modules/imXPCOMUtils.sys.mjs b/comm/chat/modules/imXPCOMUtils.sys.mjs new file mode 100644 index 0000000000..4a48f2116d --- /dev/null +++ b/comm/chat/modules/imXPCOMUtils.sys.mjs @@ -0,0 +1,249 @@ +/* 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"; + +var kLogLevelPref = "purple.debug.loglevel"; + +/** + * Creates an nsIScriptError instance and logs it. + * + * @param aModule + * string identifying the module within which the error occurred. + * @param aLevel + * the error level as defined in imIDebugMessage. + * @param aMessage + * the error message string. + * @param aOriginalError + * (optional) JS Error object containing the location where the + * actual error occurred. Its error message is appended to aMessage. + */ +export function scriptError(aModule, aLevel, aMessage, aOriginalError) { + // Figure out the log level, based on the module and the prefs set. + // The module name is split on periods, and if no pref is set the pref with + // the last section removed is attempted (until no sections are left, using + // the global default log level). + let logLevel = -1; + let logKeys = ["level"].concat(aModule.split(".")); + for (; logKeys.length > 0; logKeys.pop()) { + let logKey = logKeys.join("."); + if (logKey in lazy.gLogLevels) { + logLevel = lazy.gLogLevels[logKey]; + break; + } + } + + // Only continue if we will log this message. + if (logLevel > aLevel && !("imAccount" in this)) { + return; + } + + let flag = Ci.nsIScriptError.warningFlag; + if (aLevel >= Ci.imIDebugMessage.LEVEL_ERROR) { + flag = Ci.nsIScriptError.errorFlag; + } + + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + let caller = Components.stack.caller; + let sourceLine = aModule || caller.sourceLine; + if (caller.name) { + if (sourceLine) { + sourceLine += ": "; + } + sourceLine += caller.name; + } + let fileName = caller.filename; + let lineNumber = caller.lineNumber; + if (aOriginalError) { + aMessage += "\n" + (aOriginalError.message || aOriginalError); + if (aOriginalError.fileName) { + fileName = aOriginalError.fileName; + } + if (aOriginalError.lineNumber) { + lineNumber = aOriginalError.lineNumber; + } + } + scriptError.init( + aMessage, + fileName, + sourceLine, + lineNumber, + null, + flag, + "component javascript" + ); + + if (logLevel <= aLevel) { + dump(aModule + ": " + aMessage + "\n"); + if (aLevel == Ci.imIDebugMessage.LEVEL_LOG && logLevel == aLevel) { + Services.console.logStringMessage(aMessage); + } else { + Services.console.logMessage(scriptError); + } + } + if ("imAccount" in this) { + this.imAccount.logDebugMessage(scriptError, aLevel); + } +} + +export function initLogModule(aModule, aObj = {}) { + aObj.DEBUG = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_DEBUG); + aObj.LOG = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_LOG); + aObj.WARN = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_WARNING); + aObj.ERROR = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_ERROR); + return aObj; +} + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "gLogLevels", function () { + // This object functions both as an obsever as well as a dict keeping the + // log levels with prefs; the log levels all start with "level" (i.e. "level" + // for the global level, "level.irc" for the IRC module). The dual-purpose + // is necessary to make sure the observe is left alive while being a weak ref + // to avoid cycles with the pref service. + let logLevels = { + observe(aSubject, aTopic, aData) { + let module = "level" + aData.substr(kLogLevelPref.length); + if (Services.prefs.getPrefType(aData) == Services.prefs.PREF_INT) { + lazy.gLogLevels[module] = Services.prefs.getIntPref(aData); + } else { + delete lazy.gLogLevels[module]; + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + + // Add weak pref observer to see log level pref changes. + Services.prefs.addObserver(kLogLevelPref, logLevels, true /* weak */); + + // Initialize with existing log level prefs. + for (let pref of Services.prefs.getChildList(kLogLevelPref)) { + if (Services.prefs.getPrefType(pref) == Services.prefs.PREF_INT) { + logLevels["level" + pref.substr(kLogLevelPref.length)] = + Services.prefs.getIntPref(pref); + } + } + + // Let environment variables override prefs. + Services.env + .get("PRPL_LOG") + .split(/[;,]/) + .filter(n => n != "") + .forEach(function (env) { + let [, module, level] = env.match(/(?:(.*?)[:=])?(\d+)/); + logLevels["level" + (module ? "." + module : "")] = parseInt(level, 10); + }); + + return logLevels; +}); + +export function executeSoon(aFunction) { + Services.tm.mainThread.dispatch(aFunction, Ci.nsIEventTarget.DISPATCH_NORMAL); +} + +/* Common nsIClassInfo and QueryInterface implementation + * shared by all generic objects implemented in this file. */ +export function ClassInfo(aInterfaces, aDescription = "JS Proto Object") { + if (!(this instanceof ClassInfo)) { + return new ClassInfo(aInterfaces, aDescription); + } + + if (!Array.isArray(aInterfaces)) { + aInterfaces = [aInterfaces]; + } + + for (let i of aInterfaces) { + if (typeof i == "string" && !(i in Ci)) { + Services.console.logStringMessage("ClassInfo: unknown interface " + i); + } + } + + this._interfaces = aInterfaces.map(i => (typeof i == "string" ? Ci[i] : i)); + + this.classDescription = aDescription; +} + +ClassInfo.prototype = { + // eslint-disable-next-line mozilla/use-chromeutils-generateqi + QueryInterface(iid) { + if ( + iid.equals(Ci.nsISupports) || + iid.equals(Ci.nsIClassInfo) || + this._interfaces.some(i => i.equals(iid)) + ) { + return this; + } + + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + get interfaces() { + return [Ci.nsIClassInfo, Ci.nsISupports].concat(this._interfaces); + }, + getScriptableHelper: () => null, + contractID: null, + classID: null, + flags: 0, +}; + +export function l10nHelper(aChromeURL) { + let bundle = Services.strings.createBundle(aChromeURL); + return function (aStringId) { + try { + if (arguments.length == 1) { + return bundle.GetStringFromName(aStringId); + } + return bundle.formatStringFromName( + aStringId, + Array.prototype.slice.call(arguments, 1) + ); + } catch (e) { + console.error(e); + dump("Failed to get " + aStringId + "\n"); + return aStringId; + } + }; +} + +/** + * Constructs an nsISimpleEnumerator for the given array of items. + * Copied from netwerk/test/httpserver/httpd.js + * + * @param items : Array + * the items, which must all implement nsISupports + */ +export function nsSimpleEnumerator(items) { + this._items = items; + this._nextIndex = 0; +} + +nsSimpleEnumerator.prototype = { + hasMoreElements() { + return this._nextIndex < this._items.length; + }, + getNext() { + if (!this.hasMoreElements()) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + return this._items[this._nextIndex++]; + }, + QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]), + [Symbol.iterator]() { + return this._items.values(); + }, +}; + +export var EmptyEnumerator = { + hasMoreElements: () => false, + getNext() { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + }, + QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]), + *[Symbol.iterator]() {}, +}; diff --git a/comm/chat/modules/jsProtoHelper.sys.mjs b/comm/chat/modules/jsProtoHelper.sys.mjs new file mode 100644 index 0000000000..b792a02ffe --- /dev/null +++ b/comm/chat/modules/jsProtoHelper.sys.mjs @@ -0,0 +1,1796 @@ +/* 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"; +import { + initLogModule, + nsSimpleEnumerator, + l10nHelper, + ClassInfo, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/conversations.properties") +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTXT => cs.scanTXT(aTXT, cs.kEntities); +}); + +function OutgoingMessage(aMsg, aConversation) { + this.message = aMsg; + this.conversation = aConversation; +} +OutgoingMessage.prototype = { + __proto__: ClassInfo("imIOutgoingMessage", "Outgoing Message"), + cancelled: false, + action: false, + notification: false, +}; + +export var GenericAccountPrototype = { + __proto__: ClassInfo("prplIAccount", "generic account object"), + get wrappedJSObject() { + return this; + }, + _init(aProtocol, aImAccount) { + this.protocol = aProtocol; + this.imAccount = aImAccount; + initLogModule(aProtocol.id, this); + }, + observe(aSubject, aTopic, aData) {}, + remove() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + unInit() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + connect() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + disconnect() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + createConversation(aName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + joinChat(aComponents) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + setBool(aName, aVal) {}, + setInt(aName, aVal) {}, + setString(aName, aVal) {}, + + get name() { + return this.imAccount.name; + }, + get connected() { + return this.imAccount.connected; + }, + get connecting() { + return this.imAccount.connecting; + }, + get disconnected() { + return this.imAccount.disconnected; + }, + get disconnecting() { + return this.imAccount.disconnecting; + }, + _connectionErrorReason: Ci.prplIAccount.NO_ERROR, + get connectionErrorReason() { + return this._connectionErrorReason; + }, + + /** + * Convert a socket's nsITransportSecurityInfo into a prplIAccount connection error. Store + * the nsITransportSecurityInfo and the connection location on the account so the + * certificate exception dialog can access the information. + * + * @param {Socket} aSocket - Socket where the connection error occurred. + * @returns {number} The prplIAccount error constant describing the problem. + */ + handleConnectionSecurityError(aSocket) { + // Stash away the connectionTarget and securityInfo. + this._connectionTarget = aSocket.host + ":" + aSocket.port; + let securityInfo = (this._securityInfo = aSocket.securityInfo); + + if (!securityInfo) { + return Ci.prplIAccount.ERROR_CERT_NOT_PROVIDED; + } + + if (securityInfo.isUntrusted) { + if (securityInfo.serverCert && securityInfo.serverCert.isSelfSigned) { + return Ci.prplIAccount.ERROR_CERT_SELF_SIGNED; + } + return Ci.prplIAccount.ERROR_CERT_UNTRUSTED; + } + + if (securityInfo.isNotValidAtThisTime) { + if ( + securityInfo.serverCert && + securityInfo.serverCert.validity.notBefore < Date.now() * 1000 + ) { + return Ci.prplIAccount.ERROR_CERT_NOT_ACTIVATED; + } + return Ci.prplIAccount.ERROR_CERT_EXPIRED; + } + + if (securityInfo.isDomainMismatch) { + return Ci.prplIAccount.ERROR_CERT_HOSTNAME_MISMATCH; + } + + // XXX ERROR_CERT_FINGERPRINT_MISMATCH + + return Ci.prplIAccount.ERROR_CERT_OTHER_ERROR; + }, + _connectionTarget: "", + get connectionTarget() { + return this._connectionTarget; + }, + _securityInfo: null, + get securityInfo() { + return this._securityInfo; + }, + + reportConnected() { + this.imAccount.observe(this, "account-connected", null); + }, + reportConnecting(aConnectionStateMsg) { + // Delete any leftover errors from the previous connection. + delete this._connectionTarget; + delete this._securityInfo; + + if (!this.connecting) { + this.imAccount.observe(this, "account-connecting", null); + } + if (aConnectionStateMsg) { + this.imAccount.observe( + this, + "account-connect-progress", + aConnectionStateMsg + ); + } + }, + reportDisconnected() { + this.imAccount.observe(this, "account-disconnected", null); + }, + reportDisconnecting(aConnectionErrorReason, aConnectionErrorMessage) { + this._connectionErrorReason = aConnectionErrorReason; + this.imAccount.observe( + this, + "account-disconnecting", + aConnectionErrorMessage + ); + this.cancelPendingBuddyRequests(); + this.cancelPendingChatRequests(); + this.cancelPendingVerificationRequests(); + }, + + // Called when the user adds a new buddy from the UI. + addBuddy(aTag, aName) { + IMServices.contacts.accountBuddyAdded( + new AccountBuddy(this, null, aTag, aName) + ); + }, + // Called during startup for each of the buddies in the local buddy list. + loadBuddy(aBuddy, aTag) { + try { + return new AccountBuddy(this, aBuddy, aTag); + } catch (x) { + dump(x + "\n"); + return null; + } + }, + + _pendingBuddyRequests: null, + addBuddyRequest(aUserName, aGrantCallback, aDenyCallback) { + if (!this._pendingBuddyRequests) { + this._pendingBuddyRequests = []; + } + let buddyRequest = { + get account() { + return this._account.imAccount; + }, + get userName() { + return aUserName; + }, + _account: this, + // Grant and deny callbacks both receive the auth request object as an + // argument for further use. + grant() { + aGrantCallback(this); + this._remove(); + }, + deny() { + aDenyCallback(this); + this._remove(); + }, + cancel() { + Services.obs.notifyObservers( + this, + "buddy-authorization-request-canceled" + ); + this._remove(); + }, + _remove() { + this._account.removeBuddyRequest(this); + }, + QueryInterface: ChromeUtils.generateQI(["prplIBuddyRequest"]), + }; + this._pendingBuddyRequests.push(buddyRequest); + Services.obs.notifyObservers(buddyRequest, "buddy-authorization-request"); + }, + removeBuddyRequest(aRequest) { + if (!this._pendingBuddyRequests) { + return; + } + + this._pendingBuddyRequests = this._pendingBuddyRequests.filter( + r => r !== aRequest + ); + }, + /** + * Cancel a pending buddy request. + * + * @param {string} aUserName - The username the request is for. + */ + cancelBuddyRequest(aUserName) { + if (!this._pendingBuddyRequests) { + return; + } + + for (let request of this._pendingBuddyRequests) { + if (request.userName == aUserName) { + request.cancel(); + break; + } + } + }, + cancelPendingBuddyRequests() { + if (!this._pendingBuddyRequests) { + return; + } + + for (let request of this._pendingBuddyRequests) { + request.cancel(); + } + delete this._pendingBuddyRequests; + }, + + _pendingChatRequests: null, + /** + * Inform the user about a new conversation invitation. + * + * @param {string} conversationName - Name of the conversation the user is + * invited to. + * @param {(prplIChatRequest) => void} grantCallback - Function to be called + * when the invite is accepted. + * @param {(prplIChatRequest?, boolean) => void} [denyCallback] - Function to + * be called when the invite is rejected. If omitted, |canDeny| will be + * |false|. Callback is passed a boolean indicating whether the rejection should be + * sent to the other party. It being false is equivalent to ignoring the invite, in + * which case the callback should try to apply the ignore on the protocol level. + */ + addChatRequest(conversationName, grantCallback, denyCallback) { + if (!this._pendingChatRequests) { + this._pendingChatRequests = new Set(); + } + let inviteHandling = Services.prefs.getIntPref( + "messenger.conversations.autoAcceptChatInvitations" + ); + // Only auto-reject invites that can be denied. + if (inviteHandling <= 0 && denyCallback) { + const shouldReject = inviteHandling == -1; + denyCallback(null, shouldReject); + return; + } + let resolvePromise; + let rejectPromise; + let completePromise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + /** @implements {prplIChatRequest} */ + let chatRequest = { + get account() { + return this._account.imAccount; + }, + get conversationName() { + return conversationName; + }, + get canDeny() { + return Boolean(denyCallback); + }, + _account: this, + // Grant and deny callbacks both receive the auth request object as an + // argument for further use. + grant() { + resolvePromise(true); + grantCallback(this); + this._remove(); + }, + deny() { + if (!denyCallback) { + throw new Error("Can not deny this invitation."); + } + resolvePromise(false); + denyCallback(this, true); + this._remove(); + }, + cancel() { + rejectPromise(new Error("Cancelled")); + this._remove(); + }, + completePromise, + _remove() { + this._account.removeChatRequest(this); + }, + QueryInterface: ChromeUtils.generateQI(["prplIChatRequest"]), + }; + this._pendingChatRequests.add(chatRequest); + Services.obs.notifyObservers(chatRequest, "conv-authorization-request"); + }, + removeChatRequest(aRequest) { + if (!this._pendingChatRequests) { + return; + } + + this._pendingChatRequests.delete(aRequest); + }, + /** + * Cancel a pending chat request. + * + * @param {string} conversationName - The conversation the request is for. + */ + cancelChatRequest(conversationName) { + if (!this._pendingChatRequests) { + return; + } + + for (let request of this._pendingChatRequests) { + if (request.conversationName == conversationName) { + request.cancel(); + break; + } + } + }, + cancelPendingChatRequests() { + if (!this._pendingChatRequests) { + return; + } + + for (let request of this._pendingChatRequests) { + request.cancel(); + } + this._pendingChatRequests = null; + }, + + requestBuddyInfo(aBuddyName) {}, + + get canJoinChat() { + return false; + }, + getChatRoomFields() { + if (!this.chatRoomFields) { + return []; + } + let fieldNames = Object.keys(this.chatRoomFields); + return fieldNames.map( + fieldName => new ChatRoomField(fieldName, this.chatRoomFields[fieldName]) + ); + }, + getChatRoomDefaultFieldValues(aDefaultChatName) { + if (!this.chatRoomFields) { + return new ChatRoomFieldValues({}); + } + + let defaultFieldValues = {}; + for (let fieldName in this.chatRoomFields) { + defaultFieldValues[fieldName] = this.chatRoomFields[fieldName].default; + } + + if (aDefaultChatName && "parseDefaultChatName" in this) { + let parsedDefaultChatName = this.parseDefaultChatName(aDefaultChatName); + for (let field in parsedDefaultChatName) { + defaultFieldValues[field] = parsedDefaultChatName[field]; + } + } + + return new ChatRoomFieldValues(defaultFieldValues); + }, + requestRoomInfo(aCallback) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + getRoomInfo(aName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + get isRoomInfoStale() { + return false; + }, + + getPref(aName, aType) { + return this.prefs.prefHasUserValue(aName) + ? this.prefs["get" + aType + "Pref"](aName) + : this.protocol._getOptionDefault(aName); + }, + getInt(aName) { + return this.getPref(aName, "Int"); + }, + getBool(aName) { + return this.getPref(aName, "Bool"); + }, + getString(aName) { + return this.prefs.prefHasUserValue(aName) + ? this.prefs.getStringPref(aName) + : this.protocol._getOptionDefault(aName); + }, + + get prefs() { + return ( + this._prefs || + (this._prefs = Services.prefs.getBranch( + "messenger.account." + this.imAccount.id + ".options." + )) + ); + }, + + get normalizedName() { + return this.normalize(this.name); + }, + normalize(aName) { + return aName.toLowerCase(); + }, + + getSessions() { + return []; + }, + reportSessionsChanged() { + Services.obs.notifyObservers(this.imAccount, "account-sessions-changed"); + }, + + _pendingVerificationRequests: null, + /** + * + * @param {string} aDisplayName - Display name the request is from. + * @param {() => Promise<{challenge: string, challengeDescription: string?}>} aGetChallenge - Accept request and generate + * the challenge. + * @param {AbortSignal} [aAbortSignal] - Abort signal to indicate the request + * was cancelled. + * @returns {Promise} Completion promise for the verification. + * Boolean indicates the result of the verification, rejection is a cancel. + */ + addVerificationRequest(aDisplayName, aGetChallenge, aAbortSignal) { + if (!this._pendingVerificationRequests) { + this._pendingVerificationRequests = []; + } + let verificationRequest = { + _account: this, + get account() { + return this._account.imAccount; + }, + get subject() { + return aDisplayName; + }, + get challengeType() { + return Ci.imISessionVerification.CHALLENGE_TEXT; + }, + get challenge() { + return this._challenge; + }, + get challengeDescription() { + return this._challengeDescription; + }, + _challenge: "", + _challengeDescription: "", + _canceled: false, + completePromise: null, + async verify() { + const { challenge, challengeDescription = "" } = await aGetChallenge(); + this._challenge = challenge; + this._challengeDescription = challengeDescription; + }, + submitResponse(challengeMatches) { + this._accept(challengeMatches); + this._remove(); + }, + cancel() { + if (this._canceled) { + return; + } + this._canceled = true; + Services.obs.notifyObservers( + this, + "buddy-verification-request-canceled" + ); + this._deny(); + this._remove(); + }, + _remove() { + this._account.removeVerificationRequest(this); + }, + QueryInterface: ChromeUtils.generateQI([ + "imIIncomingSessionVerification", + ]), + }; + verificationRequest.completePromise = new Promise((resolve, reject) => { + verificationRequest._accept = resolve; + verificationRequest._deny = reject; + }); + this._pendingVerificationRequests.push(verificationRequest); + Services.obs.notifyObservers( + verificationRequest, + "buddy-verification-request" + ); + if (aAbortSignal) { + aAbortSignal.addEventListener( + "abort", + () => { + verificationRequest.cancel(); + }, + { once: true } + ); + if (aAbortSignal.aborted) { + verificationRequest.cancel(); + } + } + return verificationRequest.completePromise; + }, + /** + * Remove a verification request for this account. + * + * @param {imIIncomingSessionVerification} aRequest + */ + removeVerificationRequest(aRequest) { + if (!this._pendingVerificationRequests) { + return; + } + this._pendingVerificationRequests = + this._pendingVerificationRequests.filter(r => r !== aRequest); + }, + cancelPendingVerificationRequests() { + if (!this._pendingVerificationRequests) { + return; + } + for (let request of this._pendingVerificationRequests) { + request.cancel(); + } + this._pendingVerificationRequests = null; + }, + + _encryptionStatus: [], + get encryptionStatus() { + return this._encryptionStatus; + }, + set encryptionStatus(newStatus) { + this._encryptionStatus = newStatus; + Services.obs.notifyObservers( + this.imAccount, + "account-encryption-status-changed", + newStatus + ); + }, +}; + +export var GenericAccountBuddyPrototype = { + __proto__: ClassInfo("prplIAccountBuddy", "generic account buddy object"), + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _init(aAccount, aBuddy, aTag, aUserName) { + if (!aBuddy && !aUserName) { + throw new Error("aUserName is required when aBuddy is null"); + } + + this._tag = aTag; + this._account = aAccount; + this._buddy = aBuddy; + if (aBuddy) { + let displayName = aBuddy.displayName; + if (displayName != aUserName) { + this._serverAlias = displayName; + } + } + this._userName = aUserName; + }, + unInit() { + delete this._tag; + delete this._account; + delete this._buddy; + }, + + get account() { + return this._account.imAccount; + }, + set buddy(aBuddy) { + if (this._buddy) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + this._buddy = aBuddy; + }, + get buddy() { + return this._buddy; + }, + get tag() { + return this._tag; + }, + set tag(aNewTag) { + let oldTag = this._tag; + this._tag = aNewTag; + IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag); + }, + + _notifyObservers(aTopic, aData) { + try { + this._buddy.observe(this, "account-buddy-" + aTopic, aData); + } catch (e) { + this.ERROR(e); + } + }, + + _userName: "", + get userName() { + return this._userName || this._buddy.userName; + }, + get normalizedName() { + return this._account.normalize(this.userName); + }, + _serverAlias: "", + get serverAlias() { + return this._serverAlias; + }, + set serverAlias(aNewAlias) { + let old = this.displayName; + this._serverAlias = aNewAlias; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + + /** + * Method called to start verification of the buddy. Same signature as + * _startVerification of GenericSessionPrototype. If the property is not a + * function, |canVerifyIdentity| is false. + * + * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?} + */ + _startVerification: null, + get canVerifyIdentity() { + return typeof this._startVerification === "function"; + }, + _identityVerified: false, + get identityVerified() { + return this.canVerifyIdentity && this._identityVerified; + }, + verifyIdentity() { + if (!this.canVerifyIdentity) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + if (this.identityVerified) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.userName, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, + + remove() { + IMServices.contacts.accountBuddyRemoved(this); + }, + + // imIStatusInfo implementation + get displayName() { + return this.serverAlias || this.userName; + }, + _buddyIconFilename: "", + get buddyIconFilename() { + return this._buddyIconFilename; + }, + set buddyIconFilename(aNewFileName) { + this._buddyIconFilename = aNewFileName; + this._notifyObservers("icon-changed"); + }, + _statusType: 0, + get statusType() { + return this._statusType; + }, + get online() { + return this._statusType > Ci.imIStatusInfo.STATUS_OFFLINE; + }, + get available() { + return this._statusType == Ci.imIStatusInfo.STATUS_AVAILABLE; + }, + get idle() { + return this._statusType == Ci.imIStatusInfo.STATUS_IDLE; + }, + get mobile() { + return this._statusType == Ci.imIStatusInfo.STATUS_MOBILE; + }, + _statusText: "", + get statusText() { + return this._statusText; + }, + + // This is for use by the protocol plugin, it's not exposed in the + // imIStatusInfo interface. + // All parameters are optional and will be ignored if they are null + // or undefined. + setStatus(aStatusType, aStatusText, aAvailabilityDetails) { + // Ignore omitted parameters. + if (aStatusType === undefined || aStatusType === null) { + aStatusType = this._statusType; + } + if (aStatusText === undefined || aStatusText === null) { + aStatusText = this._statusText; + } + if (aAvailabilityDetails === undefined || aAvailabilityDetails === null) { + aAvailabilityDetails = this._availabilityDetails; + } + + // Decide which notifications should be fired. + let notifications = []; + if ( + this._statusType != aStatusType || + this._availabilityDetails != aAvailabilityDetails + ) { + notifications.push("availability-changed"); + } + if (this._statusType != aStatusType || this._statusText != aStatusText) { + notifications.push("status-changed"); + if (this.online && aStatusType <= Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-off"); + } + if (!this.online && aStatusType > Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-on"); + } + } + + // Actually change the stored status. + [this._statusType, this._statusText, this._availabilityDetails] = [ + aStatusType, + aStatusText, + aAvailabilityDetails, + ]; + + // Fire the notifications. + notifications.forEach(function (aTopic) { + this._notifyObservers(aTopic); + }, this); + }, + + _availabilityDetails: 0, + get availabilityDetails() { + return this._availabilityDetails; + }, + + get canSendMessage() { + return this.online; + }, + + getTooltipInfo: () => [], + createConversation() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, +}; + +// aUserName is required only if aBuddy is null, i.e., we are adding a buddy. +function AccountBuddy(aAccount, aBuddy, aTag, aUserName) { + this._init(aAccount, aBuddy, aTag, aUserName); +} +AccountBuddy.prototype = GenericAccountBuddyPrototype; + +export var GenericMessagePrototype = { + __proto__: ClassInfo("prplIMessage", "generic message object"), + + _lastId: 0, + _init(aWho, aMessage, aObject, aConversation) { + this.id = ++GenericMessagePrototype._lastId; + this.time = Math.floor(new Date() / 1000); + this.who = aWho; + this.message = aMessage; + this.originalMessage = aMessage; + this.conversation = aConversation; + + if (aObject) { + for (let i in aObject) { + this[i] = aObject[i]; + } + } + }, + _alias: "", + get alias() { + return this._alias || this.who; + }, + _iconURL: "", + get iconURL() { + // If the protocol plugin has explicitly set an icon for the message, use it. + if (this._iconURL) { + return this._iconURL; + } + + // Otherwise, attempt to find a buddy for incoming messages, and forward the call. + if (this.incoming && this.conversation && !this.conversation.isChat) { + let buddy = this.conversation.buddy; + if (buddy) { + return buddy.buddyIconFilename; + } + } + return ""; + }, + conversation: null, + remoteId: "", + + outgoing: false, + incoming: false, + system: false, + autoResponse: false, + containsNick: false, + noLog: false, + error: false, + delayed: false, + noFormat: false, + containsImages: false, + notification: false, + noLinkification: false, + noCollapse: false, + isEncrypted: false, + action: false, + deleted: false, + + getActions() { + return []; + }, + + whenDisplayed() {}, + whenRead() {}, +}; + +export function Message(aWho, aMessage, aObject, aConversation) { + this._init(aWho, aMessage, aObject, aConversation); +} + +Message.prototype = GenericMessagePrototype; + +export var GenericConversationPrototype = { + __proto__: ClassInfo("prplIConversation", "generic conversation object"), + get wrappedJSObject() { + return this; + }, + + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _init(aAccount, aName) { + this._account = aAccount; + this._name = aName; + this._observers = []; + this._date = new Date() * 1000; + IMServices.conversations.addConversation(this); + }, + + _id: 0, + get id() { + return this._id; + }, + set id(aId) { + if (this._id) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + this._id = aId; + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + try { + observer.observe(aSubject, aTopic, aData); + } catch (e) { + this.ERROR(e); + } + } + }, + + prepareForSending: aOutgoingMessage => [aOutgoingMessage.message], + prepareForDisplaying(aImMessage) { + if (aImMessage.displayMessage !== aImMessage.message) { + this.DEBUG( + "Preparing:\n" + + aImMessage.message + + "\nDisplaying:\n" + + aImMessage.displayMessage + ); + } + }, + sendMsg(aMsg, aAction = false, aNotification = false) { + // Add-ons (eg. pastebin) have an opportunity to cancel the message at this + // point, or change the text content of the message. + // If an add-on wants to split a message, it should truncate the first + // message, and insert new messages using the conversation's sendMsg method. + let om = new OutgoingMessage(aMsg, this); + om.action = aAction; + om.notification = aNotification; + this.notifyObservers(om, "preparing-message"); + if (om.cancelled) { + return; + } + + // Protocols have an opportunity here to preprocess messages before they are + // sent (eg. split long messages). If a message is split here, the split + // will be visible in the UI. + let messages = this.prepareForSending(om); + let isAction = om.action; + let isNotification = om.notification; + + for (let msg of messages) { + // Add-ons (eg. OTR) have an opportunity to tweak or cancel the message + // at this point. + om = new OutgoingMessage(msg, this); + om.action = isAction; + om.notification = isNotification; + this.notifyObservers(om, "sending-message"); + if (om.cancelled) { + continue; + } + this.dispatchMessage(om.message, om.action, om.notification); + } + }, + dispatchMessage(message, action, notification) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + sendTyping: aString => Ci.prplIConversation.NO_TYPING_LIMIT, + + close() { + Services.obs.notifyObservers(this, "closing-conversation"); + IMServices.conversations.removeConversation(this); + }, + unInit() { + delete this._account; + delete this._observers; + }, + + /** + * Create a prplIMessage instance from params. + * + * @param {string} who - Nick of the participant who sent the message. + * @param {string} text - Raw message contents. + * @param {object} properties - Additional properties of the message. + * @returns {prplIMessage} + */ + createMessage(who, text, properties) { + return new Message(who, text, properties, this); + }, + + writeMessage(aWho, aText, aProperties) { + const message = this.createMessage(aWho, aText, aProperties); + this.notifyObservers(message, "new-text"); + }, + + /** + * Update the contents of a message. + * + * @param {string} who - Nick of the participant who sent the message. + * @param {string} text - Raw contents of the message. + * @param {object} properties - Additional properties of the message. Should + * specify a |remoteId| to find the previous version of this message. + */ + updateMessage(who, text, properties) { + const message = this.createMessage(who, text, properties); + this.notifyObservers(message, "update-text"); + }, + + /** + * Remove a message from the conversation. Does not affect logs, use + * updateMessage with a deleted property to remove from logs. + * + * @param {string} remoteId - Remote ID of the event to remove. + */ + removeMessage(remoteId) { + this.notifyObservers(null, "remove-text", remoteId); + }, + + get account() { + return this._account.imAccount; + }, + get name() { + return this._name; + }, + get normalizedName() { + return this._account.normalize(this.name); + }, + get title() { + return this.name; + }, + get startDate() { + return this._date; + }, + _convIconFilename: "", + get convIconFilename() { + return this._convIconFilename; + }, + set convIconFilename(aNewFilename) { + this._convIconFilename = aNewFilename; + this.notifyObservers(this, "update-conv-icon"); + }, + + get encryptionState() { + return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED; + }, + initializeEncryption() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, +}; + +export var GenericConvIMPrototype = { + __proto__: GenericConversationPrototype, + _interfaces: [Ci.prplIConversation, Ci.prplIConvIM], + classDescription: "generic ConvIM object", + + updateTyping(aState, aName) { + if (aState == this.typingState) { + return; + } + + if (aState == Ci.prplIConvIM.NOT_TYPING) { + delete this.typingState; + } else { + this.typingState = aState; + } + this.notifyObservers(null, "update-typing", aName); + }, + + get isChat() { + return false; + }, + buddy: null, + typingState: Ci.prplIConvIM.NOT_TYPING, + get convIconFilename() { + // By default, pass through information from the buddy for IM conversations + // that don't have their own icon. + const convIconFilename = this._convIconFilename; + if (convIconFilename) { + return convIconFilename; + } + return this.buddy?.buddyIconFilename; + }, +}; + +export var GenericConvChatPrototype = { + __proto__: GenericConversationPrototype, + _interfaces: [Ci.prplIConversation, Ci.prplIConvChat], + classDescription: "generic ConvChat object", + + _init(aAccount, aName, aNick) { + // _participants holds prplIConvChatBuddy objects. + this._participants = new Map(); + this.nick = aNick; + GenericConversationPrototype._init.call(this, aAccount, aName); + }, + + get isChat() { + return true; + }, + + // Stores the prplIChatRoomFieldValues required to join this channel + // to enable later reconnections. If null, the MUC will not be reconnected + // automatically after disconnections. + chatRoomFields: null, + + _topic: "", + _topicSetter: null, + get topic() { + return this._topic; + }, + get topicSettable() { + return false; + }, + get topicSetter() { + return this._topicSetter; + }, + /** + * Set the topic of a conversation. + * + * @param {string} aTopic - The new topic. If an update message is sent to + * the conversation, this will be HTML escaped before being sent. + * @param {string} aTopicSetter - The user who last modified the topic. + * @param {string} aQuiet - If false, a message notifying about the topic + * change will be sent to the conversation. + */ + setTopic(aTopic, aTopicSetter, aQuiet) { + // Only change the topic if the topic and/or topic setter has changed. + if ( + this._topic == aTopic && + (!this._topicSetter || this._topicSetter == aTopicSetter) + ) { + return; + } + + this._topic = aTopic; + this._topicSetter = aTopicSetter; + + this.notifyObservers(null, "chat-update-topic"); + + if (aQuiet) { + return; + } + + // Send the topic as a message. + let message; + if (aTopicSetter) { + if (aTopic) { + message = lazy._("topicChanged", aTopicSetter, lazy.TXTToHTML(aTopic)); + } else { + message = lazy._("topicCleared", aTopicSetter); + } + } else { + aTopicSetter = null; + if (aTopic) { + message = lazy._("topicSet", this.name, lazy.TXTToHTML(aTopic)); + } else { + message = lazy._("topicNotSet", this.name); + } + } + this.writeMessage(aTopicSetter, message, { system: true }); + }, + + get nick() { + return this._nick; + }, + set nick(aNick) { + this._nick = aNick; + let escapedNick = this._nick.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&"); + this._pingRegexp = new RegExp("(?:^|\\W)" + escapedNick + "(?:\\W|$)", "i"); + }, + + _left: false, + get left() { + return this._left; + }, + set left(aLeft) { + if (aLeft == this._left) { + return; + } + this._left = aLeft; + this.notifyObservers(null, "update-conv-chatleft"); + }, + + _joining: false, + get joining() { + return this._joining; + }, + set joining(aJoining) { + if (aJoining == this._joining) { + return; + } + this._joining = aJoining; + this.notifyObservers(null, "update-conv-chatjoining"); + }, + + getParticipant(aName) { + return this._participants.has(aName) ? this._participants.get(aName) : null; + }, + getParticipants() { + // Convert the values of the Map into an array. + return Array.from(this._participants.values()); + }, + getNormalizedChatBuddyName: aChatBuddyName => aChatBuddyName, + + // Updates the nick of a participant in conversation to a new one. + updateNick(aOldNick, aNewNick, isOwnNick) { + let message; + let isParticipant = this._participants.has(aOldNick); + if (isOwnNick) { + // If this is the user's nick, change it. + this.nick = aNewNick; + message = lazy._("nickSet.you", aNewNick); + + // If the account was disconnected, it's OK the user is not a participant. + if (!isParticipant) { + return; + } + } else if (!isParticipant) { + this.ERROR( + "Trying to rename nick that doesn't exist! " + + aOldNick + + " to " + + aNewNick + ); + return; + } else { + message = lazy._("nickSet", aOldNick, aNewNick); + } + + // Get the original participant and then remove it. + let participant = this._participants.get(aOldNick); + this._participants.delete(aOldNick); + + // Update the nickname and add it under the new nick. + participant.name = aNewNick; + this._participants.set(aNewNick, participant); + + this.notifyObservers(participant, "chat-buddy-update", aOldNick); + this.writeMessage(aOldNick, message, { system: true }); + }, + + // Removes a participant from conversation. + removeParticipant(aNick) { + if (!this._participants.has(aNick)) { + return; + } + + let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + stringNickname.data = aNick; + this.notifyObservers( + new nsSimpleEnumerator([stringNickname]), + "chat-buddy-remove" + ); + this._participants.delete(aNick); + }, + + // Removes all participant in conversation. + removeAllParticipants() { + let stringNicknames = []; + this._participants.forEach(function (aParticipant) { + let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + stringNickname.data = aParticipant.name; + stringNicknames.push(stringNickname); + }); + this.notifyObservers( + new nsSimpleEnumerator(stringNicknames), + "chat-buddy-remove" + ); + this._participants.clear(); + }, + + createMessage(who, text, properties) { + properties.containsNick = + "incoming" in properties && this._pingRegexp.test(text); + return GenericConversationPrototype.createMessage.apply(this, arguments); + }, +}; + +export var GenericConvChatBuddyPrototype = { + __proto__: ClassInfo("prplIConvChatBuddy", "generic ConvChatBuddy object"), + + _name: "", + get name() { + return this._name; + }, + set name(aName) { + this._name = aName; + }, + alias: "", + buddy: false, + buddyIconFilename: "", + + voiced: false, + moderator: false, + admin: false, + founder: false, + typing: false, + + /** + * Method called to start verification of the buddy. Same signature as + * _startVerification of GenericSessionPrototype. If the property is not a + * function, |canVerifyIdentity| is false. + * + * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?} + */ + _startVerification: null, + get canVerifyIdentity() { + return typeof this._startVerification === "function"; + }, + _identityVerified: false, + get identityVerified() { + return this.canVerifyIdentity && this._identityVerified; + }, + verifyIdentity() { + if (!this.canVerifyIdentity) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + if (this.identityVerified) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.name, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, +}; + +export function TooltipInfo(aLabel, aValue, aType = Ci.prplITooltipInfo.pair) { + this.type = aType; + if (aType == Ci.prplITooltipInfo.status) { + this.label = aLabel.toString(); + this.value = aValue || ""; + } else if (aType == Ci.prplITooltipInfo.icon) { + this.value = aValue; + } else if ( + aLabel === undefined || + aType == Ci.prplITooltipInfo.sectionBreak + ) { + this.type = Ci.prplITooltipInfo.sectionBreak; + } else { + this.label = aLabel; + if (aValue === undefined) { + this.type = Ci.prplITooltipInfo.sectionHeader; + } else { + this.value = aValue; + } + } +} + +TooltipInfo.prototype = ClassInfo("prplITooltipInfo", "generic tooltip info"); + +/* aOption is an object containing: + * - label: localized text to display (recommended: use a getter with _) + * - default: the default value for this option. The type of the + * option will be determined based on the type of the default value. + * If the default value is a string, the option will be of type + * list if listValues has been provided. In that case the default + * value should be one of the listed values. + * - [optional] listValues: only if this option can only take a list of + * predefined values. This is an object of the form: + * {value1: localizedLabel, value2: ...}. + * - [optional] masked: boolean, if true the UI shouldn't display the value. + * This could typically be used for password field. + * Warning: The UI currently doesn't support this. + */ +function purplePref(aName, aOption) { + this.name = aName; // Preference name + this.label = aOption.label; // Text to display + + if (aOption.default === undefined || aOption.default === null) { + throw new Error( + "A default value for the option is required to determine its type." + ); + } + this._defaultValue = aOption.default; + + const kTypes = { boolean: "Bool", string: "String", number: "Int" }; + let type = kTypes[typeof aOption.default]; + if (!type) { + throw new Error("Invalid option type"); + } + + if (type == "String" && "listValues" in aOption) { + type = "List"; + this._listValues = aOption.listValues; + } + this.type = Ci.prplIPref["type" + type]; + + if ("masked" in aOption && aOption.masked) { + this.masked = true; + } +} +purplePref.prototype = { + __proto__: ClassInfo("prplIPref", "generic account option preference"), + + masked: false, + + // Default value + getBool() { + return this._defaultValue; + }, + getInt() { + return this._defaultValue; + }, + getString() { + return this._defaultValue; + }, + getList() { + // Convert a JavaScript object map {"value 1": "label 1", ...} + let keys = Object.keys(this._listValues); + return keys.map(key => new purpleKeyValuePair(this._listValues[key], key)); + }, + getListDefault() { + return this._defaultValue; + }, +}; + +function purpleKeyValuePair(aName, aValue) { + this.name = aName; + this.value = aValue; +} +purpleKeyValuePair.prototype = ClassInfo( + "prplIKeyValuePair", + "generic Key Value Pair" +); + +function UsernameSplit(aValues) { + this._values = aValues; +} +UsernameSplit.prototype = { + __proto__: ClassInfo("prplIUsernameSplit", "username split object"), + + get label() { + return this._values.label; + }, + get separator() { + return this._values.separator; + }, + get defaultValue() { + return this._values.defaultValue; + }, +}; + +function ChatRoomField(aIdentifier, aField) { + this.identifier = aIdentifier; + this.label = aField.label; + this.required = !!aField.required; + + let type = "TEXT"; + if (typeof aField.default == "number") { + type = "INT"; + this.min = aField.min; + this.max = aField.max; + } else if (aField.isPassword) { + type = "PASSWORD"; + } + this.type = Ci.prplIChatRoomField["TYPE_" + type]; +} +ChatRoomField.prototype = ClassInfo( + "prplIChatRoomField", + "ChatRoomField object" +); + +function ChatRoomFieldValues(aMap) { + this.values = aMap; +} +ChatRoomFieldValues.prototype = { + __proto__: ClassInfo("prplIChatRoomFieldValues", "ChatRoomFieldValues"), + + getValue(aIdentifier) { + return this.values.hasOwnProperty(aIdentifier) + ? this.values[aIdentifier] + : null; + }, + setValue(aIdentifier, aValue) { + this.values[aIdentifier] = aValue; + }, +}; + +// the name getter and the getAccount method need to be implemented by +// protocol plugins. +export var GenericProtocolPrototype = { + __proto__: ClassInfo("prplIProtocol", "Generic protocol object"), + + init(aId) { + if (aId != this.id) { + throw new Error( + "Creating an instance of " + + aId + + " but this object implements " + + this.id + ); + } + }, + get id() { + return "prpl-" + this.normalizedName; + }, + get iconBaseURI() { + return "chrome://chat/skin/prpl-generic/"; + }, + + getAccount(aImAccount) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + _getOptionDefault(aName) { + if (this.options && this.options.hasOwnProperty(aName)) { + return this.options[aName].default; + } + throw new Error(aName + " has no default value in " + this.id + "."); + }, + getOptions() { + if (!this.options) { + return []; + } + + let purplePrefs = []; + for (let [name, option] of Object.entries(this.options)) { + purplePrefs.push(new purplePref(name, option)); + } + return purplePrefs; + }, + usernamePrefix: "", + getUsernameSplit() { + if (!this.usernameSplits || !this.usernameSplits.length) { + return []; + } + return this.usernameSplits.map(split => new UsernameSplit(split)); + }, + + /** + * Protocol agnostic implementation that splits the username by the pattern + * defined with |usernamePrefix| and |usernameSplits| on the protocol. + * Prefers the first occurrence of a separator. + * + * @param {string} aName - Username to split. + * @returns {string[]} Parts of the username or empty array if the username + * doesn't match the splitting format. + */ + splitUsername(aName) { + let remainingName = aName; + if (this.usernamePrefix) { + if (!remainingName.startsWith(this.usernamePrefix)) { + return []; + } + remainingName = remainingName.slice(this.usernamePrefix.length); + } + if (!this.usernameSplits || !this.usernameSplits.length) { + return [remainingName]; + } + const parts = []; + for (const split of this.usernameSplits) { + if (!remainingName.includes(split.separator)) { + return []; + } + const separatorIndex = remainingName.indexOf(split.separator); + parts.push(remainingName.slice(0, separatorIndex)); + remainingName = remainingName.slice( + separatorIndex + split.separator.length + ); + } + parts.push(remainingName); + return parts; + }, + + registerCommands() { + if (!this.commands) { + return; + } + + this.commands.forEach(function (command) { + if (!command.hasOwnProperty("name") || !command.hasOwnProperty("run")) { + throw new Error("Every command must have a name and a run function."); + } + if (!("QueryInterface" in command)) { + command.QueryInterface = ChromeUtils.generateQI(["imICommand"]); + } + if (!command.hasOwnProperty("usageContext")) { + command.usageContext = Ci.imICommand.CMD_CONTEXT_ALL; + } + if (!command.hasOwnProperty("priority")) { + command.priority = Ci.imICommand.CMD_PRIORITY_PRPL; + } + IMServices.cmd.registerCommand(command, this.id); + }, this); + }, + + // NS_ERROR_XPC_JSOBJECT_HAS_NO_FUNCTION_NAMED errors are too noisy + get usernameEmptyText() { + return ""; + }, + accountExists: () => false, // FIXME + + get chatHasTopic() { + return false; + }, + get noPassword() { + return false; + }, + get passwordOptional() { + return false; + }, + get slashCommandsNative() { + return false; + }, + get canEncrypt() { + return false; + }, + + get classDescription() { + return this.name + " Protocol"; + }, + get contractID() { + return "@mozilla.org/chat/" + this.normalizedName + ";1"; + }, +}; + +/** + * Text challenge session verification flow. Starts the UI flow. + * + * @param {string} challenge - String the challenge should display. + * @param {string} subject - Human readable identifier of the other side of the + * challenge. + * @param {string} [challengeDescription] - Description of the challenge + * contents. + */ +function SessionVerification(challenge, subject, challengeDescription) { + this._challenge = challenge; + this._subject = subject; + if (challengeDescription) { + this._description = challengeDescription; + } + this._responsePromise = new Promise((resolve, reject) => { + this._submit = resolve; + this._cancel = reject; + }); +} +SessionVerification.prototype = { + __proto__: ClassInfo( + "imISessionVerification", + "generic session verification object" + ), + _challengeType: Ci.imISessionVerification.CHALLENGE_TEXT, + _challenge: "", + _description: "", + _responsePromise: null, + _submit: null, + _cancel: null, + _cancelled: false, + get challengeType() { + return this._challengeType; + }, + get challenge() { + return this._challenge; + }, + get challengeDescription() { + return this._description; + }, + get subject() { + return this._subject; + }, + get completePromise() { + return this._responsePromise; + }, + submitResponse(challengeMatches) { + this._submit(challengeMatches); + }, + cancel() { + if (this._cancelled) { + return; + } + this._cancelled = true; + this._cancel(); + }, +}; + +export var GenericSessionPrototype = { + __proto__: ClassInfo("prplISession", "generic session object"), + /** + * Initialize the session. + * + * @param {prplIAccount} account - Account the session is related to. + * @param {string} id - ID of the session. + * @param {boolean} [trusted=false] - If the session is trusted. + * @param {boolean} [currentSession=false] - If the session represents the. + * session we're connected as. + */ + _init(account, id, trusted = false, currentSession = false) { + this._account = account; + this._id = id; + this._trusted = trusted; + this._currentSession = currentSession; + }, + _account: null, + _id: "", + _trusted: false, + _currentSession: false, + get id() { + return this._id; + }, + get trusted() { + return this._trusted; + }, + set trusted(newTrust) { + this._trusted = newTrust; + this._account.reportSessionsChanged(); + }, + get currentSession() { + return this._currentSession; + }, + /** + * Handle the start of the session verification process. The protocol is + * expected to update the trusted property on the session if it becomes + * trusted after verification. + * + * @returns {Promise<{challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}>} + * Promise resolves to an object holding the challenge string, as well as a + * callback that handles the result of the verification flow. The cancel + * callback is called when the verification is cancelled and the cancelPromise + * is used for the protocol to report when the other side cancels. + * The cancel callback will be called when the cancel promise resolves. + */ + _startVerification() { + return Promise.reject( + Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED) + ); + }, + verify() { + if (this.trusted) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.id, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, +}; diff --git a/comm/chat/modules/moz.build b/comm/chat/modules/moz.build new file mode 100644 index 0000000000..b3ae019739 --- /dev/null +++ b/comm/chat/modules/moz.build @@ -0,0 +1,25 @@ +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"] + +EXTRA_JS_MODULES += [ + "CLib.sys.mjs", + "imContentSink.sys.mjs", + "IMServices.sys.mjs", + "imSmileys.sys.mjs", + "imStatusUtils.sys.mjs", + "imTextboxUtils.sys.mjs", + "imThemes.sys.mjs", + "imXPCOMUtils.sys.mjs", + "InteractiveBrowser.sys.mjs", + "jsProtoHelper.sys.mjs", + "NormalizedMap.sys.mjs", + "OTR.sys.mjs", + "OTRLib.sys.mjs", + "OTRUI.sys.mjs", + "socket.sys.mjs", + "ToLocaleFormat.sys.mjs", +] diff --git a/comm/chat/modules/socket.sys.mjs b/comm/chat/modules/socket.sys.mjs new file mode 100644 index 0000000000..9253e0e96b --- /dev/null +++ b/comm/chat/modules/socket.sys.mjs @@ -0,0 +1,644 @@ +/* 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/. */ + +/* + * Combines a lot of the Mozilla networking interfaces into a sane interface for + * simple(r) handling of a low-level socket which sends text content. + * + * This implements nsIStreamListener, nsIRequestObserver, nsITransportEventSink + * and nsIProtocolProxyCallback. + * + * This uses nsIRoutedSocketTransportService, nsIServerSocket, nsIThreadManager, + * nsIScriptableInputStream, nsIInputStreamPump, nsIProxyService, nsIProxyInfo. + * + * High-level methods: + * connect(, [, ("starttls" | "ssl" | "udp") + * [, [, , ]]]) + * disconnect() + * sendData(String [, ]) + * sendString(String [, [, ]]) + * startTLS() + * resetPingTimer() + * cancelDisconnectTimer() + * + * High-level properties: + * delimiter + * inputSegmentSize + * outputSegmentSize + * proxyFlags + * connectTimeout (default is no timeout) + * readWriteTimeout (default is no timeout) + * disconnected + * securityInfo + * + * Users should "subclass" this object, i.e. set their .__proto__ to be it. And + * then implement: + * onConnection() + * onConnectionHeard() + * onConnectionTimedOut() + * onConnectionReset() + * onConnectionSecurityError(unsigned long aTLSError, optional AString aNSSErrorMessage) + * onConnectionClosed() + * onDataReceived(String ) + * onTransportStatus(nsISocketTransport , nsresult , + * unsigned long , unsigned long ) + * sendPing() + * LOG() + * DEBUG() + * + * Optional features: + * The ping functionality: Included in the socket object is a higher level + * "ping" messaging system, which is commonly used in instant messaging + * protocols. The ping functionality works by calling a user defined method, + * sendPing(), if resetPingTimer() is not called after two minutes. If no + * ping response is received after 30 seconds, the socket will disconnect. + * Thus, a socket using this functionality should: + * 1. Implement sendPing() to send an appropriate ping message for the + * protocol. + * 2. Call resetPingTimer() to start the ping messages. + * 3. Call resetPingTimer() each time a message is received (i.e. the + * socket is known to still be alive). + * 4. Call cancelDisconnectTimer() when a ping response is received. + */ + +/* + * To Do: + * Add a message queue to keep from flooding a server (just an array, just + * keep shifting the first element off and calling as setTimeout for the + * desired flood time?). + */ + +import { executeSoon } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { + clearTimeout, + requestIdleCallback, + setTimeout, +} from "resource://gre/modules/Timer.sys.mjs"; + +// Network errors see: xpcom/base/nsError.h +var NS_ERROR_MODULE_NETWORK = 2152398848; +var NS_ERROR_NET_TIMEOUT = NS_ERROR_MODULE_NETWORK + 14; +var NS_ERROR_NET_RESET = NS_ERROR_MODULE_NETWORK + 20; +var NS_ERROR_UNKNOWN_HOST = NS_ERROR_MODULE_NETWORK + 30; + +var ScriptableInputStream = Components.Constructor( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" +); +var InputStreamPump = Components.Constructor( + "@mozilla.org/network/input-stream-pump;1", + "nsIInputStreamPump", + "init" +); +var ScriptableUnicodeConverter = Components.Constructor( + "@mozilla.org/intl/scriptableunicodeconverter", + "nsIScriptableUnicodeConverter" +); + +/** + * @implements {nsIStreamListener} + * @implements {nsIRequestObserver} + * @implements {nsITransportEventSink} + * @implements {nsIProtocolProxyCallback} + */ +export var Socket = { + // Set this for non-binary mode to automatically parse the stream into chunks + // separated by delimiter. + delimiter: "", + + // Set this for the segment size of outgoing binary streams. + outputSegmentSize: 0, + + // Flags used by nsIProxyService when resolving a proxy. + proxyFlags: Ci.nsIProtocolProxyService.RESOLVE_PREFER_SOCKS_PROXY, + + // Time (in seconds) for nsISocketTransport to continue trying before + // reporting a failure, 0 is forever. + connectTimeout: 0, + readWriteTimeout: 0, + + // A nsITransportSecurityInfo instance giving details about the certificate error. + securityInfo: null, + + /* + ***************************************************************************** + ******************************* Public methods ****************************** + ***************************************************************************** + */ + // Synchronously open a connection. + // It connects to aHost and aPort, but uses aOriginHost and aOriginPort for + // checking the certificate for them (see nsIRoutedSocketTransportService + // in nsISocketTransportService.idl). + connect( + aOriginHost, + aOriginPort, + aSecurity, + aProxy, + aHost = aOriginHost, + aPort = aOriginPort + ) { + if (Services.io.offline) { + throw Components.Exception("Offline, can't connect", Cr.NS_ERROR_FAILURE); + } + + // This won't work for Linux due to bug 758848. + Services.obs.addObserver(this, "wake_notification"); + + this.LOG("Connecting to: " + aHost + ":" + aPort); + this.originHost = aOriginHost; + this.originPort = aOriginPort; + this.host = aHost; + this.port = aPort; + this.disconnected = false; + + this._pendingData = []; + delete this._stopRequestStatus; + + // Array of security options + this.security = aSecurity || []; + + // Choose a proxy, use the given one, otherwise get one from the proxy + // service + if (aProxy) { + this._createTransport(aProxy); + } else { + try { + // Attempt to get a default proxy from the proxy service. + let proxyService = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" + ].getService(Ci.nsIProtocolProxyService); + + // Add a URI scheme since, by default, some protocols (i.e. IRC) don't + // have a URI scheme before the host. + let uri = Services.io.newURI("http://" + this.host); + // This will return null when the result is known immediately and + // the callback will just be dispatched to the current thread. + this._proxyCancel = proxyService.asyncResolve( + uri, + this.proxyFlags, + this + ); + } catch (e) { + console.error(e); + // We had some error getting the proxy service, just don't use one. + this._createTransport(null); + } + } + }, + + // Disconnect all open streams. + disconnect() { + this.LOG("Disconnect"); + + // Don't handle any remaining unhandled data. + this._pendingData = []; + + // Close all input and output streams. + if ("_inputStream" in this) { + this._inputStream.close(); + delete this._inputStream; + } + if ("_outputStream" in this) { + this._outputStream.close(); + delete this._outputStream; + } + if ("transport" in this) { + this.transport.close(Cr.NS_OK); + delete this.transport; + } + + if ("_proxyCancel" in this) { + if (this._proxyCancel) { + // Has to give a failure code. + this._proxyCancel.cancel(Cr.NS_ERROR_ABORT); + } + delete this._proxyCancel; + } + + if (this._pingTimer) { + clearTimeout(this._pingTimer); + delete this._pingTimer; + delete this._resetPingTimerPending; + } + this.cancelDisconnectTimer(); + + delete this._lastAliveTime; + Services.obs.removeObserver(this, "wake_notification"); + + this.disconnected = true; + }, + + // Send data on the output stream. Provide aLoggedData to log something + // different than what is actually sent. + sendData(/* string */ aData, aLoggedData = aData) { + this.LOG("Sending:\n" + aLoggedData); + + try { + this._outputStream.write(aData, aData.length); + } catch (e) { + console.error(e); + } + }, + + // Send a string to the output stream after converting the encoding. Provide + // aLoggedData to log something different than what is actually sent. + sendString(aString, aEncoding = "UTF-8", aLoggedData = aString) { + this.LOG("Sending:\n" + aLoggedData); + + let converter = new ScriptableUnicodeConverter(); + converter.charset = aEncoding; + try { + let stream = converter.convertToInputStream(aString); + this._outputStream.writeFrom(stream, stream.available()); + } catch (e) { + console.error(e); + } + }, + + disconnected: true, + + startTLS() { + this.transport.tlsSocketControl + .QueryInterface(Ci.nsITLSSocketControl) + .StartTLS(); + }, + + // If using the ping functionality, this should be called whenever a message is + // received (e.g. when it is known the socket is still open). Calling this for + // the first time enables the ping functionality. + resetPingTimer() { + // Clearing and setting timeouts is expensive, so we do it at most + // once per eventloop spin cycle. + if (this._resetPingTimerPending) { + return; + } + this._resetPingTimerPending = true; + executeSoon(this._delayedResetPingTimer.bind(this)); + }, + kTimeBeforePing: 120000, // 2 min + kTimeAfterPingBeforeDisconnect: 30000, // 30 s + _delayedResetPingTimer() { + if (!this._resetPingTimerPending) { + return; + } + delete this._resetPingTimerPending; + if (this._pingTimer) { + clearTimeout(this._pingTimer); + } + // Send a ping every 2 minutes if there's no traffic on the socket. + this._pingTimer = setTimeout( + this._sendPing.bind(this), + this.kTimeBeforePing + ); + }, + + // If using the ping functionality, this should be called when a ping receives + // a response. + cancelDisconnectTimer() { + if (!this._disconnectTimer) { + return; + } + clearTimeout(this._disconnectTimer); + delete this._disconnectTimer; + }, + + // Plenty of time may have elapsed if the computer wakes from sleep, so check + // if we should reconnect immediately. + _lastAliveTime: null, + observe(aSubject, aTopic, aData) { + if (aTopic != "wake_notification") { + return; + } + let elapsedTime = Date.now() - this._lastAliveTime; + // If there never was any activity before we went to sleep, + // or if we've been waiting for a ping response for over 30s, + // or if the last activity on the socket is longer ago than we usually + // allow before we timeout, + // declare the connection timed out immediately. + if ( + !this._lastAliveTime || + (this._disconnectTimer && + elapsedTime > this.kTimeAfterPingBeforeDisconnect) || + elapsedTime > this.kTimeBeforePing + this.kTimeAfterPingBeforeDisconnect + ) { + this.onConnectionTimedOut(); + } else if (this._pingTimer) { + // If there was a ping timer running when the computer went to sleep, + // ping immediately to discover if we are still connected. + clearTimeout(this._pingTimer); + this._sendPing(); + } + }, + + /* + ***************************************************************************** + ***************************** Interface methods ***************************** + ***************************************************************************** + */ + /* + * nsIProtocolProxyCallback methods + */ + onProxyAvailable(aRequest, aURI, aProxyInfo, aStatus) { + if (!("_proxyCancel" in this)) { + this.LOG("onProxyAvailable called, but disconnect() was called before."); + return; + } + + if (aProxyInfo) { + if (aProxyInfo.type == "http") { + this.LOG("ignoring http proxy"); + aProxyInfo = null; + } else { + this.LOG( + "using " + + aProxyInfo.type + + " proxy: " + + aProxyInfo.host + + ":" + + aProxyInfo.port + ); + } + } + this._createTransport(aProxyInfo); + delete this._proxyCancel; + }, + + /* + * nsIStreamListener methods + */ + // onDataAvailable, called by Mozilla's networking code. + // Buffers the data, and parses it into discrete messages. + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + if (this.disconnected) { + return; + } + this._lastAliveTime = Date.now(); + + if (this.delimiter) { + // Load the data from the stream. + this._incomingDataBuffer += this._scriptableInputStream.read(aCount); + let data = this._incomingDataBuffer.split(this.delimiter); + + // Store the (possibly) incomplete part. + this._incomingDataBuffer = data.pop(); + if (!data.length) { + return; + } + + // Add the strings to the queue. + this._pendingData = this._pendingData.concat(data); + } else { + // Add the whole string to the queue. + this._pendingData.push(this._scriptableInputStream.read(aCount)); + } + this._activateQueue(); + }, + + _pendingData: [], + _handlingQueue: false, + _activateQueue() { + if (this._handlingQueue) { + return; + } + this._handlingQueue = requestIdleCallback(this._handleQueue.bind(this)); + }, + // Asynchronously send each string to the handle data function. + async _handleQueue(timing) { + while (this._pendingData.length) { + this.onDataReceived(this._pendingData.shift()); + // One pendingData entry generally takes less than 1ms to handle. + if (timing.timeRemaining() < 1) { + break; + } + } + if (this._pendingData.length) { + this._handlingQueue = requestIdleCallback(this._handleQueue.bind(this)); + return; + } + delete this._handlingQueue; + // If there was a stop request, handle it. + if ("_stopRequestStatus" in this) { + await this._handleStopRequest(this._stopRequestStatus); + } + }, + + /* + * nsIRequestObserver methods + */ + // Signifies the beginning of an async request + onStartRequest(aRequest) { + if (this.disconnected) { + // Ignore this if we're already disconnected. + return; + } + this.DEBUG("onStartRequest"); + }, + // Called to signify the end of an asynchronous request. + onStopRequest(aRequest, aStatus) { + if (this.disconnected) { + // We're already disconnected, so nothing left to do here. + return; + } + + this.DEBUG("onStopRequest (" + aStatus + ")"); + this._stopRequestStatus = aStatus; + // The stop request will be handled when the queue is next empty. + this._activateQueue(); + }, + // Close the connection after receiving a stop request. + async _handleStopRequest(aStatus) { + if (this.disconnected) { + return; + } + this.disconnected = true; + // If the host cannot be resolved, reset the connection to attempt to + // reconnect. + if (aStatus == NS_ERROR_NET_RESET || aStatus == NS_ERROR_UNKNOWN_HOST) { + this.onConnectionReset(); + } else if (aStatus == NS_ERROR_NET_TIMEOUT) { + this.onConnectionTimedOut(); + } else if (!Components.isSuccessCode(aStatus)) { + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + this.securityInfo = + await this.transport.tlsSocketControl?.asyncGetSecurityInfo(); + this.onConnectionSecurityError( + aStatus, + nssErrorsService.getErrorMessage(aStatus) + ); + } + this.onConnectionClosed(); + }, + + /* + * nsITransportEventSink methods + */ + onTransportStatus(aTransport, aStatus, aProgress, aProgressmax) { + // Don't send status change notifications after the socket has been closed. + // The event sink can't be removed after opening the transport, so we can't + // do better than adding a null check here. + if (!this.transport) { + return; + } + + const nsITransportEventSinkStatus = { + 0x4b0003: "STATUS_RESOLVING", + 0x4b000b: "STATUS_RESOLVED", + 0x4b0007: "STATUS_CONNECTING_TO", + 0x4b0004: "STATUS_CONNECTED_TO", + 0x4b0005: "STATUS_SENDING_TO", + 0x4b000a: "STATUS_WAITING_FOR", + 0x4b0006: "STATUS_RECEIVING_FROM", + }; + let status = nsITransportEventSinkStatus[aStatus]; + this.DEBUG( + "onTransportStatus(" + (status || "0x" + aStatus.toString(16)) + ")" + ); + + if (status == "STATUS_CONNECTED_TO") { + // Notify that the connection has been established. + this.onConnection(); + } + }, + + /* + ***************************************************************************** + ****************************** Private methods ****************************** + ***************************************************************************** + */ + _resetBuffers() { + this._incomingDataBuffer = ""; + this._outgoingDataBuffer = []; + }, + + _createTransport(aProxy) { + this.proxy = aProxy; + + // Empty incoming and outgoing data storage buffers + this._resetBuffers(); + + // Create a routed socket transport + // We connect to host and port, but the origin host and origin port are + // given to PSM (e.g. check the certificate). + let socketTS = Cc[ + "@mozilla.org/network/socket-transport-service;1" + ].getService(Ci.nsIRoutedSocketTransportService); + this.transport = socketTS.createRoutedTransport( + this.security, + this.originHost, + this.originPort, + this.host, + this.port, + this.proxy, + null + ); + + this._openStreams(); + }, + + // Open the incoming and outgoing streams, and init the nsISocketTransport. + _openStreams() { + // TODO: is this still required after bug 1547096? + this.transport.securityCallbacks = this; + + // Set the timeouts for the nsISocketTransport for both a connect event and + // a read/write. Only set them if the user has provided them. + if (this.connectTimeout) { + this.transport.setTimeout( + Ci.nsISocketTransport.TIMEOUT_CONNECT, + this.connectTimeout + ); + } + if (this.readWriteTimeout) { + this.transport.setTimeout( + Ci.nsISocketTransport.TIMEOUT_READ_WRITE, + this.readWriteTimeout + ); + } + + this.transport.setEventSink(this, Services.tm.currentThread); + + // No limit on the output stream buffer + this._outputStream = this.transport.openOutputStream( + 0, + this.outputSegmentSize, + -1 + ); + if (!this._outputStream) { + throw new Error("Error getting output stream."); + } + + this._inputStream = this.transport.openInputStream( + 0, // flags + 0, // Use default segment size + 0 + ); // Use default segment count + if (!this._inputStream) { + throw new Error("Error getting input stream."); + } + + // Handle character mode + this._scriptableInputStream = new ScriptableInputStream(this._inputStream); + + this.pump = new InputStreamPump( + this._inputStream, // Data to read + 0, // Use default segment size + 0, // Use default segment length + false + ); // Do not close when done + this.pump.asyncRead(this); + }, + + _pingTimer: null, + _disconnectTimer: null, + _sendPing() { + delete this._pingTimer; + this.sendPing(); + this._disconnectTimer = setTimeout( + this.onConnectionTimedOut.bind(this), + this.kTimeAfterPingBeforeDisconnect + ); + }, + + /* + ***************************************************************************** + ********************* Methods for subtypes to override ********************** + ***************************************************************************** + */ + LOG(aString) {}, + DEBUG(aString) {}, + // Called when a connection is established. + onConnection() {}, + // Called when a socket is accepted after listening. + onConnectionHeard() {}, + // Called when a connection times out. + onConnectionTimedOut() {}, + // Called when a socket request's network is reset. + onConnectionReset() {}, + // Called when the certificate provided by the server didn't satisfy NSS. + onConnectionSecurityError(aTLSError, aNSSErrorMessage) {}, + // Called when the other end has closed the connection. + onConnectionClosed() {}, + + // Called when ASCII data is available. + onDataReceived(/* string */ aData) {}, + + // If using the ping functionality, this is called when a new ping message + // should be sent on the socket. + sendPing() {}, + + /* QueryInterface and nsIInterfaceRequestor implementations */ + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIRequestObserver", + "nsITransportEventSink", + "nsIProtocolProxyCallback", + ]), + + getInterface(iid) { + return this.QueryInterface(iid); + }, +}; diff --git a/comm/chat/modules/test/test_InteractiveBrowser.js b/comm/chat/modules/test/test_InteractiveBrowser.js new file mode 100644 index 0000000000..eb39d7048b --- /dev/null +++ b/comm/chat/modules/test/test_InteractiveBrowser.js @@ -0,0 +1,280 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { InteractiveBrowser, CancelledError } = ChromeUtils.importESModule( + "resource:///modules/InteractiveBrowser.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +add_task(async function test_waitForRedirectOnLocationChange() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + + const observeTopic = TestUtils.topicObserved("browser-request"); + let resolved = false; + const request = InteractiveBrowser.waitForRedirect(url, promptText).then( + redirectUrl => { + resolved = true; + return redirectUrl; + } + ); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + await TestUtils.waitForTick(); + ok(webProgress.listener, "Progress listener added"); + equal(window.document.title, promptText, "Window title set"); + + const intermediate = "https://intermediate.example.com/"; + webProgress.listener.onLocationChange( + webProgress, + { + name: intermediate + 1, + }, + { + spec: intermediate + 1, + } + ); + ok( + webProgress.listener, + "Progress listener still there after intermediary redirect" + ); + ok(!resolved, "Still waiting for redirect"); + webProgress.listener.onStateChange( + webProgress, + { + name: intermediate + 2, + }, + Ci.nsIWebProgressListener.STATE_START, + null + ); + ok(webProgress.listener, "Listener still there after second redirect"); + ok(!resolved, "Still waiting for redirect 2"); + + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf"; + webProgress.listener.onLocationChange( + webProgress, + { + name: completionUrl, + }, + { + spec: completionUrl, + } + ); + + const redirectedUrl = await request; + ok(resolved, "Redirect complete"); + equal(redirectedUrl, completionUrl); + + ok(!webProgress.listener); + ok(window.closed); +}); + +add_task(async function test_waitForRedirectOnStateChangeStart() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + + const observeTopic = TestUtils.topicObserved("browser-request"); + let resolved = false; + const request = InteractiveBrowser.waitForRedirect(url, promptText).then( + redirectUrl => { + resolved = true; + return redirectUrl; + } + ); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + await TestUtils.waitForTick(); + ok(webProgress.listener, "Progress listener added"); + equal(window.document.title, promptText, "Window title set"); + + const intermediate = "https://intermediate.example.com/"; + webProgress.listener.onStateChange( + webProgress, + { + name: intermediate, + }, + Ci.nsIWebProgressListener.STATE_START, + null + ); + ok(webProgress.listener); + ok(!resolved); + + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf"; + webProgress.listener.onStateChange( + webProgress, + { + name: completionUrl, + }, + Ci.nsIWebProgressListener.STATE_START + ); + + const redirectedUrl = await request; + ok(resolved, "Redirect complete"); + equal(redirectedUrl, completionUrl); + + ok(!webProgress.listener); + ok(window.closed); +}); + +add_task(async function test_waitForRedirectOnStateChangeStart() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + + const observeTopic = TestUtils.topicObserved("browser-request"); + let resolved = false; + const request = InteractiveBrowser.waitForRedirect(url, promptText).then( + redirectUrl => { + resolved = true; + return redirectUrl; + } + ); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + await TestUtils.waitForTick(); + ok(webProgress.listener, "Progress listener added"); + equal(window.document.title, promptText, "Window title set"); + + const intermediate = "https://intermediate.example.com/"; + webProgress.listener.onStateChange( + webProgress, + { + name: intermediate, + }, + Ci.nsIWebProgressListener.STATE_IS_NETWORK, + null + ); + ok(webProgress.listener); + ok(!resolved); + + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf"; + webProgress.listener.onStateChange( + webProgress, + { + name: completionUrl, + }, + Ci.nsIWebProgressListener.STATE_IS_NETWORK + ); + + const redirectedUrl = await request; + ok(resolved, "Redirect complete"); + equal(redirectedUrl, completionUrl); + + ok(!webProgress.listener); + ok(window.closed); +}); + +add_task(async function test_waitForRedirectCancelled() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const observeTopic = TestUtils.topicObserved("browser-request"); + const request = InteractiveBrowser.waitForRedirect(url, promptText); + const [subject] = await observeTopic; + + subject.wrappedJSObject.cancelled(); + + await rejects(request, CancelledError); +}); + +add_task(async function test_waitForRedirectImmediatelyAborted() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + + const observeTopic = TestUtils.topicObserved("browser-request"); + const request = InteractiveBrowser.waitForRedirect(url, promptText); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + subject.wrappedJSObject.cancelled(); + await TestUtils.waitForTick(); + ok(!webProgress.listener); + + await rejects(request, CancelledError); +}); + +add_task(async function test_waitForRedirectAbortEvent() { + const url = "https://example.com"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + + const observeTopic = TestUtils.topicObserved("browser-request"); + const request = InteractiveBrowser.waitForRedirect(url, promptText); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + await TestUtils.waitForTick(); + ok(webProgress.listener); + equal(window.document.title, promptText); + + subject.wrappedJSObject.cancelled(); + await rejects(request, CancelledError); + ok(!webProgress.listener); + ok(window.closed); +}); + +add_task(async function test_waitForRedirectAlreadyArrived() { + const url = "https://example.com"; + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf"; + const promptText = "lorem ipsum"; + const { window, webProgress } = getRequestStubs(); + window.initialURI = completionUrl; + + const observeTopic = TestUtils.topicObserved("browser-request"); + let resolved = false; + const request = InteractiveBrowser.waitForRedirect(url, promptText).then( + redirectUrl => { + resolved = true; + return redirectUrl; + } + ); + const [subject] = await observeTopic; + + subject.wrappedJSObject.loaded(window, webProgress); + const redirectedUrl = await request; + + equal(window.document.title, promptText, "Window title set"); + ok(resolved, "Redirect complete"); + equal(redirectedUrl, completionUrl); + + ok(!webProgress.listener); + ok(window.closed); +}); + +function getRequestStubs() { + const mocks = { + window: { + close() { + this.closed = true; + }, + document: { + getElementById() { + return { + currentURI: { + spec: mocks.window.initialURI, + }, + }; + }, + }, + initialURI: "", + }, + webProgress: { + addProgressListener(listener) { + this.listener = listener; + }, + removeProgressListener(listener) { + if (this.listener === listener) { + delete this.listener; + } + }, + }, + }; + return mocks; +} diff --git a/comm/chat/modules/test/test_NormalizedMap.js b/comm/chat/modules/test/test_NormalizedMap.js new file mode 100644 index 0000000000..cad5bcd4d8 --- /dev/null +++ b/comm/chat/modules/test/test_NormalizedMap.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { NormalizedMap } = ChromeUtils.importESModule( + "resource:///modules/NormalizedMap.sys.mjs" +); + +function test_setter_getter() { + let m = new NormalizedMap(aStr => aStr.toLowerCase()); + m.set("foo", "bar"); + m.set("BaZ", "blah"); + Assert.equal(m.has("FOO"), true); + Assert.equal(m.has("BaZ"), true); + Assert.equal(m.get("FOO"), "bar"); + + let keys = Array.from(m.keys()); + Assert.equal(keys[0], "foo"); + Assert.equal(keys[1], "baz"); + + let values = Array.from(m.values()); + Assert.equal(values[0], "bar"); + Assert.equal(values[1], "blah"); + + Assert.equal(m.size, 2); + + run_next_test(); +} + +function test_constructor() { + let k = new NormalizedMap( + aStr => aStr.toLowerCase(), + [ + ["A", 2], + ["b", 3], + ] + ); + Assert.equal(k.get("b"), 3); + Assert.equal(k.get("a"), 2); + Assert.equal(k.get("B"), 3); + Assert.equal(k.get("A"), 2); + + run_next_test(); +} + +function test_iterator() { + let k = new NormalizedMap(aStr => aStr.toLowerCase()); + k.set("FoO", "bar"); + + for (let [key, value] of k) { + Assert.equal(key, "foo"); + Assert.equal(value, "bar"); + } + + run_next_test(); +} + +function test_delete() { + let m = new NormalizedMap(aStr => aStr.toLowerCase()); + m.set("foo", "bar"); + m.set("BaZ", "blah"); + + Assert.equal(m.delete("blah"), false); + + Assert.equal(m.delete("FOO"), true); + Assert.equal(m.size, 1); + + Assert.equal(m.delete("baz"), true); + Assert.equal(m.size, 0); + + run_next_test(); +} + +function run_test() { + add_test(test_setter_getter); + add_test(test_constructor); + add_test(test_iterator); + add_test(test_delete); + + run_next_test(); +} diff --git a/comm/chat/modules/test/test_filtering.js b/comm/chat/modules/test/test_filtering.js new file mode 100644 index 0000000000..33c8fcf262 --- /dev/null +++ b/comm/chat/modules/test/test_filtering.js @@ -0,0 +1,479 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// These tests run into issues if there isn't a profile directory, see bug 1542397. +do_get_profile(); + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { + cleanupImMarkup, + createDerivedRuleset, + addGlobalAllowedTag, + removeGlobalAllowedTag, + addGlobalAllowedAttribute, + removeGlobalAllowedAttribute, + addGlobalAllowedStyleRule, + removeGlobalAllowedStyleRule, +} = ChromeUtils.importESModule("resource:///modules/imContentSink.sys.mjs"); + +var kModePref = "messenger.options.filterMode"; +var kStrictMode = 0, + kStandardMode = 1, + kPermissiveMode = 2; + +function run_test() { + let defaultMode = Services.prefs.getIntPref(kModePref); + + add_test(test_strictMode); + add_test(test_standardMode); + add_test(test_permissiveMode); + add_test(test_addGlobalAllowedTag); + add_test(test_addGlobalAllowedAttribute); + add_test(test_addGlobalAllowedStyleRule); + add_test(test_createDerivedRuleset); + + Services.prefs.setIntPref(kModePref, defaultMode); + run_next_test(); +} + +// Sanity check: a string without HTML markup shouldn't be modified. +function test_plainText() { + const strings = [ + "foo", + "foo ", // preserve trailing whitespace + " foo", // preserve leading indent + "<html>&", // keep escaped characters + ]; + for (let string of strings) { + Assert.equal(string, cleanupImMarkup(string)); + } +} + +function test_paragraphs() { + const strings = ["

foo

bar

", "

foo
bar

", "foo
bar"]; + for (let string of strings) { + Assert.equal(string, cleanupImMarkup(string)); + } +} + +function test_stripScripts() { + const strings = [ + ["", ""], + ["foo ", "foo "], + ["

foo

", "

foo

"], + ["

foo

", "

foo

"], + ]; + for (let [input, expectedOutput] of strings) { + Assert.equal(expectedOutput, cleanupImMarkup(input)); + } +} + +function test_links() { + // http, https, ftp and mailto links should be preserved. + const ok = [ + "http://example.com/", + "https://example.com/", + "ftp://example.com/", + "mailto:foo@example.com", + ]; + for (let string of ok) { + string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + + // other links should be removed + const bad = [ + "chrome://global/content/", + "about:", + "about:blank", + "foo://bar/", + "", + ]; + for (let string of bad) { + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + } + + // keep link titles + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); +} + +function test_table() { + const table = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
test table
keydata
loremipsum
"; + Assert.equal(table, cleanupImMarkup(table)); +} + +function test_allModes() { + test_plainText(); + test_paragraphs(); + test_stripScripts(); + test_links(); + // Remove random classes. + Assert.equal("

foo

", cleanupImMarkup('

foo

')); + // Test unparsable style. + Assert.equal("

foo

", cleanupImMarkup('

foo

')); +} + +function test_strictMode() { + Services.prefs.setIntPref(kModePref, kStrictMode); + test_allModes(); + + // check that basic formatting is stripped in strict mode. + for (let tag of [ + "div", + "em", + "strong", + "b", + "i", + "u", + "s", + "span", + "code", + "ul", + "li", + "ol", + "cite", + "blockquote", + "del", + "strike", + "ins", + "sub", + "sup", + "pre", + "td", + "details", + "h1", + ]) { + Assert.equal("foo", cleanupImMarkup("<" + tag + ">foo")); + } + + // check that font settings are removed. + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + Assert.equal( + "

foo

", + cleanupImMarkup('

foo

') + ); + + // Discard hr + Assert.equal("foobar", cleanupImMarkup("foo
bar")); + + run_next_test(); +} + +function test_standardMode() { + Services.prefs.setIntPref(kModePref, kStandardMode); + test_allModes(); + test_table(); + + // check that basic formatting is kept in standard mode. + for (let tag of [ + "div", + "em", + "strong", + "b", + "i", + "u", + "s", + "span", + "code", + "ul", + "li", + "ol", + "cite", + "blockquote", + "del", + "sub", + "sup", + "pre", + "strike", + "ins", + "details", + ]) { + let string = "<" + tag + ">foo"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep special allowed classes. + for (let className of ["moz-txt-underscore", "moz-txt-tag"]) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Remove font settings + let font_string = 'foo'; + Assert.equal("foo", cleanupImMarkup(font_string)); + + // Discard hr + Assert.equal("foobar", cleanupImMarkup("foo
bar")); + + const okCSS = ["font-style: italic", "font-weight: bold"]; + for (let css of okCSS) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + // text-decoration is a shorthand for several text-decoration properties, but + // standard mode only allows text-decoration-line. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + const badCSS = [ + "color: pink;", + "font-family: Times", + "font-size: larger", + "display: none", + "visibility: hidden", + "unsupported-by-gecko: blah", + ]; + for (let css of badCSS) { + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + } + // The shorthand 'font' is decomposed to non-shorthand properties, + // and not recomposed as some non-shorthand properties are filtered out. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + // Discard headings + const heading1 = "test heading"; + Assert.equal(heading1, cleanupImMarkup(`

${heading1}

`)); + + // Setting the start number of an
    is allowed + const olWithOffset = '
    1. two
    2. three
    '; + Assert.equal(olWithOffset, cleanupImMarkup(olWithOffset)); + + run_next_test(); +} + +function test_permissiveMode() { + Services.prefs.setIntPref(kModePref, kPermissiveMode); + test_allModes(); + test_table(); + + // Check that all formatting is kept in permissive mode. + for (let tag of [ + "div", + "em", + "strong", + "b", + "i", + "u", + "span", + "code", + "ul", + "li", + "ol", + "cite", + "blockquote", + "del", + "sub", + "sup", + "pre", + "strike", + "ins", + "details", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + ]) { + let string = "<" + tag + ">foo"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep special allowed classes. + for (let className of ["moz-txt-underscore", "moz-txt-tag"]) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep font settings + const fontAttributes = ['face="Times"', 'color="pink"', 'size="3"']; + for (let fontAttribute of fontAttributes) { + let string = "foo"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Allow hr + let hr_string = "foo
    bar"; + Assert.equal(hr_string, cleanupImMarkup(hr_string)); + + // Allow most CSS rules changing the text appearance. + const okCSS = [ + "font-style: italic", + "font-weight: bold", + "color: pink;", + "font-family: Times", + "font-size: larger", + ]; + for (let css of okCSS) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + // text-decoration is a shorthand for several text-decoration properties, but + // permissive mode only allows text-decoration-color, text-decoration-line, + // and text-decoration-style. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + // The shorthand 'font' is decomposed to non-shorthand properties, + // and not recomposed as some non-shorthand properties are filtered out. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + // But still filter out dangerous CSS rules. + const badCSS = [ + "display: none", + "visibility: hidden", + "unsupported-by-gecko: blah", + ]; + for (let css of badCSS) { + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + } + + run_next_test(); +} + +function test_addGlobalAllowedTag() { + Services.prefs.setIntPref(kModePref, kStrictMode); + + // Check that
    isn't allowed by default in strict mode. + // Note: we use
    instead of to avoid mailnews' content policy + // messing things up. + Assert.equal("", cleanupImMarkup("
    ")); + + // Allow
    without attributes. + addGlobalAllowedTag("hr"); + Assert.equal("
    ", cleanupImMarkup("
    ")); + Assert.equal("
    ", cleanupImMarkup('
    ')); + removeGlobalAllowedTag("hr"); + + // Allow
    with an unfiltered src attribute. + addGlobalAllowedTag("hr", { src: true }); + Assert.equal("
    ", cleanupImMarkup('
    ')); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + removeGlobalAllowedTag("hr"); + + // Allow
    with an src attribute taking only http(s) urls. + addGlobalAllowedTag("hr", { src: aValue => /^https?:/.test(aValue) }); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + Assert.equal( + "
    ", + cleanupImMarkup('
    ') + ); + removeGlobalAllowedTag("hr"); + + run_next_test(); +} + +function test_addGlobalAllowedAttribute() { + Services.prefs.setIntPref(kModePref, kStrictMode); + + // Check that id isn't allowed by default in strict mode. + Assert.equal("
    ", cleanupImMarkup('
    ')); + + // Allow id unconditionally. + addGlobalAllowedAttribute("id"); + Assert.equal('
    ', cleanupImMarkup('
    ')); + removeGlobalAllowedAttribute("id"); + + // Allow id only with numbers. + addGlobalAllowedAttribute("id", aId => /^\d+$/.test(aId)); + Assert.equal('
    ', cleanupImMarkup('
    ')); + Assert.equal("
    ", cleanupImMarkup('
    ')); + removeGlobalAllowedAttribute("id"); + + run_next_test(); +} + +function test_addGlobalAllowedStyleRule() { + // We need at least the standard mode to have the style attribute allowed. + Services.prefs.setIntPref(kModePref, kStandardMode); + + // Check that clear isn't allowed by default in strict mode. + Assert.equal("
    ", cleanupImMarkup('
    ')); + + // Allow clear. + addGlobalAllowedStyleRule("clear"); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + removeGlobalAllowedStyleRule("clear"); + + run_next_test(); +} + +function test_createDerivedRuleset() { + Services.prefs.setIntPref(kModePref, kStandardMode); + + let rules = createDerivedRuleset(); + + let string = "
    "; + Assert.equal("", cleanupImMarkup(string)); + Assert.equal("", cleanupImMarkup(string, rules)); + rules.tags.hr = true; + Assert.equal(string, cleanupImMarkup(string, rules)); + + string = '
    '; + Assert.equal("
    ", cleanupImMarkup(string)); + Assert.equal("
    ", cleanupImMarkup(string, rules)); + rules.attrs.id = true; + Assert.equal(string, cleanupImMarkup(string, rules)); + + string = '
    '; + Assert.equal("
    ", cleanupImMarkup(string)); + Assert.equal("
    ", cleanupImMarkup(string, rules)); + rules.styles.clear = true; + Assert.equal(string, cleanupImMarkup(string, rules)); + + run_next_test(); +} diff --git a/comm/chat/modules/test/test_imThemes.js b/comm/chat/modules/test/test_imThemes.js new file mode 100644 index 0000000000..61171fe121 --- /dev/null +++ b/comm/chat/modules/test/test_imThemes.js @@ -0,0 +1,342 @@ +/* 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 { + initHTMLDocument, + insertHTMLForMessage, + getHTMLForMessage, + replaceHTMLForMessage, + wasNextMessage, + removeMessage, + isNextMessage, +} = ChromeUtils.importESModule("resource:///modules/imThemes.sys.mjs"); +const { MockDocument } = ChromeUtils.importESModule( + "resource://testing-common/MockDocument.sys.mjs" +); + +const BASIC_CONV_DOCUMENT_HTML = + '
    '; + +add_task(function test_initHTMLDocument() { + const window = {}; + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + "" + ); + Object.defineProperty(document, "defaultView", { + value: window, + }); + const conversation = { + title: "test", + }; + const theme = { + baseURI: "chrome://messenger-messagestyles/skin/test/", + variant: "default", + metadata: {}, + html: { + footer: "", + script: 'console.log("hi");', + }, + }; + initHTMLDocument(conversation, theme, document); + equal(typeof document.defaultView.convertTimeUnits, "function"); + equal(document.querySelector("base").href, theme.baseURI); + ok( + document.querySelector( + 'link[rel="stylesheet"][href="chrome://chat/skin/conv.css"]' + ) + ); + ok(document.querySelector('link[rel="stylesheet"][href="main.css"]')); + + equal(document.body.id, "ibcontent"); + ok(document.getElementById("Chat")); + equal(document.querySelector("script").src, theme.baseURI + "inline.js"); +}); + +add_task(function test_insertHTMLForMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = {}; + insertHTMLForMessage(message, html, document, false); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + ok(!messageElement.dataset.isNext); +}); + +add_task(function test_insertHTMLForMessage_next() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = {}; + insertHTMLForMessage(message, html, document, true); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + ok(messageElement.dataset.isNext); +}); + +add_task(function test_getHTMLForMessage() { + const message = { + incoming: true, + system: false, + message: "foo bar", + who: "userId", + alias: "display name", + color: "#ffbbff", + }; + const theme = { + html: { + incomingContent: + '%sender%%message%', + }, + }; + const html = getHTMLForMessage(message, theme, false, false); + equal( + html, + 'display namefoo bar' + ); +}); + +add_task(function test_replaceHTMLForMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, false); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + equal(messageElement.dataset.remoteId, "foo"); + ok(!messageElement.dataset.isNext); + const updatedHtml = + '
    lorem ipsum
    '; + const updatedMessage = { + remoteId: "foo", + }; + replaceHTMLForMessage(updatedMessage, updatedHtml, document, true); + const updatedMessageElement = document.querySelector("#Chat > div"); + strictEqual(updatedMessageElement._originalMsg, updatedMessage); + equal(updatedMessageElement.style.backgroundColor, "green"); + equal(updatedMessageElement.textContent, "lorem ipsum"); + equal(updatedMessageElement.dataset.remoteId, "foo"); + ok(updatedMessageElement.dataset.isNext); + ok( + !document.querySelector("#insert"), + "Insert anchor in template is ignored when replacing" + ); +}); + +add_task(function test_replaceHTMLForMessageWithoutExistingMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const updatedHtml = '
    lorem ipsum
    '; + const updatedMessage = { + remoteId: "foo", + }; + replaceHTMLForMessage(updatedMessage, updatedHtml, document, false); + const updatedMessageElement = document.querySelector("#Chat > div"); + ok(!updatedMessageElement); +}); + +add_task(function test_replaceHTMLForMessageWithoutRemoteId() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, false); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + equal(messageElement.dataset.remoteId, "foo"); + ok(!messageElement.dataset.isNext); + const updatedHtml = '
    lorem ipsum
    '; + const updatedMessage = {}; + replaceHTMLForMessage(updatedMessage, updatedHtml, document, false); + const updatedMessageElement = document.querySelector("#Chat > div"); + strictEqual(updatedMessageElement._originalMsg, message); + equal(updatedMessageElement.style.backgroundColor, "blue"); + equal(updatedMessageElement.textContent, "foo bar"); + equal(updatedMessageElement.dataset.remoteId, "foo"); + ok(!updatedMessageElement.dataset.isNext); +}); + +add_task(function test_wasNextMessage_isNext() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = "
    foo bar
    "; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, true); + ok(wasNextMessage(message, document)); +}); + +add_task(function test_wasNextMessage_isNotNext() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = "
    foo bar
    "; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, false); + ok(!wasNextMessage(message, document)); +}); + +add_task(function test_wasNextMessage_noPreviousVersion() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const message = { + remoteId: "foo", + }; + ok(!wasNextMessage(message, document)); +}); + +add_task(function test_removeMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, false); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + equal(messageElement.dataset.remoteId, "foo"); + ok(!messageElement.dataset.isNext); + removeMessage("foo", document); + const messageElements = document.querySelectorAll("#Chat > div"); + equal(messageElements.length, 0); +}); + +add_task(function test_removeMessage_noMatchingMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + const message = { + remoteId: "foo", + }; + insertHTMLForMessage(message, html, document, false); + const messageElement = document.querySelector("#Chat > div"); + strictEqual(messageElement._originalMsg, message); + equal(messageElement.style.backgroundColor, "blue"); + equal(messageElement.textContent, "foo bar"); + equal(messageElement.dataset.remoteId, "foo"); + ok(!messageElement.dataset.isNext); + removeMessage("bar", document); + const messageElements = document.querySelectorAll("#Chat > div"); + notEqual(messageElements.length, 0); +}); + +add_task(function test_isNextMessage() { + const theme = { + combineConsecutive: true, + metadata: {}, + combineConsecutiveInterval: 300, + }; + const messagePairs = [ + { + message: {}, + previousMessage: null, + isNext: false, + }, + { + message: { + system: true, + }, + previousMessage: { + system: true, + }, + isNext: true, + }, + { + message: { + who: "foo", + }, + previousMessage: { + who: "bar", + }, + isNext: false, + }, + { + message: { + outgoing: true, + }, + isNext: false, + }, + { + message: { + incoming: true, + }, + isNext: false, + }, + { + message: { + system: true, + }, + isNext: false, + }, + { + message: { + time: 100, + }, + previousMessage: { + time: 100, + }, + isNext: true, + }, + { + message: { + time: 300, + }, + previousMessage: { + time: 100, + }, + isNext: true, + }, + { + message: { + time: 500, + }, + previousMessage: { + time: 100, + }, + isNext: false, + }, + ]; + for (const { message, previousMessage = {}, isNext } of messagePairs) { + equal(isNextMessage(theme, message, previousMessage), isNext); + } +}); diff --git a/comm/chat/modules/test/test_jsProtoHelper.js b/comm/chat/modules/test/test_jsProtoHelper.js new file mode 100644 index 0000000000..b87ec27241 --- /dev/null +++ b/comm/chat/modules/test/test_jsProtoHelper.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { GenericConvIMPrototype } = ChromeUtils.importESModule( + "resource:///modules/jsProtoHelper.sys.mjs" +); + +var _id = 0; +function Conversation(name) { + this._name = name; + this._observers = []; + this._date = Date.now() * 1000; + this.id = ++_id; +} +Conversation.prototype = { + __proto__: GenericConvIMPrototype, + _account: { + imAccount: { + protocol: { name: "Fake Protocol" }, + alias: "", + name: "Fake Account", + }, + ERROR(e) { + throw e; + }, + DEBUG() {}, + }, +}; + +// ROT13, used as an example transformation. +function rot13(aString) { + return aString.replace(/[a-zA-Z]/g, function (c) { + return String.fromCharCode( + c.charCodeAt(0) + (c.toLowerCase() < "n" ? 1 : -1) * 13 + ); + }); +} + +// A test that cancels a message before it can be sent. +add_task(function test_cancel_send_message() { + let conv = new Conversation(); + conv.dispatchMessage = function (aMsg) { + ok( + false, + "The message should have been halted in the conversation service." + ); + }; + + let sending = false; + conv.addObserver({ + observe(aObject, aTopic, aMsg) { + switch (aTopic) { + case "sending-message": + ok( + aObject.QueryInterface(Ci.imIOutgoingMessage), + "Wrong message type." + ); + aObject.cancelled = true; + sending = true; + break; + case "new-text": + ok( + false, + "No other notification should be fired for a cancelled message." + ); + break; + } + }, + }); + conv.sendMsg("Hi!"); + ok(sending, "The sending-message notification was never fired."); +}); + +// A test that ensures protocols get a chance to prepare a message before +// sending and displaying. +add_task(function test_prpl_message_prep() { + let conv = new Conversation(); + conv.dispatchMessage = function (aMsg) { + this.writeMessage("user", aMsg, { outgoing: true }); + }; + + conv.prepareForSending = function (aMsg) { + ok(aMsg.QueryInterface(Ci.imIOutgoingMessage), "Wrong message type."); + equal(aMsg.message, msg, "Expected the original message."); + prepared = true; + return [prefix + aMsg.message]; + }; + + conv.prepareForDisplaying = function (aMsg) { + equal(aMsg.displayMessage, prefix + msg, "Expected the prefixed message."); + aMsg.displayMessage = aMsg.displayMessage.slice(prefix.length); + }; + + let msg = "Hi!"; + let prefix = "test> "; + + let prepared = false; + let receivedMsg = false; + conv.addObserver({ + observe(aObject, aTopic) { + if (aTopic === "preparing-message") { + equal(aObject.message, msg, "Expected the original message"); + } else if (aTopic === "sending-message") { + equal(aObject.message, prefix + msg, "Expected the prefixed message."); + } else if (aTopic === "new-text") { + ok(aObject.QueryInterface(Ci.prplIMessage), "Wrong message type."); + ok(prepared, "The message was not prepared before sending."); + equal(aObject.message, prefix + msg, "Expected the prefixed message."); + receivedMsg = true; + aObject.displayMessage = aObject.originalMessage; + conv.prepareForDisplaying(aObject); + equal(aObject.displayMessage, msg, "Expected the original message"); + } + }, + }); + + conv.sendMsg(msg); + ok(receivedMsg, "The new-text notification was never fired."); +}); + +// A test that ensures protocols can split messages before they are sent. +add_task(function test_split_message_before_sending() { + let msgCount = 0; + let prepared = false; + + let msg = "This is a looo\nooong message.\nThis one is short."; + let msgs = msg.split("\n"); + + let conv = new Conversation(); + conv.dispatchMessage = function (aMsg) { + equal(aMsg, msgs[msgCount++], "Sending an unexpected message."); + }; + conv.prepareForSending = function (aMsg) { + ok(aMsg.QueryInterface(Ci.imIOutgoingMessage), "Wrong message type."); + prepared = true; + return aMsg.message.split("\n"); + }; + + conv.sendMsg(msg); + + ok(prepared, "Message wasn't prepared for sending."); + equal(msgCount, 3, "Not enough messages were sent."); +}); + +add_task(function test_removeMessage() { + let didRemove = false; + let conv = new Conversation(); + conv.addObserver({ + observe(subject, topic, data) { + if (topic === "remove-text") { + equal(data, "foo"); + didRemove = true; + } + }, + }); + + conv.removeMessage("foo"); + ok(didRemove); +}); diff --git a/comm/chat/modules/test/test_otrlib.js b/comm/chat/modules/test/test_otrlib.js new file mode 100644 index 0000000000..4b321359f9 --- /dev/null +++ b/comm/chat/modules/test/test_otrlib.js @@ -0,0 +1,21 @@ +/* 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/. */ + +/** + * Test for libotr. + */ + +"use strict"; + +const { OTRLibLoader } = ChromeUtils.importESModule( + "resource:///modules/OTRLib.sys.mjs" +); + +/** + * Initialize libotr. + */ +add_setup(async function () { + let libOTR = await OTRLibLoader.init(); + Assert.ok(libOTR.otrl_version, "libotr did load"); +}); diff --git a/comm/chat/modules/test/xpcshell.ini b/comm/chat/modules/test/xpcshell.ini new file mode 100644 index 0000000000..d12004fd37 --- /dev/null +++ b/comm/chat/modules/test/xpcshell.ini @@ -0,0 +1,10 @@ +[DEFAULT] +head = +tail = + +[test_filtering.js] +[test_imThemes.js] +[test_InteractiveBrowser.js] +[test_jsProtoHelper.js] +[test_NormalizedMap.js] +[test_otrlib.js] -- cgit v1.2.3