summaryrefslogtreecommitdiffstats
path: root/comm/chat/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat/modules
parentInitial commit. (diff)
downloadthunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz
thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/chat/modules')
-rw-r--r--comm/chat/modules/CLib.sys.mjs64
-rw-r--r--comm/chat/modules/IMServices.sys.mjs50
-rw-r--r--comm/chat/modules/InteractiveBrowser.sys.mjs138
-rw-r--r--comm/chat/modules/NormalizedMap.sys.mjs48
-rw-r--r--comm/chat/modules/OTR.sys.mjs1506
-rw-r--r--comm/chat/modules/OTRLib.sys.mjs1151
-rw-r--r--comm/chat/modules/OTRUI.sys.mjs998
-rw-r--r--comm/chat/modules/ToLocaleFormat.sys.mjs208
-rw-r--r--comm/chat/modules/imContentSink.sys.mjs495
-rw-r--r--comm/chat/modules/imSmileys.sys.mjs184
-rw-r--r--comm/chat/modules/imStatusUtils.sys.mjs57
-rw-r--r--comm/chat/modules/imTextboxUtils.sys.mjs19
-rw-r--r--comm/chat/modules/imThemes.sys.mjs1333
-rw-r--r--comm/chat/modules/imXPCOMUtils.sys.mjs249
-rw-r--r--comm/chat/modules/jsProtoHelper.sys.mjs1796
-rw-r--r--comm/chat/modules/moz.build25
-rw-r--r--comm/chat/modules/socket.sys.mjs644
-rw-r--r--comm/chat/modules/test/test_InteractiveBrowser.js280
-rw-r--r--comm/chat/modules/test/test_NormalizedMap.js80
-rw-r--r--comm/chat/modules/test/test_filtering.js479
-rw-r--r--comm/chat/modules/test/test_imThemes.js342
-rw-r--r--comm/chat/modules/test/test_jsProtoHelper.js159
-rw-r--r--comm/chat/modules/test/test_otrlib.js21
-rw-r--r--comm/chat/modules/test/xpcshell.ini10
24 files changed, 10336 insertions, 0 deletions
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<object>} 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<object>} 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<string>} 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
+ // <progressmeter mode="determined" value="10" />
+ 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<string, (boolean | ValueRule)>}}
+ */
+
+/**
+ * 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<string, (boolean|Ruleset)>} 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<string, boolean>} 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 <img> 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 <img> 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 <span> to avoid losing leading whitespace.
+ let doc = parser.parseFromString(
+ "<!DOCTYPE html><html><body><span>" + aText + "</span></body></html>",
+ "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 =>
+ '<span class="ib-msg-txt">' +
+ (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) +
+ "</span>",
+ 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 `<span class="ib-sender${otr}">${lazy.TXTToHTML(aName)}</span>`;
+}
+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 = '<div id="Chat" aria-live="polite"></div>';
+ 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<boolean>} 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<void>}>}
+ * 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(<originHost>, <originPort>[, ("starttls" | "ssl" | "udp")
+ * [, <proxy>[, <host>, <port>]]])
+ * disconnect()
+ * sendData(String <data>[, <logged data>])
+ * sendString(String <data>[, <encoding>[, <logged data>]])
+ * 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 <data>)
+ * onTransportStatus(nsISocketTransport <transport>, nsresult <status>,
+ * unsigned long <progress>, unsigned long <progress max>)
+ * sendPing()
+ * LOG(<message>)
+ * DEBUG(<message>)
+ *
+ * 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
+ "&lt;html&gt;&amp;", // keep escaped characters
+ ];
+ for (let string of strings) {
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+}
+
+function test_paragraphs() {
+ const strings = ["<p>foo</p><p>bar</p>", "<p>foo<br>bar</p>", "foo<br>bar"];
+ for (let string of strings) {
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+}
+
+function test_stripScripts() {
+ const strings = [
+ ["<script>alert('hey')</script>", ""],
+ ["foo <script>alert('hey')</script>", "foo "],
+ ["<p onclick=\"alert('hey')\">foo</p>", "<p>foo</p>"],
+ ["<p onmouseover=\"alert('hey')\">foo</p>", "<p>foo</p>"],
+ ];
+ 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 = '<a href="' + string + '">foo</a>';
+ 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(
+ "<a>foo</a>",
+ cleanupImMarkup('<a href="' + string + '">foo</a>')
+ );
+ }
+
+ // keep link titles
+ let string = '<a title="foo bar">foo</a>';
+ Assert.equal(string, cleanupImMarkup(string));
+}
+
+function test_table() {
+ const table =
+ "<table>" +
+ "<caption>test table</caption>" +
+ "<thead>" +
+ "<tr>" +
+ "<th>key</th>" +
+ "<th>data</th>" +
+ "</tr>" +
+ "</thead>" +
+ "<tbody>" +
+ "<tr>" +
+ "<td>lorem</td>" +
+ "<td>ipsum</td>" +
+ "</tr>" +
+ "</tbody>" +
+ "</table>";
+ Assert.equal(table, cleanupImMarkup(table));
+}
+
+function test_allModes() {
+ test_plainText();
+ test_paragraphs();
+ test_stripScripts();
+ test_links();
+ // Remove random classes.
+ Assert.equal("<p>foo</p>", cleanupImMarkup('<p class="foobar">foo</p>'));
+ // Test unparsable style.
+ Assert.equal("<p>foo</p>", cleanupImMarkup('<p style="not-valid">foo</p>'));
+}
+
+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</" + tag + ">"));
+ }
+
+ // check that font settings are removed.
+ Assert.equal(
+ "foo",
+ cleanupImMarkup('<font face="Times" color="pink">foo</font>')
+ );
+ Assert.equal(
+ "<p>foo</p>",
+ cleanupImMarkup('<p style="font-weight: bold;">foo</p>')
+ );
+
+ // Discard hr
+ Assert.equal("foobar", cleanupImMarkup("foo<hr>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</" + tag + ">";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep special allowed classes.
+ for (let className of ["moz-txt-underscore", "moz-txt-tag"]) {
+ let string = '<span class="' + className + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Remove font settings
+ let font_string = '<font face="Times" color="pink" size="3">foo</font>';
+ Assert.equal("foo", cleanupImMarkup(font_string));
+
+ // Discard hr
+ Assert.equal("foobar", cleanupImMarkup("foo<hr>bar"));
+
+ const okCSS = ["font-style: italic", "font-weight: bold"];
+ for (let css of okCSS) {
+ let string = '<span style="' + css + '">foo</span>';
+ 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(
+ '<span style="text-decoration-line: underline;">foo</span>',
+ cleanupImMarkup('<span style="text-decoration: underline">foo</span>')
+ );
+
+ 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(
+ "<span>foo</span>",
+ cleanupImMarkup('<span style="' + css + '">foo</span>')
+ );
+ }
+ // The shorthand 'font' is decomposed to non-shorthand properties,
+ // and not recomposed as some non-shorthand properties are filtered out.
+ Assert.equal(
+ '<span style="font-style: normal; font-weight: normal;">foo</span>',
+ cleanupImMarkup('<span style="font: 15px normal">foo</span>')
+ );
+
+ // Discard headings
+ const heading1 = "test heading";
+ Assert.equal(heading1, cleanupImMarkup(`<h1>${heading1}</h1>`));
+
+ // Setting the start number of an <ol> is allowed
+ const olWithOffset = '<ol start="2"><li>two</li><li>three</li></ol>';
+ 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</" + tag + ">";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep special allowed classes.
+ for (let className of ["moz-txt-underscore", "moz-txt-tag"]) {
+ let string = '<span class="' + className + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep font settings
+ const fontAttributes = ['face="Times"', 'color="pink"', 'size="3"'];
+ for (let fontAttribute of fontAttributes) {
+ let string = "<font " + fontAttribute + ">foo</font>";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Allow hr
+ let hr_string = "foo<hr>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 = '<span style="' + css + '">foo</span>';
+ 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(
+ '<span style="text-decoration-color: currentcolor; text-decoration-line: underline; text-decoration-style: solid;">foo</span>',
+ cleanupImMarkup('<span style="text-decoration: underline;">foo</span>')
+ );
+
+ // The shorthand 'font' is decomposed to non-shorthand properties,
+ // and not recomposed as some non-shorthand properties are filtered out.
+ Assert.equal(
+ '<span style="font-family: normal; font-size: 15px; ' +
+ 'font-style: normal; font-weight: normal;">foo</span>',
+ cleanupImMarkup('<span style="font: 15px normal">foo</span>')
+ );
+
+ // But still filter out dangerous CSS rules.
+ const badCSS = [
+ "display: none",
+ "visibility: hidden",
+ "unsupported-by-gecko: blah",
+ ];
+ for (let css of badCSS) {
+ Assert.equal(
+ "<span>foo</span>",
+ cleanupImMarkup('<span style="' + css + '">foo</span>')
+ );
+ }
+
+ run_next_test();
+}
+
+function test_addGlobalAllowedTag() {
+ Services.prefs.setIntPref(kModePref, kStrictMode);
+
+ // Check that <hr> isn't allowed by default in strict mode.
+ // Note: we use <hr> instead of <img> to avoid mailnews' content policy
+ // messing things up.
+ Assert.equal("", cleanupImMarkup("<hr>"));
+
+ // Allow <hr> without attributes.
+ addGlobalAllowedTag("hr");
+ Assert.equal("<hr>", cleanupImMarkup("<hr>"));
+ Assert.equal("<hr>", cleanupImMarkup('<hr src="http://example.com/">'));
+ removeGlobalAllowedTag("hr");
+
+ // Allow <hr> with an unfiltered src attribute.
+ addGlobalAllowedTag("hr", { src: true });
+ Assert.equal("<hr>", cleanupImMarkup('<hr alt="foo">'));
+ Assert.equal(
+ '<hr src="http://example.com/">',
+ cleanupImMarkup('<hr src="http://example.com/">')
+ );
+ Assert.equal(
+ '<hr src="chrome://global/skin/img.png">',
+ cleanupImMarkup('<hr src="chrome://global/skin/img.png">')
+ );
+ removeGlobalAllowedTag("hr");
+
+ // Allow <hr> with an src attribute taking only http(s) urls.
+ addGlobalAllowedTag("hr", { src: aValue => /^https?:/.test(aValue) });
+ Assert.equal(
+ '<hr src="http://example.com/">',
+ cleanupImMarkup('<hr src="http://example.com/">')
+ );
+ Assert.equal(
+ "<hr>",
+ cleanupImMarkup('<hr src="chrome://global/skin/img.png">')
+ );
+ 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("<br>", cleanupImMarkup('<br id="foo">'));
+
+ // Allow id unconditionally.
+ addGlobalAllowedAttribute("id");
+ Assert.equal('<br id="foo">', cleanupImMarkup('<br id="foo">'));
+ removeGlobalAllowedAttribute("id");
+
+ // Allow id only with numbers.
+ addGlobalAllowedAttribute("id", aId => /^\d+$/.test(aId));
+ Assert.equal('<br id="123">', cleanupImMarkup('<br id="123">'));
+ Assert.equal("<br>", cleanupImMarkup('<br id="foo">'));
+ 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("<br>", cleanupImMarkup('<br style="clear: both;">'));
+
+ // Allow clear.
+ addGlobalAllowedStyleRule("clear");
+ Assert.equal(
+ '<br style="clear: both;">',
+ cleanupImMarkup('<br style="clear: both;">')
+ );
+ removeGlobalAllowedStyleRule("clear");
+
+ run_next_test();
+}
+
+function test_createDerivedRuleset() {
+ Services.prefs.setIntPref(kModePref, kStandardMode);
+
+ let rules = createDerivedRuleset();
+
+ let string = "<hr>";
+ Assert.equal("", cleanupImMarkup(string));
+ Assert.equal("", cleanupImMarkup(string, rules));
+ rules.tags.hr = true;
+ Assert.equal(string, cleanupImMarkup(string, rules));
+
+ string = '<br id="123">';
+ Assert.equal("<br>", cleanupImMarkup(string));
+ Assert.equal("<br>", cleanupImMarkup(string, rules));
+ rules.attrs.id = true;
+ Assert.equal(string, cleanupImMarkup(string, rules));
+
+ string = '<br style="clear: both;">';
+ Assert.equal("<br>", cleanupImMarkup(string));
+ Assert.equal("<br>", 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 =
+ '<!DOCTYPE html><html><body><div id="Chat"></div></body></html>';
+
+add_task(function test_initHTMLDocument() {
+ const window = {};
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ "<!DOCTYPE html><html><head></head><body></body></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 = '<div style="background: blue;">foo bar</div>';
+ 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 = '<div style="background: blue;">foo bar</div>';
+ 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:
+ '<span style="color: %senderColor%;">%sender%</span>%message%',
+ },
+ };
+ const html = getHTMLForMessage(message, theme, false, false);
+ equal(
+ html,
+ '<span style="color: #ffbbff;"><span class="ib-sender">display name</span></span><span class="ib-msg-txt">foo bar</span>'
+ );
+});
+
+add_task(function test_replaceHTMLForMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ 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 =
+ '<div style="background: green;">lorem ipsum</div><div id="insert"></div>';
+ 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 = '<div style="background: green;">lorem ipsum</div>';
+ 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 = '<div style="background: blue;">foo bar</div>';
+ 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 = '<div style="background: green;">lorem ipsum</div>';
+ 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 = "<div>foo bar</div>";
+ 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 = "<div>foo bar</div>";
+ 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 = '<div style="background: blue;">foo bar</div>';
+ 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 = '<div style="background: blue;">foo bar</div>';
+ 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]