summaryrefslogtreecommitdiffstats
path: root/comm/chat/content
diff options
context:
space:
mode:
Diffstat (limited to 'comm/chat/content')
-rw-r--r--comm/chat/content/chat-account-richlistitem.js354
-rw-r--r--comm/chat/content/chat-tooltip.js604
-rw-r--r--comm/chat/content/conv.html4
-rw-r--r--comm/chat/content/conversation-browser.js906
-rw-r--r--comm/chat/content/imAccountOptionsHelper.js121
-rw-r--r--comm/chat/content/jar.mn18
-rw-r--r--comm/chat/content/moz.build6
-rw-r--r--comm/chat/content/otr-add-fingerprint.js84
-rw-r--r--comm/chat/content/otr-add-fingerprint.xhtml91
-rw-r--r--comm/chat/content/otr-auth.js198
-rw-r--r--comm/chat/content/otr-auth.xhtml163
-rw-r--r--comm/chat/content/otr-finger.js159
-rw-r--r--comm/chat/content/otr-finger.xhtml74
-rw-r--r--comm/chat/content/otrWorker.js61
14 files changed, 2843 insertions, 0 deletions
diff --git a/comm/chat/content/chat-account-richlistitem.js b/comm/chat/content/chat-account-richlistitem.js
new file mode 100644
index 0000000000..23efcdc596
--- /dev/null
+++ b/comm/chat/content/chat-account-richlistitem.js
@@ -0,0 +1,354 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement, gAccountManager */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatAccountRichlistitem widget displays the information about the
+ * configured account: i.e. icon, state, name, error, checkbox for
+ * auto sign in and buttons for disconnect and properties.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatAccountRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ stack: "tooltiptext=protocol",
+ ".accountName": "value=name",
+ ".autoSignOn": "checked=autologin",
+ ".account-buttons": "autologin,name",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-account-richlistitem");
+ this.addEventListener("dblclick", event => {
+ if (event.button == 0) {
+ // If we double clicked on a widget that has already done
+ // something with the first click, we should ignore the event
+ let localName = event.target.localName;
+ if (localName != "button" && localName != "checkbox") {
+ this.proceedDefaultAction();
+ }
+ }
+ // Prevent from loading an account wizard
+ event.stopPropagation();
+ });
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <vbox flex="1">
+ <hbox flex="1" align="start">
+ <vbox>
+ <stack>
+ <html:img class="accountIcon" alt="" />
+ <html:img class="statusTypeIcon" alt="" />
+ </stack>
+ <spacer flex="1"></spacer>
+ </vbox>
+ <vbox flex="1" align="start">
+ <label crop="end" class="accountName"></label>
+ <label class="connecting" crop="end" value="&account.connecting;"></label>
+ <label class="connected" crop="end"></label>
+ <label class="disconnecting" crop="end" value="&account.disconnecting;"></label>
+ <label class="disconnected" crop="end" value="&account.disconnected;"></label>
+ <description class="error error-description"></description>
+ <description class="error error-reconnect"></description>
+ <spacer flex="1"></spacer>
+ </vbox>
+ <checkbox label="&account.autoSignOn.label;"
+ class="autoSignOn"
+ accesskey="&account.autoSignOn.accesskey;"
+ oncommand="gAccountManager.autologin()"></checkbox>
+ </hbox>
+ <hbox flex="1" class="account-buttons">
+ <button class="disconnectButton" command="cmd_disconnect"></button>
+ <button class="connectButton" command="cmd_connect"></button>
+ <spacer flex="1"></spacer>
+ <button command="cmd_edit"></button>
+ </hbox>
+ </vbox>
+ `,
+ ["chrome://chat/locale/accounts.dtd"]
+ )
+ );
+ this._buttons = this.querySelector(".account-buttons");
+ this._connectedLabel = this.querySelector(".connected");
+ this._stateIcon = this.querySelector(".statusTypeIcon");
+ this.initializeAttributeInheritance();
+ }
+
+ set autoLogin(val) {
+ if (val) {
+ this.setAttribute("autologin", "true");
+ } else {
+ this.removeAttribute("autologin");
+ }
+ if (this._account.autoLogin != val) {
+ this._account.autoLogin = val;
+ }
+ }
+
+ get autoLogin() {
+ return this.hasAttribute("autologin");
+ }
+
+ /**
+ * override the default accessible name
+ */
+ get label() {
+ return this.getAttribute("name");
+ }
+
+ get account() {
+ return this._account;
+ }
+
+ get buttons() {
+ return this._buttons;
+ }
+
+ build(aAccount) {
+ this._account = aAccount;
+ this.setAttribute("name", aAccount.name);
+ this.setAttribute("id", aAccount.id);
+ let proto = aAccount.protocol;
+ this.setAttribute("protocol", proto.name);
+ this.querySelector(".accountIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(proto, 32)
+ );
+ this.refreshState();
+ this.autoLogin = aAccount.autoLogin;
+ }
+
+ /**
+ * Refresh the shown connection state.
+ *
+ * @param {"connected"|"connecting"|"disconnected"|"disconnecting"}
+ * [forceState] - The connection state to show. Otherwise, determined
+ * through the account status.
+ */
+ refreshState(forceState) {
+ let account = this._account;
+ let state = "unknown";
+ if (forceState) {
+ state = forceState;
+ } else if (account.connected) {
+ state = "connected";
+ } else if (account.disconnected) {
+ state = "disconnected";
+ } else if (this._account.connecting) {
+ state = "connecting";
+ } else if (this._account.disconnecting) {
+ state = "disconnecting";
+ }
+
+ switch (state) {
+ case "connected":
+ this.refreshConnectedLabel();
+ break;
+ case "connecting":
+ this.updateConnectingProgress();
+ break;
+ }
+
+ /* "state" and "error" attributes are needed for CSS styling of the
+ * accountIcon and the connection buttons. */
+ this.setAttribute("state", state);
+
+ if (account.connectionErrorReason !== Ci.prplIAccount.NO_ERROR) {
+ /* Icon and error attribute set in other method. */
+ this.updateConnectionError();
+ return;
+ }
+
+ this.removeAttribute("error");
+
+ this._stateIcon.setAttribute("src", ChatIcons.getStatusIconURI(state));
+ }
+
+ updateConnectingProgress() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ const key = "account.connection.progress";
+ let text = this._account.connectionStateMsg;
+ text = text
+ ? bundle.formatStringFromName(key, [text])
+ : bundle.GetStringFromName("account.connecting");
+
+ let progress = this.querySelector(".connecting");
+ progress.setAttribute("value", text);
+ if (this.reconnectUpdateInterval) {
+ this._cancelReconnectTimer();
+ }
+ }
+
+ updateConnectionError() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ const key = "account.connection.error";
+ let account = this._account;
+ let text;
+ let errorReason = account.connectionErrorReason;
+ if (errorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL) {
+ text = bundle.formatStringFromName(key + "UnknownPrpl", [
+ account.protocol.id,
+ ]);
+ } else if (errorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD) {
+ text = bundle.GetStringFromName(key + "EnteringPasswordRequired");
+ } else if (errorReason == Ci.imIAccount.ERROR_CRASHED) {
+ text = bundle.GetStringFromName(key + "CrashedAccount");
+ } else {
+ text = account.connectionErrorMessage;
+ }
+
+ if (errorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD) {
+ text = bundle.formatStringFromName(key, [text]);
+ }
+
+ /* "error" attribute is needed for CSS styling of the accountIcon and the
+ * connection buttons. */
+ this.setAttribute("error", "true");
+ this._stateIcon.setAttribute(
+ "src",
+ "chrome://global/skin/icons/warning.svg"
+ );
+ let error = this.querySelector(".error-description");
+ error.textContent = text;
+
+ let updateReconnect = () => {
+ let date = Math.round(
+ (account.timeOfNextReconnect - Date.now()) / 1000
+ );
+ let reconnect = "";
+ if (date > 0) {
+ let [val1, unit1, val2, unit2] = DownloadUtils.convertTimeUnits(date);
+ if (!val2) {
+ reconnect = bundle.formatStringFromName(
+ "account.reconnectInSingle",
+ [val1, unit1]
+ );
+ } else {
+ reconnect = bundle.formatStringFromName(
+ "account.reconnectInDouble",
+ [val1, unit1, val2, unit2]
+ );
+ }
+ }
+ this.querySelector(".error-reconnect").textContent = reconnect;
+ return reconnect;
+ };
+ if (updateReconnect() && !this.reconnectUpdateInterval) {
+ this.setAttribute("reconnectPending", "true");
+ this.reconnectUpdateInterval = setInterval(updateReconnect, 1000);
+ gAccountManager.disableCommandItems();
+ }
+ }
+
+ refreshConnectedLabel() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ let date =
+ 60 * Math.floor((Date.now() - this._account.timeOfLastConnect) / 60000);
+ let value;
+ if (date > 0) {
+ let [val1, unit1, val2, unit2] = DownloadUtils.convertTimeUnits(date);
+ if (!val2) {
+ value = bundle.formatStringFromName("account.connectedForSingle", [
+ val1,
+ unit1,
+ ]);
+ } else {
+ value = bundle.formatStringFromName("account.connectedForDouble", [
+ val1,
+ unit1,
+ val2,
+ unit2,
+ ]);
+ }
+ } else {
+ value = bundle.GetStringFromName("account.connectedForSeconds");
+ }
+ this._connectedLabel.value = value;
+ }
+
+ _cancelReconnectTimer() {
+ this.removeAttribute("reconnectPending");
+ clearInterval(this.reconnectUpdateInterval);
+ delete this.reconnectUpdateInterval;
+ gAccountManager.disableCommandItems();
+ }
+
+ cancelReconnection() {
+ if (this.reconnectUpdateInterval) {
+ this._cancelReconnectTimer();
+ this._account.cancelReconnection();
+ }
+ }
+
+ destroy() {
+ // If we have a reconnect timer, stop it:
+ // it will throw errors otherwise (see bug 480).
+ if (!this.reconnectUpdateInterval) {
+ return;
+ }
+ clearInterval(this.reconnectUpdateInterval);
+ delete this.reconnectUpdateInterval;
+ }
+
+ get activeButton() {
+ let action = this.account.disconnected
+ ? ".connectButton"
+ : ".disconnectButton";
+ return this.querySelector(action);
+ }
+
+ setFocus() {
+ let focusTarget = this.activeButton;
+ let accountName = this.getAttribute("name");
+ focusTarget.setAttribute(
+ "aria-label",
+ focusTarget.label + " " + accountName
+ );
+ if (focusTarget.disabled) {
+ focusTarget = document.getElementById("accountlist");
+ }
+ focusTarget.focus();
+ }
+
+ proceedDefaultAction() {
+ this.activeButton.click();
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatAccountRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define(
+ "chat-account-richlistitem",
+ MozChatAccountRichlistitem,
+ { extends: "richlistitem" }
+ );
+}
diff --git a/comm/chat/content/chat-tooltip.js b/comm/chat/content/chat-tooltip.js
new file mode 100644
index 0000000000..1bb3fd36bf
--- /dev/null
+++ b/comm/chat/content/chat-tooltip.js
@@ -0,0 +1,604 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozElements */
+/* global MozXULElement */
+/* global getBrowser */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ let { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+ const LazyModules = {};
+
+ ChromeUtils.defineESModuleGetters(LazyModules, {
+ Status: "resource:///modules/imStatusUtils.sys.mjs",
+ });
+
+ /**
+ * The MozChatTooltip widget implements a custom tooltip for chat. This tooltip
+ * is used to display a rich tooltip when you mouse over contacts, channels
+ * etc. in the chat view.
+ *
+ * @augments {XULPopupElement}
+ */
+ class MozChatTooltip extends MozElements.MozElementMixin(XULPopupElement) {
+ static get inheritedAttributes() {
+ return { ".displayName": "value=displayname" };
+ }
+
+ constructor() {
+ super();
+ this._buddy = null;
+
+ this.observer = {
+ // @see {nsIObserver}
+ observe: (subject, topic, data) => {
+ if (
+ subject == this.buddy &&
+ (topic == "account-buddy-status-changed" ||
+ topic == "account-buddy-status-detail-changed" ||
+ topic == "account-buddy-display-name-changed" ||
+ topic == "account-buddy-icon-changed")
+ ) {
+ this.updateTooltipFromBuddy(this.buddy);
+ } else if (
+ topic == "user-info-received" &&
+ data == this.observedUserInfo
+ ) {
+ this.updateTooltipInfo(
+ subject.QueryInterface(Ci.nsISimpleEnumerator)
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ this.addEventListener("popupshowing", event => {
+ if (!this._onPopupShowing()) {
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("popuphiding", event => {
+ this.buddy = null;
+ if ("observedUserInfo" in this && this.observedUserInfo) {
+ Services.obs.removeObserver(this.observer, "user-info-received");
+ delete this.observedUserInfo;
+ }
+ });
+ }
+
+ _onPopupShowing() {
+ // No tooltip for elements that have already been removed.
+ if (!this.triggerNode.parentNode) {
+ return false;
+ }
+
+ let showHTMLTooltip = false;
+
+ // Reset tooltip.
+ let largeTooltip = this.querySelector(".largeTooltip");
+ largeTooltip.hidden = false;
+ this.removeAttribute("label");
+ let htmlTooltip = this.querySelector(".htmlTooltip");
+ htmlTooltip.hidden = true;
+
+ this.hasBestAvatar = false;
+
+ // We have a few cases that have special behavior. These are richlistitems
+ // and have tooltip="<myid>".
+ let item = this.triggerNode.closest(
+ `[tooltip="${this.id}"] richlistitem`
+ );
+
+ // No tooltip on search results
+ if (item?.hasAttribute("is-search-result")) {
+ return false;
+ }
+
+ // No tooltip on the group headers
+ if (item && item.matches(`:scope[is="chat-group-richlistitem"]`)) {
+ return false;
+ }
+
+ if (item && item.matches(`:scope[is="chat-imconv-richlistitem"]`)) {
+ return this.updateTooltipFromConversation(item.conv);
+ }
+
+ if (item && item.matches(`:scope[is="chat-contact-richlistitem"]`)) {
+ return this.updateTooltipFromBuddy(
+ item.contact.preferredBuddy.preferredAccountBuddy
+ );
+ }
+
+ if (item) {
+ let contactlistbox = document.getElementById("contactlistbox");
+ let conv = contactlistbox.selectedItem.conv;
+ return this.updateTooltipFromParticipant(
+ item.chatBuddy.name,
+ conv,
+ item.chatBuddy
+ );
+ }
+
+ // Tooltips are also used for the chat content, where we need to do
+ // some more general checks.
+ let elt = this.triggerNode;
+ let classList = elt.classList;
+ // ib-sender nicks are handled with _originalMsg if possible
+ if (classList.contains("ib-nick") || classList.contains("ib-person")) {
+ let conv = getBrowser()._conv;
+ if (conv.isChat) {
+ return this.updateTooltipFromParticipant(elt.textContent, conv);
+ }
+ if (!conv.isChat && elt.textContent == conv.name) {
+ return this.updateTooltipFromConversation(conv);
+ }
+ }
+
+ let sender = elt.textContent;
+ let overrideAvatar = undefined;
+
+ // Are we over a message?
+ for (let node = elt; node; node = node.parentNode) {
+ if (!node._originalMsg) {
+ continue;
+ }
+ // Nick, build tooltip with original who information from message
+ if (classList.contains("ib-sender")) {
+ sender = node._originalMsg.who;
+ overrideAvatar = node._originalMsg.iconURL;
+ break;
+ }
+ // It's a message, so add a date/time tooltip.
+ let date = new Date(node._originalMsg.time * 1000);
+ let text;
+ if (new Date().toDateString() == date.toDateString()) {
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ {
+ timeStyle: "medium",
+ }
+ );
+ text = dateTimeFormatter.format(date);
+ } else {
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ {
+ dateStyle: "short",
+ timeStyle: "medium",
+ }
+ );
+ text = dateTimeFormatter.format(date);
+ }
+ // Setting the attribute on this node means that if the element
+ // we are pointing at carries a title set by the prpl,
+ // that title won't be overridden.
+ node.setAttribute("title", text);
+ showHTMLTooltip = true;
+ break;
+ }
+
+ if (classList.contains("ib-sender")) {
+ let conv = getBrowser()._conv;
+ if (conv.isChat) {
+ return this.updateTooltipFromParticipant(
+ sender,
+ conv,
+ undefined,
+ overrideAvatar
+ );
+ }
+ if (!conv.isChat && elt.textContent == conv.name) {
+ return this.updateTooltipFromConversation(conv, overrideAvatar);
+ }
+ }
+
+ largeTooltip.hidden = true;
+ // Show the title in the tooltip
+ if (showHTMLTooltip) {
+ let content = this.triggerNode.getAttribute("title");
+ if (!content) {
+ let closestTitle = this.triggerNode.closest("[title]");
+ if (closestTitle) {
+ content = closestTitle.getAttribute("title");
+ }
+ }
+ if (!content) {
+ return false;
+ }
+ htmlTooltip.textContent = content;
+ htmlTooltip.hidden = false;
+ return true;
+ }
+ return false;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.textContent = "";
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <vbox class="largeTooltip">
+ <html:div class="displayUserAccount tooltipDisplayUserAccount">
+ <stack>
+ <html:img class="userIcon" alt=""/>
+ <html:img class="statusTypeIcon status" alt=""/>
+ </stack>
+ <html:div class="nameAndStatusGrid">
+ <description class="displayName" crop="end"></description>
+ <html:img class="protoIcon status" alt=""/>
+ <html:hr />
+ <description class="statusMessage" crop="end"></description>
+ </html:div>
+ </html:div>
+ <html:table class="tooltipTable">
+ </html:table>
+ </vbox>
+ <html:div class="htmlTooltip" hidden="hidden"></html:div>
+ `)
+ );
+ this.initializeAttributeInheritance();
+ }
+
+ get bundle() {
+ if (!this._bundle) {
+ this._bundle = Services.strings.createBundle(
+ "chrome://chat/locale/imtooltip.properties"
+ );
+ }
+ return this._bundle;
+ }
+
+ set buddy(val) {
+ if (val == this._buddy) {
+ return;
+ }
+
+ if (!val) {
+ this._buddy.buddy.removeObserver(this.observer);
+ } else {
+ val.buddy.addObserver(this.observer);
+ }
+
+ this._buddy = val;
+ }
+
+ get buddy() {
+ return this._buddy;
+ }
+
+ get table() {
+ if (!("_table" in this)) {
+ this._table = this.querySelector(".tooltipTable");
+ }
+ return this._table;
+ }
+
+ setMessage(aMessage, noTopic = false) {
+ let msg = this.querySelector(".statusMessage");
+ msg.value = aMessage;
+ msg.toggleAttribute("noTopic", noTopic);
+ }
+
+ reset() {
+ while (this.table.hasChildNodes()) {
+ this.table.lastChild.remove();
+ }
+ }
+
+ /**
+ * Add a row to the tooltip's table
+ *
+ * @param {string} aLabel - Label for the table row.
+ * @param {string} aValue - Value for the table row.
+ * @param {{label: boolean, value: boolean}} [l10nIds] - Treat the label
+ * and value as l10n IDs
+ */
+ addRow(aLabel, aValue, l10nIds = { label: false, value: false }) {
+ let description;
+ let row = [...this.table.querySelectorAll("tr")].find(row => {
+ let th = row.querySelector("th");
+ if (l10nIds?.label) {
+ return th.dataset.l10nId == aLabel;
+ }
+ return th.textContent == aLabel;
+ });
+ if (!row) {
+ // Create a new row for this label.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ let th = document.createElementNS("http://www.w3.org/1999/xhtml", "th");
+ if (l10nIds?.label) {
+ document.l10n.setAttributes(th, aLabel);
+ } else {
+ th.textContent = aLabel;
+ }
+ th.setAttribute("valign", "top");
+ row.appendChild(th);
+ description = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "td"
+ );
+ row.appendChild(description);
+ this.table.appendChild(row);
+ } else {
+ // Row with this label already exists - just update.
+ description = row.querySelector("td");
+ }
+ if (l10nIds?.value) {
+ document.l10n.setAttributes(description, aValue);
+ } else {
+ description.textContent = aValue;
+ }
+ }
+
+ addSeparator() {
+ if (this.table.hasChildNodes()) {
+ let lastElement = this.table.lastElementChild;
+ lastElement.querySelector("th").classList.add("chatTooltipSeparator");
+ lastElement.querySelector("td").classList.add("chatTooltipSeparator");
+ }
+ }
+
+ requestBuddyInfo(aAccount, aObservedName) {
+ // Libpurple prpls don't necessarily return data in response to
+ // requestBuddyInfo that is suitable for displaying inside a
+ // tooltip (e.g. too many objects, or <img> and <a> tags),
+ // so we only use it for JavaScript prpls.
+ // This is a terrible, terrible hack to work around the fact that
+ // ClassInfo.implementationLanguage has gone.
+ if (!aAccount.prplAccount || !aAccount.prplAccount.wrappedJSObject) {
+ return;
+ }
+ this.observedUserInfo = aObservedName;
+ Services.obs.addObserver(this.observer, "user-info-received");
+ aAccount.requestBuddyInfo(aObservedName);
+ }
+
+ /**
+ * Sets the shown user icon.
+ *
+ * @param {string|null} iconURI - The image uri to show, or "" to use the
+ * fallback, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIcon(iconUri, useFalback) {
+ ChatIcons.setUserIconSrc(
+ this.querySelector(".userIcon"),
+ iconUri,
+ useFalback
+ );
+ }
+
+ setProtocolIcon(protocol) {
+ this.querySelector(".protoIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(protocol)
+ );
+ }
+
+ setStatusIcon(statusName) {
+ this.querySelector(".statusTypeIcon").setAttribute(
+ "src",
+ ChatIcons.getStatusIconURI(statusName)
+ );
+ ChatIcons.setProtocolIconOpacity(
+ this.querySelector(".protoIcon"),
+ statusName
+ );
+ }
+
+ /**
+ * Regenerate the tooltip based on a buddy.
+ *
+ * @param {prplIAccountBuddy} aBuddy - The buddy to generate the conversation.
+ * @param {imIConversation} [aConv] - A conversation associated with this buddy.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead.
+ */
+ updateTooltipFromBuddy(aBuddy, aConv, overrideAvatar) {
+ this.buddy = aBuddy;
+
+ this.reset();
+ let name = aBuddy.userName;
+ let displayName = aBuddy.displayName;
+ this.setAttribute("displayname", displayName);
+ let account = aBuddy.account;
+ this.setProtocolIcon(account.protocol);
+ // If a conversation is provided, use the icon from it. Otherwise, use the
+ // buddy icon filename.
+ if (overrideAvatar) {
+ this.setUserIcon(overrideAvatar, true);
+ this.hasBestAvatar = true;
+ } else if (aConv && !aConv.isChat) {
+ this.setUserIcon(aConv.convIconFilename, true);
+ this.hasBestAvatar = true;
+ } else {
+ this.setUserIcon(aBuddy.buddyIconFilename, true);
+ }
+
+ let statusType = aBuddy.statusType;
+ this.setStatusIcon(LazyModules.Status.toAttribute(statusType));
+ this.setMessage(
+ LazyModules.Status.toLabel(statusType, aBuddy.statusText)
+ );
+
+ if (displayName != name) {
+ this.addRow(this.bundle.GetStringFromName("buddy.username"), name);
+ }
+
+ this.addRow(this.bundle.GetStringFromName("buddy.account"), account.name);
+
+ if (aBuddy.canVerifyIdentity) {
+ const identityStatus = aBuddy.identityVerified
+ ? "chat-buddy-identity-status-verified"
+ : "chat-buddy-identity-status-unverified";
+ this.addRow("chat-buddy-identity-status", identityStatus, {
+ label: true,
+ value: true,
+ });
+ }
+
+ // Add encryption status.
+ if (this.triggerNode.classList.contains("message-encrypted")) {
+ this.addRow(
+ this.bundle.GetStringFromName("encryption.tag"),
+ this.bundle.GetStringFromName("message.status")
+ );
+ }
+
+ this.requestBuddyInfo(account, aBuddy.normalizedName);
+
+ let tooltipInfo = aBuddy.getTooltipInfo();
+ if (tooltipInfo) {
+ this.updateTooltipInfo(tooltipInfo);
+ }
+ return true;
+ }
+
+ updateTooltipInfo(aTooltipInfo) {
+ for (let elt of aTooltipInfo) {
+ switch (elt.type) {
+ case Ci.prplITooltipInfo.pair:
+ case Ci.prplITooltipInfo.sectionHeader:
+ this.addRow(elt.label, elt.value);
+ break;
+ case Ci.prplITooltipInfo.sectionBreak:
+ this.addSeparator();
+ break;
+ case Ci.prplITooltipInfo.status:
+ let statusType = parseInt(elt.label);
+ this.setStatusIcon(LazyModules.Status.toAttribute(statusType));
+ this.setMessage(LazyModules.Status.toLabel(statusType, elt.value));
+ break;
+ case Ci.prplITooltipInfo.icon:
+ if (!this.hasBestAvatar) {
+ this.setUserIcon(elt.value);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Regenerate the tooltip based on a conversation.
+ *
+ * @param {imIConversation} aConv - The conversation to generate the tooltip from.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead if the conversation is a direct conversation.
+ */
+ updateTooltipFromConversation(aConv, overrideAvatar) {
+ if (!aConv.isChat && aConv.buddy) {
+ return this.updateTooltipFromBuddy(aConv.buddy, aConv, overrideAvatar);
+ }
+
+ this.reset();
+ this.setAttribute("displayname", aConv.name);
+ let account = aConv.account;
+ this.setProtocolIcon(account.protocol);
+ if (overrideAvatar && !aConv.isChat) {
+ this.setUserIcon(overrideAvatar, true);
+ this.hasBestAvatar = true;
+ } else {
+ // Set the icon, potentially showing a fallback icon if this is an IM.
+ this.setUserIcon(aConv.convIconFilename, !aConv.isChat);
+ }
+ if (aConv.isChat) {
+ if (!account.connected || aConv.left) {
+ this.setStatusIcon("chat-left");
+ } else {
+ this.setStatusIcon("chat");
+ }
+ let topic = aConv.topic;
+ let noTopic = !topic;
+ this.setMessage(topic || aConv.noTopicString, noTopic);
+ } else {
+ this.setStatusIcon("unknown");
+ this.setMessage(LazyModules.Status.toLabel("unknown"));
+ // Last ditch attempt to get some tooltip info. This call relies on
+ // the account's requestBuddyInfo implementation working correctly
+ // with aConv.normalizedName.
+ this.requestBuddyInfo(account, aConv.normalizedName);
+ }
+ this.addRow(this.bundle.GetStringFromName("buddy.account"), account.name);
+ return true;
+ }
+
+ /**
+ * Set the tooltip details based on a conversation participant.
+ *
+ * @param {string} aNick - Nick of the user this tooltip is for.
+ * @param {prplIConversation} aConv - Conversation this tooltip is shown
+ * in.
+ * @param {prplIConvChatBuddy} [aParticipant] - Participant to use instead
+ * of looking it up in the conversation by the passed nick.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead.
+ */
+ updateTooltipFromParticipant(aNick, aConv, aParticipant, overrideAvatar) {
+ if (!aConv.target) {
+ return false; // We're viewing a log.
+ }
+ if (!aParticipant) {
+ aParticipant = aConv.target.getParticipant(aNick);
+ }
+
+ let account = aConv.account;
+ let normalizedNick = aConv.target.getNormalizedChatBuddyName(aNick);
+ // To try to ensure that we aren't misidentifying a nick with a
+ // contact, we require at least that the normalizedChatBuddyName of
+ // the nick is normalized like a normalizedName for contacts.
+ if (normalizedNick == account.normalize(normalizedNick)) {
+ let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount(
+ normalizedNick,
+ account
+ );
+ if (accountBuddy) {
+ return this.updateTooltipFromBuddy(
+ accountBuddy,
+ aConv,
+ overrideAvatar
+ );
+ }
+ }
+
+ this.reset();
+ this.setAttribute("displayname", aNick);
+ this.setProtocolIcon(account.protocol);
+ this.setStatusIcon("unknown");
+ this.setMessage(LazyModules.Status.toLabel("unknown"));
+ this.setUserIcon(overrideAvatar ?? aParticipant?.buddyIconFilename, true);
+ if (overrideAvatar) {
+ this.hasBestAvatar = true;
+ }
+
+ if (aParticipant.canVerifyIdentity) {
+ const identityStatus = aParticipant.identityVerified
+ ? "chat-buddy-identity-status-verified"
+ : "chat-buddy-identity-status-unverified";
+ this.addRow("chat-buddy-identity-status", identityStatus, {
+ label: true,
+ value: true,
+ });
+ }
+
+ this.requestBuddyInfo(account, normalizedNick);
+ return true;
+ }
+ }
+ customElements.define("chat-tooltip", MozChatTooltip, { extends: "tooltip" });
+}
diff --git a/comm/chat/content/conv.html b/comm/chat/content/conv.html
new file mode 100644
index 0000000000..ebcb33cb93
--- /dev/null
+++ b/comm/chat/content/conv.html
@@ -0,0 +1,4 @@
+<!DOCTYPE html>
+<!-- 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/. -->
diff --git a/comm/chat/content/conversation-browser.js b/comm/chat/content/conversation-browser.js
new file mode 100644
index 0000000000..baa7f57447
--- /dev/null
+++ b/comm/chat/content/conversation-browser.js
@@ -0,0 +1,906 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const LazyModules = {};
+ ChromeUtils.defineESModuleGetters(LazyModules, {
+ cleanupImMarkup: "resource:///modules/imContentSink.sys.mjs",
+ getCurrentTheme: "resource:///modules/imThemes.sys.mjs",
+ getDocumentFragmentFromHTML: "resource:///modules/imThemes.sys.mjs",
+ getHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ initHTMLDocument: "resource:///modules/imThemes.sys.mjs",
+ insertHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ isNextMessage: "resource:///modules/imThemes.sys.mjs",
+ wasNextMessage: "resource:///modules/imThemes.sys.mjs",
+ replaceHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ removeMessage: "resource:///modules/imThemes.sys.mjs",
+ serializeSelection: "resource:///modules/imThemes.sys.mjs",
+ smileTextNode: "resource:///modules/imSmileys.sys.mjs",
+ });
+
+ (function () {
+ // <browser> is lazily set up through setElementCreationCallback,
+ // i.e. put into customElements the first time it's really seen.
+ // Create a fake to ensure browser exists in customElements, since otherwise
+ // we can't extend it. Then make sure this fake doesn't stay around.
+ if (!customElements.get("browser")) {
+ delete document.createXULElement("browser");
+ }
+ })();
+
+ /**
+ * The chat conversation browser, i.e. the main content on the chat tab.
+ *
+ * @augments {MozBrowser}
+ */
+ class MozConversationBrowser extends customElements.get("browser") {
+ constructor() {
+ super();
+
+ this._conv = null;
+
+ // Make sure to load URLs externally.
+ this.addEventListener("click", event => {
+ // Right click should open the context menu.
+ if (event.button == 2) {
+ return;
+ }
+
+ // The 'click' event is fired even when the link is
+ // activated with the keyboard.
+
+ // The event target may be a descendant of the actual link.
+ let url;
+ for (let elem = event.target; elem; elem = elem.parentNode) {
+ if (HTMLAnchorElement.isInstance(elem)) {
+ url = elem.href;
+ if (url) {
+ break;
+ }
+ }
+ }
+ if (!url) {
+ return;
+ }
+
+ let uri = Services.io.newURI(url);
+
+ // http and https are the only schemes that are both
+ // allowed by our IM filters and exposed.
+ if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ // loadURI can throw if the default browser is misconfigured.
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ });
+
+ this.addEventListener("keypress", event => {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_PAGE_UP: {
+ if (event.shiftKey) {
+ this.contentWindow.scrollByPages(-1);
+ } else if (event.altKey) {
+ this.scrollToPreviousSection();
+ }
+ break;
+ }
+ case KeyEvent.DOM_VK_PAGE_DOWN: {
+ if (event.shiftKey) {
+ this.contentWindow.scrollByPages(1);
+ } else if (event.altKey) {
+ this.scrollToNextSection();
+ }
+ break;
+ }
+ case KeyEvent.DOM_VK_HOME: {
+ this.scrollToPreviousSection();
+ event.preventDefault();
+ break;
+ }
+ case KeyEvent.DOM_VK_END: {
+ this.scrollToNextSection();
+ event.preventDefault();
+ break;
+ }
+ }
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ super.connectedCallback();
+
+ this._theme = null;
+
+ this.autoCopyEnabled = false;
+
+ this.magicCopyPref =
+ "messenger.conversations.selections.magicCopyEnabled";
+
+ this.magicCopyInitialized = false;
+
+ this._destroyed = false;
+
+ // Makes the chat browser scroll to the bottom automatically when we append
+ // a new message. This behavior gets disabled when the user scrolls up to
+ // look at the history, and we re-enable it when the user scrolls to
+ // (within 10px) of the bottom.
+ this._convScrollEnabled = true;
+
+ this._textModifiers = [LazyModules.smileTextNode];
+
+ // These variables are reset in onStateChange:
+ this._lastMessage = null;
+ this._lastMessageIsContext = true;
+ this._firstNonContextElt = null;
+ this._messageDisplayPending = false;
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ this._sessions = [];
+
+ this.progressBar = null;
+
+ this.addEventListener("scroll", this.browserScroll);
+ this.addEventListener("resize", this.browserResize);
+
+ // @implements {nsIObserver}
+ this.prefObserver = (subject, topic, data) => {
+ if (this.magicCopyEnabled) {
+ this.enableMagicCopy();
+ } else {
+ this.disableMagicCopy();
+ }
+ };
+
+ // @implements {nsIController}
+ this.copyController = {
+ supportsCommand(command) {
+ return command == "cmd_copy" || command == "cmd_cut";
+ },
+ isCommandEnabled: command => {
+ return (
+ command == "cmd_copy" &&
+ !this.contentWindow.getSelection().isCollapsed
+ );
+ },
+ doCommand: command => {
+ let selection = this.contentWindow.getSelection();
+ if (selection.isCollapsed) {
+ return;
+ }
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(LazyModules.serializeSelection(selection));
+ },
+ onEvent(command) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIController"]),
+ };
+
+ // @implements {nsISelectionListener}
+ this.chatSelectionListener = {
+ notifySelectionChanged(document, selection, reason) {
+ if (
+ !(
+ reason & Ci.nsISelectionListener.MOUSEUP_REASON ||
+ reason & Ci.nsISelectionListener.SELECTALL_REASON ||
+ reason & Ci.nsISelectionListener.KEYPRESS_REASON
+ )
+ ) {
+ // We are still dragging, don't bother with the selection.
+ return;
+ }
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyStringToClipboard(
+ LazyModules.serializeSelection(selection),
+ Ci.nsIClipboard.kSelectionClipboard
+ );
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsISelectionListener"]),
+ };
+ }
+
+ init(conversation) {
+ // Magic Copy may be initialized if the convbrowser is already
+ // displaying a conversation.
+ this.uninitMagicCopy();
+
+ this._conv = conversation;
+
+ // init is called when the message style preview is
+ // reloaded so we need to reset _theme.
+ this._theme = null;
+
+ // Prevent ongoing asynchronous message display from continuing.
+ this._messageDisplayPending = false;
+
+ this.addEventListener(
+ "load",
+ () => {
+ LazyModules.initHTMLDocument(
+ this._conv,
+ this.theme,
+ this.contentDocument
+ );
+
+ this._exposeMethodsToContent();
+ this.initMagicCopy();
+
+ // We need to reset these variables here to avoid a race
+ // condition if we are starting to display a new conversation
+ // but the display of the previous conversation wasn't finished.
+ // This can happen if the user quickly changes the selected
+ // conversation in the log viewer.
+ this._lastMessage = null;
+ this._lastMessageIsContext = true;
+ this._firstNonContextElt = null;
+ this._messageDisplayPending = false;
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ this._sessions = [];
+ if (this.progressBar) {
+ this.progressBar.hidden = true;
+ }
+
+ this.onChatNodeContentLoad = this.onContentElementLoad.bind(this);
+ this.contentChatNode.addEventListener(
+ "load",
+ this.onChatNodeContentLoad,
+ true
+ );
+
+ // Notify observers to get the conversation shown.
+ Services.obs.notifyObservers(this, "conversation-loaded");
+ },
+ {
+ once: true,
+ capture: true,
+ }
+ );
+ this.loadURI(Services.io.newURI("chrome://chat/content/conv.html"), {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+
+ get theme() {
+ return this._theme || (this._theme = LazyModules.getCurrentTheme());
+ }
+
+ get contentDocument() {
+ return this.webNavigation.document;
+ }
+
+ get contentChatNode() {
+ return this.contentDocument.getElementById("Chat");
+ }
+
+ get magicCopyEnabled() {
+ return Services.prefs.getBoolPref(this.magicCopyPref);
+ }
+
+ enableMagicCopy() {
+ this.contentWindow.controllers.insertControllerAt(0, this.copyController);
+ this.autoCopyEnabled =
+ Services.clipboard.isClipboardTypeSupported(
+ Services.clipboard.kSelectionClipboard
+ ) && Services.prefs.getBoolPref("clipboard.autocopy");
+ if (this.autoCopyEnabled) {
+ let selection = this.contentWindow.getSelection();
+ if (selection) {
+ selection.addSelectionListener(this.chatSelectionListener);
+ }
+ }
+ }
+
+ disableMagicCopy() {
+ this.contentWindow.controllers.removeController(this.copyController);
+ if (this.autoCopyEnabled) {
+ let selection = this.contentWindow.getSelection();
+ if (selection) {
+ selection.removeSelectionListener(this.chatSelectionListener);
+ }
+ }
+ }
+
+ initMagicCopy() {
+ if (this.magicCopyInitialized) {
+ return;
+ }
+ Services.prefs.addObserver(this.magicCopyPref, this.prefObserver);
+ this.magicCopyInitialized = true;
+ if (this.magicCopyEnabled) {
+ this.enableMagicCopy();
+ }
+ }
+
+ uninitMagicCopy() {
+ if (!this.magicCopyInitialized) {
+ return;
+ }
+ Services.prefs.removeObserver(this.magicCopyPref, this.prefObserver);
+ if (this.magicCopyEnabled) {
+ this.disableMagicCopy();
+ }
+ this.magicCopyInitialized = false;
+ }
+
+ destroy() {
+ super.destroy();
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+ this._messageDisplayPending = false;
+
+ this.uninitMagicCopy();
+
+ if (this.contentChatNode) {
+ // Remove the listener only if the conversation was initialized.
+ this.contentChatNode.removeEventListener(
+ "load",
+ this.onChatNodeContentLoad,
+ true
+ );
+ }
+ }
+
+ _updateConvScrollEnabled() {
+ // Enable auto-scroll if the scrollbar is at the bottom.
+ let body = this.contentDocument.querySelector("body");
+ this._convScrollEnabled =
+ body.scrollHeight <= body.scrollTop + body.clientHeight + 10;
+ return this._convScrollEnabled;
+ }
+
+ convScrollEnabled() {
+ return this._convScrollEnabled || this._updateConvScrollEnabled();
+ }
+
+ _scrollToElement(aElt) {
+ aElt.scrollIntoView(true);
+ this._scrollingIntoView = true;
+ }
+
+ _exposeMethodsToContent() {
+ // Expose scrollToElement and convScrollEnabled to the message styles.
+ this.contentWindow.scrollToElement = this._scrollToElement.bind(this);
+ this.contentWindow.convScrollEnabled = this.convScrollEnabled.bind(this);
+ }
+
+ addTextModifier(aModifier) {
+ if (!this._textModifiers.includes(aModifier)) {
+ this._textModifiers.push(aModifier);
+ }
+ }
+
+ set isActive(value) {
+ if (!value && !this.browsingContext) {
+ return;
+ }
+ this.browsingContext.isActive = value;
+ if (value && this._pendingMessages.length) {
+ this.startDisplayingPendingMessages(false);
+ }
+ }
+
+ appendMessage(aMsg, aContext, aFirstUnread) {
+ this._pendingMessages.push({
+ msg: aMsg,
+ context: aContext,
+ firstUnread: aFirstUnread,
+ });
+ if (this.browsingContext.isActive) {
+ this.startDisplayingPendingMessages(true);
+ }
+ }
+
+ /**
+ * Replace an existing message in the conversation based on the remote ID.
+ *
+ * @param {imIMessage} msg - Message to use as replacement.
+ */
+ replaceMessage(msg) {
+ if (!msg.remoteId) {
+ // No remote id, nothing existing to replace.
+ return;
+ }
+ if (this._messageDisplayPending || this._pendingMessages.length) {
+ let pendingIndex = this._pendingMessages.findIndex(
+ ({ msg: pendingMsg }) => pendingMsg.remoteId === msg.remoteId
+ );
+ if (
+ pendingIndex > -1 &&
+ pendingIndex >= this._nextPendingMessageIndex
+ ) {
+ this._pendingMessages[pendingIndex].msg = msg;
+ }
+ }
+ if (this.browsingContext.isActive) {
+ msg.message = this.prepareMessageContent(msg);
+ const isNext = LazyModules.wasNextMessage(msg, this.contentDocument);
+ const htmlMessage = LazyModules.getHTMLForMessage(
+ msg,
+ this.theme,
+ isNext,
+ false
+ );
+ let ruler = this.contentDocument.getElementById("unread-ruler");
+ if (ruler?._originalMsg?.remoteId === msg.remoteId) {
+ ruler._originalMsg = msg;
+ ruler.nextMsgHtml = htmlMessage;
+ }
+ LazyModules.replaceHTMLForMessage(
+ msg,
+ htmlMessage,
+ this.contentDocument,
+ isNext
+ );
+ }
+ if (this._lastMessage?.remoteId === msg.remoteId) {
+ this._lastMessage = msg;
+ }
+ }
+
+ /**
+ * Remove an existing message in the conversation based on the remote ID.
+ *
+ * @param {string} remoteId - Remote ID of the message to remove.
+ */
+ removeMessage(remoteId) {
+ if (this.browsingContext.isActive) {
+ LazyModules.removeMessage(remoteId, this.contentDocument);
+ }
+ if (this._lastMessage?.remoteId === remoteId) {
+ // Reset last message info if we removed the last message.
+ this._lastMessage = null;
+ }
+ }
+
+ startDisplayingPendingMessages(delayed) {
+ if (this._messageDisplayPending) {
+ return;
+ }
+ this._messageDisplayPending = true;
+ this.contentWindow.messageInsertPending = true;
+ if (delayed) {
+ requestIdleCallback(this.displayPendingMessages.bind(this));
+ } else {
+ // 200ms here is a generous amount of time. The conversation switch
+ // should take no more than 100ms to feel 'immediate', but the perceived
+ // performance if we flicker is likely even worse than having a barely
+ // perceptible delay.
+ let deadline = Cu.now() + 200;
+ this.displayPendingMessages({
+ timeRemaining() {
+ return deadline - Cu.now();
+ },
+ });
+ }
+ }
+
+ // getNextPendingMessage and getPendingMessagesCount are the
+ // only 2 methods accessing the this._pendingMessages array
+ // directly during the chunked display of messages. It is
+ // possible to override these 2 methods to replace the array
+ // with something else. The log viewer for example uses an
+ // enumerator that creates message objects lazily to avoid
+ // jank when displaying lots of messages.
+ getNextPendingMessage() {
+ let length = this._pendingMessages.length;
+ if (this._nextPendingMessageIndex == length) {
+ return null;
+ }
+
+ let result = this._pendingMessages[this._nextPendingMessageIndex++];
+
+ if (this._nextPendingMessageIndex == length) {
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ }
+
+ return result;
+ }
+
+ getPendingMessagesCount() {
+ return this._pendingMessages.length;
+ }
+
+ displayPendingMessages(timing) {
+ if (!this._messageDisplayPending) {
+ return;
+ }
+
+ let max = this.getPendingMessagesCount();
+ do {
+ // One message takes less than 2ms on average.
+ let msg = this.getNextPendingMessage();
+ if (!msg) {
+ break;
+ }
+ this.displayMessage(
+ msg.msg,
+ msg.context,
+ ++this._pendingMessagesDisplayed < max,
+ msg.firstUnread
+ );
+ } while (timing.timeRemaining() > 2);
+
+ let event = document.createEvent("UIEvents");
+ event.initUIEvent("MessagesDisplayed", false, false, window, 0);
+ if (this._pendingMessagesDisplayed < max) {
+ if (this.progressBar) {
+ // Show progress bar if after the third call (ca. 120ms)
+ // less than half the messages have been displayed.
+ if (
+ ++this._displayPendingMessagesCalls > 2 &&
+ max > 2 * this._pendingMessagesDisplayed
+ ) {
+ this.progressBar.hidden = false;
+ }
+ this.progressBar.max = max;
+ this.progressBar.value = this._pendingMessagesDisplayed;
+ }
+ requestIdleCallback(this.displayPendingMessages.bind(this));
+ this.dispatchEvent(event);
+ return;
+ }
+ this.contentWindow.messageInsertPending = false;
+ this._messageDisplayPending = false;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ if (this.progressBar) {
+ this.progressBar.hidden = true;
+ }
+ this.dispatchEvent(event);
+ }
+
+ displayMessage(aMsg, aContext, aNoAutoScroll, aFirstUnread) {
+ let doc = this.contentDocument;
+
+ if (aMsg.noLog && aMsg.notification && aMsg.who == "sessionstart") {
+ // New session log.
+ if (this._lastMessage) {
+ let ruler = doc.createElement("hr");
+ ruler.className = "sessionstart-ruler";
+ this.contentChatNode.appendChild(ruler);
+ this._sessions.push(ruler);
+ // Close any open bubble.
+ this._lastMessage = null;
+ }
+ // Suppress this message unless it was an error message.
+ if (!aMsg.error) {
+ return;
+ }
+ }
+
+ if (aFirstUnread) {
+ this.setUnreadRuler();
+ }
+
+ aMsg.message = this.prepareMessageContent(aMsg);
+
+ let next =
+ (aContext == this._lastMessageIsContext || aMsg.system) &&
+ LazyModules.isNextMessage(this.theme, aMsg, this._lastMessage);
+ let newElt;
+ if (next && aFirstUnread) {
+ // If there wasn't an unread ruler, this would be a Next message.
+ // Therefore, save that version for later.
+ let html = LazyModules.getHTMLForMessage(
+ aMsg,
+ this.theme,
+ next,
+ aContext
+ );
+ let ruler = doc.getElementById("unread-ruler");
+ ruler.nextMsgHtml = html;
+ ruler._originalMsg = aMsg;
+
+ // Remember where the Next message(s) would have gone.
+ let insert = doc.getElementById("insert");
+ if (!insert) {
+ insert = doc.createElement("div");
+ ruler.parentNode.insertBefore(insert, ruler);
+ }
+ insert.id = "insert-before";
+
+ next = false;
+ html = LazyModules.getHTMLForMessage(aMsg, this.theme, next, aContext);
+ newElt = LazyModules.insertHTMLForMessage(aMsg, html, doc, next);
+ let marker = doc.createElement("div");
+ marker.id = "end-of-split-block";
+ newElt.parentNode.appendChild(marker);
+
+ // Bracket the place where additional Next messages will be added,
+ // if that's not after the end-of-split-block element.
+ insert = doc.getElementById("insert");
+ if (insert) {
+ marker = doc.createElement("div");
+ marker.id = "next-messages-start";
+ insert.parentNode.insertBefore(marker, insert);
+ marker = doc.createElement("div");
+ marker.id = "next-messages-end";
+ insert.parentNode.insertBefore(marker, insert.nextElementSibling);
+ }
+ } else {
+ let html = LazyModules.getHTMLForMessage(
+ aMsg,
+ this.theme,
+ next,
+ aContext
+ );
+ newElt = LazyModules.insertHTMLForMessage(aMsg, html, doc, next);
+ }
+
+ if (!aNoAutoScroll) {
+ newElt.getBoundingClientRect(); // avoid ireflow bugs
+ if (this.convScrollEnabled()) {
+ this._scrollToElement(newElt);
+ }
+ }
+ this._lastElement = newElt;
+ this._lastMessage = aMsg;
+ if (!aContext && !this._firstNonContextElt && !aMsg.system) {
+ this._firstNonContextElt = newElt;
+ }
+ this._lastMessageIsContext = aContext;
+ }
+
+ /**
+ * Prepare the message text for display. Transforms plain text formatting
+ * and removes any unwanted formatting.
+ *
+ * @param {imIMessage} message - Raw message.
+ * @returns {string} Message content ready for insertion.
+ */
+ prepareMessageContent(message) {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(
+ Ci.mozITXTToHTMLConv
+ );
+
+ // kStructPhrase creates tags for plaintext-markup like *bold*,
+ // /italics/, etc. We always use this; the content filter will
+ // filter it out if the user does not want styling.
+ let csFlags = cs.kStructPhrase;
+ // Automatically find and link freetext URLs
+ if (!message.noLinkification) {
+ csFlags |= cs.kURLs;
+ }
+
+ // Right trim before displaying. This removes any OTR related
+ // whitespace when the extension isn't enabled.
+ let msg = message.displayMessage?.trimRight() ?? "";
+ msg = cs
+ .scanHTML(msg.replace(/&/g, "FROM-DTD-amp"), csFlags)
+ .replace(/FROM-DTD-amp/g, "&");
+
+ return LazyModules.cleanupImMarkup(
+ msg.replace(/\r?\n/g, "<br/>"),
+ null,
+ this._textModifiers
+ );
+ }
+
+ setUnreadRuler() {
+ // Remove any existing ruler (occurs when the window has lost focus).
+ this.removeUnreadRuler();
+
+ let ruler = this.contentDocument.createElement("hr");
+ ruler.id = "unread-ruler";
+ this.contentChatNode.appendChild(ruler);
+ }
+
+ removeUnreadRuler() {
+ if (this._lastMessage) {
+ this._lastMessage.whenRead();
+ }
+
+ let doc = this.contentDocument;
+ let ruler = doc.getElementById("unread-ruler");
+ if (!ruler) {
+ return;
+ }
+
+ // If a message block was split by the ruler, rejoin it.
+ let moveTo = doc.getElementById("insert-before");
+ if (moveTo) {
+ // Protect an existing insert node.
+ let actualInsert = doc.getElementById("insert");
+ if (actualInsert) {
+ actualInsert.id = "actual-insert";
+ }
+
+ // Add first message following the ruler as a Next type message.
+ // Replicates the relevant parts of insertHTMLForMessage().
+ let range = doc.createRange();
+ let moveToParent = moveTo.parentNode;
+ range.selectNode(moveToParent);
+ // eslint-disable-next-line no-unsanitized/method
+ let documentFragment = LazyModules.getDocumentFragmentFromHTML(
+ doc,
+ ruler.nextMsgHtml
+ );
+ for (
+ let root = documentFragment.firstElementChild;
+ root;
+ root = root.nextElementSibling
+ ) {
+ root._originalMsg = ruler._originalMsg;
+ root.dataset.remoteId = ruler._originalMsg.remoteId;
+ }
+ moveToParent.insertBefore(documentFragment, moveTo);
+
+ // If this added an insert node, insert the next messages there.
+ let insert = doc.getElementById("insert");
+ if (insert) {
+ moveTo.remove();
+ moveTo = insert;
+ moveToParent = moveTo.parentNode;
+ }
+
+ // Move remaining messages from the message block following the ruler.
+ let nextMessagesStart = doc.getElementById("next-messages-start");
+ if (nextMessagesStart) {
+ range = doc.createRange();
+ range.setStartAfter(nextMessagesStart);
+ range.setEndBefore(doc.getElementById("next-messages-end"));
+ moveToParent.insertBefore(range.extractContents(), moveTo);
+ }
+ moveTo.remove();
+
+ // Restore existing insert node.
+ if (actualInsert) {
+ actualInsert.id = "insert";
+ }
+
+ // Delete surplus message block.
+ range = doc.createRange();
+ range.setStartAfter(ruler);
+ range.setEndAfter(doc.getElementById("end-of-split-block"));
+ range.deleteContents();
+ }
+ ruler.remove();
+ }
+
+ _getSections() {
+ // If a section is displayed below this point, we assume not enough of
+ // it is visible, so we must scroll to it.
+ // The 3/4 constant is arbitrary, but it has to be greater than 1/2.
+ this._maximalSectionOffset = Math.round((this.clientHeight * 3) / 4);
+
+ // Get list of current section elements.
+ let sectionElements = [];
+ if (this._firstNonContextElt) {
+ sectionElements.push(this._firstNonContextElt);
+ }
+ let ruler = this.contentDocument.getElementById("unread-ruler");
+ if (ruler) {
+ sectionElements.push(ruler);
+ }
+ sectionElements = sectionElements.concat(this._sessions);
+
+ // Return ordered array of sections with entries
+ // [Y, scrollY such that Y is centered]
+ let sections = [];
+ let maxY = this.contentWindow.scrollMaxY;
+ for (let i = 0; i < sectionElements.length; ++i) {
+ let y = sectionElements[i].offsetTop;
+ // The section is unnecessary if close to top/bottom of conversation.
+ if (y < this._maximalSectionOffset || maxY < y) {
+ continue;
+ }
+ sections.push([y, y - Math.round(this.clientHeight / 2)]);
+ }
+ sections.sort((a, b) => a[0] - b[0]);
+ return sections;
+ }
+
+ scrollToPreviousSection() {
+ let sections = this._getSections();
+ let y = this.contentWindow.scrollY;
+ let newY = 0;
+ for (let i = sections.length - 1; i >= 0; --i) {
+ let section = sections[i];
+ if (y > section[0]) {
+ newY = section[1];
+ break;
+ }
+ }
+ this.contentWindow.scrollTo(0, newY);
+ }
+
+ scrollToNextSection() {
+ let sections = this._getSections();
+ let y = this.contentWindow.scrollY;
+ let newY = this.contentWindow.scrollMaxY;
+ for (let i = 0; i < sections.length; ++i) {
+ let section = sections[i];
+ if (y + this._maximalSectionOffset < section[0]) {
+ newY = section[1];
+ break;
+ }
+ }
+ this.contentWindow.scrollTo(0, newY);
+ }
+
+ browserScroll(event) {
+ if (this._scrollingIntoView) {
+ // We have explicitly requested a scrollIntoView, ignore the event.
+ this._scrollingIntoView = false;
+ this._lastScrollHeight = this.scrollHeight;
+ this._lastScrollWidth = this.scrollWidth;
+ return;
+ }
+
+ if (
+ !("_lastScrollHeight" in this) ||
+ this._lastScrollHeight != this.scrollHeight ||
+ this._lastScrollWidth != this.scrollWidth
+ ) {
+ // Ensure scroll events triggered by a change of the
+ // content area size (eg. resizing the window or moving the
+ // textbox splitter) don't affect the auto-scroll behavior.
+ this._lastScrollHeight = this.scrollHeight;
+ this._lastScrollWidth = this.scrollWidth;
+ }
+
+ // If images higher than one line of text load they will trigger a
+ // scroll event, which shouldn't disable auto-scroll while messages
+ // are being appended without being scrolled.
+ if (this._messageDisplayPending) {
+ return;
+ }
+
+ // Enable or disable auto-scroll based on the scrollbar position.
+ this._updateConvScrollEnabled();
+ }
+
+ browserResize(event) {
+ if (this._convScrollEnabled && this._lastElement) {
+ // The content area was resized and auto-scroll is enabled,
+ // make sure the last inserted element is still visible
+ this._scrollToElement(this._lastElement);
+ }
+ }
+
+ onContentElementLoad(event) {
+ if (
+ event.target.localName == "img" &&
+ this._convScrollEnabled &&
+ !this._messageDisplayPending &&
+ this._lastElement
+ ) {
+ // An image loaded while auto-scroll is enabled and no further
+ // messages are currently being appended. So we need to scroll
+ // the last element fully back into view.
+ this._scrollToElement(this._lastElement);
+ }
+ }
+ }
+ customElements.define("conversation-browser", MozConversationBrowser, {
+ extends: "browser",
+ });
+}
diff --git a/comm/chat/content/imAccountOptionsHelper.js b/comm/chat/content/imAccountOptionsHelper.js
new file mode 100644
index 0000000000..cbe8c486d8
--- /dev/null
+++ b/comm/chat/content/imAccountOptionsHelper.js
@@ -0,0 +1,121 @@
+/* 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 accountOptionsHelper = {
+ /**
+ * Create a new label and a corresponding input.
+ *
+ * @param {string} aType - The input type ("number" or "text").
+ * @param {string} aValue - The initial value for the input.
+ * @param {string} aLabel - The text for the label.
+ * @param {string} aName - The id for the input.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createTextbox(aType, aValue, aLabel, aName, grid) {
+ let label = document.createXULElement("label");
+ label.textContent = aLabel;
+ label.setAttribute("control", aName);
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ let input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ if (aType == "number") {
+ input.classList.add("input-number-inline");
+ } else {
+ input.classList.add("input-inline");
+ }
+ if (aType) {
+ input.setAttribute("type", aType);
+ }
+ input.setAttribute("value", aValue);
+ input.setAttribute("id", aName);
+
+ grid.appendChild(input);
+ },
+
+ /**
+ * Create a new label and a corresponding menulist.
+ *
+ * @param {object[]} aList - The list of items to fill the menulist with.
+ * @param {string} aList[].label - The label for the menuitem.
+ * @param {string} aList[].value - The value for the menuitem.
+ * @param {string} aLabel - The text for the label.
+ * @param {string} aName - The id for the menulist.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createMenulist(aList, aLabel, aName, grid) {
+ let label = document.createXULElement("label");
+ label.setAttribute("value", aLabel);
+ label.setAttribute("control", aName);
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ let menulist = document.createXULElement("menulist");
+ menulist.setAttribute("id", aName);
+ menulist.setAttribute("flex", "1");
+ menulist.classList.add("input-inline");
+ let popup = menulist.appendChild(document.createXULElement("menupopup"));
+ for (let elt of aList) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", elt.name);
+ item.setAttribute("value", elt.value);
+ popup.appendChild(item);
+ }
+ grid.appendChild(menulist);
+ },
+
+ // Adds options with specific prefix for ids to UI according to their types
+ // with optional attributes for each type and returns true if at least one
+ // option has been added to UI, otherwise returns false.
+ addOptions(aIdPrefix, aOptions, aAttributes) {
+ let grid = document.getElementById("protoSpecific");
+ while (grid.hasChildNodes()) {
+ grid.lastChild.remove();
+ }
+
+ let haveOptions = false;
+ for (let opt of aOptions) {
+ let text = opt.label;
+ let name = aIdPrefix + opt.name;
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ let chk = document.createXULElement("checkbox");
+ chk.setAttribute("label", text);
+ chk.setAttribute("id", name);
+ if (opt.getBool()) {
+ chk.setAttribute("checked", "true");
+ }
+ // Span two columns.
+ chk.classList.add("grid-item-span-row");
+ grid.appendChild(chk);
+ break;
+ case Ci.prplIPref.typeInt:
+ this.createTextbox("number", opt.getInt(), text, name, grid);
+ break;
+ case Ci.prplIPref.typeString:
+ this.createTextbox("text", opt.getString(), text, name, grid);
+ break;
+ case Ci.prplIPref.typeList:
+ this.createMenulist(opt.getList(), text, name, grid);
+ document.getElementById(name).value = opt.getListDefault();
+ break;
+ default:
+ throw new Error("unknown preference type " + opt.type);
+ }
+ if (aAttributes && aAttributes[opt.type]) {
+ let element = document.getElementById(name);
+ for (let attr of aAttributes[opt.type]) {
+ element.setAttribute(attr.name, attr.value);
+ }
+ }
+ haveOptions = true;
+ }
+ return haveOptions;
+ },
+};
diff --git a/comm/chat/content/jar.mn b/comm/chat/content/jar.mn
new file mode 100644
index 0000000000..6016af2220
--- /dev/null
+++ b/comm/chat/content/jar.mn
@@ -0,0 +1,18 @@
+# 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/.
+
+chat.jar:
+% content chat %content/chat/
+ content/chat/imAccountOptionsHelper.js
+ content/chat/chat-account-richlistitem.js
+ content/chat/chat-tooltip.js
+ content/chat/conversation-browser.js
+ content/chat/conv.html
+ content/chat/otr-add-fingerprint.js
+ content/chat/otr-add-fingerprint.xhtml
+ content/chat/otr-auth.js
+ content/chat/otr-auth.xhtml
+ content/chat/otr-finger.js
+ content/chat/otr-finger.xhtml
+ content/chat/otrWorker.js
diff --git a/comm/chat/content/moz.build b/comm/chat/content/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/chat/content/moz.build
@@ -0,0 +1,6 @@
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/chat/content/otr-add-fingerprint.js b/comm/chat/content/otr-add-fingerprint.js
new file mode 100644
index 0000000000..fb6d6c037d
--- /dev/null
+++ b/comm/chat/content/otr-add-fingerprint.js
@@ -0,0 +1,84 @@
+/* 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 { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+var { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+window.addEventListener("DOMContentLoaded", () => {
+ otrAddFinger.onload();
+});
+
+var otrAddFinger = {
+ onload() {
+ let args = window.arguments[0].wrappedJSObject;
+
+ this.fingerWarning = document.getElementById("fingerWarning");
+ this.fingerError = document.getElementById("fingerError");
+ this.keyCount = document.getElementById("keyCount");
+
+ document.l10n.setAttributes(
+ document.getElementById("otrDescription"),
+ "otr-add-finger-description",
+ {
+ name: args.screenname,
+ }
+ );
+
+ document.addEventListener("dialogaccept", event => {
+ let hex = document.getElementById("fingerprint").value;
+ let context = OTR.getContextFromRecipient(
+ args.account,
+ args.protocol,
+ args.screenname
+ );
+ let finger = OTR.addFingerprint(context, hex);
+ if (finger.isNull()) {
+ event.preventDefault();
+ return;
+ }
+ try {
+ // Ignore the return, this is just a test.
+ OTR.getUIConvFromContext(context);
+ } catch (error) {
+ // We expect that a conversation may not have been started.
+ context = null;
+ }
+ OTR.setTrust(finger, true, context);
+ });
+ },
+
+ addBlankSpace(value) {
+ return value
+ .replace(/\s/g, "")
+ .trim()
+ .replace(/(.{8})/g, "$1 ")
+ .trim();
+ },
+
+ oninput(input) {
+ let hex = input.value.replace(/\s/g, "");
+
+ if (/[^0-9A-F]/gi.test(hex)) {
+ this.keyCount.hidden = true;
+ this.fingerWarning.hidden = false;
+ this.fingerError.hidden = false;
+ } else {
+ this.keyCount.hidden = false;
+ this.fingerWarning.hidden = true;
+ this.fingerError.hidden = true;
+ }
+
+ document.querySelector("dialog").getButton("accept").disabled =
+ input.value && !input.validity.valid;
+
+ this.keyCount.value = `${hex.length}/40`;
+ input.value = this.addBlankSpace(input.value);
+ },
+
+ onblur(input) {
+ input.value = this.addBlankSpace(input.value);
+ },
+};
diff --git a/comm/chat/content/otr-add-fingerprint.xhtml b/comm/chat/content/otr-add-fingerprint.xhtml
new file mode 100644
index 0000000000..cb5c17cea5
--- /dev/null
+++ b/comm/chat/content/otr-add-fingerprint.xhtml
@@ -0,0 +1,91 @@
+<?xml version="1.0" ?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+<?xml-stylesheet href="chrome://chat/skin/otrFingerprintDialog.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="OTR:AddFinger"
+ width="540"
+ height="200"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="otr-add-finger-title"></title>
+ <link rel="localization" href="messenger/otr/add-finger.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/otr-add-fingerprint.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" buttondisabledaccept="true">
+ <hbox align="center" pack="center" class="header-container">
+ <vbox>
+ <html:img
+ class="header-icon"
+ src="chrome://messenger/skin/icons/login.svg"
+ alt=""
+ />
+ </vbox>
+ <vbox flex="1">
+ <description id="otrDescription" />
+ </vbox>
+ </hbox>
+ <hbox class="form-control" align="center">
+ <label
+ data-l10n-id="otr-add-finger-fingerprint"
+ class="label-box"
+ control="fingerprint"
+ />
+ <hbox class="input-control" align="center" flex="1">
+ <html:input
+ id="fingerprint"
+ type="text"
+ data-l10n-id="otr-add-finger-input"
+ class="input-field"
+ oninput="otrAddFinger.oninput(this);"
+ onblur="otrAddFinger.onblur(this);"
+ pattern="[ 0-9a-fA-F]*"
+ minlength="44"
+ maxlength="44"
+ />
+ </hbox>
+ <html:img
+ id="fingerWarning"
+ class="warning-icon"
+ src="chrome://global/skin/icons/warning.svg"
+ alt=""
+ width="16"
+ height="16"
+ hidden="hidden"
+ />
+ </hbox>
+ <vbox class="input-helper-container" flex="1" align="end">
+ <label
+ id="fingerError"
+ data-l10n-id="otr-add-finger-tooltip-error"
+ class="msg-error"
+ hidden="true"
+ />
+ <label id="keyCount" class="input-helper" value="0/40" />
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otr-auth.js b/comm/chat/content/otr-auth.js
new file mode 100644
index 0000000000..24199a6acc
--- /dev/null
+++ b/comm/chat/content/otr-auth.js
@@ -0,0 +1,198 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+const { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+window.addEventListener("DOMContentLoaded", event => {
+ otrAuth.onload();
+});
+
+var [mode, uiConv, contactInfo] = window.arguments;
+
+function showSection(selected, hideMenu) {
+ document.getElementById("how").hidden = !!hideMenu;
+ ["questionAndAnswer", "sharedSecret", "manualVerification", "ask"].forEach(
+ function (key) {
+ document.getElementById(key).hidden = key !== selected;
+ }
+ );
+ window.sizeToContent();
+}
+
+function startSMP(context, answer, question) {
+ OTR.sendSecret(context, answer, question);
+ OTR.authUpdate(context, 10);
+}
+
+function manualVerification(fingerprint, context) {
+ let opts = document.getElementById("verifiedOption");
+ let trust = opts.selectedItem.value === "yes";
+ OTR.setTrust(fingerprint, trust, context);
+}
+
+async function populateFingers(context, theirs, trust) {
+ let yours = OTR.privateKeyFingerprint(context.account, context.protocol);
+ if (!yours) {
+ throw new Error("Fingerprint should already be generated.");
+ }
+
+ let [yourFPLabel, theirFPLabel] = await document.l10n.formatValues([
+ { id: "auth-your-fp-value", args: { own_name: context.account } },
+ { id: "auth-their-fp-value", args: { their_name: context.username } },
+ ]);
+
+ document.getElementById("yourFPLabel").value = yourFPLabel;
+ document.getElementById("theirFPLabel").value = theirFPLabel;
+
+ document.getElementById("yourFPValue").value = yours;
+ document.getElementById("theirFPValue").value = theirs;
+
+ let opts = document.getElementById("verifiedOption");
+ let verified = trust ? "yes" : "no";
+ for (let item of opts.menupopup.children) {
+ if (verified === item.value) {
+ opts.selectedItem = item;
+ break;
+ }
+ }
+}
+
+var otrAuth = {
+ async onload() {
+ // This window implements the interactive authentication of a buddy's
+ // key. At open time, we're given several parameters, and the "mode"
+ // parameter tells us from where we've been called.
+ // mode == "pref" means that we have been opened from the preferences,
+ // and it means we cannot rely on the other user being online, and
+ // we there might be no uiConv active currently, so we fall back.
+
+ let nameSource =
+ mode === "pref" ? contactInfo.screenname : uiConv.normalizedName;
+ let title = await document.l10n.formatValue("auth-title", {
+ name: nameSource,
+ });
+ document.title = title;
+
+ document.addEventListener("dialogaccept", () => {
+ return this.accept();
+ });
+
+ document.addEventListener("dialogcancel", () => {
+ return this.cancel();
+ });
+
+ let context, theirs;
+ switch (mode) {
+ case "start":
+ context = OTR.getContext(uiConv.target);
+ theirs = OTR.hashToHuman(context.fingerprint);
+ populateFingers(context, theirs, context.trust);
+ showSection("questionAndAnswer");
+ break;
+ case "pref":
+ context = OTR.getContextFromRecipient(
+ contactInfo.account,
+ contactInfo.protocol,
+ contactInfo.screenname
+ );
+ theirs = contactInfo.fingerprint;
+ populateFingers(context, theirs, contactInfo.trust);
+ showSection("manualVerification", true);
+ this.oninput({ value: true });
+ break;
+ case "ask":
+ let receivedQuestionLabel = document.getElementById(
+ "receivedQuestionLabel"
+ );
+ let receivedQuestionDisplay =
+ document.getElementById("receivedQuestion");
+ let responseLabel = document.getElementById("responseLabel");
+ if (contactInfo.question) {
+ receivedQuestionLabel.hidden = false;
+ receivedQuestionDisplay.hidden = false;
+ receivedQuestionDisplay.value = contactInfo.question;
+ responseLabel.value = await document.l10n.formatValue("auth-answer");
+ } else {
+ receivedQuestionLabel.hidden = true;
+ receivedQuestionDisplay.hidden = true;
+ responseLabel.value = await document.l10n.formatValue("auth-secret");
+ }
+ showSection("ask", true);
+ break;
+ }
+ },
+
+ accept() {
+ // uiConv may not be present in pref mode
+ let context = uiConv ? OTR.getContext(uiConv.target) : null;
+ if (mode === "pref") {
+ manualVerification(contactInfo.fpointer, context);
+ } else if (mode === "start") {
+ let how = document.getElementById("howOption");
+ switch (how.selectedItem.value) {
+ case "questionAndAnswer":
+ let question = document.getElementById("question").value;
+ let answer = document.getElementById("answer").value;
+ startSMP(context, answer, question);
+ break;
+ case "sharedSecret":
+ let secret = document.getElementById("secret").value;
+ startSMP(context, secret);
+ break;
+ case "manualVerification":
+ manualVerification(context.fingerprint, context);
+ break;
+ default:
+ throw new Error("Unreachable!");
+ }
+ } else if (mode === "ask") {
+ let response = document.getElementById("response").value;
+ OTR.sendResponse(context, response);
+ OTR.authUpdate(context, contactInfo.progress);
+ } else {
+ throw new Error("Unreachable!");
+ }
+ return true;
+ },
+
+ cancel() {
+ if (mode === "ask") {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ // Close the ask-auth notification if it was previously triggered.
+ OTR.notifyObservers(
+ {
+ context,
+ },
+ "otr:cancel-ask-auth"
+ );
+ }
+ },
+
+ oninput(e) {
+ document.querySelector("dialog").getButton("accept").disabled = !e.value;
+ },
+
+ how() {
+ let how = document.getElementById("howOption").selectedItem.value;
+ switch (how) {
+ case "questionAndAnswer":
+ this.oninput(document.getElementById("answer"));
+ break;
+ case "sharedSecret":
+ this.oninput(document.getElementById("secret"));
+ break;
+ case "manualVerification":
+ this.oninput({ value: true });
+ break;
+ }
+ showSection(how);
+ },
+};
diff --git a/comm/chat/content/otr-auth.xhtml b/comm/chat/content/otr-auth.xhtml
new file mode 100644
index 0000000000..4269db475f
--- /dev/null
+++ b/comm/chat/content/otr-auth.xhtml
@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title><!-- auth-title --></title>
+ <link rel="localization" href="messenger/otr/auth.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script defer="defer" src="chrome://chat/content/otr-auth.js"></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog
+ buttons="accept,cancel"
+ buttondisabledaccept="true"
+ data-l10n-id="otr-auth"
+ data-l10n-attrs="buttonlabelaccept"
+ >
+ <html:fieldset id="how" hidden="hidden">
+ <html:legend data-l10n-id="auth-how"></html:legend>
+ <vbox>
+ <menulist id="howOption" oncommand="otrAuth.how();">
+ <menupopup>
+ <menuitem
+ data-l10n-id="auth-question-and-answer-label"
+ value="questionAndAnswer"
+ />
+ <menuitem
+ data-l10n-id="auth-shared-secret-label"
+ value="sharedSecret"
+ />
+ <menuitem
+ data-l10n-id="auth-manual-verification-label"
+ value="manualVerification"
+ />
+ </menupopup>
+ </menulist>
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="questionAndAnswer" hidden="hidden">
+ <html:legend data-l10n-id="auth-question-and-answer"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-qa-instruction"
+ ></description>
+ <label data-l10n-id="auth-question" control="question" flex="1" />
+ <html:input
+ id="question"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-question"
+ />
+ <label data-l10n-id="auth-answer" control="answer" flex="1" />
+ <html:input
+ id="answer"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-answer"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="sharedSecret" hidden="hidden">
+ <html:legend data-l10n-id="auth-shared-secret"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-secret-instruction"
+ ></description>
+ <label data-l10n-id="auth-secret" control="secret" flex="1" />
+ <html:input
+ id="secret"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-secret"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="manualVerification" hidden="hidden">
+ <html:legend data-l10n-id="auth-manual-verification"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-manual-instruction"
+ ></description>
+
+ <label id="yourFPLabel" />
+ <html:input
+ id="yourFPValue"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ aria-labelledby="yourFPLabel"
+ />
+ <label id="theirFPLabel" />
+ <html:input
+ id="theirFPValue"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ aria-labelledby="theirFPLabel"
+ />
+
+ <hbox align="center">
+ <label data-l10n-id="auth-verified" />
+ <menulist id="verifiedOption">
+ <menupopup>
+ <menuitem data-l10n-id="auth-yes" value="yes" />
+ <menuitem data-l10n-id="auth-no" value="no" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="ask" hidden="hidden">
+ <label
+ id="receivedQuestionLabel"
+ data-l10n-id="auth-question-received"
+ />
+ <vbox>
+ <description
+ id="receivedQuestion"
+ style="width: 300px; white-space: pre-wrap"
+ />
+ <label id="responseLabel" control="response" flex="1" />
+ <html:input
+ id="response"
+ type="text"
+ class="input-inline"
+ aria-labelledby="responseLabel"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otr-finger.js b/comm/chat/content/otr-finger.js
new file mode 100644
index 0000000000..56c9422cf9
--- /dev/null
+++ b/comm/chat/content/otr-finger.js
@@ -0,0 +1,159 @@
+/* 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 { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+var l10n = new Localization(["messenger/otr/finger-sync.ftl"], true);
+
+window.addEventListener("DOMContentLoaded", event => {
+ otrFinger.onload();
+});
+
+var gFingers;
+var fingerTreeView = {
+ selection: null,
+ rowCount: 0,
+
+ setTree(tree) {},
+ getImageSrc(row, column) {},
+ getProgressMode(row, column) {},
+ getCellValue(row, column) {},
+
+ getCellText(row, column) {
+ let finger = gFingers[row];
+ switch (column.id) {
+ case "verified": {
+ let id = finger.trust ? "finger-yes" : "finger-no";
+ return l10n.formatValueSync(id);
+ }
+ default:
+ return finger[column.id] || "";
+ }
+ },
+
+ isSeparator(index) {
+ return false;
+ },
+
+ isSorted() {
+ return false;
+ },
+
+ isContainer(index) {
+ return false;
+ },
+
+ cycleHeader(column) {},
+
+ getRowProperties(row) {
+ return "";
+ },
+
+ getColumnProperties(column) {
+ return "";
+ },
+
+ getCellProperties(row, column) {
+ return "";
+ },
+};
+
+var fingerTree;
+var otrFinger = {
+ onload() {
+ fingerTree = document.getElementById("fingerTree");
+ gFingers = OTR.knownFingerprints(window.arguments[0].account);
+ fingerTreeView.rowCount = gFingers.length;
+ fingerTree.view = fingerTreeView;
+ document.getElementById("remove-all").disabled = !gFingers.length;
+ },
+
+ getSelections(tree) {
+ let selections = [];
+ let selection = tree.view.selection;
+ if (selection) {
+ let count = selection.getRangeCount();
+ let min = {};
+ let max = {};
+ for (let i = 0; i < count; i++) {
+ selection.getRangeAt(i, min, max);
+ for (let k = min.value; k <= max.value; k++) {
+ if (k != -1) {
+ selections.push(k);
+ }
+ }
+ }
+ }
+ return selections;
+ },
+
+ select() {
+ let selections = this.getSelections(fingerTree);
+ document.getElementById("remove").disabled = !selections.length;
+ },
+
+ remove() {
+ fingerTreeView.selection.selectEventsSuppressed = true;
+ // mark fingers for removal
+ for (let sel of this.getSelections(fingerTree)) {
+ gFingers[sel].purge = true;
+ }
+ this.commonRemove();
+ },
+
+ removeAll() {
+ let confirmAllTitle = l10n.formatValueSync("finger-remove-all-title");
+ let confirmAllText = l10n.formatValueSync("finger-remove-all-message");
+
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ confirmAllTitle,
+ confirmAllText,
+ Services.prompt.BUTTON_POS_1_DEFAULT +
+ Services.prompt.STD_OK_CANCEL_BUTTONS +
+ Services.prompt.BUTTON_DELAY_ENABLE,
+ 0,
+ 0,
+ 0,
+ null,
+ {}
+ );
+ if (buttonPressed != 0) {
+ return;
+ }
+
+ for (let j = 0; j < gFingers.length; j++) {
+ gFingers[j].purge = true;
+ }
+ this.commonRemove();
+ },
+
+ commonRemove() {
+ // OTR.forgetFingerprints will null out removed fingers.
+ let removalComplete = OTR.forgetFingerprints(gFingers);
+ for (let j = 0; j < gFingers.length; j++) {
+ if (gFingers[j] === null) {
+ let k = j;
+ while (k < gFingers.length && gFingers[k] === null) {
+ k++;
+ }
+ gFingers.splice(j, k - j);
+ fingerTreeView.rowCount -= k - j;
+ fingerTree.rowCountChanged(j, j - k); // negative
+ }
+ }
+ fingerTreeView.selection.selectEventsSuppressed = false;
+
+ if (!removalComplete) {
+ let infoTitle = l10n.formatValueSync("finger-subset-title");
+ let infoText = l10n.formatValueSync("finger-subset-message");
+ Services.prompt.alert(window, infoTitle, infoText);
+ }
+
+ document.getElementById("remove-all").disabled = !gFingers.length;
+ },
+};
diff --git a/comm/chat/content/otr-finger.xhtml b/comm/chat/content/otr-finger.xhtml
new file mode 100644
index 0000000000..95b3024565
--- /dev/null
+++ b/comm/chat/content/otr-finger.xhtml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="otr-finger-title"></title>
+ <link rel="localization" href="messenger/otr/finger.ftl" />
+ <script defer="defer" src="chrome://chat/content/otr-finger.js"></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept" style="width: 100vw; height: 100vh">
+ <label data-l10n-id="finger-intro" />
+ <separator class="thin" />
+ <vbox id="fingerprints" class="contentPane" flex="1">
+ <tree
+ id="fingerTree"
+ flex="1"
+ width="800"
+ style="height: 20em"
+ onselect="otrFinger.select()"
+ >
+ <treecols>
+ <treecol
+ id="screenname"
+ data-l10n-id="finger-screen-name"
+ style="flex: 20 20 auto"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="fingerprint"
+ data-l10n-id="finger-fingerprint"
+ style="flex: 120 120 auto"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="verified"
+ data-l10n-id="finger-verified"
+ style="flex: 10 10 auto"
+ />
+ <splitter class="tree-splitter" />
+ </treecols>
+ <treechildren />
+ </tree>
+ <separator class="thin" />
+ <hbox>
+ <button
+ id="remove"
+ data-l10n-id="finger-remove"
+ disabled="true"
+ oncommand="otrFinger.remove()"
+ />
+ <button
+ id="remove-all"
+ data-l10n-id="finger-remove-all"
+ disabled="true"
+ oncommand="otrFinger.removeAll()"
+ />
+ </hbox>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otrWorker.js b/comm/chat/content/otrWorker.js
new file mode 100644
index 0000000000..32d96ea9dd
--- /dev/null
+++ b/comm/chat/content/otrWorker.js
@@ -0,0 +1,61 @@
+/* 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/. */
+
+/* eslint-env mozilla/chrome-worker, node */
+importScripts("resource://gre/modules/workers/require.js");
+var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+var Funcs = {};
+
+// Only what we need from libotr.js
+Funcs.generateKey = function (path, otrl_version, address) {
+ let libotr = ctypes.open(path);
+
+ let abi = ctypes.default_abi;
+ let gcry_error_t = ctypes.unsigned_int;
+
+ // Initialize the OTR library. Pass the version of the API you are using.
+ let otrl_init = libotr.declare(
+ "otrl_init",
+ abi,
+ gcry_error_t,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int
+ );
+
+ // 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.
+ let otrl_privkey_generate_calculate = libotr.declare(
+ "otrl_privkey_generate_calculate",
+ abi,
+ gcry_error_t,
+ ctypes.void_t.ptr
+ );
+
+ otrl_init.apply(libotr, otrl_version);
+
+ let newkey = ctypes.voidptr_t(ctypes.UInt64("0x" + address));
+ let err = otrl_privkey_generate_calculate(newkey);
+ libotr.close();
+ if (err) {
+ throw new Error("otrl_privkey_generate_calculate (" + err + ")");
+ }
+};
+
+var worker = new PromiseWorker.AbstractWorker();
+
+worker.dispatch = function (method, args = []) {
+ return Funcs[method](...args);
+};
+
+worker.postMessage = function (res, ...args) {
+ self.postMessage(res, ...args);
+};
+
+worker.close = function () {
+ self.close();
+};
+
+self.addEventListener("message", msg => worker.handleMessage(msg));