diff options
Diffstat (limited to 'comm/chat/content')
-rw-r--r-- | comm/chat/content/chat-account-richlistitem.js | 354 | ||||
-rw-r--r-- | comm/chat/content/chat-tooltip.js | 604 | ||||
-rw-r--r-- | comm/chat/content/conv.html | 4 | ||||
-rw-r--r-- | comm/chat/content/conversation-browser.js | 906 | ||||
-rw-r--r-- | comm/chat/content/imAccountOptionsHelper.js | 121 | ||||
-rw-r--r-- | comm/chat/content/jar.mn | 18 | ||||
-rw-r--r-- | comm/chat/content/moz.build | 6 | ||||
-rw-r--r-- | comm/chat/content/otr-add-fingerprint.js | 84 | ||||
-rw-r--r-- | comm/chat/content/otr-add-fingerprint.xhtml | 91 | ||||
-rw-r--r-- | comm/chat/content/otr-auth.js | 198 | ||||
-rw-r--r-- | comm/chat/content/otr-auth.xhtml | 163 | ||||
-rw-r--r-- | comm/chat/content/otr-finger.js | 159 | ||||
-rw-r--r-- | comm/chat/content/otr-finger.xhtml | 74 | ||||
-rw-r--r-- | comm/chat/content/otrWorker.js | 61 |
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)); |