diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/im/content | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/im/content')
24 files changed, 8844 insertions, 0 deletions
diff --git a/comm/mail/components/im/content/.eslintrc.js b/comm/mail/components/im/content/.eslintrc.js new file mode 100644 index 0000000000..c862f88e3e --- /dev/null +++ b/comm/mail/components/im/content/.eslintrc.js @@ -0,0 +1,22 @@ +"use strict"; + +module.exports = { + overrides: [ + { + files: ["imconversation.xml"], + globals: { + AppConstants: true, + chatHandler: true, + gChatTab: true, + Services: true, + + // chat/modules/imStatusUtils.jsm + Status: true, + + // chat/modules/imTextboxUtils.jsm + MessageFormat: true, + TextboxSize: true, + }, + }, + ], +}; diff --git a/comm/mail/components/im/content/addbuddy.js b/comm/mail/components/im/content/addbuddy.js new file mode 100644 index 0000000000..f5b3eb7deb --- /dev/null +++ b/comm/mail/components/im/content/addbuddy.js @@ -0,0 +1,58 @@ +/* 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 { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var addBuddy = { + onload() { + let accountList = document.getElementById("accountlist"); + for (let acc of IMServices.accounts.getAccounts()) { + if (!acc.connected) { + continue; + } + let proto = acc.protocol; + let item = accountList.appendItem(acc.name, acc.id, proto.name); + item.setAttribute("image", ChatIcons.getProtocolIconURI(proto)); + item.setAttribute("class", "menuitem-iconic"); + } + if (!accountList.itemCount) { + document + .getElementById("addBuddyDialog") + .querySelector("dialog") + .cancelDialog(); + throw new Error("No connected account!"); + } + accountList.selectedIndex = 0; + }, + + oninput() { + document.querySelector("dialog").getButton("accept").disabled = + !addBuddy.getValue("name"); + }, + + getValue(aId) { + return document.getElementById(aId).value; + }, + + create() { + let account = IMServices.accounts.getAccountById( + this.getValue("accountlist") + ); + let group = Services.strings + .createBundle("chrome://messenger/locale/chat.properties") + .GetStringFromName("defaultGroup"); + account.addBuddy(IMServices.tags.createTag(group), this.getValue("name")); + }, +}; + +document.addEventListener("dialogaccept", addBuddy.create.bind(addBuddy)); + +window.addEventListener("load", event => { + addBuddy.onload(); +}); diff --git a/comm/mail/components/im/content/addbuddy.xhtml b/comm/mail/components/im/content/addbuddy.xhtml new file mode 100644 index 0000000000..5c4fbfbf94 --- /dev/null +++ b/comm/mail/components/im/content/addbuddy.xhtml @@ -0,0 +1,59 @@ +<?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://messenger/skin/imMenulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/addbuddy.dtd"> + +<html + id="addBuddyDialog" + 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>&addBuddyWindow.title;</title> + <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://messenger/content/chat/addbuddy.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <dialog buttons="accept,cancel" buttondisabledaccept="true"> + <hbox> + <vbox id="nameBox"> + <hbox align="center" flex="1"> + <label value="&name.label;" control="name" /> + </hbox> + <hbox align="center" flex="1"> + <label value="&account.label;" control="accountlist" /> + </hbox> + </vbox> + <vbox id="accountBox"> + <html:input + id="name" + type="text" + class="input-inline" + oninput="addBuddy.oninput()" + /> + <menulist id="accountlist" /> + </vbox> + </hbox> + </dialog> + </html:body> +</html> diff --git a/comm/mail/components/im/content/am-im.js b/comm/mail/components/im/content/am-im.js new file mode 100644 index 0000000000..494e0aa1fd --- /dev/null +++ b/comm/mail/components/im/content/am-im.js @@ -0,0 +1,291 @@ +/* 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/content/imAccountOptionsHelper.js +/* globals accountOptionsHelper */ + +const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + OTR: "resource:///modules/OTR.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", +}); + +var autoJoinPref = "autoJoin"; + +function onPreInit(aAccount, aAccountValue) { + account.init(aAccount.incomingServer.wrappedJSObject.imAccount); +} + +function onBeforeUnload() { + if (account.encryptionObserver) { + Services.obs.removeObserver( + account.encryptionObserver, + "account-sessions-changed" + ); + Services.obs.removeObserver( + account.encryptionObserver, + "account-encryption-status-changed" + ); + } +} + +var account = { + async init(aAccount) { + let title = document.querySelector(".dialogheader .dialogheader-title"); + let defaultTitle = title.getAttribute("defaultTitle"); + let titleValue; + + if (aAccount.name) { + titleValue = defaultTitle + " - <" + aAccount.name + ">"; + } else { + titleValue = defaultTitle; + } + + title.setAttribute("value", titleValue); + document.title = titleValue; + + this.account = aAccount; + this.proto = this.account.protocol; + document.getElementById("accountName").value = this.account.name; + document.getElementById("protocolName").value = + this.proto.name || this.proto.id; + document.getElementById("protocolIcon").src = ChatIcons.getProtocolIconURI( + this.proto, + 48 + ); + + let password = document.getElementById("server.password"); + let passwordBox = document.getElementById("passwordBox"); + if (this.proto.noPassword) { + passwordBox.hidden = true; + password.removeAttribute("wsm_persist"); + } else { + passwordBox.hidden = false; + try { + // Should we force layout here to ensure password.value works? + // Will throw if we don't have a protocol plugin for the account. + password.value = this.account.password; + password.setAttribute("wsm_persist", "true"); + } catch (e) { + passwordBox.hidden = true; + password.removeAttribute("wsm_persist"); + } + } + + document.getElementById("server.alias").value = this.account.alias; + + if (ChatEncryption.canConfigureEncryption(this.account.protocol)) { + document.getElementById("imTabEncryption").hidden = false; + document.querySelector(".otr-settings").hidden = !OTRUI.enabled; + document.getElementById("server.otrAllowMsgLog").value = + this.account.otrAllowMsgLog; + if (OTRUI.enabled) { + document.getElementById("server.otrVerifyNudge").value = + this.account.otrVerifyNudge; + document.getElementById("server.otrRequireEncryption").value = + this.account.otrRequireEncryption; + + let fpa = this.account.normalizedName; + let fpp = this.account.protocol.normalizedName; + let fp = OTR.privateKeyFingerprint(fpa, fpp); + if (!fp) { + fp = await document.l10n.formatValue("otr-not-yet-available"); + } + document.getElementById("otrFingerprint").value = fp; + } + document.querySelector(".chat-encryption-settings").hidden = + !this.account.protocol.canEncrypt; + if (this.account.protocol.canEncrypt) { + document.l10n.setAttributes( + document.getElementById("chat-encryption-description"), + "chat-encryption-description", + { + protocol: this.proto.name, + } + ); + this.buildEncryptionStatus(); + this.buildAccountSessionsList(); + this.encryptionObserver = { + observe: (subject, topic) => { + if ( + topic === "account-sessions-changed" && + subject.id === this.account.id + ) { + this.buildAccountSessionsList(); + } else if ( + topic === "account-encryption-status-changed" && + subject.id === this.account.id + ) { + this.buildEncryptionStatus(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + Services.obs.addObserver( + this.encryptionObserver, + "account-sessions-changed", + true + ); + Services.obs.addObserver( + this.encryptionObserver, + "account-encryption-status-changed", + true + ); + } + } + + let protoId = this.proto.id; + let canAutoJoin = + protoId == "prpl-irc" || + protoId == "prpl-jabber" || + protoId == "prpl-gtalk"; + document.getElementById("autojoinBox").hidden = !canAutoJoin; + let autojoin = document.getElementById("server.autojoin"); + if (canAutoJoin) { + autojoin.setAttribute("wsm_persist", "true"); + } else { + autojoin.removeAttribute("wsm_persist"); + } + + this.prefs = Services.prefs.getBranch( + "messenger.account." + this.account.id + ".options." + ); + this.populateProtoSpecificBox(); + }, + + encryptionObserver: null, + buildEncryptionStatus() { + const encryptionStatus = document.querySelector(".chat-encryption-status"); + if (this.account.encryptionStatus.length) { + encryptionStatus.replaceChildren( + ...this.account.encryptionStatus.map(status => { + const item = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + item.textContent = status; + return item; + }) + ); + } else { + const placeholder = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + document.l10n.setAttributes(placeholder, "chat-encryption-placeholder"); + encryptionStatus.replaceChildren(placeholder); + } + }, + buildAccountSessionsList() { + const sessions = this.account.getSessions(); + document.querySelector(".chat-encryption-sessions-container").hidden = + sessions.length === 0; + const sessionList = document.querySelector(".chat-encryption-sessions"); + sessionList.replaceChildren( + ...sessions.map(session => { + const button = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "button" + ); + document.l10n.setAttributes( + button, + "chat-encryption-session-" + (session.trusted ? "trusted" : "verify") + ); + button.disabled = session.trusted; + if (!button.disabled) { + button.addEventListener("click", async () => { + try { + const sessionInfo = await session.verify(); + parent.gSubDialog.open( + "chrome://messenger/content/chat/verify.xhtml", + { features: "resizable=no" }, + sessionInfo + ); + } catch (error) { + // Verification was probably aborted by the other side. + this.account.prplAccount.wrappedJSObject.WARN(error); + } + }); + } + const sessionLabel = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + sessionLabel.textContent = session.id; + const row = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + row.append(sessionLabel, button); + row.classList.toggle("chat-current-session", session.currentSession); + return row; + }) + ); + }, + + populateProtoSpecificBox() { + let attributes = {}; + attributes[Ci.prplIPref.typeBool] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "bool" }, + { name: "genericattr", value: "true" }, + ]; + attributes[Ci.prplIPref.typeInt] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "int" }, + { name: "genericattr", value: "true" }, + ]; + attributes[Ci.prplIPref.typeString] = attributes[Ci.prplIPref.typeList] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "wstring" }, + { name: "genericattr", value: "true" }, + ]; + let haveOptions = accountOptionsHelper.addOptions( + "server.", + this.proto.getOptions(), + attributes + ); + let advanced = document.getElementById("advanced"); + if (advanced.hidden && haveOptions) { + advanced.hidden = false; + // Force textbox XBL binding attachment by forcing layout, + // otherwise setFormElementValue from AccountManager.js sets + // properties that don't exist when restoring values. + document.getElementById("protoSpecific").getBoundingClientRect(); + } else if (!haveOptions) { + advanced.hidden = true; + } + let inputElements = document.querySelectorAll( + "#protoSpecific :is(checkbox, input, menulist)" + ); + // Because the elements are added after the document loaded we have to + // notify the parent document that there are prefs to save. + for (let input of inputElements) { + if (input.localName == "input" || input.localName == "textarea") { + input.addEventListener("change", event => { + document.dispatchEvent(new CustomEvent("prefchange")); + }); + } else { + input.addEventListener("command", event => { + document.dispatchEvent(new CustomEvent("prefchange")); + }); + } + } + }, + + viewFingerprintKeys() { + let otrAccount = { account: this.account }; + parent.gSubDialog.open( + "chrome://chat/content/otr-finger.xhtml", + undefined, + otrAccount + ); + }, +}; diff --git a/comm/mail/components/im/content/am-im.xhtml b/comm/mail/components/im/content/am-im.xhtml new file mode 100644 index 0000000000..5455309da8 --- /dev/null +++ b/comm/mail/components/im/content/am-im.xhtml @@ -0,0 +1,235 @@ +<?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://messenger/skin/accountManage.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % imDTD SYSTEM "chrome://messenger/locale/am-im.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%imDTD; %brandDTD; ]> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + id="account" + title="&accountWindow.title;" + buttons="accept,cancel" + onload="parent.onPanelLoaded('am-im.xhtml');" + onbeforeunload="onBeforeUnload();" +> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://chat/content/imAccountOptionsHelper.js" /> + <script src="chrome://messenger/content/am-im.js" /> + + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="messenger/preferences/am-im.ftl" /> + <html:link rel="localization" href="messenger/otr/am-im-otr.ftl" /> + </linkset> + + <vbox flex="1" style="overflow: auto; padding: 0" + ><vbox id="containerBox" flex="1"> + <hbox class="dialogheader"> + <label + class="dialogheader-title" + defaultTitle="&accountWindow.title;" + /> + </hbox> + + <hbox align="center"> + <html:img id="protocolIcon" alt="" /> + <vbox flex="1"> + <label id="accountName" crop="end" class="header" /> + <label id="protocolName" class="tip-caption" /> + </vbox> + </hbox> + + <tabbox id="imTabbox" flex="1"> + <tabs> + <tab id="imTabGeneral" label="&account.general;" /> + <tab + id="imTabEncryption" + data-l10n-id="account-encryption" + hidden="true" + /> + </tabs> + <tabpanels flex="1"> + <tabpanel orient="vertical"> + <label class="header" data-l10n-id="account-settings-title" /> + <hbox id="passwordBox" align="baseline" class="input-container"> + <label + value="&account.password;" + control="server.password" + class="label-inline" + /> + <html:input + id="server.password" + type="password" + preftype="wstring" + genericattr="true" + class="input-inline" + /> + </hbox> + <hbox id="aliasBox" align="baseline" class="input-container"> + <label + value="&account.alias;" + control="server.alias" + class="label-inline" + /> + <html:input + id="server.alias" + type="text" + preftype="wstring" + wsm_persist="true" + genericattr="true" + class="input-inline" + /> + </hbox> + <vbox id="autologinBox"> + <checkbox + id="server.autologin" + data-l10n-id="chat-autologin" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + <separator class="thin" /> + + <vbox id="autojoinBox" hidden="true"> + <label class="header" data-l10n-id="account-channel-title" /> + <hbox class="input-container"> + <label + class="label-inline" + value="&account.autojoin;" + control="server.autojoin" + /> + <html:input + id="server.autojoin" + type="text" + preftype="wstring" + genericattr="true" + class="input-inline" + /> + </hbox> + <separator class="thin" /> + </vbox> + <vbox id="advanced"> + <label class="header">&account.advanced;</label> + <html:div + id="protoSpecific" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + </vbox> + </tabpanel> + + <tabpanel orient="vertical"> + <html:div> + <html:h1 data-l10n-id="chat-encryption-generic" /> + <separator class="thin" /> + + <vbox> + <checkbox + id="server.otrAllowMsgLog" + data-l10n-id="chat-encryption-log" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + </html:div> + <separator /> + <html:div class="chat-encryption-settings"> + <html:h1 data-l10n-id="chat-encryption-label" /> + <description id="chat-encryption-description" /> + + <separator class="thin" /> + + <label class="header" data-l10n-id="chat-encryption-status" /> + <html:div class="indent"> + <html:ul class="chat-encryption-status"> + <html:li data-l10n-id="chat-encryption-placeholder" /> + </html:ul> + </html:div> + + <html:div class="chat-encryption-sessions-container"> + <separator class="thin" /> + <label class="header" data-l10n-id="chat-encryption-sessions" /> + <description + data-l10n-id="chat-encryption-sessions-description" + /> + <html:div class="indent"> + <html:ul class="chat-encryption-sessions"></html:ul> + </html:div> + </html:div> + <separator /> + </html:div> + <html:div class="otr-settings"> + <html:h1 data-l10n-id="account-otr-label" /> + <description data-l10n-id="account-otr-description2" /> + + <separator /> + + <vbox> + <label class="header" data-l10n-id="otr-settings-title" /> + <checkbox + id="server.otrRequireEncryption" + data-l10n-id="otr-require-encryption" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + <html:p + id="otrRequireEncryptionInfo" + class="option-description" + data-l10n-id="otr-require-encryption-info" + ></html:p> + <checkbox + id="server.otrVerifyNudge" + data-l10n-id="otr-verify-nudge" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + + <separator /> + + <vbox> + <label class="header" data-l10n-id="otr-encryption-title" /> + <label data-l10n-id="otr-encryption-caption" /> + <separator class="thin" /> + <hbox align="center"> + <label data-l10n-id="otr-fingerprint-label" /> + <hbox class="input-container" flex="1"> + <html:input + id="otrFingerprint" + type="text" + class="input-inline" + readonly="readonly" + /> + </hbox> + </hbox> + <separator class="thin" /> + <hbox pack="end"> + <button + id="viewFingerprintButton" + data-l10n-id="view-fingerprint-button" + oncommand="account.viewFingerprintKeys();" + /> + </hbox> + </vbox> + </html:div> + </tabpanel> + </tabpanels> + </tabbox> </vbox + ></vbox> +</window> diff --git a/comm/mail/components/im/content/chat-contact.js b/comm/mail/components/im/content/chat-contact.js new file mode 100644 index 0000000000..d3e9baf974 --- /dev/null +++ b/comm/mail/components/im/content/chat-contact.js @@ -0,0 +1,282 @@ +/* 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, MozElements, Status, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" + ); + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + /** + * The MozChatContactRichlistitem widget displays contact information about user under + * chat-groups, online contacts and offline contacts: i.e. icon and username. + * On double clicking the element, it gets moved into the conversations. + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatContactRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + ".box-line": "selected", + ".contactDisplayName": "value=displayname", + ".contactDisplayNameInput": "value=displayname", + ".contactStatusText": "value=statusTextWithDash", + }; + } + + static get markup() { + return ` + <vbox class="box-line"></vbox> + <stack class="prplBuddyIcon"> + <html:img class="protoIcon" alt="" /> + <html:img class="smallStatusIcon" /> + </stack> + <hbox flex="1" class="contact-hbox"> + <stack> + <label crop="end" + class="contactDisplayName blistDisplayName"> + </label> + <html:input type="text" + class="contactDisplayNameInput" + hidden="hidden"/> + </stack> + <label crop="end" + style="flex: 100000 100000;" + class="contactStatusText"> + </label> + <button class="startChatBubble" + tooltiptext="&openConversationButton.tooltip;"> + </button> + </hbox> + `; + } + + static get entities() { + return ["chrome://messenger/locale/chat.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-contact-richlistitem"); + + this.addEventListener("blur", event => { + if (!this.hasAttribute("aliasing")) { + return; + } + + if (Services.focus.activeWindow == document.defaultView) { + this.finishAliasing(true); + } + }); + + this.addEventListener("mousedown", event => { + if ( + !this.hasAttribute("aliasing") && + this.canOpenConversation() && + event.target.classList.contains("startChatBubble") + ) { + this.openConversation(); + event.preventDefault(); + } + }); + + this.addEventListener("click", event => { + if ( + !this.hasAttribute("aliasing") && + this.canOpenConversation() && + event.detail == 2 + ) { + this.openConversation(); + } + }); + + this.parentNode.addEventListener("mousedown", event => { + event.preventDefault(); + }); + + // @implements {nsIObserver} + this.observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe: function (subject, topic, data) { + if ( + topic == "contact-preferred-buddy-changed" || + topic == "contact-display-name-changed" || + topic == "contact-status-changed" + ) { + this.update(); + } + if ( + topic == "contact-availability-changed" || + topic == "contact-display-name-changed" + ) { + this.group.updateContactPosition(subject); + } + }.bind(this), + }; + + this.appendChild(this.constructor.fragment); + + this.initializeAttributeInheritance(); + } + + get displayName() { + return this.contact.displayName; + } + + update() { + this.setAttribute("displayname", this.contact.displayName); + + let statusText = this.contact.statusText; + if (statusText) { + statusText = " - " + statusText; + } + this.setAttribute("statusTextWithDash", statusText); + let statusType = this.contact.statusType; + + let statusIcon = this.querySelector(".smallStatusIcon"); + let statusName = Status.toAttribute(statusType); + statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName)); + statusIcon.setAttribute("alt", Status.toLabel(statusType)); + + if (this.contact.canSendMessage) { + this.setAttribute("cansend", "true"); + } else { + this.removeAttribute("cansend"); + } + + let protoIcon = this.querySelector(".protoIcon"); + protoIcon.setAttribute( + "src", + ChatIcons.getProtocolIconURI(this.contact.preferredBuddy.protocol) + ); + ChatIcons.setProtocolIconOpacity(protoIcon, statusName); + } + + build(contact) { + this.contact = contact; + this.contact.addObserver(this.observer); + this.update(); + } + + destroy() { + this.contact.removeObserver(this.observer); + delete this.contact; + this.remove(); + } + + startAliasing() { + if (this.hasAttribute("aliasing")) { + return; // prevent re-entry. + } + + this.setAttribute("aliasing", "true"); + let input = this.querySelector(".contactDisplayNameInput"); + let label = this.querySelector(".contactDisplayName"); + input.removeAttribute("hidden"); + label.setAttribute("hidden", "true"); + input.focus(); + + this._inputBlurListener = function (event) { + this.finishAliasing(true); + }.bind(this); + input.addEventListener("blur", this._inputBlurListener); + + // Some keys (home/end for example) can make the selected item + // of the richlistbox change without producing a blur event on + // our textbox. Make sure we watch richlistbox selection changes. + this._parentSelectListener = function (event) { + if (event.target == this.parentNode) { + this.finishAliasing(true); + } + }.bind(this); + this.parentNode.addEventListener("select", this._parentSelectListener); + } + + finishAliasing(save) { + // Cache the parentNode because when we change the contact alias, we + // trigger a re-order (and a removeContact call), which sets + // this.parentNode to undefined. + let listbox = this.parentNode; + let input = this.querySelector(".contactDisplayNameInput"); + let label = this.querySelector(".contactDisplayName"); + input.setAttribute("hidden", "hidden"); + label.removeAttribute("hidden"); + if (save) { + this.contact.alias = input.value; + } + this.removeAttribute("aliasing"); + listbox.removeEventListener("select", this._parentSelectListener); + input.removeEventListener("blur", this._inputBlurListener); + delete this._parentSelectListener; + listbox.focus(); + } + + deleteContact() { + this.contact.remove(); + } + + canOpenConversation() { + return this.contact.canSendMessage; + } + + openConversation() { + let prplConv = this.contact.createConversation(); + let uiConv = IMServices.conversations.getUIConversation(prplConv); + chatHandler.focusConversation(uiConv); + } + + keyPress(event) { + switch (event.keyCode) { + // If Enter or Return is pressed, open a new conversation + case event.DOM_VK_RETURN: + if (this.hasAttribute("aliasing")) { + this.finishAliasing(true); + } else if (this.canOpenConversation()) { + this.openConversation(); + } + break; + + case event.DOM_VK_F2: + if (!this.hasAttribute("aliasing")) { + this.startAliasing(); + } + break; + + case event.DOM_VK_ESCAPE: + if (this.hasAttribute("aliasing")) { + this.finishAliasing(false); + } + break; + } + } + disconnectedCallback() { + if (this.contact) { + this.contact.removeObserver(this.observer); + delete this.contact; + } + } + } + + MozXULElement.implementCustomInterface(MozChatContactRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define( + "chat-contact-richlistitem", + MozChatContactRichlistitem, + { + extends: "richlistitem", + } + ); +} diff --git a/comm/mail/components/im/content/chat-conversation-info.js b/comm/mail/components/im/content/chat-conversation-info.js new file mode 100644 index 0000000000..a8004a4c3f --- /dev/null +++ b/comm/mail/components/im/content/chat-conversation-info.js @@ -0,0 +1,353 @@ +/* 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"; + +/* globals MozElements MozXULElement chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + ChromeUtils.defineESModuleGetters(this, { + OTR: "resource:///modules/OTR.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", + }); + + /** + * The MozChatConversationInfo widget displays information about a chat: + * e.g. the channel name and topic of an IRC channel, or nick, user image and + * status of a conversation partner. + * It is typically shown at the top right of the chat UI. + * + * @augments {MozXULElement} + */ + class MozChatConversationInfo extends MozXULElement { + static get inheritedAttributes() { + return { ".displayName": "value=displayName" }; + } + + static get markup() { + return ` + <linkset> + <html:link rel="localization" href="messenger/otr/chat.ftl"/> + </linkset> + + <html:div class="displayUserAccount"> + <stack> + <html:img class="userIcon" alt="" /> + <html:img class="statusTypeIcon" alt="" /> + </stack> + <html:div class="nameAndStatusGrid"> + <description class="displayName" crop="end"></description> + <html:img class="protoIcon" alt="" /> + <html:hr /> + <description class="statusMessage" crop="end"></description> + <!-- FIXME: A keyboard user cannot focus the hidden input, nor + - click the above description box in order to reveal it. --> + <html:input class="statusMessageInput input-inline" + hidden="hidden"/> + </html:div> + </html:div> + <hbox class="encryption-container themeable-brighttext" + align="center" + hidden="true"> + <label class="encryption-label" + crop="end" + data-l10n-id="state-label" + flex="1"/> + <toolbarbutton id="chatEncryptionButton" + mode="dialog" + class="encryption-button" + type="menu" + wantdropmarker="true" + label="Insecure" + data-l10n-id="start-tooltip"> + <menupopup class="encryption-menu-popup"> + <menuitem class="otr-start" data-l10n-id="start-label" + oncommand='this.closest("chat-conversation-info").onOtrStartClicked();'/> + <menuitem class="otr-end" data-l10n-id="end-label" + oncommand='this.closest("chat-conversation-info").onOtrEndClicked();'/> + <menuitem class="otr-auth" data-l10n-id="auth-label" + oncommand='this.closest("chat-conversation-info").onOtrAuthClicked();'/> + <menuitem class="protocol-encrypt" data-l10n-id="start-label"/> + </menupopup> + </toolbarbutton> + </hbox> + `; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + this.setAttribute("orient", "vertical"); + + this.appendChild(this.constructor.fragment); + + this.topicEditable = false; + this.editingTopic = false; + this.noTopic = false; + + this.topic.addEventListener("click", this.startEditTopic.bind(this)); + + this.querySelector(".protocol-encrypt").addEventListener("click", () => + this.initializeEncryption() + ); + + let encryptionButton = this.querySelector(".encryption-button"); + encryptionButton.addEventListener( + "command", + this.encryptionButtonClicked + ); + if (Services.prefs.getBoolPref("chat.otr.enable")) { + OTRUI.setNotificationBox(chatHandler.msgNotificationBar); + } + this.initializeAttributeInheritance(); + } + + get topic() { + return this.querySelector(".statusMessage"); + } + + get topicInput() { + return this.querySelector(".statusMessageInput"); + } + + finishEditTopic(save) { + if (!this.editingTopic) { + return; + } + + let panel = this.getSelectedPanel(); + let topic = this.topic; + let topicInput = this.topicInput; + topic.removeAttribute("hidden"); + topicInput.hidden = true; + if (save) { + // apply the new topic only if it is different from the current one + if (topicInput.value != topicInput.getAttribute("value")) { + panel._conv.topic = topicInput.value; + } + } + this.editingTopic = false; + + topicInput.removeEventListener("keypress", this._topicKeyPress, true); + delete this._topicKeyPress; + topicInput.removeEventListener("blur", this._topicBlur); + delete this._topicBlur; + + // After hiding the input, the focus is on an element that can't receive + // keyboard events, so move it to somewhere else. + // FIXME: jumping focus should be removed once editing the topic input + // becomes accessible to keyboard users. + panel.editor.focus(); + } + + topicKeyPress(event) { + switch (event.keyCode) { + case event.DOM_VK_RETURN: + this.finishEditTopic(true); + break; + + case event.DOM_VK_ESCAPE: + this.finishEditTopic(false); + event.stopPropagation(); + event.preventDefault(); + break; + } + } + + topicBlur(event) { + if (event.target == this.topicInput) { + this.finishEditTopic(true); + } + } + + startEditTopic() { + let topic = this.topic; + let topicInput = this.topicInput; + if (!this.topicEditable || this.editingTopic) { + return; + } + + this.editingTopic = true; + + topicInput.hidden = false; + topic.setAttribute("hidden", "true"); + this._topicKeyPress = this.topicKeyPress.bind(this); + topicInput.addEventListener("keypress", this._topicKeyPress); + this._topicBlur = this.topicBlur.bind(this); + topicInput.addEventListener("blur", this._topicBlur); + topicInput.getBoundingClientRect(); + if (this.noTopic) { + topicInput.value = ""; + } else { + topicInput.value = topic.value; + } + topicInput.select(); + } + + encryptionButtonClicked(aEvent) { + aEvent.preventDefault(); + let encryptionMenu = this.querySelector(".encryption-menu-popup"); + encryptionMenu.openPopup(encryptionMenu.parentNode, "after_start"); + } + + onOtrStartClicked() { + // check if start-menu-command is disabled, if yes exit + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + let context = OTR.getContext(conv); + let bundleId = + "alert-" + + (context.msgstate === OTR.getMessageState().OTRL_MSGSTATE_ENCRYPTED + ? "refresh" + : "start"); + OTRUI.sendSystemAlert(uiConv, conv, bundleId); + OTR.sendQueryMsg(conv); + } + + onOtrEndClicked() { + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + OTR.disconnect(conv, false); + let bundleId = "alert-gone-insecure"; + OTRUI.sendSystemAlert(uiConv, conv, bundleId); + } + + onOtrAuthClicked() { + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + OTRUI.openAuth(window, conv.normalizedName, "start", uiConv); + } + + initializeEncryption() { + const convBinding = this.getSelectedPanel(); + const uiConv = convBinding._conv; + uiConv.initializeEncryption(); + } + + getSelectedPanel() { + for (let element of document.getElementById("conversationsBox") + .children) { + if (!element.hidden) { + return element; + } + } + return null; + } + + /** + * Sets the shown protocol icon. + * + * @param {prplIProtocol} protocol - The protocol to show. + */ + setProtocol(protocol) { + this.querySelector(".protoIcon").setAttribute( + "src", + ChatIcons.getProtocolIconURI(protocol) + ); + } + + /** + * 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, useFallback) { + ChatIcons.setUserIconSrc( + this.querySelector(".userIcon"), + iconURI, + useFallback + ); + } + + /** + * Sets the shown status icon. + * + * @param {string} statusName - The name of the status. + */ + setStatusIcon(statusName) { + let statusIcon = this.querySelector(".statusTypeIcon"); + if (statusName === null) { + statusIcon.hidden = true; + statusIcon.removeAttribute("src"); + } else { + statusIcon.hidden = false; + let src = ChatIcons.getStatusIconURI(statusName); + if (src) { + statusIcon.setAttribute("src", src); + } else { + /* Unexpected missing icon. */ + statusIcon.removeAttribute("src"); + } + } + } + + /** + * Sets the text for the status of a user, or the topic of a chat. + * + * @param {string} text - The text to display. + * @param {boolean} [noTopic=false] - Whether to stylize the status to + * indicate the status is some fallback text. + */ + setStatusText(text, noTopic = false) { + let statusEl = this.topic; + + statusEl.setAttribute("value", text); + statusEl.setAttribute("tooltiptext", text); + statusEl.toggleAttribute("noTopic", noTopic); + } + + /** + * Sets the element to display a user status. The user icon needs to be set + * separately with setUserIcon. + * + * @param {string} statusName - The internal name for the status. + * @param {string} statusText - The text to display as the status. + */ + setStatus(statusName, statusText) { + this.setStatusIcon(statusName); + this.setStatusText(statusText); + this.topicEditable = false; + } + + /** + * Sets the element to display a chat status. + * + * @param {string} topicText - The topic text for the chat, or some fallback + * text used if the chat has no topic. + * @param {boolean} noTopic - Whether the chat has no topic. + * @param {boolean} topicEditable - Whether the topic can be set by the + * user. + */ + setAsChat(topicText, noTopic, topicEditable) { + this.noTopic = noTopic; + this.topicEditable = topicEditable; + this.setStatusText(topicText, noTopic); + this.setStatusIcon("chat"); + } + + /** + * Empty the element's display. + */ + clear() { + this.querySelector(".protoIcon").removeAttribute("src"); + this.setStatusText(""); + this.setStatusIcon(null); + this.setUserIcon("", false); + this.topicEditable = false; + } + } + customElements.define("chat-conversation-info", MozChatConversationInfo); +} diff --git a/comm/mail/components/im/content/chat-conversation.js b/comm/mail/components/im/content/chat-conversation.js new file mode 100644 index 0000000000..9d0068ac6f --- /dev/null +++ b/comm/mail/components/im/content/chat-conversation.js @@ -0,0 +1,1760 @@ +/* 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"; + +/* globals MozElements, MozXULElement, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" + ); + const { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" + ); + const { TextboxSize } = ChromeUtils.importESModule( + "resource:///modules/imTextboxUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { InlineSpellChecker } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" + ); + + /** + * The MozChatConversation widget displays the entire chat conversation + * including status notifications + * + * @augments {MozXULElement} + */ + class MozChatConversation extends MozXULElement { + static get inheritedAttributes() { + return { + browser: "autoscrollpopup", + }; + } + + constructor() { + super(); + + ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + }); + + this.observer = { + // @see {nsIObserver} + observe: (subject, topic, data) => { + if (topic == "conversation-loaded") { + if (subject != this.convBrowser) { + return; + } + + this.convBrowser.progressBar = this.progressBar; + + // Display all queued messages. Use a timeout so that message text + // modifiers can be added with observers for this notification. + if (!this.loaded) { + setTimeout(this._showFirstMessages.bind(this), 0); + } + + Services.obs.removeObserver(this.observer, "conversation-loaded"); + + // Report the active chat message theme via telemetry. This is not + // inside the conv browser itself, since the browser is also used + // for the theme preview in the settings. + Services.telemetry.scalarSet( + "tb.chat.active_message_theme", + `${this.convBrowser.theme.name}:${this.convBrowser.theme.variant}` + ); + + return; + } + + switch (topic) { + case "new-text": + if (this.loaded && this.addMsg(subject)) { + // This will mark the conv as read, but also update the conv title + // with the new unread count etc. + this.tab.update(); + } + break; + + case "update-text": + if (this.loaded) { + this.updateMsg(subject); + } + break; + + case "remove-text": + if (this.loaded) { + this.removeMsg(data); + } + break; + + case "status-text-changed": + this._statusText = data || ""; + this.displayStatusText(); + break; + + case "replying-to-prompt": + this.addPrompt(data); + break; + + case "target-prpl-conversation-changed": + case "update-conv-title": + if (this.tab && this.conv) { + this.tab.setAttribute("label", this.conv.title); + } + break; + + // Update the status too. + case "update-buddy-status": + case "update-buddy-icon": + case "update-conv-icon": + case "update-conv-chatleft": + if (this.tab && this._isConversationSelected) { + this.updateConvStatus(); + } + break; + + case "update-typing": + if (this.tab && this._isConversationSelected) { + this._currentTypingName = data; + this.updateConvStatus(); + } + break; + + case "chat-buddy-add": + if (!this._isConversationSelected) { + break; + } + for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) { + this.insertBuddy(this.createBuddy(nick)); + } + this.updateParticipantCount(); + break; + + case "chat-buddy-remove": + if (!this._isConversationSelected) { + for (let nick of subject.QueryInterface( + Ci.nsISimpleEnumerator + )) { + let name = nick.toString(); + if (this._isBuddyActive(name)) { + delete this._activeBuddies[name]; + } + } + break; + } + for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) { + this.removeBuddy(nick.toString()); + } + this.updateParticipantCount(); + break; + + case "chat-buddy-update": + this.updateBuddy(subject, data); + break; + + case "chat-update-topic": + if (this._isConversationSelected) { + this.updateTopic(); + } + break; + case "update-conv-encryption": + if (this._isConversationSelected) { + this.ChatEncryption.updateEncryptionButton(document, this.conv); + } + break; + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + + this.loaded = false; + this._readCount = 0; + this._statusText = ""; + this._pendingValueChangedCall = false; + this._nickEscape = /[[\]{}()*+?.\\^$|]/g; + this._currentTypingName = ""; + + // This value represents the difference between the deck's height and the + // textbox's content height (borders, margins, paddings). + // Differ according to the Operating System native theme. + this._TEXTBOX_VERTICAL_OVERHEAD = 0; + + // Ratio textbox height / conversation height. + // 0.1 means that the textbox's height is 10% of the conversation's height. + this._TEXTBOX_RATIO = 0.1; + + this.setAttribute("orient", "vertical"); + this.setAttribute("flex", "1"); + this.classList.add("convBox"); + + this.convTop = document.createXULElement("vbox"); + this.convTop.setAttribute("flex", "1"); + this.convTop.classList.add("conv-top"); + + this.notification = document.createXULElement("vbox"); + + this.convBrowser = document.createXULElement("browser", { + is: "conversation-browser", + }); + this.convBrowser.setAttribute("flex", "1"); + this.convBrowser.setAttribute("type", "content"); + this.convBrowser.setAttribute("messagemanagergroup", "browsers"); + + this.progressBar = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "progress" + ); + this.progressBar.setAttribute("hidden", "hidden"); + + this.findbar = document.createXULElement("findbar"); + this.findbar.setAttribute("reversed", "true"); + + this.convTop.appendChild(this.notification); + this.convTop.appendChild(this.convBrowser); + this.convTop.appendChild(this.progressBar); + this.convTop.appendChild(this.findbar); + + this.splitter = document.createXULElement("splitter"); + this.splitter.setAttribute("orient", "vertical"); + this.splitter.classList.add("splitter"); + + this.convStatusContainer = document.createXULElement("hbox"); + this.convStatusContainer.setAttribute("hidden", "true"); + this.convStatusContainer.classList.add("conv-status-container"); + + this.convStatus = document.createXULElement("description"); + this.convStatus.classList.add("plain"); + this.convStatus.classList.add("conv-status"); + this.convStatus.setAttribute("crop", "end"); + + this.convStatusContainer.appendChild(this.convStatus); + + this.convBottom = document.createXULElement("stack"); + this.convBottom.classList.add("conv-bottom"); + + this.inputBox = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "textarea" + ); + this.inputBox.classList.add("conv-textbox"); + + this.charCounter = document.createXULElement("description"); + this.charCounter.classList.add("conv-counter"); + this.convBottom.appendChild(this.inputBox); + this.convBottom.appendChild(this.charCounter); + + this.appendChild(this.convTop); + this.appendChild(this.splitter); + this.appendChild(this.convStatusContainer); + this.appendChild(this.convBottom); + + this.inputBox.addEventListener("keypress", this.inputKeyPress.bind(this)); + this.inputBox.addEventListener( + "input", + this.inputValueChanged.bind(this) + ); + this.inputBox.addEventListener( + "overflow", + this.inputExpand.bind(this), + true + ); + this.inputBox.addEventListener( + "underflow", + this._onTextboxUnderflow, + true + ); + + new MutationObserver( + function (aMutations) { + for (let mutation of aMutations) { + if (mutation.oldValue == "dragging") { + this._onSplitterChange(); + break; + } + } + }.bind(this) + ).observe(this.splitter, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["state"], + }); + + this.convBrowser.addEventListener( + "keypress", + this.browserKeyPress.bind(this) + ); + this.convBrowser.addEventListener( + "dblclick", + this.browserDblClick.bind(this) + ); + Services.obs.addObserver(this.observer, "conversation-loaded"); + + // @implements {nsIObserver} + this.prefObserver = (subject, topic, data) => { + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + this.inputBox.setAttribute("spellcheck", "true"); + this.spellchecker.enabled = true; + } else { + this.inputBox.removeAttribute("spellcheck"); + this.spellchecker.enabled = false; + } + }; + Services.prefs.addObserver("mail.spellcheck.inline", this.prefObserver); + + this.initializeAttributeInheritance(); + } + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + this.notification.prepend(element); + }); + } + return this._notificationBox; + } + + destroy() { + if (this._conv) { + this._forgetConv(); + } + + Services.prefs.removeObserver( + "mail.spellcheck.inline", + this.prefObserver + ); + } + + _forgetConv(shouldClose) { + this._conv.removeObserver(this.observer); + delete this._conv; + this.convBrowser.destroy(); + this.findbar.destroy(); + } + + close() { + this._forgetConv(true); + } + + _showFirstMessages() { + this.loaded = true; + let messages = this._conv.getMessages(); + this._readCount = messages.length - this._conv.unreadMessageCount; + if (this._readCount) { + this._writingContextMessages = true; + } + messages.forEach(this.addMsg.bind(this)); + delete this._writingContextMessages; + + if (this.tab && this.tab.selected && document.hasFocus()) { + // This will mark the conv as read, but also update the conv title + // with the new unread count etc. + this.tab.update(); + } + } + + displayStatusText() { + this.convStatus.value = this._statusText; + if (this._statusText) { + this.convStatusContainer.removeAttribute("hidden"); + } else { + this.convStatusContainer.setAttribute("hidden", "true"); + } + } + + addMsg(aMsg) { + if (!this.loaded) { + throw new Error("Calling addMsg before the browser is ready?"); + } + + var conv = aMsg.conversation; + if (!conv) { + // The conversation has already been destroyed, + // probably because the window was closed. + // Return without doing anything. + return false; + } + + // Ugly hack... :( + if (!aMsg.system && conv.isChat) { + let name = aMsg.who; + let color; + if (this.buddies.has(name)) { + let buddy = this.buddies.get(name); + color = buddy.color; + buddy.removeAttribute("inactive"); + this._activeBuddies[name] = true; + } else { + // Buddy no longer in the room + color = this._computeColor(name); + } + aMsg.color = "color: hsl(" + color + ", 100%, 40%);"; + } + + // Porting note: In TB, this.tab points at the imconv richlistitem element. + let read = this._readCount > 0; + let isUnreadMessage = !read && aMsg.incoming && !aMsg.system; + let isTabFocused = this.tab && this.tab.selected && document.hasFocus(); + let shouldSetUnreadFlag = this.tab && isUnreadMessage && !isTabFocused; + let firstUnread = + this.tab && + !this.tab.hasAttribute("unread") && + isUnreadMessage && + this._isAfterFirstRealMessage && + (!isTabFocused || this._writingContextMessages); + + // Since the unread flag won't be set if the tab is focused, + // we need the following when showing the first messages to stop + // firstUnread being set for subsequent messages. + if (firstUnread) { + delete this._writingContextMessages; + } + + this.convBrowser.appendMessage(aMsg, read, firstUnread); + if (!aMsg.system) { + this._isAfterFirstRealMessage = true; + } + + if (read) { + --this._readCount; + if (!this._readCount && !this._isAfterFirstRealMessage) { + // If all the context messages were system messages, we don't want + // an unread ruler after the context messages, so we forget that + // we had context messages. + delete this._writingContextMessages; + } + return false; + } + + if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) { + this._lastPing = aMsg.who; + this._lastPingTime = aMsg.time; + } + + if (shouldSetUnreadFlag) { + if (conv.isChat && aMsg.containsNick) { + this.tab.setAttribute("attention", "true"); + } + this.tab.setAttribute("unread", "true"); + } + + return isTabFocused; + } + + /** + * Updates an existing message with the matching remote ID. + * + * @param {imIMessage} aMsg - Message to update. + */ + updateMsg(aMsg) { + if (!this.loaded) { + throw new Error("Calling updateMsg before the browser is ready?"); + } + + var conv = aMsg.conversation; + if (!conv) { + // The conversation has already been destroyed, + // probably because the window was closed. + // Return without doing anything. + return; + } + + // Update buddy color. + // Ugly hack... :( + if (!aMsg.system && conv.isChat) { + let name = aMsg.who; + let color; + if (this.buddies.has(name)) { + let buddy = this.buddies.get(name); + color = buddy.color; + buddy.removeAttribute("inactive"); + this._activeBuddies[name] = true; + } else { + // Buddy no longer in the room + color = this._computeColor(name); + } + aMsg.color = "color: hsl(" + color + ", 100%, 40%);"; + } + + this.convBrowser.replaceMessage(aMsg); + } + + /** + * Removes an existing message with matching remote ID. + * + * @param {string} remoteId - Remote ID of the message to remove. + */ + removeMsg(remoteId) { + if (!this.loaded) { + throw new Error("Calling removeMsg before the browser is ready?"); + } + + this.convBrowser.removeMessage(remoteId); + } + + sendMsg(aMsg) { + if (!aMsg) { + return; + } + + let account = this._conv.account; + + if (aMsg.startsWith("/")) { + let convToFocus = {}; + + // The /say command is used to bypass command processing + // (/say can be shortened to just /). + // "/say" or "/say " should be ignored, as should "/" and "/ ". + if (aMsg.match(/^\/(?:say)? ?$/)) { + this.resetInput(); + return; + } + + if (aMsg.match(/^\/(?:say)? .*/)) { + aMsg = aMsg.slice(aMsg.indexOf(" ") + 1); + } else if ( + IMServices.cmd.executeCommand(aMsg, this._conv.target, convToFocus) + ) { + this._conv.sendTyping(""); + this.resetInput(); + if (convToFocus.value) { + chatHandler.focusConversation(convToFocus.value); + } + return; + } + + if (account.protocol.slashCommandsNative && account.connected) { + let cmd = aMsg.match(/^\/[^ ]+/); + if (cmd && cmd != "/me") { + this._conv.systemMessage( + this.bundle.formatStringFromName("unknownCommand", [cmd], 1), + true + ); + return; + } + } + } + + this._conv.sendMsg(aMsg, false, false); + + // reset the textbox to its original size + this.resetInput(); + } + + _onSplitterChange() { + // set the default height as the deck height (modified by the splitter) + this.inputBox.defaultHeight = + parseInt(this.inputBox.parentNode.getBoundingClientRect().height) - + this._TEXTBOX_VERTICAL_OVERHEAD; + } + + calculateTextboxDefaultHeight() { + let totalSpace = parseInt( + window.getComputedStyle(this).getPropertyValue("height") + ); + let textboxStyle = window.getComputedStyle(this.inputBox); + let lineHeight = textboxStyle.lineHeight; + if (lineHeight == "normal") { + lineHeight = parseFloat(textboxStyle.fontSize) * 1.2; + } else { + lineHeight = parseFloat(lineHeight); + } + + // Compute the overhead size. + let textboxHeight = this.inputBox.clientHeight; + let deckHeight = this.inputBox.parentNode.getBoundingClientRect().height; + this._TEXTBOX_VERTICAL_OVERHEAD = deckHeight - textboxHeight; + + // Calculate the number of lines to display. + let numberOfLines = Math.round( + (totalSpace * this._TEXTBOX_RATIO) / lineHeight + ); + if (numberOfLines <= 0) { + numberOfLines = 1; + } + if (!this._maxEmptyLines) { + this._maxEmptyLines = Services.prefs.getIntPref( + "messenger.conversations.textbox.defaultMaxLines" + ); + } + + if (numberOfLines > this._maxEmptyLines) { + numberOfLines = this._maxEmptyLines; + } + this.inputBox.defaultHeight = numberOfLines * lineHeight; + + // set minimum height (in case the user moves the splitter) + this.inputBox.parentNode.style.minHeight = + lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + } + + initTextboxFormat() { + // Init the textbox size + this.calculateTextboxDefaultHeight(); + this.inputBox.parentNode.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + this.inputBox.style.overflowY = "hidden"; + + this.spellchecker = new InlineSpellChecker(this.inputBox); + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + this.inputBox.setAttribute("spellcheck", "true"); + this.spellchecker.enabled = true; + } else { + this.inputBox.removeAttribute("spellcheck"); + this.spellchecker.enabled = false; + } + } + + // eslint-disable-next-line complexity + inputKeyPress(event) { + let text = this.inputBox.value; + + const navKeyCodes = [ + KeyEvent.DOM_VK_PAGE_UP, + KeyEvent.DOM_VK_PAGE_DOWN, + KeyEvent.DOM_VK_HOME, + KeyEvent.DOM_VK_END, + KeyEvent.DOM_VK_UP, + KeyEvent.DOM_VK_DOWN, + ]; + + // Pass navigation keys to the browser if + // 1) the textbox is empty or 2) it's an IB-specific key combination + if ( + (!text && navKeyCodes.includes(event.keyCode)) || + ((event.shiftKey || event.altKey) && + (event.keyCode == KeyEvent.DOM_VK_PAGE_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN)) + ) { + let newEvent = new KeyboardEvent("keypress", event); + event.preventDefault(); + event.stopPropagation(); + // Keyboard events must be sent to the focused element for bubbling to work. + this.convBrowser.focus(); + this.convBrowser.dispatchEvent(newEvent); + this.inputBox.focus(); + return; + } + + // When attempting to copy an empty selection, copy the + // browser selection instead (see bug 693). + // The 'C' won't be lowercase if caps lock is enabled. + if ( + (event.charCode == 99 /* 'c' */ || + (event.charCode == 67 /* 'C' */ && !event.shiftKey)) && + (navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey) && + this.inputBox.selectionStart == this.inputBox.selectionEnd + ) { + this.convBrowser.doCommand(); + return; + } + + // We don't want to enable tab completion if the user has selected + // some text, as it's not clear what the user would expect + // to happen in that case. + let noSelection = !( + this.inputBox.selectionEnd - this.inputBox.selectionStart + ); + + // Undo tab complete. + if ( + noSelection && + this._completions && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + if (text == this._beforeTabComplete) { + // Nothing to undo, so let backspace act normally. + delete this._completions; + } else { + event.preventDefault(); + + // First undo the comma separating multiple nicks or the suffix. + // More than one nick: + // "nick1, nick2: " -> "nick1: nick2" + // Single nick: remove the suffix + // "nick1: " -> "nick1" + let pos = this.inputBox.selectionStart; + const suffix = ": "; + if ( + pos > suffix.length && + text.substring(pos - suffix.length, pos) == suffix + ) { + let completions = Array.from(this.buddies.keys()); + // Check if the preceding words are a sequence of nick completions. + let preceding = text.substring(0, pos - suffix.length).split(", "); + if (preceding.every(n => completions.includes(n))) { + let s = preceding.pop(); + if (preceding.length) { + s = suffix + s; + } + this.inputBox.selectionStart -= s.length + suffix.length; + this.addString(s); + if (this._completions[0].slice(-suffix.length) == suffix) { + this._completions = this._completions.map(c => + c.slice(0, -suffix.length) + ); + } + if ( + this._completions.length == 1 && + this.inputBox.value == this._beforeTabComplete + ) { + // Nothing left to undo or to cycle through. + delete this._completions; + } + return; + } + } + + // Full undo. + this.inputBox.selectionStart = 0; + this.addString(this._beforeTabComplete); + delete this._completions; + return; + } + } + + // Tab complete. + // Keep the default behavior of the tab key if the input box + // is empty or a modifier is used. + if ( + event.keyCode == KeyEvent.DOM_VK_TAB && + text.length != 0 && + noSelection && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (!event.shiftKey || this._completions) + ) { + event.preventDefault(); + + if (this._completions) { + // Tab has been pressed more than once. + if (this._completions.length == 1) { + return; + } + if (this._shouldListCompletionsLater) { + this._conv.systemMessage(this._shouldListCompletionsLater); + delete this._shouldListCompletionsLater; + } + + this.inputBox.selectionStart = this._completionsStart; + if (event.shiftKey) { + // Reverse cycle completions. + this._completionsIndex -= 2; + if (this._completionsIndex < 0) { + this._completionsIndex += this._completions.length; + } + } + this.addString(this._completions[this._completionsIndex++]); + this._completionsIndex %= this._completions.length; + return; + } + + let completions = []; + let firstWordSuffix = " "; + let secondNick = false; + + // Second regex result will contain word without leading special characters. + this._beforeTabComplete = text.substring( + 0, + this.inputBox.selectionStart + ); + let words = this._beforeTabComplete.match(/\S*?([\w-]+)?$/); + let word = words[0]; + if (!word) { + return; + } + let isFirstWord = this.inputBox.selectionStart == word.length; + + // Check if we are completing a command. + let completingCommand = isFirstWord && word[0] == "/"; + if (completingCommand) { + for (let cmd of IMServices.cmd.listCommandsForConversation( + this._conv + )) { + // It's possible to have a global and a protocol specific command + // with the same name. Avoid duplicates in the |completions| array. + let name = "/" + cmd.name; + if (!completions.includes(name)) { + completions.push(name); + } + } + } else { + // If it's not a command, the only thing we can complete is a nick. + if (!this._conv.isChat) { + return; + } + + firstWordSuffix = ": "; + completions = Array.from(this.buddies.keys()); + + let outgoingNick = this._conv.nick; + completions = completions.filter(c => c != outgoingNick); + + // Check if the preceding words are a sequence of nick completions. + let wordStart = this.inputBox.selectionStart - word.length; + if (wordStart > 2) { + let separator = text.substring(wordStart - 2, wordStart); + if (separator == ": " || separator == ", ") { + let preceding = text.substring(0, wordStart - 2).split(", "); + if (preceding.every(n => completions.includes(n))) { + secondNick = true; + isFirstWord = true; + // Remove preceding completions from possible completions. + completions = completions.filter(c => !preceding.includes(c)); + } + } + } + } + + // Keep only the completions that share |word| as a prefix. + // Be case insensitive only if |word| is entirely lower case. + let condition; + if (word.toLocaleLowerCase() == word) { + condition = c => c.toLocaleLowerCase().startsWith(word); + } else { + condition = c => c.startsWith(word); + } + let matchingCompletions = completions.filter(condition); + if (!matchingCompletions.length && words[1]) { + word = words[1]; + firstWordSuffix = " "; + matchingCompletions = completions.filter(condition); + } + if (!matchingCompletions.length) { + return; + } + + // If the cursor is in the middle of a word, and the word is a nick, + // there is no need to complete - just jump to the end of the nick. + let wholeWord = text.substring( + this.inputBox.selectionStart - word.length + ); + for (let completion of matchingCompletions) { + if (wholeWord.lastIndexOf(completion, 0) == 0) { + let moveCursor = completion.length - word.length; + this.inputBox.selectionStart += moveCursor; + let separator = text.substring( + this.inputBox.selectionStart, + this.inputBox.selectionStart + 2 + ); + if (separator == ": " || separator == ", ") { + this.inputBox.selectionStart += 2; + } else if (!moveCursor) { + // If we're already at the end of a nick, carry on to display + // a list of possible alternatives and/or apply punctuation. + break; + } + return; + } + } + + // We have possible completions! + this._completions = matchingCompletions.sort(); + this._completionsIndex = 0; + // Save now the first and last completions in alphabetical order, + // as we will need them to find a common prefix. However they may + // not be the first and last completions in the list of completions + // actually exposed to the user, as if there are active nicks + // they will be moved to the beginning of the list. + let firstCompletion = this._completions[0]; + let lastCompletion = this._completions.slice(-1)[0]; + + let preferredNick = false; + if (this._conv.isChat && !completingCommand) { + // If there are active nicks, prefer those. + let activeCompletions = this._completions.filter( + c => + this.buddies.has(c) && + !this.buddies.get(c).hasAttribute("inactive") + ); + if (activeCompletions.length == 1) { + preferredNick = true; + } + if (activeCompletions.length) { + // Move active nicks to the front of the queue. + activeCompletions.reverse(); + activeCompletions.forEach(function (c) { + this._completions.splice(this._completions.indexOf(c), 1); + this._completions.unshift(c); + }, this); + } + + // If one of the completions is the sender of the last ping, + // take it, if it was less than an hour ago. + if ( + this._lastPing && + this.buddies.has(this._lastPing) && + this._completions.includes(this._lastPing) && + Date.now() / 1000 - this._lastPingTime < 3600 + ) { + preferredNick = true; + this._completionsIndex = this._completions.indexOf(this._lastPing); + } + } + + // Display the possible completions in a system message. + delete this._shouldListCompletionsLater; + if (this._completions.length > 1) { + let completionsList = this._completions.join(" "); + if (preferredNick) { + // If we have a preferred nick (which is completed as a whole + // even if there are alternatives), only show the list of + // completions on the next <tab> press. + this._shouldListCompletionsLater = completionsList; + } else { + this._conv.systemMessage(completionsList); + } + } + + let suffix = isFirstWord ? firstWordSuffix : ""; + this._completions = this._completions.map(c => c + suffix); + + let completion; + if (this._completions.length == 1 || preferredNick) { + // Only one possible completion? Apply it! :-) + completion = this._completions[this._completionsIndex++]; + this._completionsIndex %= this._completions.length; + } else { + // We have several possible completions, attempt to find a common prefix. + let maxLength = Math.min( + firstCompletion.length, + lastCompletion.length + ); + let i = 0; + while (i < maxLength && firstCompletion[i] == lastCompletion[i]) { + ++i; + } + + if (i) { + completion = firstCompletion.substring(0, i); + } else { + // Include this case so that secondNick is applied anyway, + // in case a completion is added by another tab press. + completion = word; + } + } + + // Always replace what the user typed as its upper/lowercase may + // not be correct. + this.inputBox.selectionStart -= word.length; + this._completionsStart = this.inputBox.selectionStart; + + if (secondNick) { + // Replace the trailing colon with a comma before the completed nick. + this.inputBox.selectionStart -= 2; + completion = ", " + completion; + } + + this.addString(completion); + } else if (this._completions) { + delete this._completions; + } + + if (event.keyCode != 13) { + return; + } + + if (!event.ctrlKey && !event.shiftKey && !event.altKey) { + // Prevent the default action before calling sendMsg to avoid having + // a line break inserted in the textbox if sendMsg throws. + event.preventDefault(); + this.sendMsg(text); + } else if (!event.shiftKey) { + this.addString("\n"); + } + } + + inputValueChanged() { + // Delaying typing notifications will avoid sending several updates in + // a row if the user is on a slow or overloaded machine that has + // trouble to handle keystrokes in a timely fashion. + // Make sure only one typing notification call can be pending. + if (this._pendingValueChangedCall) { + return; + } + + this._pendingValueChangedCall = true; + Services.tm.mainThread.dispatch( + this.delayedInputValueChanged.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + } + + delayedInputValueChanged() { + this._pendingValueChangedCall = false; + + // By the time this function is executed, the conversation may have + // been closed. + if (!this._conv) { + return; + } + + let text = this.inputBox.value; + + // Try to avoid sending typing notifications when the user is + // typing a command in the conversation. + // These checks are not perfect (especially if non-existing + // commands are sent as regular messages on the in-use prpl). + let left = Ci.prplIConversation.NO_TYPING_LIMIT; + if (!text.startsWith("/")) { + left = this._conv.sendTyping(text); + } else if (/^\/me /.test(text)) { + left = this._conv.sendTyping(text.slice(4)); + } + + // When the input box is cleared or there is no character limit, + // don't show the character limit. + if (left == Ci.prplIConversation.NO_TYPING_LIMIT || !text.length) { + this.charCounter.setAttribute("value", ""); + this.inputBox.removeAttribute("invalidInput"); + } else { + // 200 is a 'magic' constant to avoid showing big numbers. + this.charCounter.setAttribute("value", left < 200 ? left : ""); + + if (left >= 0) { + this.inputBox.removeAttribute("invalidInput"); + } else if (left < 0) { + this.inputBox.setAttribute("invalidInput", "true"); + } + } + } + + resetInput() { + this.inputBox.value = ""; + this.charCounter.setAttribute("value", ""); + this.inputBox.removeAttribute("invalidInput"); + + this._statusText = ""; + this.displayStatusText(); + + if (TextboxSize.autoResize) { + let currHeight = Math.round( + this.inputBox.parentNode.getBoundingClientRect().height + ); + if ( + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD > + currHeight + ) { + this.inputBox.defaultHeight = + currHeight - this._TEXTBOX_VERTICAL_OVERHEAD; + } + this.convBottom.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + this.inputBox.style.overflowY = "hidden"; + } + } + + inputExpand(event) { + // This feature has been disabled, or the user is currently dragging + // the splitter and the textbox has received an overflow event + if ( + !TextboxSize.autoResize || + this.splitter.getAttribute("state") == "dragging" + ) { + this.inputBox.style.overflowY = ""; + return; + } + + // Check whether we can increase the height without hiding the status bar + // (ensure the min-height property on the top part of this dialog) + let topBoxStyle = window.getComputedStyle(this.convTop); + let topMinSize = parseInt(topBoxStyle.getPropertyValue("min-height")); + let topSize = parseInt(topBoxStyle.getPropertyValue("height")); + let deck = this.inputBox.parentNode; + let oldDeckHeight = Math.round(deck.getBoundingClientRect().height); + let newDeckHeight = + parseInt(this.inputBox.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD; + + if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) { + // Hide a possible vertical scrollbar. + this.inputBox.style.overflowY = "hidden"; + deck.style.height = newDeckHeight + "px"; + } else { + this.inputBox.style.overflowY = ""; + // Set it to the maximum possible value. + deck.style.height = oldDeckHeight + (topSize - topMinSize) + "px"; + } + } + + onConvResize() { + if (!this.splitter.hasAttribute("state")) { + this.calculateTextboxDefaultHeight(); + this.inputBox.parentNode.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + } else { + // Used in case the browser is already on its min-height, resize the + // textbox to avoid hiding the status bar. + let convTopStyle = window.getComputedStyle(this.convTop); + let convTopHeight = parseInt(convTopStyle.getPropertyValue("height")); + let convTopMinHeight = parseInt( + convTopStyle.getPropertyValue("min-height") + ); + + if (convTopHeight == convTopMinHeight) { + this.inputBox.parentNode.style.height = + this.inputBox.parentNode.style.minHeight; + convTopHeight = parseInt(convTopStyle.getPropertyValue("height")); + this.inputBox.parentNode.style.height = + parseInt(this.inputBox.parentNode.style.minHeight) + + (convTopHeight - convTopMinHeight) + + "px"; + } + } + if (TextboxSize.autoResize) { + this.inputExpand(); + } + } + + _onTextboxUnderflow(event) { + if (TextboxSize.autoResize) { + this.style.overflowY = "hidden"; + } + } + + browserKeyPress(event) { + let accelKeyPressed = + AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey; + + // 118 is the decimal code for "v" character, 13 keyCode for "return" key + if ( + ((accelKeyPressed && event.charCode != 118) || event.altKey) && + event.keyCode != 13 + ) { + return; + } + + if ( + event.charCode == 0 && // it's not a character, it's a command key + event.keyCode != 13 && // Return + event.keyCode != 8 && // Backspace + event.keyCode != 46 + ) { + // Delete + return; + } + + if ( + accelKeyPressed || + !Services.prefs.getBoolPref("accessibility.typeaheadfind") + ) { + this.inputBox.focus(); + + // A common use case is to click somewhere in the conversation and + // start typing a command (often /me). If quick find is enabled, it + // will pick up the "/" keypress and open the findbar. + if (event.charCode == "/".charCodeAt(0)) { + event.preventDefault(); + } + } + + // Returns for Ctrl+V + if (accelKeyPressed) { + return; + } + + // resend the event + let clonedEvent = new KeyboardEvent("keypress", event); + this.inputBox.dispatchEvent(clonedEvent); + } + + browserDblClick(event) { + if ( + !Services.prefs.getBoolPref( + "messenger.conversations.doubleClickToReply" + ) + ) { + return; + } + + for (let node = event.target; node; node = node.parentNode) { + if (node._originalMsg) { + let msg = node._originalMsg; + if ( + msg.system || + msg.outgoing || + !msg.incoming || + msg.error || + !this._conv.isChat + ) { + return; + } + this.addPrompt(msg.who + ": "); + return; + } + } + } + + /** + * Replace the current selection in the inputBox by the given string + * + * @param {string} aString + */ + addString(aString) { + let cursorPosition = this.inputBox.selectionStart + aString.length; + + this.inputBox.value = + this.inputBox.value.substr(0, this.inputBox.selectionStart) + + aString + + this.inputBox.value.substr(this.inputBox.selectionEnd); + this.inputBox.selectionStart = this.inputBox.selectionEnd = + cursorPosition; + this.inputValueChanged(); + } + + addPrompt(aPrompt) { + let currentEditorValue = this.inputBox.value; + if (!currentEditorValue.startsWith(aPrompt)) { + this.inputBox.value = aPrompt + currentEditorValue; + } + + this.inputBox.focus(); + this.inputValueChanged(); + } + + /** + * Update the participant count of a chat conversation + */ + updateParticipantCount() { + document.getElementById("participantCount").value = this.buddies.size; + } + + /** + * Set the attributes (flags) of a chat buddy + * + * @param {object} aItem + */ + setBuddyAttributes(aItem) { + let buddy = aItem.chatBuddy; + let src; + let l10nId; + if (buddy.founder) { + src = "chrome://messenger/skin/icons/founder.png"; + l10nId = "chat-participant-owner-role-icon2"; + } else if (buddy.admin) { + src = "chrome://messenger/skin/icons/operator.png"; + l10nId = "chat-participant-administrator-role-icon2"; + } else if (buddy.moderator) { + src = "chrome://messenger/skin/icons/half-operator.png"; + l10nId = "chat-participant-moderator-role-icon2"; + } else if (buddy.voiced) { + src = "chrome://messenger/skin/icons/voice.png"; + l10nId = "chat-participant-voiced-role-icon2"; + } + let imageEl = aItem.querySelector(".conv-nicklist-image"); + if (src) { + imageEl.setAttribute("src", src); + document.l10n.setAttributes(imageEl, l10nId); + } else { + imageEl.removeAttribute("src"); + imageEl.removeAttribute("data-l10n-id"); + imageEl.removeAttribute("alt"); + } + } + + /** + * Compute color for a nick + * + * @param {string} aName + */ + _computeColor(aName) { + // Compute the color based on the nick + let nick = aName.match(/[a-zA-Z0-9]+/); + nick = nick ? nick[0].toLowerCase() : (nick = aName); + // We compute a hue value (between 0 and 359) based on the + // characters of the nick. + // The first character weights kInitialWeight, each following + // character weights kWeightReductionPerChar * the weight of the + // previous character. + const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters. + const kWeightReductionPerChar = 0.52; // arbitrary value + let weight = kInitialWeight; + let res = 0; + for (let i = 0; i < nick.length; ++i) { + let char = nick.charCodeAt(i) - 47; + if (char > 10) { + char -= 39; + } + // now char contains a value between 1 and 36 + res += char * weight; + weight *= kWeightReductionPerChar; + } + return Math.round(res) % 360; + } + + _isBuddyActive(aBuddyName) { + return Object.prototype.hasOwnProperty.call( + this._activeBuddies, + aBuddyName + ); + } + + /** + * Create a buddy item to add in the visible list of participants + * + * @param {object} aBuddy + */ + createBuddy(aBuddy) { + let name = aBuddy.name; + if (!name) { + throw new Error("The empty string isn't a valid nick."); + } + if (this.buddies.has(name)) { + throw new Error("Adding chat buddy " + name + " twice?!"); + } + + this.trackNick(name); + + let image = document.createElement("img"); + image.classList.add("conv-nicklist-image"); + let label = document.createXULElement("label"); + label.classList.add("conv-nicklist-label"); + label.setAttribute("value", name); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + + // Fix insertBuddy below if you change the DOM makeup! + let item = document.createXULElement("richlistitem"); + item.chatBuddy = aBuddy; + item.appendChild(image); + item.appendChild(label); + this.setBuddyAttributes(item); + + let color = this._computeColor(name); + let style = "color: hsl(" + color + ", 100%, 40%);"; + item.colorStyle = style; + item.setAttribute("style", style); + item.setAttribute("align", "center"); + if (!this._isBuddyActive(name)) { + item.setAttribute("inactive", "true"); + } + item.color = color; + this.buddies.set(name, item); + + return item; + } + + /** + * Insert item at the right position + * + * @param {Node} aListItem + */ + insertBuddy(aListItem) { + let nicklist = document.getElementById("nicklist"); + let nick = aListItem.querySelector("label").value.toLowerCase(); + + // Look for the place of the nick in the list + let start = 0; + let end = nicklist.itemCount; + while (start < end) { + let middle = start + Math.floor((end - start) / 2); + if ( + nick < + nicklist + .getItemAtIndex(middle) + .firstElementChild.nextElementSibling.getAttribute("value") + .toLowerCase() + ) { + end = middle; + } else { + start = middle + 1; + } + } + + // Now insert the element + if (end == nicklist.itemCount) { + nicklist.appendChild(aListItem); + } else { + nicklist.insertBefore(aListItem, nicklist.getItemAtIndex(end)); + } + } + + /** + * Update a buddy in the visible list of participants + * + * @param {object} aBuddy + * @param {string} aOldName + */ + updateBuddy(aBuddy, aOldName) { + let name = aBuddy.name; + if (!name) { + throw new Error("The empty string isn't a valid nick."); + } + + if (!aOldName) { + if (!this._isConversationSelected) { + return; + } + // If aOldName is null, we are changing the flags of the buddy + let item = this.buddies.get(name); + item.chatBuddy = aBuddy; + this.setBuddyAttributes(item); + return; + } + + if (this._isBuddyActive(aOldName)) { + delete this._activeBuddies[aOldName]; + this._activeBuddies[aBuddy.name] = true; + } + + this.trackNick(name); + + if (!this._isConversationSelected) { + return; + } + + // Is aOldName is not null, then we are renaming the buddy + if (!this.buddies.has(aOldName)) { + throw new Error( + "Updating a chat buddy that does not exist: " + aOldName + ); + } + + if (this.buddies.has(name)) { + throw new Error( + "Updating a chat buddy to an already existing one: " + name + ); + } + + let item = this.buddies.get(aOldName); + item.chatBuddy = aBuddy; + this.buddies.delete(aOldName); + this.buddies.set(name, item); + item.querySelector("label").value = name; + + // Move this item to the right position if its name changed + item.remove(); + this.insertBuddy(item); + } + + removeBuddy(aName) { + if (!this.buddies.has(aName)) { + throw new Error("Cannot remove a buddy that was not in the room"); + } + this.buddies.get(aName).remove(); + this.buddies.delete(aName); + if (this._isBuddyActive(aName)) { + delete this._activeBuddies[aName]; + } + } + + trackNick(aNick) { + if ("_showNickList" in this) { + this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true; + delete this._showNickRegExp; + } + } + + getShowNickModifier() { + return function (aNode) { + if (!("_showNickRegExp" in this)) { + if (!("_showNickList" in this)) { + this._showNickList = {}; + for (let n of this.buddies.keys()) { + this._showNickList[n.replace(this._nickEscape, "\\$&")] = true; + } + } + + // The reverse sort ensures that if we have "foo" and "foobar", + // "foobar" will be matched first by the regexp. + let nicks = Object.keys(this._showNickList) + .sort() + .reverse() + .join("|"); + if (nicks) { + // We use \W to match for word-boundaries, as \b will not match the + // nick if it starts/ends with \W characters. + // XXX Ideally we would use unicode word boundaries: + // http://www.unicode.org/reports/tr29/#Word_Boundaries + this._showNickRegExp = new RegExp("\\W(?:" + nicks + ")\\W"); + } else { + // nobody, disable... + this._showNickRegExp = { exec: () => null }; + return 0; + } + } + let exp = this._showNickRegExp; + let result = 0; + let match; + // Add leading/trailing spaces to match at beginning and end of + // the string as well. (If we used regex ^ and $, match.index would + // not be reliable.) + while ((match = exp.exec(" " + aNode.data + " "))) { + // \W is not zero-length, but this is cancelled by the + // extra leading space here. + let nickNode = aNode.splitText(match.index); + // subtract the 2 \W's to get the length of the nick. + aNode = nickNode.splitText(match[0].length - 2); + // at this point, nickNode is a text node with only the text + // of the nick and aNode is a text node with the text after + // the nick. The text in aNode hasn't been processed yet. + let nick = nickNode.data; + let elt = aNode.ownerDocument.createElement("span"); + elt.setAttribute("class", "ib-nick"); + if (this.buddies.has(nick)) { + let buddy = this.buddies.get(nick); + elt.setAttribute("style", buddy.colorStyle); + elt.setAttribute("data-nickColor", buddy.color); + } else { + elt.setAttribute("data-left", "true"); + } + nickNode.parentNode.replaceChild(elt, nickNode); + elt.textContent = nick; + result += 2; + } + return result; + }.bind(this); + } + + /** + * Display the topic and topic editable flag for the current MUC in the + * conversation header. + */ + updateTopic() { + let cti = document.getElementById("conv-top-info"); + let editable = !!this._conv.topicSettable; + + let topicText = this._conv.topic; + let noTopic = !topicText; + cti.setAsChat(topicText || this._conv.noTopicString, noTopic, editable); + } + + focus() { + this.inputBox.focus(); + + if (!this.loaded) { + return; + } + + if (this.tab) { + this.tab.removeAttribute("unread"); + this.tab.removeAttribute("attention"); + } + this._conv.markAsRead(); + } + + switchingToPanel() { + if (this._visibleTimer) { + return; + } + + // Start a timer to detect if the tab has been visible to the + // user for long enough to actually be seen (as opposed to the + // tab only being visible "accidentally in passing"). + delete this._wasVisible; + this._visibleTimer = setTimeout(() => { + this._wasVisible = true; + delete this._visibleTimer; + + // Porting note: For TB, we also need to update the conv title + // and reset the unread flag. In IB, this is done by tabbrowser. + this.tab.update(); + }, 1000); + this.convBrowser.isActive = true; + } + + switchingAwayFromPanel(aHidden) { + if (this._visibleTimer) { + clearTimeout(this._visibleTimer); + delete this._visibleTimer; + } + // Remove the unread ruler if the tab has been visible without + // interruptions for sufficiently long. + if (this._wasVisible) { + this.convBrowser.removeUnreadRuler(); + } + + if (aHidden) { + this.convBrowser.isActive = false; + } + } + + updateConvStatus() { + let cti = document.getElementById("conv-top-info"); + cti.setProtocol(this._conv.account.protocol); + + // Set the icon, potentially showing a fallback icon if this is an IM. + cti.setUserIcon(this._conv.convIconFilename, !this._conv.isChat); + + if (this._conv.isChat) { + this.updateTopic(); + cti.setAttribute("displayName", this._conv.title); + } else { + let displayName = this._conv.title; + let statusText = ""; + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + + let buddy = this._conv.buddy; + if (buddy?.account.connected) { + displayName = buddy.displayName; + statusText = buddy.statusText; + statusType = buddy.statusType; + } + cti.setAttribute("displayName", displayName); + + let statusName; + + let typingState = this._conv.typingState; + let typingName = this._currentTypingName || this._conv.title; + + switch (typingState) { + case Ci.prplIConvIM.TYPING: + statusName = "active-typing"; + statusText = this.bundle.formatStringFromName( + "chat.contactIsTyping", + [typingName], + 1 + ); + break; + case Ci.prplIConvIM.TYPED: + statusName = "paused-typing"; + statusText = this.bundle.formatStringFromName( + "chat.contactHasStoppedTyping", + [typingName], + 1 + ); + break; + default: + statusName = Status.toAttribute(statusType); + statusText = Status.toLabel(statusType, statusText); + break; + } + cti.setStatus(statusName, statusText); + } + } + + showParticipants() { + if (this._conv.isChat) { + let nicklist = document.getElementById("nicklist"); + while (nicklist.hasChildNodes()) { + nicklist.lastChild.remove(); + } + // Populate the nicklist + this.buddies = new Map(); + for (let n of this.conv.getParticipants()) { + this.createBuddy(n); + } + nicklist.append( + ...Array.from(this.buddies.keys()) + .sort((a, b) => a.localeCompare(b)) + .map(nick => this.buddies.get(nick)) + ); + this.updateParticipantCount(); + } + } + + /** + * Set up the shared conversation specific components (conversation browser + * references, status header, participants list, text input) for this + * conversation. + */ + initConversationUI() { + this._activeBuddies = {}; + if (this._conv.isChat) { + let cti = document.getElementById("conv-top-info"); + cti.setAttribute("displayName", this._conv.title); + + this.showParticipants(); + + if (Services.prefs.getBoolPref("messenger.conversations.showNicks")) { + this.convBrowser.addTextModifier(this.getShowNickModifier()); + } + } + + if (this.tab) { + this.tab.setAttribute("label", this._conv.title); + } + + this.findbar.browser = this.convBrowser; + + this.updateConvStatus(); + this.initTextboxFormat(); + } + + /** + * Change the UI Conversation attached to this component and its browser. + * Does not clear any existing messages in the conversation browser. + * + * @param {imIConversation} conv + */ + changeConversation(conv) { + this._conv.removeObserver(this.observer); + this._conv = conv; + this._conv.addObserver(this.observer); + this.convBrowser._conv = conv; + this.initConversationUI(); + } + + get editor() { + return this.inputBox; + } + + get _isConversationSelected() { + // TB-only: returns true if the chat conversation element is the currently + // selected one, i.e if it has to maintain the participant list. + // The JS property this.tab.selected is always false when the chat tab + // is inactive, so we need to double-check to be sure. + return this.tab.selected || this.tab.hasAttribute("selected"); + } + + get convId() { + return this._conv.id; + } + + get conv() { + return this._conv; + } + + set conv(val) { + if (this._conv && val) { + throw new Error("chat-conversation already initialized"); + } + if (!val) { + // this conversation has probably been moved to another + // tab. Forget the prplConversation so that it isn't + // closed when destroying this binding. + this._forgetConv(); + return; + } + this._conv = val; + this._conv.addObserver(this.observer); + this.convBrowser.init(this._conv); + this.initConversationUI(); + } + + get contentWindow() { + return this.convBrowser.contentWindow; + } + + get bundle() { + if (!this._bundle) { + this._bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + } + return this._bundle; + } + } + + customElements.define("chat-conversation", MozChatConversation); +} diff --git a/comm/mail/components/im/content/chat-group.js b/comm/mail/components/im/content/chat-group.js new file mode 100644 index 0000000000..80bf25159c --- /dev/null +++ b/comm/mail/components/im/content/chat-group.js @@ -0,0 +1,255 @@ +/* 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, MozElements */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozChatGroupRichlistitem widget displays chat group name and behave as a + * expansion twisty for groups such as "Conversations", + * "Online Contacts" and "Offline Contacts". + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatGroupRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + label: "value=name", + }; + } + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-group-richlistitem"); + this.setAttribute("collapsed", "true"); + + /* Here we use a div, rather than the usual img because the icon image + * relies on CSS -moz-locale-dir(rtl). The corresponding icon + * twisty-collapsed-rtl icon is not a simple mirror transformation of + * twisty-collapsed. + * Currently, CSS sets the background-image based on the "closed" state. + * The element is a visual decoration and does not require any alt text + * since the aria-expanded attribute describes its state. + */ + this._image = document.createElement("div"); + this._image.classList.add("twisty"); + + this._label = document.createXULElement("label"); + this._label.setAttribute("flex", "1"); + this._label.setAttribute("crop", "end"); + + this.appendChild(this._image); + this.appendChild(this._label); + + this.contacts = []; + + this.contactsById = {}; + + this.displayName = ""; + + this.addEventListener("click", event => { + // Check if there was 1 click on the image or 2 clicks on the label + if ( + (event.detail == 1 && event.target.classList.contains("twisty")) || + (event.detail == 2 && event.target.localName == "label") + ) { + this.toggleClosed(); + } else if (event.target.localName == "button") { + this.hide(); + } + }); + + this.addEventListener("contextmenu", event => { + event.preventDefault(); + }); + + if (this.classList.contains("closed")) { + this.setAttribute("aria-expanded", "true"); + } else { + this.setAttribute("aria-expanded", "false"); + } + + this.initializeAttributeInheritance(); + } + + /** + * Takes as input two contact elements (imIContact type) and compares + * their nicknames alphabetically (case insensitive). This method + * behaves as a callback that Array.prototype.sort accepts as a + * parameter. + */ + sortComparator(contactA, contactB) { + if (contactA.statusType != contactB.statusType) { + return contactB.statusType - contactA.statusType; + } + let a = contactA.displayName.toLowerCase(); + let b = contactB.displayName.toLowerCase(); + return a.localeCompare(b); + } + + addContact(contact, tagName) { + if (this.contactsById.hasOwnProperty(contact.id)) { + return null; + } + + let contactElt; + if (tagName) { + contactElt = document.createXULElement("richlistitem", { + is: "chat-imconv-richlistitem", + }); + } else { + contactElt = document.createXULElement("richlistitem", { + is: "chat-contact-richlistitem", + }); + } + if (this.classList.contains("closed")) { + contactElt.setAttribute("collapsed", "true"); + } + + let end = this.contacts.length; + // Avoid the binary search loop if the contacts were already sorted. + if ( + end != 0 && + this.sortComparator(contact, this.contacts[end - 1].contact) < 0 + ) { + let start = 0; + while (start < end) { + let middle = start + Math.floor((end - start) / 2); + if (this.sortComparator(contact, this.contacts[middle].contact) < 0) { + end = middle; + } else { + start = middle + 1; + } + } + } + let last = end == 0 ? this : this.contacts[end - 1]; + this.parentNode.insertBefore(contactElt, last.nextElementSibling); + contactElt.build(contact); + contactElt.group = this; + this.contacts.splice(end, 0, contactElt); + this.contactsById[contact.id] = contactElt; + this.removeAttribute("collapsed"); + this._updateGroupLabel(); + return contactElt; + } + + updateContactPosition(subject, tagName) { + let contactElt = this.contactsById[subject.id]; + let index = this.contacts.indexOf(contactElt); + if (index == -1) { + // Sometimes we get a display-name-changed notification for + // an offline contact, if it's not in the list, just ignore it. + return; + } + // See if the position of the contact should be changed. + if ( + (index != 0 && + this.sortComparator( + contactElt.contact, + this.contacts[index - 1].contact + ) < 0) || + (index != this.contacts.length - 1 && + this.sortComparator( + contactElt.contact, + this.contacts[index + 1].contact + ) > 0) + ) { + let list = this.parentNode; + let selectedItem = list.selectedItem; + let oldItem = this.removeContact(subject); + let newItem = this.addContact(subject, tagName); + if (selectedItem == oldItem) { + list.selectedItem = newItem; + } + } + } + + removeContact(contactForID) { + let contact = this.contactsById[contactForID.id]; + if (!contact) { + throw new Error("Can't remove contact for id=" + contactForID.id); + } + + // create a new array to remove without breaking for each loops. + this.contacts = this.contacts.filter(c => c !== contact); + delete this.contactsById[contact.contact.id]; + + contact.destroy(); + + // Check if some contacts remain in the group, if empty hide it. + if (!this.contacts.length) { + this.setAttribute("collapsed", "true"); + } else { + this._updateGroupLabel(); + } + + return contact; + } + + _updateClosedState(closed) { + for (let contact of this.contacts) { + contact.collapsed = closed; + } + } + + toggleClosed() { + if (this.classList.contains("closed")) { + this.classList.remove("closed"); + this.setAttribute("aria-expanded", "true"); + this._updateClosedState(false); + } else { + this.classList.add("closed"); + this.setAttribute("aria-expanded", "false"); + this._updateClosedState(true); + } + + this._updateGroupLabel(); + } + + _updateGroupLabel() { + if (!this.displayName) { + this.displayName = this.getAttribute("name"); + } + let name = this.displayName; + if (this.classList.contains("closed")) { + name += " (" + this.contacts.length + ")"; + } + + this.setAttribute("name", name); + } + + keyPress(event) { + switch (event.keyCode) { + case event.DOM_VK_RETURN: + this.toggleClosed(); + break; + + case event.DOM_VK_LEFT: + if (!this.classList.contains("closed")) { + this.toggleClosed(); + } + break; + + case event.DOM_VK_RIGHT: + if (this.classList.contains("closed")) { + this.toggleClosed(); + } + break; + } + } + } + + MozXULElement.implementCustomInterface(MozChatGroupRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("chat-group-richlistitem", MozChatGroupRichlistitem, { + extends: "richlistitem", + }); +} diff --git a/comm/mail/components/im/content/chat-imconv.js b/comm/mail/components/im/content/chat-imconv.js new file mode 100644 index 0000000000..759a3ce78a --- /dev/null +++ b/comm/mail/components/im/content/chat-imconv.js @@ -0,0 +1,366 @@ +/* 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, gChatTab, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + /** + * The MozChatConvRichlistitem widget displays opened conversation information from the + * contacts: i.e name and icon. It gets displayed under conversation expansion + * twisty in the contactlist richlistbox. + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatConvRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + ".box-line": "selected", + ".convDisplayName": "value=displayname,status", + ".convUnreadTargetedCount": "value=unreadTargetedCount", + ".convUnreadCount": "value=unreadCount", + ".convUnreadTargetedCountLabel": "value=unreadTargetedCount", + }; + } + + static get markup() { + return ` + <vbox class="box-line"></vbox> + <button class="closeConversationButton close-icon" + tooltiptext="&closeConversationButton.tooltip;"></button> + <stack class="prplBuddyIcon"> + <html:img class="protoIcon" alt="" /> + <html:img class="smallStatusIcon" /> + </stack> + <hbox flex="1" class="conv-hbox"> + <label crop="end" class="convDisplayName blistDisplayName"> + </label> + <label class="convUnreadCount" crop="end"></label> + <box class="convUnreadTargetedCount"> + <label class="convUnreadTargetedCountLabel" crop="end"></label> + </box> + <spacer style="flex: 1000000 1000000;"></spacer> + </hbox> + `; + } + + static get entities() { + return ["chrome://messenger/locale/chat.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-imconv-richlistitem"); + + this.addEventListener( + "mousedown", + event => { + if (event.target.classList.contains("closeConversationButton")) { + this.closeConversation(); + event.stopPropagation(); + event.preventDefault(); + } + }, + true + ); + + this.appendChild(this.constructor.fragment); + + this.convView = null; + + this.directedUnreadCount = 0; + + new MutationObserver(mutations => { + if (!this.convView || !this.convView.loaded) { + return; + } + if (this.hasAttribute("selected")) { + this.convView.switchingToPanel(); + } else { + this.convView.switchingAwayFromPanel(true); + } + }).observe(this, { attributes: true, attributeFilter: ["selected"] }); + + // @implements {nsIObserver} + this.observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe: function (subject, topic, data) { + if ( + topic == "target-prpl-conversation-changed" || + topic == "unread-message-count-changed" || + topic == "update-conv-title" || + topic == "update-buddy-status" || + topic == "update-buddy-status" || + topic == "update-conv-chatleft" || + topic == "update-conv-chatjoining" || + topic == "chat-update-topic" + ) { + this.update(); + } + if (topic == "update-conv-title") { + this.group.updateContactPosition( + this.conv, + "chat-imconv-richlistitem" + ); + } + }.bind(this), + }; + + if (this.hasAttribute("is-search-result")) { + let icon = this.querySelector(".protoIcon"); + icon.classList.add("searchProtoIcon"); + icon.setAttribute("src", "chrome://global/skin/icons/search-glass.svg"); + let statusIcon = this.querySelector(".smallStatusIcon"); + statusIcon.hidden = true; + this.setAttribute("unreadCount", "0"); + this.setAttribute("unreadTargetedCount", "0"); + } + + this.initializeAttributeInheritance(); + } + + get displayName() { + return this.conv.title; + } + + /** + * This getter exists to provide compatibility with the imgroup sortComparator. + */ + get contact() { + return this.conv; + } + + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + } + + get selected() { + return ( + gChatTab && + gChatTab.tabNode.selected && + this.getAttribute("selected") == "true" + ); + } + + /** + * Set the conversation this item should represent. Updates appearance and + * adds observers to keep it up to date. + * + * @param {imIConversation} conv - Conversation this item represents. + */ + build(conv) { + this.conv = conv; + this.conv.addObserver(this.observer); + this.update(); + } + + update() { + this.setAttribute("displayname", this.displayName); + if (this.selected && document.hasFocus()) { + if (this.convView && this.convView.loaded) { + this.conv.markAsRead(); + this.directedUnreadCount = 0; + chatHandler.updateTitle(); + chatHandler.updateChatButtonState(); + } + this.setAttribute("unreadCount", "0"); + this.setAttribute("unreadTargetedCount", "0"); + this.removeAttribute("unread"); + this.removeAttribute("attention"); + } else { + let unreadCount = + this.conv.unreadIncomingMessageCount + + this.conv.unreadOTRNotificationCount; + let directedMessages = unreadCount; + if (unreadCount) { + this.setAttribute("unread", "true"); + if (this.conv.isChat) { + directedMessages = this.conv.unreadTargetedMessageCount; + if (directedMessages) { + this.setAttribute("attention", "true"); + } + } + unreadCount -= directedMessages; + if (directedMessages > this.directedUnreadCount) { + this.directedUnreadCount = directedMessages; + } + } + if (unreadCount) { + unreadCount = "(" + unreadCount + ")"; + } + this.setAttribute("unreadCount", unreadCount); + if ( + Services.prefs.getBoolPref( + "messenger.options.getAttentionOnNewMessages" + ) && + directedMessages > parseInt(this.getAttribute("unreadTargetedCount")) + ) { + window.getAttention(); + } + this.setAttribute("unreadTargetedCount", directedMessages); + chatHandler.updateTitle(); + } + + let statusIcon = this.querySelector(".smallStatusIcon"); + let statusName; + statusIcon.hidden = false; + if (this.conv.isChat) { + if (this.conv.joining) { + statusName = "joining"; + } else if (!this.conv.account.connected || this.conv.left) { + statusName = "left"; + } + if (statusName) { + statusIcon.setAttribute( + "src", + ChatIcons.getStatusIconURI(statusName) + ); + // Set alt using messenger/chat.ftl. + document.l10n.setAttributes( + statusIcon, + `chat-${statusName}-chat-icon2` + ); + } else { + statusIcon.removeAttribute("src"); + statusIcon.removeAttribute("data-l10n-id"); + statusIcon.removeAttribute("alt"); + statusIcon.hidden = true; + // Treat protoIcon as if connected. + statusName = "connected"; + } + } else { + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + let buddy = this.conv.buddy; + if (buddy && buddy.account.connected) { + statusType = buddy.statusType; + } + statusName = Status.toAttribute(statusType); + statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName)); + statusIcon.removeAttribute("data-l10n-id"); + statusIcon.setAttribute("alt", Status.toLabel(statusType)); + } + + if (!this.hasAttribute("is-search-result")) { + let protoIcon = this.querySelector(".protoIcon"); + protoIcon.setAttribute( + "src", + ChatIcons.getProtocolIconURI(this.conv.account.protocol) + ); + ChatIcons.setProtocolIconOpacity(protoIcon, statusName); + } + } + + destroy() { + if (this.conv) { + this.conv.removeObserver(this.observer); + } + if (this.convView) { + this.convView.destroy(); + this.convView.remove(); + } + + // If the conversation we are destroying was selected, we should + // select something else, but the 'select' event handler of + // the listbox will choke while updating the Chat tab title if + // there are conversation nodes associated with a conversation + // that no longer exists from the chat core's point of view, so + // we do the actual selection change only after this conversation + // item is fully destroyed and removed from the list. + let newSelectedItem; + let list = this.parentNode; + if (list.selectedItem == this) { + newSelectedItem = this.previousElementSibling; + } + + if (this.log) { + this.hidden = true; + delete this.log; + } else { + this.remove(); + delete this.conv; + } + if (newSelectedItem) { + list.selectedItem = newSelectedItem; + } + } + + closeConversation() { + if (this.conv) { + this.conv.close(); + } else { + this.destroy(); + } + } + + keyPress(event) { + // If Enter or Return is pressed, focus the input box. + if (event.keyCode == event.DOM_VK_RETURN) { + this.convView.focus(); + return; + } + + let accelKeyPressed = + AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey; + // If a character was typed or the accel+v copy shortcut was used, + // focus the input box and resend the key event. + if ( + event.charCode != 0 && + !event.altKey && + ((accelKeyPressed && event.charCode == "v".charCodeAt(0)) || + (!event.ctrlKey && !event.metaKey)) + ) { + this.convView.focus(); + + let clonedEvent = new KeyboardEvent("keypress", event); + this.convView.editor.dispatchEvent(clonedEvent); + event.preventDefault(); + } + } + + /** + * Replace the conversation that this item represents. + * + * @param {imIConversation} conv - Updated conversation this should + * represent. + */ + changeConversation(conv) { + this.conv?.removeObserver(this.observer); + this.build(conv); + } + + disconnectedCallback() { + if (this.conv) { + this.conv.removeObserver(this.observer); + delete this.conv; + } + } + } + + MozXULElement.implementCustomInterface(MozChatConvRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("chat-imconv-richlistitem", MozChatConvRichlistitem, { + extends: "richlistitem", + }); +} diff --git a/comm/mail/components/im/content/chat-menu.inc.xhtml b/comm/mail/components/im/content/chat-menu.inc.xhtml new file mode 100644 index 0000000000..8ded5e0edb --- /dev/null +++ b/comm/mail/components/im/content/chat-menu.inc.xhtml @@ -0,0 +1,109 @@ +# 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/. + + <tooltip is="chat-tooltip" id="imTooltip"/> + + <menupopup id="buddyListContextMenu" + onpopupshowing="if (event.target != this) { return true; } gBuddyListContextMenu = new buddyListContextMenu(this); return gBuddyListContextMenu.shouldDisplay;" + onpopuphiding="if (event.target == this) { gBuddyListContextMenu = null; }"> + <menuitem id="context-openconversation" + label="&openConversationCmd.label;" + accesskey="&openConversationCmd.accesskey;" + oncommand="gBuddyListContextMenu.openConversation();"/> + <menuitem id="context-close-conversation" + label="&closeConversationCmd.label;" + accesskey="&closeConversationCmd.accesskey;" + oncommand="gBuddyListContextMenu.closeConversation();"/> + <menuitem id="context-verifyBuddy" + data-l10n-id="chat-verify-identity" + oncommand="gBuddyListContextMenu.verifyIdentity();"/> + <menuseparator id="context-edit-buddy-separator"/> + <menuitem id="context-alias" + label="&aliasCmd.label;" + accesskey="&aliasCmd.accesskey;" + oncommand="gBuddyListContextMenu.alias();"/> + <menuitem id="context-delete" + data-l10n-id="text-action-delete" + oncommand="gBuddyListContextMenu.delete();"/> + </menupopup> + + <menupopup id="chatConversationContextMenu" + onpopupshowing="if (event.target != this) { return true; } gChatContextMenu = new imContextMenu(this); return gChatContextMenu.shouldDisplay;" + onpopuphiding="if (event.target == this && gChatContextMenu) { gChatContextMenu.cleanup(); gChatContextMenu = null; }"> + <menuitem id="context-openlink" + label="&openLinkCmd.label;" + accesskey="&openLinkCmd.accesskey;" + oncommand="gChatContextMenu.openLink();"/> + <menuitem id="context-copyemail" + label="©EmailCmd.label;" + accesskey="©EmailCmd.accesskey;" + oncommand="gChatContextMenu.copyEmail();"/> + <menuitem id="context-copylink" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + oncommand="goDoCommand('cmd_copyLink');"/> + <menuseparator id="context-sep-copylink"/> + + <menuitem id="context-copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuitem id="context-selectall" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + <menuseparator id="context-sep-messageactions"/> + </menupopup> + + <menupopup id="chat-toolbar-context-menu"> + <menuitem id="CustomizeChatToolbar" + oncommand="CustomizeMailToolbar('chat-view-toolbox', 'CustomizeChatToolbar')" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;"/> + </menupopup> + + <menupopup id="chatContextMenu" + onpopupshowing="if (event.target != this) { return true; } openChatContextMenu(this);" + onpopuphiding="if (event.target == this) { clearChatContextMenu(this); }"> + + <!-- Spellchecking menu items --> + <menuitem id="spellCheckNoSuggestions" + data-l10n-id="text-action-spell-no-suggestions" + disabled="true"/> + <menuseparator id="spellCheckAddSep" /> + <menuitem id="spellCheckAddToDictionary" + data-l10n-id="text-action-spell-add-to-dictionary" + oncommand="gChatSpellChecker.addToDictionary();"/> + <menuseparator id="spellCheckSuggestionsSeparator"/> + + <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/> + <menuseparator/> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/> + + <!-- Spellchecking general menu items (enable, add dictionaries...) --> + <menuseparator id="spellCheckSeparator"/> + <menuitem id="spellCheckEnable" + data-l10n-id="text-action-spell-check-toggle" + type="checkbox" + oncommand="enableInlineSpellCheck(!gChatSpellChecker.enabled);"/> + <menu id="spellCheckDictionaries" + data-l10n-id="text-action-spell-dictionaries"> + <menupopup id="spellCheckDictionariesMenu"> + <menuseparator id="spellCheckLanguageSeparator"/> + <menuitem id="spellCheckAddDictionaries" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="openDictionaryList();"/> + </menupopup> + </menu> + + </menupopup> + + <menupopup id="participantListContextMenu" + onpopupshowing="return showParticipantMenu(this);"> + <menuitem id="context-verifyParticipant" + data-l10n-id="chat-verify-identity" + oncommand="verifyChatParticipant();"/> + </menupopup> diff --git a/comm/mail/components/im/content/chat-messenger.inc.xhtml b/comm/mail/components/im/content/chat-messenger.inc.xhtml new file mode 100644 index 0000000000..6b1fbb9f8f --- /dev/null +++ b/comm/mail/components/im/content/chat-messenger.inc.xhtml @@ -0,0 +1,192 @@ +# 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/. + + <vbox id="chatTabPanel"> + <toolbox id="chat-view-toolbox" class="mail-toolbox" + mode="full" defaultmode="full" + labelalign="end" defaultlabelalign="end"> + <toolbar is="customizable-toolbar" id="chat-toolbar" + class="inline-toolbar chromeclass-toolbar themeable-full" + fullscreentoolbar="true" + customizable="true" + context="chat-toolbar-context-menu" + mode="full" +#ifdef XP_MACOSX + iconsize="small" +#endif + defaultset="button-add-buddy,button-join-chat,spacer,chat-status-selector,button-chat-accounts,spacer,gloda-im-search"/> + + <toolbarpalette id="ChatToolbarPalette"> + <toolbarbutton id="button-add-buddy" + class="toolbarbutton-1" + label="&addBuddyButton.label;" + oncommand="chatHandler.addBuddy()"/> + <toolbarbutton id="button-join-chat" + class="toolbarbutton-1" + label="&joinChatButton.label;" + oncommand="chatHandler.joinChat()"/> + <toolbaritem id="chat-status-selector" + orient="horizontal" + align="center" flex="1"> + <toolbarbutton id="statusTypeIcon" + type="menu" + wantdropmarker="true" + class="toolbarbutton-1" + status="available"> + <menupopup id="setStatusTypeMenupopup" + oncommand="statusSelector.editStatus(event);"> + <menuitem id="statusTypeAvailable" label="&status.available;" + status="available" class="menuitem-iconic"/> + <menuitem id="statusTypeUnavailable" label="&status.unavailable;" + status="unavailable" class="menuitem-iconic"/> + <menuseparator id="statusTypeOfflineSeparator"/> + <menuitem id="statusTypeOffline" label="&status.offline;" + status="offline" class="menuitem-iconic"/> + </menupopup> + </toolbarbutton> + <vbox flex="1" + orient="horizontal" + align="center" + class="input-container status-container"> + <label id="statusMessageLabel" + flex="1" + value="" + class="statusMessageToolbarItem label-inline" + onclick="statusSelector.statusMessageClick();"/> + <html:input id="statusMessageInput" + value="" + class="statusMessageInput statusMessageToolbarItem status-message-input" + hidden="hidden"/> + </vbox> + </toolbaritem> + <toolbarbutton id="button-chat-accounts" + class="toolbarbutton-1" + label="&chatAccountsButton.label;" + oncommand="openIMAccountMgr()"/> + </toolbarpalette> + </toolbox> + + <vbox flex="1"> + <hbox id="chatPanel" flex="1"> + <vbox id="listPaneBox" style="min-width:125px;" width="200" persist="width"> + <richlistbox id="contactlistbox" + context="buddyListContextMenu" + tooltip="imTooltip" flex="1"> + <richlistitem is="chat-group-richlistitem" id="conversationsGroup" + name="&conversationsHeader.label;"/> + <richlistitem is="chat-imconv-richlistitem" + id="searchResultConv" + displayname="&searchResultConversation.label;" + is-search-result="" + hidden="true"/> + <richlistitem is="chat-group-richlistitem" id="onlinecontactsGroup" + name="&onlineContactsHeader.label;"/> + <richlistitem is="chat-group-richlistitem" id="offlinecontactsGroup" + name="&offlineContactsHeader.label;" + class="closed"/> + </richlistbox> + </vbox> + <splitter id="listSplitter" collapse="before"/> + <vbox id="chat-notification-top" flex="1"> + <!-- notificationbox will be added here lazily. --> + <vbox id="conversationsBox" flex="1"> + + <vbox flex="1" id="noConvScreen" class="im-placeholder-screen" align="center" pack="center"> + <hbox id="noConvBox" class="im-placeholder-box" align="start"> + <vbox id="noConvInnerBox" class="im-placeholder-innerbox" flex="1"> + <label id="noConvTitle" class="im-placeholder-title">&chat.noConv.title;</label> + <description id="noConvDesc" + class="im-placeholder-desc">&chat.noConv.description;</description> + </vbox> + <vbox id="noAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true"> + <label id="noAccountTitle" class="im-placeholder-title">&chat.noAccount.title;</label> + <description id="noAccountDesc" + class="im-placeholder-desc">&chat.noAccount.description;</description> + <hbox class="im-placeholder-button-box" flex="1"> + <spacer flex="1"/> + <button id="openIMAccountWizardButton" label="&chat.accountWizard.button;" + oncommand="openIMAccountWizard();"/> + </hbox> + </vbox> + <vbox id="noConnectedAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true"> + <label id="noConnectedAccountTitle" + class="im-placeholder-title">&chat.noConnectedAccount.title;</label> + <description id="noConnectedAccountDesc" + class="im-placeholder-desc">&chat.noConnectedAccount.description;</description> + <hbox class="im-placeholder-button-box" flex="1"> + <spacer flex="1"/> + <button id="openIMAccountManagerButton" label="&chat.showAccountManager.button;" + oncommand="openIMAccountMgr();"/> + </hbox> + </vbox> + </hbox> + </vbox> + + <vbox id="logDisplay" flex="1" hidden="true"> + <vbox flex="1"> + <vbox flex="1" id="noPreviousConvScreen" class="im-placeholder-screen" align="center" pack="center"> + <hbox id="noPreviousConvBox" class="im-placeholder-box" align="start"> + <vbox id="noPreviousConvInnerBox" class="im-placeholder-innerbox" flex="1"> + <description id="noPreviousConvDesc" + class="im-placeholder-desc">&chat.noPreviousConv.description;</description> + </vbox> + </hbox> + </vbox> + <vbox flex="1" id="logDisplayBrowserBox"> + <browser id="conv-log-browser" is="conversation-browser" type="content" + contextmenu="chatConversationContextMenu" flex="1" + tooltip="imTooltip" + messagemanagergroup="browsers"/> + <html:progress id="log-browserProgress" max="100" hidden="true"/> + <findbar id="log-findbar" browserid="conv-log-browser"/> + </vbox> + </vbox> + <button id="goToConversation" hidden="true" + oncommand="chatHandler.showCurrentConversation();"/> + </vbox> + + </vbox> + </vbox> + <splitter id="contextSplitter" hidden="true" collapse="after"/> + <vbox id="contextPane" hidden="true" width="250" persist="width"> + <chat-conversation-info id="conv-top-info" class="conv-top-info"/> + <vbox id="contextPaneFlexibleBox" flex="1"> + <vbox class="conv-chat" width="150"> + <hbox align="baseline" class="conv-nicklist-header input-container"> + <label class="conv-nicklist-header-label conv-header-label" + control="participantCount" + value="&chat.participants;" + crop="end"/> + <html:input id="participantCount" readonly="readonly" class="plain"/> + </hbox> + <richlistbox id="nicklist" class="conv-nicklist" + flex="1" seltype="multiple" + tooltip="imTooltip" + context="participantListContextMenu" + onclick="chatHandler.onNickClick(event);" + onkeypress="chatHandler.onNicklistKeyPress(event);"/> + </vbox> + <splitter id="logsSplitter" class="conv-chat" collapse="after" orient="vertical"/> + <vbox id="previousConversations" style="min-height: 200px;"> + <label class="conv-logs-header-label conv-header-label" + crop="end" + value="&chat.previousConversations;"/> + <tree id="logTree" flex="1" hidecolumnpicker="true" seltype="single" + context="logTreeContext" onselect="chatHandler.onLogSelect();"> + <treecols> + <treecol id="logCol" + style="flex: 1 auto" + primary="true" + hideheader="true" + crop="center" + ignoreincolumnpicker="true"/> + </treecols> + <treechildren/> + </tree> + </vbox> + </vbox> + </vbox> + </hbox> + </vbox> + </vbox> diff --git a/comm/mail/components/im/content/chat-messenger.js b/comm/mail/components/im/content/chat-messenger.js new file mode 100644 index 0000000000..b3030bf9df --- /dev/null +++ b/comm/mail/components/im/content/chat-messenger.js @@ -0,0 +1,2162 @@ +/* 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/. */ + +/* global MozElements MozXULElement */ +/* import-globals-from ../../../base/content/globalOverlay.js */ + +// This file is loaded in messenger.xhtml. +/* globals MailToolboxCustomizeDone, openIMAccountMgr, + PROTO_TREE_VIEW, statusSelector, ZoomManager, gSpacesToolbar */ + +var { Notifications } = ChromeUtils.importESModule( + "resource:///modules/chatNotifications.sys.mjs" +); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", +}); + +var gChatSpellChecker; +var gRangeParent; +var gRangeOffset; + +var gBuddyListContextMenu = null; +var gChatBundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" +); + +function openChatContextMenu(popup) { + let conv = chatHandler._getActiveConvView(); + let spellchecker = conv.spellchecker; + let textbox = conv.editor; + + // The context menu uses gChatSpellChecker, so set it here for the duration of the menu. + gChatSpellChecker = spellchecker; + + spellchecker.init(textbox.editor); + spellchecker.initFromEvent(gRangeParent, gRangeOffset); + let onMisspelling = spellchecker.overMisspelling; + document.getElementById("spellCheckSuggestionsSeparator").hidden = + !onMisspelling; + document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling; + let separator = document.getElementById("spellCheckAddSep"); + separator.hidden = !onMisspelling; + document.getElementById("spellCheckNoSuggestions").hidden = + !onMisspelling || spellchecker.addSuggestionsToMenu(popup, separator, 5); + + let dictMenu = document.getElementById("spellCheckDictionariesMenu"); + let dictSep = document.getElementById("spellCheckLanguageSeparator"); + spellchecker.addDictionaryListToMenu(dictMenu, dictSep); + + document + .getElementById("spellCheckEnable") + .setAttribute("checked", spellchecker.enabled); + document + .getElementById("spellCheckDictionaries") + .setAttribute("hidden", !spellchecker.enabled); + + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_selectAll"); +} + +function clearChatContextMenu(popup) { + let conv = chatHandler._getActiveConvView(); + let spellchecker = conv.spellchecker; + spellchecker.clearDictionaryListFromMenu(); + spellchecker.clearSuggestionsFromMenu(); +} + +function getSelectedPanel() { + for (let element of document.getElementById("conversationsBox").children) { + if (!element.hidden) { + return element; + } + } + return null; +} + +/** + * Hide all the child elements in the conversations box. After hiding all the + * child elements, one element will be from chat conversation, chat log or + * no conversation screen. + */ +function hideConversationsBoxPanels() { + for (let element of document.getElementById("conversationsBox").children) { + element.hidden = true; + } +} + +// This function modifies gChatSpellChecker and updates the UI accordingly. It's +// called when the user clicks on context menu to toggle the spellcheck feature. +function enableInlineSpellCheck(aEnableInlineSpellCheck) { + gChatSpellChecker.enabled = aEnableInlineSpellCheck; + document + .getElementById("spellCheckEnable") + .setAttribute("checked", aEnableInlineSpellCheck); + document + .getElementById("spellCheckDictionaries") + .setAttribute("hidden", !aEnableInlineSpellCheck); +} + +function buddyListContextMenu(aXulMenu) { + // Clear the context menu from OTR related entries. + OTRUI.removeBuddyContextMenu(document); + + this.target = aXulMenu.triggerNode.closest("richlistitem"); + if (!this.target) { + this.shouldDisplay = false; + return; + } + + this.menu = aXulMenu; + let localName = this.target.localName; + this.onContact = + localName == "richlistitem" && + this.target.getAttribute("is") == "chat-contact-richlistitem"; + this.onConv = + localName == "richlistitem" && + this.target.getAttribute("is") == "chat-imconv-richlistitem"; + this.shouldDisplay = this.onContact || this.onConv; + + let hide = !this.onContact; + [ + "context-openconversation", + "context-edit-buddy-separator", + "context-alias", + "context-delete", + ].forEach(function (aId) { + document.getElementById(aId).hidden = hide; + }); + + document.getElementById("context-close-conversation").hidden = !this.onConv; + document.getElementById("context-openconversation").disabled = + !hide && !this.target.canOpenConversation(); + + // Show OTR related context menu items if: + // - The OTR feature is currently enabled. + // - The target's status is not currently offline or unknown. + // - The target can send messages. + if ( + ChatEncryption.otrEnabled && + this.target.contact && + this.target.contact.statusType != Ci.imIStatusInfo.STATUS_UNKNOWN && + this.target.contact.statusType != Ci.imIStatusInfo.STATUS_OFFLINE && + this.target.contact.canSendMessage + ) { + OTRUI.addBuddyContextMenu(this.menu, document, this.target.contact); + } + + const accountBuddy = this._getAccountBuddy(); + const canVerifyBuddy = accountBuddy?.canVerifyIdentity; + const verifyMenuItem = document.getElementById("context-verifyBuddy"); + verifyMenuItem.hidden = !canVerifyBuddy; + if (canVerifyBuddy) { + const identityVerified = accountBuddy.identityVerified; + verifyMenuItem.disabled = identityVerified; + document.l10n.setAttributes( + verifyMenuItem, + identityVerified ? "chat-identity-verified" : "chat-verify-identity" + ); + } +} + +buddyListContextMenu.prototype = { + /** + * Get the prplIAccountBuddy instance that is related to the current context. + * + * @returns {prplIAccountBuddy?} + */ + _getAccountBuddy() { + if (this.onConv && this.target.conv?.buddy) { + return this.target.conv.buddy; + } + return this.target.contact?.preferredBuddy?.preferredAccountBuddy; + }, + openConversation() { + if (this.onContact || this.onConv) { + this.target.openConversation(); + } + }, + closeConversation() { + if (this.onConv) { + this.target.closeConversation(); + } + }, + alias() { + if (this.onContact) { + this.target.startAliasing(); + } + }, + delete() { + if (!this.onContact) { + return; + } + + let buddy = this.target.contact.preferredBuddy; + let displayName = this.target.displayName; + let promptTitle = gChatBundle.formatStringFromName( + "buddy.deletePrompt.title", + [displayName] + ); + let userName = buddy.userName; + if (displayName != userName) { + displayName = gChatBundle.formatStringFromName( + "buddy.deletePrompt.displayName", + [displayName, userName] + ); + } + let proto = buddy.protocol.name; // FIXME build a list + let promptMessage = gChatBundle.formatStringFromName( + "buddy.deletePrompt.message", + [displayName, proto] + ); + let deleteButton = gChatBundle.GetStringFromName( + "buddy.deletePrompt.button" + ); + let prompts = Services.prompt; + let flags = + prompts.BUTTON_TITLE_IS_STRING * prompts.BUTTON_POS_0 + + prompts.BUTTON_TITLE_CANCEL * prompts.BUTTON_POS_1 + + prompts.BUTTON_POS_1_DEFAULT; + if ( + prompts.confirmEx( + window, + promptTitle, + promptMessage, + flags, + deleteButton, + null, + null, + null, + {} + ) + ) { + return; + } + + this.target.deleteContact(); + }, + /** + * Command event handler to verify the identity of the buddy the context menu + * is currently opened for. + */ + verifyIdentity() { + const accountBuddy = this._getAccountBuddy(); + if (!accountBuddy) { + return; + } + ChatEncryption.verifyIdentity(window, accountBuddy); + }, +}; + +var gChatTab = null; + +var chatTabType = { + name: "chat", + panelId: "chatTabPanel", + hasBeenOpened: false, + modes: { + chat: { + type: "chat", + }, + }, + + tabMonitor: { + monitorName: "chattab", + + // Unused, but needed functions + onTabTitleChanged() {}, + onTabOpened(aTab) {}, + onTabPersist() {}, + onTabRestored() {}, + + onTabClosing() { + chatHandler._onTabDeactivated(true); + }, + onTabSwitched(aNewTab, aOldTab) { + // aNewTab == chat is handled earlier by showTab() below. + if (aOldTab?.mode.name == "chat") { + chatHandler._onTabDeactivated(true); + } + }, + }, + + _handleArgs(aArgs) { + if ( + !aArgs || + !("convType" in aArgs) || + (aArgs.convType != "log" && aArgs.convType != "focus") + ) { + return; + } + + if (aArgs.convType == "focus") { + chatHandler.focusConversation(aArgs.conv); + return; + } + + let item = document.getElementById("searchResultConv"); + item.log = aArgs.conv; + if (aArgs.searchTerm) { + item.searchTerm = aArgs.searchTerm; + } else { + delete item.searchTerm; + } + item.hidden = false; + if (item.getAttribute("selected")) { + chatHandler.onListItemSelected(); + } else { + document.getElementById("contactlistbox").selectedItem = item; + } + }, + _onWindowActivated() { + let tabmail = document.getElementById("tabmail"); + if (tabmail.currentTabInfo.mode.name == "chat") { + chatHandler._onTabActivated(); + } + }, + _onWindowDeactivated() { + let tabmail = document.getElementById("tabmail"); + if (tabmail.currentTabInfo.mode.name == "chat") { + chatHandler._onTabDeactivated(false); + } + }, + openTab(aTab, aArgs) { + aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/chat.svg"); + if (!this.hasBeenOpened) { + if (chatHandler.ChatCore && chatHandler.ChatCore.initialized) { + let convs = IMServices.conversations.getUIConversations(); + if (convs.length != 0) { + convs.sort((a, b) => + a.title.toLowerCase().localeCompare(b.title.toLowerCase()) + ); + for (let conv of convs) { + chatHandler._addConversation(conv); + } + } + } + this.hasBeenOpened = true; + } + + // The tab monitor will inform us when a different tab is selected. + let tabmail = document.getElementById("tabmail"); + tabmail.registerTabMonitor(this.tabMonitor); + window.addEventListener("deactivate", chatTabType._onWindowDeactivated); + window.addEventListener("activate", chatTabType._onWindowActivated); + + gChatTab = aTab; + this._handleArgs(aArgs); + this.showTab(aTab); + chatHandler.updateTitle(); + }, + shouldSwitchTo(aArgs) { + if (!gChatTab) { + return -1; + } + this._handleArgs(aArgs); + return document.getElementById("tabmail").tabInfo.indexOf(gChatTab); + }, + showTab(aTab) { + gChatTab = aTab; + chatHandler._onTabActivated(); + // The next call may change the selected conversation, but that + // will be handled by the selected mutation observer of the chat-imconv-richlistitem. + chatHandler._updateSelectedConversation(); + chatHandler._updateFocus(); + }, + closeTab(aTab) { + gChatTab = null; + let tabmail = document.getElementById("tabmail"); + tabmail.unregisterTabMonitor(this.tabMonitor); + window.removeEventListener("deactivate", chatTabType._onWindowDeactivated); + window.removeEventListener("activate", chatTabType._onWindowActivated); + }, + persistTab(aTab) { + return {}; + }, + restoreTab(aTabmail, aPersistedState) { + aTabmail.openTab("chat", {}); + }, + + supportsCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + return true; + default: + return false; + } + }, + isCommandEnabled(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + return !!this.getBrowser(); + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + return !!this.getFindbar(); + default: + return false; + } + }, + doCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + ZoomManager.reduce(); + break; + case "cmd_fullZoomEnlarge": + ZoomManager.enlarge(); + break; + case "cmd_fullZoomReset": + ZoomManager.reset(); + break; + case "cmd_fullZoomToggle": + ZoomManager.toggleZoom(); + break; + case "cmd_find": + this.getFindbar().onFindCommand(); + break; + case "cmd_findAgain": + this.getFindbar().onFindAgainCommand(false); + break; + case "cmd_findPrevious": + this.getFindbar().onFindAgainCommand(true); + break; + } + }, + onEvent(aEvent, aTab) {}, + getBrowser(aTab) { + let panel = getSelectedPanel(); + if (panel == document.getElementById("logDisplay")) { + if (!document.getElementById("logDisplayBrowserBox").hidden) { + return document.getElementById("conv-log-browser"); + } + } else if (panel && panel.localName == "chat-conversation") { + return panel.convBrowser; + } + return null; + }, + getFindbar(aTab) { + let panel = getSelectedPanel(); + if (panel == document.getElementById("logDisplay")) { + if (!document.getElementById("logDisplayBrowserBox").hidden) { + return document.getElementById("log-findbar"); + } + } else if (panel && panel.localName == "chat-conversation") { + return panel.findbar; + } + return null; + }, + + saveTabState(aTab) {}, +}; + +var chatHandler = { + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document.getElementById("chat-notification-top").prepend(element); + }); + } + return this._notificationBox; + }, + + _addConversation(aConv) { + let list = document.getElementById("contactlistbox"); + let convs = document.getElementById("conversationsGroup"); + let selectedItem = list.selectedItem; + let shouldSelect = + gChatTab && + gChatTab.tabNode.selected && + (!selectedItem || + (selectedItem == convs && + convs.nextElementSibling.localName != "richlistitem" && + convs.nextSibling.getAttribute("is") != "chat-imconv-richlistitem")); + let elt = convs.addContact(aConv, "imconv"); + if (shouldSelect) { + list.selectedItem = elt; + } + + if (aConv.isChat || !aConv.buddy) { + return; + } + + let contact = aConv.buddy.buddy.contact; + elt.imContact = contact; + let groupName = (contact.online ? "on" : "off") + "linecontactsGroup"; + let item = document.getElementById(groupName).removeContact(contact); + if (list.selectedItem == item) { + list.selectedItem = elt; + } + }, + + _hasConversationForContact(aContact) { + let convs = document.getElementById("conversationsGroup").contacts; + return convs.some( + aConversation => + aConversation.hasOwnProperty("imContact") && + aConversation.imContact.id == aContact.id + ); + }, + + _chatButtonUpdatePending: false, + updateChatButtonState() { + if (this._chatButtonUpdatePending) { + return; + } + this._chatButtonUpdatePending = true; + Services.tm.mainThread.dispatch( + this._updateChatButtonState.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + }, + // This is the unread count that was part of the latest + // unread-im-count-changed notification. + _notifiedUnreadCount: 0, + _updateChatButtonState() { + delete this._chatButtonUpdatePending; + + let [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount] = + this.countUnreadMessages(); + let unreadCount = unreadTargetedCount + unreadOTRNotificationCount; + + let chatButton = document.getElementById("button-chat"); + if (chatButton) { + chatButton.badgeCount = unreadCount; + if (unreadTotalCount || unreadOTRNotificationCount) { + chatButton.setAttribute("unreadMessages", "true"); + } else { + chatButton.removeAttribute("unreadMessages"); + } + } + + let spacesChatButton = document.getElementById("chatButton"); + if (spacesChatButton) { + spacesChatButton.classList.toggle("has-badge", unreadCount); + document.l10n.setAttributes( + spacesChatButton.querySelector(".spaces-badge-container"), + "chat-button-unread-messages", + { + count: unreadCount, + } + ); + } + let spacesPopupButtonChat = document.getElementById( + "spacesPopupButtonChat" + ); + if (spacesPopupButtonChat) { + spacesPopupButtonChat.classList.toggle("has-badge", unreadCount); + gSpacesToolbar.updatePinnedBadgeState(); + } + + let unifiedToolbarButtons = document.querySelectorAll( + "#unifiedToolbarContent .chat .unified-toolbar-button" + ); + for (const button of unifiedToolbarButtons) { + if (unreadCount) { + button.badge = unreadCount; + continue; + } + button.badge = null; + } + + if (unreadCount != this._notifiedUnreadCount) { + let unreadInt = Cc["@mozilla.org/supports-PRInt32;1"].createInstance( + Ci.nsISupportsPRInt32 + ); + unreadInt.data = unreadCount; + Services.obs.notifyObservers( + unreadInt, + "unread-im-count-changed", + unreadCount + ); + this._notifiedUnreadCount = unreadCount; + } + }, + + countUnreadMessages() { + let convs = IMServices.conversations.getUIConversations(); + let unreadTargetedCount = 0; + let unreadTotalCount = 0; + let unreadOTRNotificationCount = 0; + for (let conv of convs) { + unreadTargetedCount += conv.unreadTargetedMessageCount; + unreadTotalCount += conv.unreadIncomingMessageCount; + unreadOTRNotificationCount += conv.unreadOTRNotificationCount; + } + return [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount]; + }, + + updateTitle() { + if (!gChatTab) { + return; + } + + let title = gChatBundle.GetStringFromName("chatTabTitle"); + let [unreadTargetedCount] = this.countUnreadMessages(); + if (unreadTargetedCount) { + title += " (" + unreadTargetedCount + ")"; + } else { + let selectedItem = document.getElementById("contactlistbox").selectedItem; + if ( + selectedItem && + selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-imconv-richlistitem" && + !selectedItem.hidden + ) { + title += " - " + selectedItem.getAttribute("displayname"); + } + } + gChatTab.title = title; + document.getElementById("tabmail").setTabTitle(gChatTab); + }, + + onConvResize() { + let panel = getSelectedPanel(); + if (panel && panel.localName == "chat-conversation") { + panel.onConvResize(); + } + }, + + setStatusMenupopupCommand(aEvent) { + let target = aEvent.target; + if (target.getAttribute("id") == "imStatusShowAccounts") { + openIMAccountMgr(); + return; + } + + let status = target.getAttribute("status"); + if (!status) { + // Can status really be null? Maybe because of an add-on... + return; + } + + let us = IMServices.core.globalUserStatus; + us.setStatus(Status.toFlag(status), us.statusText); + }, + + _pendingLogBrowserLoad: false, + _showLogPanel() { + hideConversationsBoxPanels(); + document.getElementById("logDisplay").hidden = false; + document.getElementById("logDisplayBrowserBox").hidden = false; + document.getElementById("noPreviousConvScreen").hidden = true; + }, + _showLog(aConversation, aSearchTerm) { + if (!aConversation) { + return; + } + this._showLogPanel(); + let browser = document.getElementById("conv-log-browser"); + browser._convScrollEnabled = false; + if (this._pendingLogBrowserLoad) { + browser._conv = aConversation; + return; + } + browser.init(aConversation); + this._pendingLogBrowserLoad = true; + if (aSearchTerm) { + this._pendingSearchTerm = aSearchTerm; + } + Services.obs.addObserver(this, "conversation-loaded"); + + // Conversation title may not be set yet if this is a search result. + let cti = document.getElementById("conv-top-info"); + cti.setAttribute("displayName", aConversation.title); + + // Find and display the contact for this log. + for (let account of IMServices.accounts.getAccounts()) { + if ( + account.normalizedName == aConversation.account.normalizedName && + account.protocol.normalizedName == aConversation.account.protocol.name + ) { + if (aConversation.isChat) { + // Display information for MUCs. + cti.setAsChat("", false, false); + cti.setProtocol(account.protocol); + return; + } + // Display information for contacts. + let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount( + aConversation.normalizedName, + account + ); + if (!accountBuddy) { + return; + } + let contact = accountBuddy.buddy.contact; + if (!contact) { + return; + } + if (this.observedContact && this.observedContact.id == contact.id) { + return; + } + this.showContactInfo(contact); + this.observedContact = contact; + return; + } + } + }, + + /** + * Display a list of logs into a tree, and optionally handle a default selection. + * + * @param {imILog} aLogs - An array of imILog. + * @param {boolean|imILog} aShouldSelect - Either a boolean (true means select the first log + * of the list, false or undefined means don't mess with the selection) or a log + * item that needs to be selected. + * @returns {boolean} True if there's at least one log in the list, false if empty. + */ + _showLogList(aLogs, aShouldSelect) { + let logTree = document.getElementById("logTree"); + let treeView = (this._treeView = new chatLogTreeView(logTree, aLogs)); + if (!treeView._rowMap.length) { + return false; + } + if (!aShouldSelect) { + return true; + } + if (aShouldSelect === true) { + // Select the first line. + let selectIndex = 0; + if (treeView.isContainer(selectIndex)) { + // If the first line is a group, open it and select the + // next line instead. + treeView.toggleOpenState(selectIndex++); + } + logTree.view.selection.select(selectIndex); + return true; + } + // Find the aShouldSelect log and select it. + let logTime = aShouldSelect.time; + for (let index = 0; index < treeView._rowMap.length; ++index) { + if ( + !treeView.isContainer(index) && + treeView._rowMap[index].log.time == logTime + ) { + logTree.view.selection.select(index); + logTree.ensureRowIsVisible(index); + return true; + } + if (!treeView._rowMap[index].children.some(i => i.log.time == logTime)) { + continue; + } + treeView.toggleOpenState(index); + ++index; + while ( + index < treeView._rowMap.length && + treeView._rowMap[index].log.time != logTime + ) { + ++index; + } + if (treeView._rowMap[index].log.time == logTime) { + logTree.view.selection.select(index); + logTree.ensureRowIsVisible(index); + } + return true; + } + throw new Error( + "Couldn't find the log to select among the set of logs passed." + ); + }, + + onLogSelect() { + let selection = this._treeView.selection; + let currentIndex = selection.currentIndex; + // The current (focused) row may not be actually selected... + if (!selection.isSelected(currentIndex)) { + return; + } + + let log = this._treeView._rowMap[currentIndex].log; + if (!log) { + return; + } + + let list = document.getElementById("contactlistbox"); + if (list.selectedItem.getAttribute("id") != "searchResultConv") { + document.getElementById("goToConversation").hidden = false; + } + log.getConversation().then(aLogConv => { + this._showLog(aLogConv); + }); + }, + + _contactObserver: { + observe(aSubject, aTopic, aData) { + if ( + aTopic == "contact-status-changed" || + aTopic == "contact-display-name-changed" || + aTopic == "contact-icon-changed" + ) { + chatHandler.showContactInfo(aSubject); + } + }, + }, + _observedContact: null, + get observedContact() { + return this._observedContact; + }, + set observedContact(aContact) { + if (aContact == this._observedContact) { + return; + } + if (this._observedContact) { + this._observedContact.removeObserver(this._contactObserver); + delete this._observedContact; + } + this._observedContact = aContact; + if (aContact) { + aContact.addObserver(this._contactObserver); + } + }, + /** + * Callback for the button that closes the log view. Resets the shared UI + * elements to match the state of the active conversation. Hides the log + * browser. + */ + showCurrentConversation() { + let item = document.getElementById("contactlistbox").selectedItem; + if (!item) { + return; + } + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" + ) { + hideConversationsBoxPanels(); + item.convView.hidden = false; + item.convView.querySelector(".conv-bottom").setAttribute("height", 90); + document.getElementById("logTree").view.selection.clearSelection(); + if (item.conv.isChat) { + item.convView.updateTopic(); + } + ChatEncryption.updateEncryptionButton(document, item.conv); + item.convView.focus(); + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-contact-richlistitem" + ) { + item.openConversation(); + } + }, + focusConversation(aUIConv) { + let conv = + document.getElementById("conversationsGroup").contactsById[aUIConv.id]; + document.getElementById("contactlistbox").selectedItem = conv; + if (conv.convView) { + conv.convView.focus(); + } + }, + showContactInfo(aContact) { + let cti = document.getElementById("conv-top-info"); + cti.setUserIcon(aContact.buddyIconFilename, true); + cti.setAttribute("displayName", aContact.displayName); + cti.setProtocol(aContact.preferredBuddy.protocol); + + let statusText = aContact.statusText; + let statusType = aContact.statusType; + cti.setStatus( + Status.toAttribute(statusType), + Status.toLabel(statusType, statusText) + ); + + let button = document.getElementById("goToConversation"); + button.label = gChatBundle.formatStringFromName( + "startAConversationWith.button", + [aContact.displayName] + ); + button.disabled = !aContact.canSendMessage; + }, + _hideContextPane(aHide) { + document.getElementById("contextSplitter").hidden = aHide; + document.getElementById("contextPane").hidden = aHide; + }, + onListItemClick(aEvent) { + // We only care about single clicks of the left button. + if (aEvent.button != 0 || aEvent.detail != 1) { + return; + } + let item = document.getElementById("contactlistbox").selectedItem; + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" && + item.convView + ) { + item.convView.focus(); + } + }, + onListItemSelected() { + let contactlistbox = document.getElementById("contactlistbox"); + let item = contactlistbox.selectedItem; + if ( + !item || + item.hidden || + (item.localName == "richlistitem" && + item.getAttribute("is") == "chat-group-richlistitem") + ) { + this._hideContextPane(true); + hideConversationsBoxPanels(); + document.getElementById("noConvScreen").hidden = false; + this.updateTitle(); + this.observedContact = null; + ChatEncryption.hideEncryptionButton(document); + return; + } + + this._hideContextPane(false); + + if (item.getAttribute("id") == "searchResultConv") { + document.getElementById("goToConversation").hidden = true; + document.getElementById("contextPane").removeAttribute("chat"); + let cti = document.getElementById("conv-top-info"); + cti.clear(); + this.observedContact = null; + // Always hide encryption options for search conv + ChatEncryption.hideEncryptionButton(document); + + let path = "logs/" + item.log.path; + path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + ...path.split("/") + ); + IMServices.logs.getLogFromFile(path, true).then(aLog => { + IMServices.logs.getSimilarLogs(aLog).then(aSimilarLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + this._pendingSearchTerm = item.searchTerm || undefined; + this._showLogList(aSimilarLogs, aLog); + }); + }); + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" + ) { + if (!item.convView) { + let convBox = document.getElementById("conversationsBox"); + let conv = document.createXULElement("chat-conversation"); + convBox.appendChild(conv); + conv.conv = item.conv; + conv.tab = item; + conv.convBrowser.setAttribute("context", "chatConversationContextMenu"); + conv.setAttribute("tooltip", "imTooltip"); + item.convView = conv; + document.getElementById("contextSplitter").hidden = false; + document.getElementById("contextPane").hidden = false; + conv.editor.addEventListener("contextmenu", e => { + // Stash away the original event's parent and range for later use. + gRangeParent = e.rangeParent; + gRangeOffset = e.rangeOffset; + let popup = document.getElementById("chatContextMenu"); + popup.openPopupAtScreen(e.screenX, e.screenY, true); + e.preventDefault(); + }); + + // Set "mail editor mask" so changing the language doesn't + // affect the global preference and multiple chats can have + // individual languages. + conv.editor.editor.flags |= Ci.nsIEditor.eEditorMailMask; + + let preferredLanguages = + Services.prefs.getStringPref("spellchecker.dictionary")?.split(",") ?? + []; + let initialLanguage = ""; + if (preferredLanguages.length === 1) { + initialLanguage = preferredLanguages[0]; + } + // Initialise language to the default. + conv.editor.setAttribute("lang", initialLanguage); + + // Attach listener so we hear about language changes. + document.addEventListener("spellcheck-changed", e => { + let conv = chatHandler._getActiveConvView(); + let activeLanguages = e.detail.dictionaries ?? []; + let languageToSet = ""; + if (activeLanguages.length === 1) { + languageToSet = activeLanguages[0]; + } + conv.editor.setAttribute("lang", languageToSet); + }); + } else { + item.convView.onConvResize(); + } + + hideConversationsBoxPanels(); + item.convView.hidden = false; + item.convView.querySelector(".conv-bottom").setAttribute("height", 90); + item.convView.updateConvStatus(); + item.update(); + + ChatEncryption.updateEncryptionButton(document, item.conv); + + IMServices.logs.getLogsForConversation(item.conv).then(aLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + this._showLogList(aLogs); + }); + + document + .querySelectorAll("#contextPaneFlexibleBox .conv-chat") + .forEach(e => { + e.setAttribute("hidden", !item.conv.isChat); + }); + if (item.conv.isChat) { + item.convView.showParticipants(); + } + + let button = document.getElementById("goToConversation"); + button.label = gChatBundle.GetStringFromName( + "goBackToCurrentConversation.button" + ); + button.disabled = false; + this.observedContact = null; + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-contact-richlistitem" + ) { + ChatEncryption.hideEncryptionButton(document); + let contact = item.contact; + if ( + this.observedContact && + contact && + this.observedContact.id == contact.id + ) { + return; // onselect has just been fired again because a status + // change caused the chat-contact-richlistitem to move. + // Return early to avoid flickering and changing the selected log. + } + + this.showContactInfo(contact); + this.observedContact = contact; + + document + .querySelectorAll("#contextPaneFlexibleBox .conv-chat") + .forEach(e => { + e.setAttribute("hidden", "true"); + }); + + IMServices.logs.getLogsForContact(contact).then(aLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + if (!this._showLogList(aLogs, true)) { + hideConversationsBoxPanels(); + document.getElementById("logDisplay").hidden = false; + document.getElementById("logDisplayBrowserBox").hidden = false; + document.getElementById("noPreviousConvScreen").hidden = true; + } + }); + } + this.updateTitle(); + }, + + onNickClick(aEvent) { + // Open a private conversation only for a middle or double click. + if (aEvent.button != 1 && (aEvent.button != 0 || aEvent.detail != 2)) { + return; + } + + let conv = document.getElementById("contactlistbox").selectedItem.conv; + let nick = aEvent.target.chatBuddy.name; + let name = conv.target.getNormalizedChatBuddyName(nick); + try { + let newconv = conv.account.createConversation(name); + this.focusConversation(newconv); + } catch (e) {} + }, + + onNicklistKeyPress(aEvent) { + if (aEvent.keyCode != aEvent.DOM_VK_RETURN) { + return; + } + + let listbox = aEvent.target; + if (listbox.selectedCount == 0) { + return; + } + + let conv = document.getElementById("contactlistbox").selectedItem.conv; + let newconv; + for (let i = 0; i < listbox.selectedCount; ++i) { + let nick = listbox.getSelectedItem(i).chatBuddy.name; + let name = conv.target.getNormalizedChatBuddyName(nick); + try { + newconv = conv.account.createConversation(name); + } catch (e) {} + } + // Only focus last of the opened conversations. + if (newconv) { + this.focusConversation(newconv); + } + }, + + addBuddy() { + window.openDialog( + "chrome://messenger/content/chat/addbuddy.xhtml", + "", + "chrome,modal,titlebar,centerscreen" + ); + }, + + joinChat() { + window.openDialog( + "chrome://messenger/content/chat/joinchat.xhtml", + "", + "chrome,modal,titlebar,centerscreen" + ); + }, + + _colorCache: {}, + // Duplicated code from chat-conversation.js :-( + _computeColor(aName) { + if (Object.prototype.hasOwnProperty.call(this._colorCache, aName)) { + return this._colorCache[aName]; + } + + // Compute the color based on the nick + var nick = aName.match(/[a-zA-Z0-9]+/); + nick = nick ? nick[0].toLowerCase() : (nick = aName); + // We compute a hue value (between 0 and 359) based on the + // characters of the nick. + // The first character weights kInitialWeight, each following + // character weights kWeightReductionPerChar * the weight of the + // previous character. + const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters. + const kWeightReductionPerChar = 0.52; // arbitrary value + var weight = kInitialWeight; + var res = 0; + for (var i = 0; i < nick.length; ++i) { + var char = nick.charCodeAt(i) - 47; + if (char > 10) { + char -= 39; + } + // now char contains a value between 1 and 36 + res += char * weight; + weight *= kWeightReductionPerChar; + } + return (this._colorCache[aName] = Math.round(res) % 360); + }, + + _placeHolderButtonId: "", + _updateNoConvPlaceHolder() { + let connected = false; + let hasAccount = false; + let canJoinChat = false; + for (let account of IMServices.accounts.getAccounts()) { + hasAccount = true; + if (account.connected) { + connected = true; + if (account.canJoinChat) { + canJoinChat = true; + break; + } + } + } + document.getElementById("noConvInnerBox").hidden = !connected; + document.getElementById("noAccountInnerBox").hidden = hasAccount; + document.getElementById("noConnectedAccountInnerBox").hidden = + connected || !hasAccount; + if (connected) { + delete this._placeHolderButtonId; + } else { + this._placeHolderButtonId = hasAccount + ? "openIMAccountManagerButton" + : "openIMAccountWizardButton"; + } + + for (let id of [ + "statusTypeIcon", + "statusMessage", + "button-chat-accounts", + ]) { + let elt = document.getElementById(id); + if (elt) { + elt.disabled = !hasAccount; + } + } + + let chatStatusCmd = document.getElementById("cmd_chatStatus"); + if (chatStatusCmd) { + if (hasAccount) { + chatStatusCmd.removeAttribute("disabled"); + } else { + chatStatusCmd.setAttribute("disabled", true); + } + } + + let addBuddyButton = document.getElementById("button-add-buddy"); + if (addBuddyButton) { + addBuddyButton.disabled = !connected; + } + + let addBuddyCmd = document.getElementById("cmd_addChatBuddy"); + if (addBuddyCmd) { + if (connected) { + addBuddyCmd.removeAttribute("disabled"); + } else { + addBuddyCmd.setAttribute("disabled", true); + } + } + + let joinChatButton = document.getElementById("button-join-chat"); + if (joinChatButton) { + joinChatButton.disabled = !canJoinChat; + } + + let joinChatCmd = document.getElementById("cmd_joinChat"); + if (joinChatCmd) { + if (canJoinChat) { + joinChatCmd.removeAttribute("disabled"); + } else { + joinChatCmd.setAttribute("disabled", true); + } + } + + let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"]; + let contactlist = document.getElementById("contactlistbox"); + if ( + !hasAccount || + (!connected && + groupIds.every( + id => document.getElementById(id + "Group").contacts.length + )) + ) { + contactlist.disabled = true; + } else { + contactlist.disabled = false; + this._updateSelectedConversation(); + } + }, + _updateSelectedConversation() { + let list = document.getElementById("contactlistbox"); + // We can't select anything if there's no account. + if (list.disabled) { + return; + } + + // If the selection is already a conversation with unread messages, keep it. + let selectedItem = list.selectedItem; + if ( + selectedItem && + selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-imconv-richlistitem" && + selectedItem.directedUnreadCount + ) { + selectedItem.update(); + return; + } + + let firstConv; + let convs = document.getElementById("conversationsGroup"); + let conv = convs.nextElementSibling; + while (conv.id != "searchResultConv") { + if (!firstConv) { + firstConv = conv; + } + // If there is a conversation with unread messages, select it. + if (conv.directedUnreadCount) { + list.selectedItem = conv; + return; + } + conv = conv.nextElementSibling; + } + + // No unread messages, select the first conversation, but only if + // the existing selection is uninteresting (a section header). + if (firstConv) { + if ( + !selectedItem || + (selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-group-richlistitem") + ) { + list.selectedItem = firstConv; + } + return; + } + + // No conversation, if a visible item is selected, keep it. + if (selectedItem && !selectedItem.collapsed) { + return; + } + + // Select the first visible group header. + let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"]; + for (let id of groupIds) { + let item = document.getElementById(id + "Group"); + if (item.collapsed) { + continue; + } + list.selectedItem = item; + return; + } + }, + _updateFocus() { + let focusId = this._placeHolderButtonId || "contactlistbox"; + document.getElementById(focusId).focus(); + }, + _getActiveConvView() { + let list = document.getElementById("contactlistbox"); + if (list.disabled) { + return null; + } + let selectedItem = list.selectedItem; + if ( + !selectedItem || + (selectedItem.localName != "richlistitem" && + selectedItem.getAttribute("is") != "chat-imconv-richlistitem") + ) { + return null; + } + let convView = selectedItem.convView; + if (!convView || !convView.loaded) { + return null; + } + return convView; + }, + _onTabActivated() { + let convView = chatHandler._getActiveConvView(); + if (convView) { + convView.switchingToPanel(); + } + }, + _onTabDeactivated(aHidden) { + let convView = chatHandler._getActiveConvView(); + if (convView) { + convView.switchingAwayFromPanel(aHidden); + } + }, + observe(aSubject, aTopic, aData) { + if (aTopic == "chat-core-initialized") { + this.initAfterChatCore(); + return; + } + + if (aTopic == "conversation-loaded") { + let browser = document.getElementById("conv-log-browser"); + if (aSubject != browser) { + return; + } + + for (let msg of browser._conv.getMessages()) { + if (!msg.system) { + msg.color = + "color: hsl(" + this._computeColor(msg.who) + ", 100%, 40%);"; + } + browser.appendMessage(msg); + } + + if (this._pendingSearchTerm) { + let findbar = document.getElementById("log-findbar"); + findbar._findField.value = this._pendingSearchTerm; + findbar.open(); + browser.focus(); + delete this._pendingSearchTerm; + let eventListener = function () { + findbar.onFindAgainCommand(); + if (findbar._findFailedString && browser._messageDisplayPending) { + return; + } + // Search result found or all messages added, we're done. + browser.removeEventListener("MessagesDisplayed", eventListener); + }; + browser.addEventListener("MessagesDisplayed", eventListener); + } + this._pendingLogBrowserLoad = false; + Services.obs.removeObserver(this, "conversation-loaded"); + return; + } + + if ( + aTopic == "account-connected" || + aTopic == "account-disconnected" || + aTopic == "account-added" || + aTopic == "account-removed" + ) { + this._updateNoConvPlaceHolder(); + return; + } + + if (aTopic == "contact-signed-on") { + if (!this._hasConversationForContact(aSubject)) { + document.getElementById("onlinecontactsGroup").addContact(aSubject); + document.getElementById("offlinecontactsGroup").removeContact(aSubject); + } + return; + } + if (aTopic == "contact-signed-off") { + if (!this._hasConversationForContact(aSubject)) { + document.getElementById("offlinecontactsGroup").addContact(aSubject); + document.getElementById("onlinecontactsGroup").removeContact(aSubject); + } + return; + } + if (aTopic == "contact-added") { + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).addContact(aSubject); + return; + } + if (aTopic == "contact-removed") { + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).removeContact(aSubject); + return; + } + if (aTopic == "contact-no-longer-dummy") { + let oldId = parseInt(aData); + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + let group = document.getElementById(groupName); + if (group.contactsById.hasOwnProperty(oldId)) { + let contact = group.contactsById[oldId]; + delete group.contactsById[oldId]; + group.contactsById[contact.contact.id] = contact; + } + return; + } + if (aTopic == "new-text") { + this.updateChatButtonState(); + return; + } + if (aTopic == "new-ui-conversation") { + if (chatTabType.hasBeenOpened) { + chatHandler._addConversation(aSubject); + } + return; + } + if (aTopic == "ui-conversation-closed") { + this.updateChatButtonState(); + if (!chatTabType.hasBeenOpened) { + return; + } + let conv = document + .getElementById("conversationsGroup") + .removeContact(aSubject); + if (conv.imContact) { + let contact = conv.imContact; + let groupName = (contact.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).addContact(contact); + } + return; + } + + if (aTopic == "buddy-authorization-request") { + aSubject.QueryInterface(Ci.prplIBuddyRequest); + let authLabel = gChatBundle.formatStringFromName( + "buddy.authRequest.label", + [aSubject.userName] + ); + let value = + "buddy-auth-request-" + aSubject.account.id + aSubject.userName; + let acceptButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.authRequest.allow.accesskey" + ), + label: gChatBundle.GetStringFromName("buddy.authRequest.allow.label"), + callback() { + aSubject.grant(); + }, + }; + let denyButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.authRequest.deny.accesskey" + ), + label: gChatBundle.GetStringFromName("buddy.authRequest.deny.label"), + callback() { + aSubject.deny(); + }, + }; + let box = this.msgNotificationBar; + let notification = box.appendNotification( + value, + { + label: authLabel, + priority: box.PRIORITY_INFO_HIGH, + }, + [acceptButton, denyButton] + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "buddy-authorization-request-canceled") { + aSubject.QueryInterface(Ci.prplIBuddyRequest); + let value = + "buddy-auth-request-" + aSubject.account.id + aSubject.userName; + let box = this.msgNotificationBar; + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + return; + } + if (aTopic == "buddy-verification-request") { + aSubject.QueryInterface(Ci.imIIncomingSessionVerification); + let barLabel = gChatBundle.formatStringFromName( + "buddy.verificationRequest.label", + [aSubject.subject] + ); + let value = + "buddy-verification-request-" + + aSubject.account.id + + "-" + + aSubject.subject; + let acceptButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.verificationRequest.allow.accesskey" + ), + label: gChatBundle.GetStringFromName( + "buddy.verificationRequest.allow.label" + ), + callback() { + aSubject + .verify() + .then(() => { + window.openDialog( + "chrome://messenger/content/chat/verify.xhtml", + "", + "chrome,modal,titlebar,centerscreen", + aSubject + ); + }) + .catch(error => { + aSubject.account.ERROR(error); + aSubject.cancel(); + }); + }, + }; + let denyButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.verificationRequest.deny.accesskey" + ), + label: gChatBundle.GetStringFromName( + "buddy.verificationRequest.deny.label" + ), + callback() { + aSubject.cancel(); + }, + }; + let box = this.msgNotificationBar; + let notification = box.appendNotification( + value, + { + label: barLabel, + priority: box.PRIORITY_INFO_HIGH, + }, + [acceptButton, denyButton] + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "buddy-verification-request-canceled") { + aSubject.QueryInterface(Ci.imIIncomingSessionVerification); + let value = + "buddy-verification-request-" + + aSubject.account.id + + "-" + + aSubject.subject; + let box = this.msgNotificationBar; + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + return; + } + if (aTopic == "conv-authorization-request") { + aSubject.QueryInterface(Ci.prplIChatRequest); + let value = + "conv-auth-request-" + aSubject.account.id + aSubject.conversationName; + let buttons = [ + { + "l10n-id": "chat-conv-invite-accept", + callback() { + aSubject.grant(); + }, + }, + ]; + if (aSubject.canDeny) { + buttons.push({ + "l10n-id": "chat-conv-invite-deny", + callback() { + aSubject.deny(); + }, + }); + } + let box = this.msgNotificationBar; + // Remove the notification when the request is cancelled. + aSubject.completePromise.catch(() => { + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + }); + let notification = box.appendNotification( + value, + { + label: "", + priority: box.PRIORITY_INFO_HIGH, + }, + buttons + ); + document.l10n.setAttributes( + notification.messageText, + "chat-conv-invite-label", + { + conversation: aSubject.conversationName, + } + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "conversation-update-type") { + // Find conversation in conversation list. + let contactlistbox = document.getElementById("contactlistbox"); + let convs = document.getElementById("conversationsGroup"); + let convItem = convs.nextElementSibling; + while ( + convItem.conv.target.id !== aSubject.target.id && + convItem.id != "searchResultConv" + ) { + convItem = convItem.nextElementSibling; + } + if (convItem.conv.target.id !== aSubject.target.id) { + // Could not find a matching conversation in the front end. + return; + } + // Update UI conversation associated with components + if (convItem.convView && convItem.convView.conv !== aSubject) { + convItem.convView.changeConversation(aSubject); + } + if (convItem.conv !== aSubject) { + convItem.changeConversation(aSubject); + } else { + convItem.update(); + } + // If the changed conversation is the selected item, make sure + // we update the UI elements to match the conversation type. + let selectedItem = contactlistbox.selectedItem; + if (selectedItem === convItem && selectedItem.convView) { + this.onListItemSelected(); + } + } + }, + initAfterChatCore() { + let onGroup = document.getElementById("onlinecontactsGroup"); + let offGroup = document.getElementById("offlinecontactsGroup"); + + for (let name in chatHandler.allContacts) { + let contact = chatHandler.allContacts[name]; + let group = contact.online ? onGroup : offGroup; + group.addContact(contact); + } + + onGroup._updateGroupLabel(); + offGroup._updateGroupLabel(); + + [ + "new-text", + "new-ui-conversation", + "ui-conversation-closed", + "contact-signed-on", + "contact-signed-off", + "contact-added", + "contact-removed", + "contact-no-longer-dummy", + "account-connected", + "account-disconnected", + "account-added", + "account-removed", + "conversation-update-type", + ].forEach(chatHandler._addObserver); + + chatHandler._updateNoConvPlaceHolder(); + statusSelector.init(); + }, + _observedTopics: [], + _addObserver(aTopic) { + Services.obs.addObserver(chatHandler, aTopic); + chatHandler._observedTopics.push(aTopic); + }, + _removeObservers() { + for (let topic of this._observedTopics) { + Services.obs.removeObserver(this, topic); + } + }, + // TODO move this function away from here and test it. + _getNextUnreadConversation(aConversations, aCurrent, aReverse) { + let convCount = aConversations.length; + if (!convCount) { + return -1; + } + + let direction = aReverse ? -1 : 1; + let next = i => { + i += direction; + if (i < 0) { + return i + convCount; + } + if (i >= convCount) { + return i - convCount; + } + return i; + }; + + // Find starting point + let start = 0; + if (Number.isInteger(aCurrent)) { + start = next(aCurrent); + } else if (aReverse) { + start = convCount - 1; + } + + // Cycle through all conversations until we are at the start again. + let i = start; + do { + // If there is a conversation with unread messages, select it. + if (aConversations[i].unreadIncomingMessageCount) { + return i; + } + i = next(i); + } while (i !== start && i !== aCurrent); + return -1; + }, + _selectNextUnreadConversation(aReverse, aList) { + let conversations = document.getElementById("conversationsGroup").contacts; + if (!conversations.length) { + return; + } + + let rawConversations = conversations.map(c => c.conv); + let current; + if ( + aList.selectedItem.localName == "richlistitem" && + aList.selectedItem.getAttribute("is") == "chat-imconv-richlistitem" + ) { + current = aList.selectedIndex - aList.getIndexOfItem(conversations[0]); + } + let newIndex = this._getNextUnreadConversation( + rawConversations, + current, + aReverse + ); + if (newIndex !== -1) { + aList.selectedItem = conversations[newIndex]; + } + }, + /** + * Restores the width in pixels stored on the width attribute of an element as + * CSS width, so it is used for flex layout calculations. Useful for restoring + * elements that were sized by a XUL splitter. + * + * @param {Element} element - Element to transfer the width attribute to CSS for. + */ + _restoreWidth: element => + (element.style.width = `${element.getAttribute("width")}px`), + async init() { + Notifications.init(); + if (!Services.prefs.getBoolPref("mail.chat.enabled")) { + [ + "chatButton", + "spacesPopupButtonChat", + "button-chat", + "menu_goChat", + "goChatSeparator", + "imAccountsStatus", + "joinChatMenuItem", + "newIMAccountMenuItem", + "newIMContactMenuItem", + "appmenu_newIMAccountMenuItem", + "appmenu_newIMContactMenuItem", + ].forEach(function (aId) { + let elt = document.getElementById(aId); + if (elt) { + elt.hidden = true; + } + }); + return; + } + + window.addEventListener("unload", this._removeObservers.bind(this)); + + // initialize the customizeDone method on the customizeable toolbar + var toolbox = document.getElementById("chat-view-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeChatToolbar"); + }; + + let tabmail = document.getElementById("tabmail"); + tabmail.registerTabType(chatTabType); + this._addObserver("buddy-authorization-request"); + this._addObserver("buddy-authorization-request-canceled"); + this._addObserver("buddy-verification-request"); + this._addObserver("buddy-verification-request-canceled"); + this._addObserver("conv-authorization-request"); + let listbox = document.getElementById("contactlistbox"); + listbox.addEventListener("keypress", function (aEvent) { + let item = listbox.selectedItem; + if (!item || !item.parentNode) { + // empty list or item no longer in the list + return; + } + item.keyPress(aEvent); + }); + listbox.addEventListener("select", this.onListItemSelected.bind(this)); + listbox.addEventListener("click", this.onListItemClick.bind(this)); + document + .getElementById("chatTabPanel") + .addEventListener("keypress", function (aEvent) { + let accelKeyPressed = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + !accelKeyPressed || + (aEvent.keyCode != aEvent.DOM_VK_DOWN && + aEvent.keyCode != aEvent.DOM_VK_UP) + ) { + return; + } + listbox._userSelecting = true; + let reverse = aEvent.keyCode != aEvent.DOM_VK_DOWN; + if (aEvent.shiftKey) { + chatHandler._selectNextUnreadConversation(reverse, listbox); + } else { + listbox.moveByOffset(reverse ? -1 : 1, true, false); + } + listbox._userSelecting = false; + let item = listbox.selectedItem; + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" && + item.convView + ) { + item.convView.focus(); + } else { + listbox.focus(); + } + }); + window.addEventListener("resize", this.onConvResize.bind(this)); + document.getElementById("conversationsGroup").sortComparator = (a, b) => + a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + + const { allContacts, onlineContacts, ChatCore } = + ChromeUtils.importESModule("resource:///modules/chatHandler.sys.mjs"); + this.allContacts = allContacts; + this.onlineContacts = onlineContacts; + this.ChatCore = ChatCore; + if (this.ChatCore.initialized) { + this.initAfterChatCore(); + } else { + this.ChatCore.init(); + this._addObserver("chat-core-initialized"); + } + + if (ChatEncryption.otrEnabled) { + this._initOTR(); + } + + this._restoreWidth(document.getElementById("listPaneBox")); + this._restoreWidth(document.getElementById("contextPane")); + }, + + async _initOTR() { + if (!IMServices.core.initialized) { + await new Promise(resolve => { + function initObserver() { + Services.obs.removeObserver(initObserver, "prpl-init"); + resolve(); + } + Services.obs.addObserver(initObserver, "prpl-init"); + }); + } + // Avoid loading OTR until we have an im account set up. + if (IMServices.accounts.getAccounts().length === 0) { + await new Promise(resolve => { + function accountsObserver() { + if (IMServices.accounts.getAccounts().length > 0) { + Services.obs.removeObserver(accountsObserver, "account-added"); + resolve(); + } + } + Services.obs.addObserver(accountsObserver, "account-added"); + }); + } + await OTRUI.init(); + }, +}; + +function chatLogTreeGroupItem(aTitle, aLogItems) { + this._title = aTitle; + this._children = aLogItems; + for (let child of this._children) { + child._parent = this; + } + this._open = false; +} +chatLogTreeGroupItem.prototype = { + getText() { + return this._title; + }, + get id() { + return this._title; + }, + get open() { + return this._open; + }, + get level() { + return 0; + }, + get _parent() { + return null; + }, + get children() { + return this._children; + }, + getProperties() { + return ""; + }, +}; + +function chatLogTreeLogItem(aLog, aText, aLevel) { + this.log = aLog; + this._text = aText; + this._level = aLevel; +} +chatLogTreeLogItem.prototype = { + getText() { + return this._text; + }, + get id() { + return this.log.title; + }, + get open() { + return false; + }, + get level() { + return this._level; + }, + get children() { + return []; + }, + getProperties() { + return ""; + }, +}; + +function chatLogTreeView(aTree, aLogs) { + this._tree = aTree; + this._logs = aLogs; + this._tree.view = this; + this._rebuild(); +} +chatLogTreeView.prototype = { + __proto__: new PROTO_TREE_VIEW(), + + _rebuild() { + // Some date helpers... + const kDayInMsecs = 24 * 60 * 60 * 1000; + const kWeekInMsecs = 7 * kDayInMsecs; + const kTwoWeeksInMsecs = 2 * kWeekInMsecs; + + // Drop the old rowMap. + if (this._tree) { + this._tree.rowCountChanged(0, -this._rowMap.length); + } + this._rowMap = []; + + let placesBundle = Services.strings.createBundle( + "chrome://places/locale/places.properties" + ); + let dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "short" }); + let monthYearFormat = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + }); + let monthFormat = new Intl.DateTimeFormat(undefined, { month: "long" }); + let weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "long" }); + let nowDate = new Date(); + let todayDate = new Date( + nowDate.getFullYear(), + nowDate.getMonth(), + nowDate.getDate() + ); + + // The keys used in the 'firstgroups' object should match string ids. + // The order is the reverse of that in which they will appear + // in the logTree. + let firstgroups = { + previousWeek: [], + currentWeek: [], + }; + + // today and yesterday are treated differently, because for JSON logs they + // represent individual logs, and are not "groups". + let today = null, + yesterday = null; + + // Build a chatLogTreeLogItem for each log, and put it in the right group. + let groups = {}; + for (let log of this._logs) { + let logDate = new Date(log.time * 1000); + // Calculate elapsed time between the log and 00:00:00 today. + let timeFromToday = todayDate - logDate; + let title = dateFormat.format(logDate); + let group; + if (timeFromToday <= 0) { + today = new chatLogTreeLogItem( + log, + gChatBundle.GetStringFromName("log.today"), + 0 + ); + continue; + } else if (timeFromToday <= kDayInMsecs) { + yesterday = new chatLogTreeLogItem( + log, + gChatBundle.GetStringFromName("log.yesterday"), + 0 + ); + continue; + } else if (timeFromToday <= kWeekInMsecs - kDayInMsecs) { + // Note that the 7 days of the current week include today. + group = firstgroups.currentWeek; + title = weekdayFormat.format(logDate); + } else if (timeFromToday <= kTwoWeeksInMsecs - kDayInMsecs) { + group = firstgroups.previousWeek; + } else { + logDate.setHours(0); + logDate.setMinutes(0); + logDate.setSeconds(0); + logDate.setDate(1); + let groupID = logDate.toISOString(); + if (!(groupID in groups)) { + let groupname; + if (logDate.getFullYear() == nowDate.getFullYear()) { + if (logDate.getMonth() == nowDate.getMonth()) { + groupname = placesBundle.GetStringFromName( + "finduri-AgeInMonths-is-0" + ); + } else { + groupname = monthFormat.format(logDate); + } + } else { + groupname = monthYearFormat.format(logDate); + } + groups[groupID] = { + entries: [], + name: groupname, + }; + } + group = groups[groupID].entries; + } + group.push(new chatLogTreeLogItem(log, title, 1)); + } + + let groupIDs = Object.keys(groups).sort().reverse(); + + // Add firstgroups to groups and groupIDs. + for (let groupID in firstgroups) { + let group = firstgroups[groupID]; + if (!group.length) { + continue; + } + groupIDs.unshift(groupID); + groups[groupID] = { + entries: firstgroups[groupID], + name: gChatBundle.GetStringFromName("log." + groupID), + }; + } + + // Build tree. + if (today) { + this._rowMap.push(today); + } + if (yesterday) { + this._rowMap.push(yesterday); + } + groupIDs.forEach(function (aGroupID) { + let group = groups[aGroupID]; + group.entries.sort((l1, l2) => l2.log.time - l1.log.time); + this._rowMap.push(new chatLogTreeGroupItem(group.name, group.entries)); + }, this); + + // Finally, notify the tree. + if (this._tree) { + this._tree.rowCountChanged(0, this._rowMap.length); + } + }, +}; + +/** + * Handler for onpopupshowing event of the participantListContextMenu. Decides + * if the menu should be shown at all and manages the disabled state of its + * items. + * + * @param {XULMenuPopupElement} menu + * @returns {boolean} If the menu should be shown, currently decided based on + * if its only item has an action to perform. + */ +function showParticipantMenu(menu) { + const target = menu.triggerNode.closest("richlistitem"); + if (!target?.chatBuddy?.canVerifyIdentity) { + return false; + } + const identityVerified = target.chatBuddy.identityVerified; + const verifyMenuItem = document.getElementById("context-verifyParticipant"); + verifyMenuItem.disabled = identityVerified; + document.l10n.setAttributes( + verifyMenuItem, + identityVerified ? "chat-identity-verified" : "chat-verify-identity" + ); + return true; +} + +/** + * Command handler for the verify identity context menu item of the participant + * context menu. Initiates the verification for the participant the menu was + * opened on. + * + * @returns {undefined} + */ +function verifyChatParticipant() { + const target = document + .getElementById("participantListContextMenu") + .triggerNode.closest("richlistitem"); + const buddy = target.chatBuddy; + if (!buddy) { + return; + } + ChatEncryption.verifyIdentity(window, buddy); +} + +window.addEventListener("load", () => chatHandler.init()); diff --git a/comm/mail/components/im/content/imAccountWizard.js b/comm/mail/components/im/content/imAccountWizard.js new file mode 100644 index 0000000000..128412aa5b --- /dev/null +++ b/comm/mail/components/im/content/imAccountWizard.js @@ -0,0 +1,526 @@ +/* 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/content/imAccountOptionsHelper.js +/* globals accountOptionsHelper */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var PREF_EXTENSIONS_GETMOREPROTOCOLSURL = "extensions.getMoreProtocolsURL"; + +var accountWizard = { + onload() { + document + .querySelector("wizard") + .addEventListener("wizardfinish", this.createAccount.bind(this)); + let accountProtocolPage = document.getElementById("accountprotocol"); + accountProtocolPage.addEventListener( + "pageadvanced", + this.selectProtocol.bind(this) + ); + let accountUsernamePage = document.getElementById("accountusername"); + accountUsernamePage.addEventListener( + "pageshow", + this.showUsernamePage.bind(this) + ); + accountUsernamePage.addEventListener( + "pagehide", + this.hideUsernamePage.bind(this) + ); + let accountAdvancedPage = document.getElementById("accountadvanced"); + accountAdvancedPage.addEventListener( + "pageshow", + this.showAdvanced.bind(this) + ); + let accountSummaryPage = document.getElementById("accountsummary"); + accountSummaryPage.addEventListener( + "pageshow", + this.showSummary.bind(this) + ); + + // Ensure the im core is initialized before we get a list of protocols. + IMServices.core.init(); + + accountWizard.setGetMoreProtocols(); + + var protoList = document.getElementById("protolist"); + var protos = IMServices.core.getProtocols(); + protos.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + return a.name > b.name ? 1 : 0; + }); + protos.forEach(function (proto) { + let image = document.createElement("img"); + image.setAttribute("src", ChatIcons.getProtocolIconURI(proto)); + image.setAttribute("alt", ""); + image.classList.add("protoIcon"); + + let label = document.createXULElement("label"); + label.setAttribute("value", proto.name); + + let item = document.createXULElement("richlistitem"); + item.setAttribute("value", proto.id); + item.appendChild(image); + item.appendChild(label); + protoList.appendChild(item); + }); + + // there is a strange selection bug without this timeout + setTimeout(function () { + protoList.selectedIndex = 0; + }, 0); + + Services.obs.addObserver(this, "prpl-quit"); + window.addEventListener("unload", this.unload); + }, + unload() { + Services.obs.removeObserver(accountWizard, "prpl-quit"); + }, + observe(aObject, aTopic, aData) { + if (aTopic == "prpl-quit") { + // libpurple is being uninitialized. We can't create any new + // account so keeping this wizard open would be pointless, close it. + window.close(); + } + }, + + /** + * Builds the full username from the username boxes. + * + * @returns {string} assembled username + */ + getUsername() { + let usernameBoxIndex = 0; + if (this.proto.usernamePrefix) { + usernameBoxIndex = 1; + } + // If the first username input is empty, make sure we return an empty + // string so that it blocks the 'next' button of the wizard. + if (!this.userNameBoxes[usernameBoxIndex].value) { + return ""; + } + + return this.userNameBoxes.reduce((prev, elt) => prev + elt.value, ""); + }, + + /** + * Check that the username fields generate a new username, and if it is valid + * allow advancing the wizard. + */ + checkUsername() { + var wizard = document.querySelector("wizard"); + var name = accountWizard.getUsername(); + var duplicateWarning = document.getElementById("duplicateAccount"); + if (!name) { + wizard.canAdvance = false; + duplicateWarning.hidden = true; + return; + } + + var exists = accountWizard.proto.accountExists(name); + wizard.canAdvance = !exists; + duplicateWarning.hidden = !exists; + }, + + /** + * Takes the value of the primary username field and splits it if the value + * matches the split field syntax. + */ + splitUsername() { + let usernameBoxIndex = 0; + if (this.proto.usernamePrefix) { + usernameBoxIndex = 1; + } + let username = this.userNameBoxes[usernameBoxIndex].value; + let splitValues = this.proto.splitUsername(username); + if (!splitValues.length) { + return; + } + for (const box of this.userNameBoxes) { + if (Element.isInstance(box)) { + box.value = splitValues.shift(); + } + } + this.checkUsername(); + }, + + selectProtocol() { + var protoList = document.getElementById("protolist"); + var id = protoList.selectedItem.value; + this.proto = IMServices.core.getProtocolById(id); + }, + + /** + * Create a new input field for receiving a username. + * + * @param {string} aName - The id for the input. + * @param {string} aLabel - The text for the username label. + * @param {Element} grid - A container with a two column grid display to + * append the new elements to. + * @param {string} [aDefaultValue] - The initial value for the username. + * + * @returns {HTMLInputElement} - The newly created username input. + */ + insertUsernameField(aName, aLabel, grid, aDefaultValue) { + var label = document.createXULElement("label"); + label.setAttribute("value", aLabel); + label.setAttribute("control", aName); + label.setAttribute("id", aName + "-label"); + label.classList.add("label-inline"); + grid.appendChild(label); + + var input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.setAttribute("id", aName); + input.classList.add("input-inline"); + if (aDefaultValue) { + input.setAttribute("value", aDefaultValue); + } + input.addEventListener("input", event => { + this.checkUsername(); + }); + // Only add the split logic to the first input field + if (!this.userNameBoxes) { + input.addEventListener("blur", event => { + this.splitUsername(); + }); + } + grid.appendChild(input); + + return input; + }, + + /** + * Builds the username input boxes from the username split defined by the + * protocol. + */ + showUsernamePage() { + var proto = this.proto.id; + if ("userNameBoxes" in this && this.userNameProto == proto) { + this.checkUsername(); + return; + } + + var bundle = document.getElementById("accountsBundle"); + var usernameInfo; + var emptyText = this.proto.usernameEmptyText; + if (emptyText) { + usernameInfo = bundle.getFormattedString( + "accountUsernameInfoWithDescription", + [emptyText, this.proto.name] + ); + } else { + usernameInfo = bundle.getFormattedString("accountUsernameInfo", [ + this.proto.name, + ]); + } + document.getElementById("usernameInfo").textContent = usernameInfo; + + var grid = document.getElementById("userNameBox"); + // remove anything that may be there for another protocol + while (grid.hasChildNodes()) { + grid.lastChild.remove(); + } + this.userNameBoxes = undefined; + + var splits = this.proto.getUsernameSplit(); + + var label = bundle.getString("accountUsername"); + this.userNameBoxes = [this.insertUsernameField("name", label, grid)]; + this.userNameBoxes[0].emptyText = emptyText; + let usernameBoxIndex = 0; + + if (this.proto.usernamePrefix) { + this.userNameBoxes.unshift({ value: this.proto.usernamePrefix }); + usernameBoxIndex = 1; + } + + for (let i = 0; i < splits.length; ++i) { + this.userNameBoxes.push({ value: splits[i].separator }); + label = bundle.getFormattedString("accountColon", [splits[i].label]); + let defaultVal = splits[i].defaultValue; + this.userNameBoxes.push( + this.insertUsernameField("username-split-" + i, label, grid, defaultVal) + ); + } + this.userNameBoxes[usernameBoxIndex].focus(); + this.userNameProto = proto; + this.checkUsername(); + }, + + hideUsernamePage() { + document.querySelector("wizard").canAdvance = true; + var next = "account" + (this.proto.noPassword ? "advanced" : "password"); + document.getElementById("accountusername").next = next; + }, + + showAdvanced() { + // ensure we don't destroy user data if it's not necessary + var id = this.proto.id; + if ("protoSpecOptId" in this && this.protoSpecOptId == id) { + return; + } + this.protoSpecOptId = id; + + this.populateProtoSpecificBox(); + + // Make sure the protocol specific options and wizard buttons are visible. + let wizard = document.querySelector("wizard"); + if (wizard.scrollHeight > window.innerHeight) { + window.resizeBy(0, wizard.scrollHeight - window.innerHeight); + } + + let alias = document.getElementById("alias"); + alias.focus(); + }, + + populateProtoSpecificBox() { + let haveOptions = accountOptionsHelper.addOptions( + this.proto.id + "-", + this.proto.getOptions() + ); + document.getElementById("protoSpecificGroupbox").hidden = !haveOptions; + if (haveOptions) { + var bundle = document.getElementById("accountsBundle"); + document.getElementById("protoSpecificCaption").textContent = + bundle.getFormattedString("protoOptions", [this.proto.name]); + } + }, + + /** + * Create new summary field and value elements. + * + * @param {string} aLabel - The name of the field being summarised. + * @param {string} aValue - The value of the field being summarised. + * @param {Element} grid - A container with a two column grid display to + * append the new elements to. + */ + createSummaryRow(aLabel, aValue, grid) { + var label = document.createXULElement("label"); + label.classList.add("header", "label-inline"); + if (aLabel.length > 20) { + aLabel = aLabel.substring(0, 20); + aLabel += "…"; + } + + label.setAttribute("value", aLabel); + grid.appendChild(label); + + var input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.setAttribute("value", aValue); + input.classList.add("plain", "input-inline"); + input.setAttribute("readonly", true); + grid.appendChild(input); + }, + + showSummary() { + var rows = document.getElementById("summaryRows"); + var bundle = document.getElementById("accountsBundle"); + while (rows.hasChildNodes()) { + rows.lastChild.remove(); + } + + var label = document.getElementById("protoLabel").value; + this.createSummaryRow(label, this.proto.name, rows); + this.username = this.getUsername(); + label = bundle.getString("accountUsername"); + this.createSummaryRow(label, this.username, rows); + if (!this.proto.noPassword) { + this.password = this.getValue("password"); + if (this.password) { + label = document.getElementById("passwordLabel").value; + var pass = ""; + for (let i = 0; i < this.password.length; ++i) { + pass += "*"; + } + this.createSummaryRow(label, pass, rows); + } + } + this.alias = this.getValue("alias"); + if (this.alias) { + label = document.getElementById("aliasLabel").value; + this.createSummaryRow(label, this.alias, rows); + } + + var id = this.proto.id; + this.prefs = []; + for (let opt of this.proto.getOptions()) { + let name = opt.name; + let eltName = id + "-" + name; + let val = this.getValue(eltName); + // The value will be undefined if the proto specific groupbox has never been opened + if (val === undefined) { + continue; + } + switch (opt.type) { + case Ci.prplIPref.typeBool: + if (val != opt.getBool()) { + this.prefs.push({ opt, name, value: !!val }); + } + break; + case Ci.prplIPref.typeInt: + if (val != opt.getInt()) { + this.prefs.push({ opt, name, value: val }); + } + break; + case Ci.prplIPref.typeString: + if (val != opt.getString()) { + this.prefs.push({ opt, name, value: val }); + } + break; + case Ci.prplIPref.typeList: + if (val != opt.getListDefault()) { + this.prefs.push({ opt, name, value: val }); + } + break; + default: + throw new Error("unknown preference type " + opt.type); + } + } + + for (let i = 0; i < this.prefs.length; ++i) { + let opt = this.prefs[i]; + let label = bundle.getFormattedString("accountColon", [opt.opt.label]); + this.createSummaryRow(label, opt.value, rows); + } + }, + + createAccount() { + var acc = IMServices.accounts.createAccount(this.username, this.proto.id); + if (!this.proto.noPassword && this.password) { + acc.password = this.password; + } + if (this.alias) { + acc.alias = this.alias; + } + + for (let i = 0; i < this.prefs.length; ++i) { + let option = this.prefs[i]; + let opt = option.opt; + switch (opt.type) { + case Ci.prplIPref.typeBool: + acc.setBool(option.name, option.value); + break; + case Ci.prplIPref.typeInt: + acc.setInt(option.name, option.value); + break; + case Ci.prplIPref.typeString: + case Ci.prplIPref.typeList: + acc.setString(option.name, option.value); + break; + default: + throw new Error("unknown type"); + } + } + var autologin = this.getValue("connectNow"); + acc.autoLogin = autologin; + + acc.save(); + + try { + if (autologin) { + acc.connect(); + } + } catch (e) { + // If the connection fails (for example if we are currently in + // offline mode), we still want to close the account wizard + } + + if (window.opener) { + var am = window.opener.gAccountManager; + if (am) { + am.selectAccount(acc.id); + } + } + + var inServer = MailServices.accounts.createIncomingServer( + this.username, + this.proto.id, // hostname + "im" + ); + inServer.wrappedJSObject.imAccount = acc; + + var account = MailServices.accounts.createAccount(); + // Avoid new folder notifications. + inServer.valid = false; + account.incomingServer = inServer; + inServer.valid = true; + MailServices.accounts.notifyServerLoaded(inServer); + + return true; + }, + + getValue(aId) { + var elt = document.getElementById(aId); + if ("selectedItem" in elt) { + return elt.selectedItem.value; + } + // Strangely various input types also have a "checked" property defined, + // so we check for the expected elements explicitly. + if ( + ((elt.localName == "input" && elt.getAttribute("type") == "checkbox") || + elt.localName == "checkbox") && + "checked" in elt + ) { + return elt.checked; + } + if ("value" in elt) { + return elt.value; + } + // If the groupbox has never been opened, the binding isn't attached + // so the attributes don't exist. The calling code in showSummary + // has a special handling of the undefined value for this case. + return undefined; + }, + + *getIter(aEnumerator) { + for (let iter of aEnumerator) { + yield iter; + } + }, + + /* Check for correctness and set URL for the "Get more protocols..."-link + * Stripped down code from preferences/themes.js + */ + setGetMoreProtocols() { + let prefURL = PREF_EXTENSIONS_GETMOREPROTOCOLSURL; + var getMore = document.getElementById("getMoreProtocols"); + var showGetMore = false; + const nsIPrefBranch = Ci.nsIPrefBranch; + + if (Services.prefs.getPrefType(prefURL) != nsIPrefBranch.PREF_INVALID) { + try { + var getMoreURL = Services.urlFormatter.formatURLPref(prefURL); + getMore.setAttribute("getMoreURL", getMoreURL); + showGetMore = getMoreURL != "about:blank"; + } catch (e) {} + } + getMore.hidden = !showGetMore; + }, + + openURL(aURL) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(aURL)); + }, +}; + +window.addEventListener("load", event => { + accountWizard.onload(); +}); diff --git a/comm/mail/components/im/content/imAccountWizard.xhtml b/comm/mail/components/im/content/imAccountWizard.xhtml new file mode 100644 index 0000000000..9ff3cf33ad --- /dev/null +++ b/comm/mail/components/im/content/imAccountWizard.xhtml @@ -0,0 +1,180 @@ +<?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://messenger/skin/accountWizard.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % accountWizardDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%accountWizardDTD; %brandDTD; ]> + +<html + id="accountWizard" + 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" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&windowTitle.label;</title> + <link rel="localization" href="toolkit/global/wizard.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/imAccountOptionsHelper.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/imAccountWizard.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <stringbundle + id="accountsBundle" + src="chrome://messenger/locale/imAccounts.properties" + /> + + <wizard id="wizard"> + <wizardpage + id="accountprotocol" + pageid="accountprotocol" + next="accountusername" + label="&accountProtocolTitle.label;" + > + <description>&accountProtocolInfo.label;</description> + <separator /> + <label + value="&accountProtocolField.label;" + control="protolist" + id="protoLabel" + hidden="true" + /> + <richlistbox + flex="1" + id="protolist" + ondblclick="document.getElementById('wizard').advance();" + /> + <hbox pack="end"> + <label + id="getMoreProtocols" + class="text-link" + value="&accountProtocolGetMore.label;" + onclick="if (event.button == 0) { accountWizard.openURL(this.getAttribute('getMoreURL')); }" + /> + </hbox> + </wizardpage> + + <wizardpage + id="accountusername" + pageid="accountusername" + next="accountpassword" + label="&accountUsernameTitle.label;" + > + <description id="usernameInfo" /> + <separator /> + <html:div + id="userNameBox" + class="grid-block-two-column-fr grid-items-center" + > + </html:div> + <separator /> + <description id="duplicateAccount" hidden="true" + >&accountUsernameDuplicate.label;</description + > + </wizardpage> + + <wizardpage + id="accountpassword" + pageid="accountpassword" + next="accountadvanced" + label="&accountPasswordTitle.label;" + > + <description>&accountPasswordInfo.label;</description> + <separator /> + <hbox id="passwordBox" align="baseline" class="input-container"> + <label + id="passwordLabel" + value="&accountPasswordField.label;" + class="label-inline" + control="password" + /> + <html:input id="password" type="password" class="input-inline" /> + </hbox> + <separator /> + <description id="passwordManagerDescription" + >&accountPasswordManager.label;</description + > + </wizardpage> + + <wizardpage + id="accountadvanced" + pageid="accountadvanced" + next="accountsummary" + label="&accountAdvancedTitle.label;" + > + <description>&accountAdvancedInfo.label;</description> + <separator class="thin" /> + <html:fieldset id="aliasGroupbox"> + <html:legend id="aliasGroupboxCaption" + >&accountAliasGroupbox.caption;</html:legend + > + <hbox id="aliasBox" align="baseline" class="input-container"> + <label + id="aliasLabel" + value="&accountAliasField.label;" + class="label-inline" + control="alias" + /> + <html:input id="alias" type="text" class="input-inline" /> + </hbox> + <description>&accountAliasInfo.label;</description> + </html:fieldset> + + <html:fieldset id="protoSpecificGroupbox"> + <html:legend id="protoSpecificCaption"></html:legend> + <html:div + id="protoSpecific" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + </html:fieldset> + </wizardpage> + + <wizardpage + id="accountsummary" + pageid="accountsummary" + label="&accountSummaryTitle.label;" + > + <description>&accountSummaryInfo.label;</description> + <separator /> + <html:div + id="summaryRows" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + <separator /> + <checkbox + id="connectNow" + label="&accountSummary.connectNow.label;" + checked="true" + /> + </wizardpage> + </wizard> + </html:body> +</html> diff --git a/comm/mail/components/im/content/imAccounts.js b/comm/mail/components/im/content/imAccounts.js new file mode 100644 index 0000000000..46bb72c197 --- /dev/null +++ b/comm/mail/components/im/content/imAccounts.js @@ -0,0 +1,663 @@ +/* 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/. */ + +/* globals MozElements */ +/* globals statusSelector */ +/* globals MsgAccountManager */ + +var { DownloadUtils } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadUtils.sys.mjs" +); + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +// This is the list of notifications that the account manager window observes +var events = [ + "prpl-quit", + "account-list-updated", + "account-added", + "account-updated", + "account-removed", + "account-connected", + "account-connecting", + "account-disconnected", + "account-disconnecting", + "account-connect-progress", + "account-connect-error", + "autologin-processed", + "status-changed", + "network:offline-status-changed", +]; + +var gAccountManager = { + // Sets the delay after connect() or disconnect() during which + // it is impossible to perform disconnect() and connect() + _disabledDelay: 500, + disableTimerID: 0, + _connectedLabelInterval: 0, + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + document.getElementById("accounts-notification-box").prepend(element); + }); + } + return this._notificationBox; + }, + + load() { + // Wait until the password service is ready before offering anything. + Services.logins.initializationPromise.then( + () => { + this.accountList = document.getElementById("accountlist"); + let defaultID; + IMServices.core.init(); // ensure the imCore is initialized. + for (let acc of this.getAccounts()) { + let elt = document.createXULElement("richlistitem", { + is: "chat-account-richlistitem", + }); + this.accountList.appendChild(elt); + elt.build(acc); + if ( + !defaultID && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + defaultID = acc.id; + } + } + for (let event of events) { + Services.obs.addObserver(this, event); + } + if (!this.accountList.getRowCount()) { + // This is horrible, but it works. Otherwise (at least on mac) + // the wizard is not centered relatively to the account manager + setTimeout(function () { + gAccountManager.new(); + }, 0); + } else { + // we have accounts, show the list + document.getElementById("noAccountScreen").hidden = true; + document.getElementById("accounts-notification-box").hidden = false; + + // ensure an account is selected + if (defaultID) { + this.selectAccount(defaultID); + } else { + this.accountList.selectedIndex = 0; + } + } + + this.setAutoLoginNotification(); + + this.accountList.addEventListener("keypress", this.onKeyPress, true); + window.addEventListener("unload", this.unload.bind(this)); + this._connectedLabelInterval = setInterval( + this.updateConnectedLabels, + 60000 + ); + statusSelector.init(); + }, + () => { + this.close(); + } + ); + }, + unload() { + clearInterval(this._connectedLabelInterval); + for (let event of events) { + Services.obs.removeObserver(this, event); + } + }, + _updateAccountList() { + let accountList = this.accountList; + let i = 0; + for (let acc of this.getAccounts()) { + let oldItem = accountList.getItemAtIndex(i); + if (oldItem.id != acc.id) { + let accElt = document.getElementById(acc.id); + accountList.insertBefore(accElt, oldItem); + accElt.refreshState(); + } + ++i; + } + + if (accountList.itemCount == 0) { + // Focus the "New Account" button if there are no accounts left. + document.getElementById("newaccount").focus(); + // Return early, otherwise we'll run into an 'undefined property' strict + // warning when trying to focus the buttons. Fixes bug 408. + return; + } + + // The selected item is still selected + if (accountList.selectedItem) { + accountList.selectedItem.setFocus(); + } + accountList.ensureSelectedElementIsVisible(); + + // We need to refresh the disabled menu items + this.disableCommandItems(); + }, + observe(aObject, aTopic, aData) { + if (aTopic == "prpl-quit") { + // libpurple is being uninitialized. We don't need the account + // manager window anymore, close it. + this.close(); + return; + } else if (aTopic == "autologin-processed") { + let notification = + this.msgNotificationBar.getNotificationWithValue("autoLoginStatus"); + if (notification) { + notification.close(); + } + return; + } else if (aTopic == "network:offline-status-changed") { + this.setOffline(aData == "offline"); + return; + } else if (aTopic == "status-changed") { + this.setOffline(aObject.statusType == Ci.imIStatusInfo.STATUS_OFFLINE); + return; + } else if (aTopic == "account-list-updated") { + this._updateAccountList(); + return; + } + + // The following notification handlers need an account. + let account = aObject.QueryInterface(Ci.imIAccount); + + if (aTopic == "account-added") { + document.getElementById("noAccountScreen").hidden = true; + document.getElementById("accounts-notification-box").hidden = false; + let elt = document.createXULElement("richlistitem", { + is: "chat-account-richlistitem", + }); + this.accountList.appendChild(elt); + elt.build(account); + if (this.accountList.getRowCount() == 1) { + this.accountList.selectedIndex = 0; + } + } else if (aTopic == "account-removed") { + let elt = document.getElementById(account.id); + elt.destroy(); + if (!elt.selected) { + elt.remove(); + return; + } + // The currently selected element is removed, + // ensure another element gets selected (if the list is not empty) + var selectedIndex = this.accountList.selectedIndex; + // Prevent errors if the timer is active and the account deleted + clearTimeout(this.disableTimerID); + this.disableTimerID = 0; + elt.remove(); + var count = this.accountList.getRowCount(); + if (!count) { + document.getElementById("noAccountScreen").hidden = false; + document.getElementById("accounts-notification-box").hidden = true; + return; + } + if (selectedIndex == count) { + --selectedIndex; + } + this.accountList.selectedIndex = selectedIndex; + } else if (aTopic == "account-updated") { + document.getElementById(account.id).build(account); + this.disableCommandItems(); + } else if (aTopic == "account-connect-progress") { + document.getElementById(account.id).updateConnectingProgress(); + } else if (aTopic == "account-connect-error") { + document.getElementById(account.id).updateConnectionError(); + // See NSSErrorsService::ErrorIsOverridable. + if ( + [ + "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED", + "MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITY", + "MOZILLA_PKIX_ERROR_EMPTY_ISSUER_NAME", + "MOZILLA_PKIX_ERROR_INADEQUATE_KEY_SIZE", + "MOZILLA_PKIX_ERROR_MITM_DETECTED", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE", + "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT", + "MOZILLA_PKIX_ERROR_V1_CERT_USED_AS_CA", + "SEC_ERROR_CA_CERT_INVALID", + "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED", + "SEC_ERROR_EXPIRED_CERTIFICATE", + "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE", + "SEC_ERROR_INVALID_TIME", + "SEC_ERROR_UNKNOWN_ISSUER", + "SSL_ERROR_BAD_CERT_DOMAIN", + ].includes(account.prplAccount.securityInfo?.errorCodeString) + ) { + this.addException(); + } + } else { + const stateEvents = { + "account-connected": "connected", + "account-connecting": "connecting", + "account-disconnected": "disconnected", + "account-disconnecting": "disconnecting", + }; + if (aTopic in stateEvents) { + let elt = document.getElementById(account.id); + if (!elt) { + // Probably disconnecting a removed account. + return; + } + elt.refreshState(stateEvents[aTopic]); + } + } + }, + cancelReconnection() { + this.accountList.selectedItem.cancelReconnection(); + }, + connect() { + let account = this.accountList.selectedItem.account; + if (account.disconnected) { + this.temporarilyDisableButtons(); + account.connect(); + } + }, + disconnect() { + let account = this.accountList.selectedItem.account; + if (account.connected || account.connecting) { + this.temporarilyDisableButtons(); + account.disconnect(); + } + }, + addException() { + let account = this.accountList.selectedItem.account; + let prplAccount = account.prplAccount; + if (!prplAccount.connectionTarget) { + return; + } + + // Open the Gecko SSL exception dialog. + let params = { + exceptionAdded: false, + securityInfo: prplAccount.securityInfo, + prefetchCert: true, + location: prplAccount.connectionTarget, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // Reconnect the account if an exception was added. + if (params.exceptionAdded) { + account.disconnect(); + account.connect(); + } + }, + copyDebugLog() { + let account = this.accountList.selectedItem.account; + let text = account + .getDebugMessages() + .map(function (dbgMsg) { + let m = dbgMsg.message; + let time = new Date(m.timeStamp); + const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "long", + }); + time = dateTimeFormatter.format(time); + let level = dbgMsg.logLevel; + if (!level) { + return "(" + m.errorMessage + ")"; + } + if (level == dbgMsg.LEVEL_ERROR) { + level = "ERROR"; + } else if (level == dbgMsg.LEVEL_WARNING) { + level = "WARN."; + } else if (level == dbgMsg.LEVEL_LOG) { + level = "LOG "; + } else { + level = "DEBUG"; + } + return ( + "[" + + time + + "] " + + level + + " (@ " + + m.sourceLine + + " " + + m.sourceName + + ":" + + m.lineNumber + + ")\n" + + m.errorMessage + ); + }) + .join("\n"); + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(text); + }, + updateConnectedLabels() { + for (let i = 0; i < gAccountManager.accountList.itemCount; ++i) { + let item = gAccountManager.accountList.getItemAtIndex(i); + if (item.account.connected) { + item.refreshConnectedLabel(); + } + } + }, + /* This function disables the connect/disconnect buttons for + * `this._disabledDelay` ms before calling disableCommandItems to restore + * the state of the buttons. + */ + temporarilyDisableButtons() { + document.getElementById("cmd_disconnect").setAttribute("disabled", "true"); + document.getElementById("cmd_connect").setAttribute("disabled", "true"); + clearTimeout(this.disableTimerID); + this.accountList.focus(); + this.disableTimerID = setTimeout( + function (aItem) { + gAccountManager.disableTimerID = 0; + gAccountManager.disableCommandItems(); + aItem.setFocus(); + }, + this._disabledDelay, + this.accountList.selectedItem + ); + }, + + new() { + this.openDialog("chrome://messenger/content/chat/imAccountWizard.xhtml"); + }, + edit() { + // Find the nsIIncomingServer for the current imIAccount. + let server = null; + let imAccountId = this.accountList.selectedItem.account.numericId; + for (let account of MailServices.accounts.accounts) { + let incomingServer = account.incomingServer; + if (!incomingServer || incomingServer.type != "im") { + continue; + } + if (incomingServer.wrappedJSObject.imAccount.numericId == imAccountId) { + server = incomingServer; + break; + } + } + + MsgAccountManager(null, server); + }, + autologin() { + var elt = this.accountList.selectedItem; + elt.autoLogin = !elt.autoLogin; + }, + close() { + // If a modal dialog is opened, we can't close this window now + if (this.modalDialog) { + setTimeout(function () { + window.close(); + }, 0); + } else { + window.close(); + } + }, + + /* This function disables or enables the currently selected button and + the corresponding context menu item */ + disableCommandItems() { + let accountList = this.accountList; + let selectedItem = accountList.selectedItem; + // When opening the account manager, if accounts have errors, we + // can be called during build(), before any item is selected. + // In this case, just return early. + if (!selectedItem) { + return; + } + + // If the timer that disables the button (for a short time) already exists, + // we don't want to interfere and set the button as enabled. + if (this.disableTimerID) { + return; + } + + let account = selectedItem.account; + let isCommandDisabled = + this.isOffline || + (account.disconnected && + account.connectionErrorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL); + + let disabledItems = ["connect", "disconnect"]; + for (let name of disabledItems) { + let elt = document.getElementById("cmd_" + name); + if (isCommandDisabled) { + elt.setAttribute("disabled", "true"); + } else { + elt.removeAttribute("disabled"); + } + } + }, + onContextMenuShowing(event) { + let targetElt = event.target.triggerNode.closest( + 'richlistitem[is="chat-account-richlistitem"]' + ); + document.querySelectorAll(".im-context-account-item").forEach(e => { + e.hidden = !targetElt; + }); + if (targetElt) { + let account = targetElt.account; + let hiddenItems = { + connect: !account.disconnected, + disconnect: account.disconnected || account.disconnecting, + cancelReconnection: !targetElt.hasAttribute("reconnectPending"), + accountsItemsSeparator: account.disconnecting, + }; + for (let name in hiddenItems) { + document.getElementById("context_" + name).hidden = hiddenItems[name]; + } + } + }, + + selectAccount(aAccountId) { + this.accountList.selectedItem = document.getElementById(aAccountId); + this.accountList.ensureSelectedElementIsVisible(); + }, + onAccountSelect() { + clearTimeout(this.disableTimerID); + this.disableTimerID = 0; + this.disableCommandItems(); + // Horrible hack here too, see Bug 177 + setTimeout( + function (aThis) { + try { + aThis.accountList.selectedItem.setFocus(); + } catch (e) { + /* Sometimes if the user goes too fast with VK_UP or VK_DOWN, the + selectedItem doesn't have the expected binding attached */ + } + }, + 0, + this + ); + }, + + onKeyPress(event) { + if (!this.selectedItem) { + return; + } + // As we stop propagation, the default action applies to the richlistbox + // so that the selected account is changed with this default action + if (event.keyCode == event.DOM_VK_DOWN) { + if (this.selectedIndex < this.itemCount - 1) { + this.ensureIndexIsVisible(this.selectedIndex + 1); + } + event.stopPropagation(); + return; + } + + if (event.keyCode == event.DOM_VK_UP) { + if (this.selectedIndex > 0) { + this.ensureIndexIsVisible(this.selectedIndex - 1); + } + event.stopPropagation(); + return; + } + + if (event.keyCode == event.DOM_VK_RETURN) { + let target = event.target; + if ( + target.localName != "checkbox" && + (target.localName != "button" || + /^(dis)?connect$/.test(target.getAttribute("anonid"))) + ) { + this.selectedItem.buttons.proceedDefaultAction(); + } + } + }, + + *getAccounts() { + for (let account of IMServices.accounts.getAccounts()) { + yield account; + } + }, + + openDialog(aUrl, aArgs) { + this.modalDialog = true; + window.openDialog(aUrl, "", "chrome,modal,titlebar,centerscreen", aArgs); + this.modalDialog = false; + }, + + setAutoLoginNotification() { + var as = IMServices.accounts; + var autoLoginStatus = as.autoLoginStatus; + let isOffline = false; + let crashCount = 0; + for (let acc of this.getAccounts()) { + if ( + acc.autoLogin && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + ++crashCount; + } + } + + if (autoLoginStatus == as.AUTOLOGIN_ENABLED && crashCount == 0) { + let status = IMServices.core.globalUserStatus.statusType; + this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE); + return; + } + + var bundle = document.getElementById("accountsBundle"); + let box = this.msgNotificationBar; + var prio = box.PRIORITY_INFO_HIGH; + var connectNowButton = { + accessKey: bundle.getString( + "accountsManager.notification.button.accessKey" + ), + callback: this.processAutoLogin, + label: bundle.getString("accountsManager.notification.button.label"), + }; + var barLabel; + + switch (autoLoginStatus) { + case as.AUTOLOGIN_USER_DISABLED: + barLabel = bundle.getString( + "accountsManager.notification.userDisabled.label" + ); + break; + + case as.AUTOLOGIN_SAFE_MODE: + barLabel = bundle.getString( + "accountsManager.notification.safeMode.label" + ); + break; + + case as.AUTOLOGIN_START_OFFLINE: + barLabel = bundle.getString( + "accountsManager.notification.startOffline.label" + ); + isOffline = true; + break; + + case as.AUTOLOGIN_CRASH: + barLabel = bundle.getString("accountsManager.notification.crash.label"); + prio = box.PRIORITY_WARNING_MEDIUM; + break; + + /* One or more accounts made the application crash during their connection. + If none, this function has already returned */ + case as.AUTOLOGIN_ENABLED: + barLabel = bundle.getString( + "accountsManager.notification.singleCrash.label" + ); + barLabel = PluralForm.get(crashCount, barLabel).replace( + "#1", + crashCount + ); + prio = box.PRIORITY_WARNING_MEDIUM; + connectNowButton.callback = this.processCrashedAccountsLogin; + break; + + default: + barLabel = bundle.getString("accountsManager.notification.other.label"); + } + let status = IMServices.core.globalUserStatus.statusType; + this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE); + + box.appendNotification( + "autologinStatus", + { + label: barLabel, + priority: prio, + }, + [connectNowButton] + ); + }, + processAutoLogin() { + var ioService = Services.io; + if (ioService.offline) { + ioService.manageOfflineStatus = false; + ioService.offline = false; + } + + IMServices.accounts.processAutoLogin(); + + gAccountManager.accountList.selectedItem.setFocus(); + }, + processCrashedAccountsLogin() { + for (let acc in gAccountManager.getAccounts()) { + if ( + acc.disconnected && + acc.autoLogin && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + acc.connect(); + } + } + + let notification = + this.msgNotificationBar.getNotificationWithValue("autoLoginStatus"); + if (notification) { + notification.close(); + } + + gAccountManager.accountList.selectedItem.setFocus(); + }, + setOffline(aState) { + this.isOffline = aState; + if (aState) { + this.accountList.setAttribute("offline", "true"); + } else { + this.accountList.removeAttribute("offline"); + } + this.disableCommandItems(); + }, +}; + +window.addEventListener("DOMContentLoaded", () => { + gAccountManager.load(); +}); diff --git a/comm/mail/components/im/content/imAccounts.xhtml b/comm/mail/components/im/content/imAccounts.xhtml new file mode 100644 index 0000000000..d123521be1 --- /dev/null +++ b/comm/mail/components/im/content/imAccounts.xhtml @@ -0,0 +1,250 @@ +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<!-- 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://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/imRichlistbox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/imAccounts.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % accountsDTD SYSTEM "chrome://chat/locale/accounts.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd"> +%accountsDTD; %brandDTD; %chatDTD; ]> + +<html + id="accountManager" + 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="Messenger:Accounts" + scrolling="false" + lightweightthemes="true" + persist="width height screenX screenY" +> + <head> + <title>&accountsWindow.title;</title> + <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://messenger/content/chat/imAccounts.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/accountUtils.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/imStatusSelector.js" + ></script> + <script + defer="defer" + src="chrome://chat/content/chat-account-richlistitem.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <stringbundle + id="accountsBundle" + src="chrome://messenger/locale/imAccounts.properties" + /> + + <commandset id="accountsCommands"> + <command + id="cmd_connect" + accesskey="&account.connect.accesskey;" + label="&account.connect.label;" + oncommand="gAccountManager.connect()" + /> + <command + id="cmd_disconnect" + label="&account.disconnect.label;" + accesskey="&account.disconnect.accesskey;" + oncommand="gAccountManager.disconnect()" + /> + <command + id="cmd_cancelReconnection" + label="&account.cancelReconnection.label;" + accesskey="&account.cancelReconnection.accesskey;" + oncommand="gAccountManager.cancelReconnection()" + /> + <command + id="cmd_copyDebugLog" + label="&account.copyDebugLog.label;" + accesskey="&account.copyDebugLog.accesskey;" + oncommand="gAccountManager.copyDebugLog();" + /> + <command + id="cmd_edit" + label="&account.edit.label;" + accesskey="&account.edit.accesskey;" + oncommand="gAccountManager.edit()" + /> + <command + id="cmd_new" + label="&accountManager.newAccount.label;" + accesskey="&accountManager.newAccount.accesskey;" + oncommand="gAccountManager.new()" + /> + <command + id="cmd_close" + label="&accountManager.close.label;" + accesskey="&accountManager.close.accesskey;" + oncommand="gAccountManager.close()" + /> + </commandset> + + <keyset id="accountsKeys"> + <key id="key_close1" key="w" modifiers="accel" command="cmd_close" /> + <key id="key_close2" keycode="VK_ESCAPE" command="cmd_close" /> + <key + id="key_close3" + command="cmd_close" + key="&accountManager.close.commandkey;" + modifiers="accel,shift" + /> + </keyset> + + <menupopup + id="accountsContextMenu" + onpopupshowing="gAccountManager.onContextMenuShowing(event)" + > + <menuitem + id="context_connect" + command="cmd_connect" + class="im-context-account-item" + /> + <menuitem + id="context_disconnect" + command="cmd_disconnect" + class="im-context-account-item" + /> + <menuitem + id="context_cancelReconnection" + command="cmd_cancelReconnection" + class="im-context-account-item" + /> + <menuitem id="context_copyDebugLog" command="cmd_copyDebugLog" /> + <menuseparator + id="context_accountsItemsSeparator" + class="im-context-account-item" + /> + <menuitem command="cmd_new" /> + <menuseparator class="im-context-account-item" /> + <menuitem command="cmd_edit" class="im-context-account-item" /> + </menupopup> + + <html:div class="displayUserAccount"> + <stack id="statusImageStack"> + <html:img + id="userIcon" + class="userIcon" + alt="" + onclick="statusSelector.userIconClick();" + /> + <button + type="menu" + id="statusTypeIcon" + class="statusTypeIcon" + status="available" + > + <menupopup + id="setStatusTypeMenupopup" + oncommand="statusSelector.editStatus(event);" + > + <menuitem + id="statusTypeAvailable" + label="&status.available;" + status="available" + class="menuitem-iconic" + /> + <menuitem + id="statusTypeUnavailable" + label="&status.unavailable;" + status="unavailable" + class="menuitem-iconic" + /> + <menuseparator id="statusTypeOfflineSeparator" /> + <menuitem + id="statusTypeOffline" + label="&status.offline;" + status="offline" + class="menuitem-iconic" + /> + </menupopup> + </button> + </stack> + <html:div id="displayNameAndstatusMessageGrid"> + <label + id="displayName" + onclick="statusSelector.displayNameClick();" + align="center" + pack="center" + /> + <!-- FIXME: A keyboard user cannot focus the hidden input, nor click + - the above label in order to reveal it. --> + <html:input + id="displayNameInput" + class="statusMessageInput input-inline" + hidden="hidden" + /> + <html:hr /> + <label + id="statusMessageLabel" + crop="end" + value="" + onclick="statusSelector.statusMessageClick();" + /> + <html:input + id="statusMessageInput" + class="statusMessageInput input-inline" + value="" + hidden="hidden" + /> + </html:div> + </html:div> + + <hbox flex="1" ondblclick="gAccountManager.new();"> + <vbox flex="1" id="noAccountScreen" align="center" pack="center"> + <hbox id="noAccountBox" align="start"> + <vbox id="noAccountInnerBox" flex="1"> + <label + id="noAccountTitle" + value="&accountManager.noAccount.title;" + /> + <description id="noAccountDesc" + >&accountManager.noAccount.description;</description + > + </vbox> + </hbox> + </vbox> + + <vbox id="accounts-notification-box" flex="1"> + <!-- notificationbox will be added here lazily. --> + <richlistbox + id="accountlist" + flex="1" + context="accountsContextMenu" + onselect="gAccountManager.onAccountSelect();" + /> + </vbox> + </hbox> + + <hbox id="bottombuttons" align="center"> + <button id="newaccount" command="cmd_new" /> + <spacer flex="1" /> + <button id="close" command="cmd_close" /> + </hbox> + </html:body> +</html> diff --git a/comm/mail/components/im/content/imContextMenu.js b/comm/mail/components/im/content/imContextMenu.js new file mode 100644 index 0000000000..0d9ecf0763 --- /dev/null +++ b/comm/mail/components/im/content/imContextMenu.js @@ -0,0 +1,276 @@ +/* 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/. */ + +// This file is loaded in messenger.xhtml. +/* globals gatherTextUnder, goUpdateGlobalEditMenuItems, makeURLAbsolute, Services */ +/* import-globals-from ../../../base/content/widgets/browserPopups.js */ + +var gChatContextMenu = null; + +function imContextMenu(aXulMenu) { + this.target = null; + this.menu = null; + this.onLink = false; + this.onMailtoLink = false; + this.onSaveableLink = false; + this.link = false; + this.linkURL = ""; + this.linkURI = null; + this.linkProtocol = null; + this.isTextSelected = false; + this.isContentSelected = false; + this.shouldDisplay = true; + this.ellipsis = "\u2026"; + this.initedActions = false; + + try { + this.ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + + // Initialize new menu. + this.initMenu(aXulMenu); +} + +// Prototype for nsContextMenu "class." +imContextMenu.prototype = { + cleanup() { + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal + ?.getActor("ChatAction") + .reportHide(); + let elt = document.getElementById( + "context-sep-messageactions" + ).nextElementSibling; + // remove the action menuitems added last time we opened the popup + while (elt && elt.localName != "menuseparator") { + let tmp = elt.nextElementSibling; + elt.remove(); + elt = tmp; + } + }, + + /** + * Initialize context menu. Shows/hides relevant items. Message actions are + * handled separately in |initActions| if the actor gets them after this is + * called. + * + * @param {XULMenuPopupElement} aPopup - The popup to initialize on. + */ + initMenu(aPopup) { + this.menu = aPopup; + + // Get contextual info. + this.setTarget(); + + this.isTextSelected = this.isTextSelection(); + this.isContentSelected = this.isContentSelection(); + + // Initialize (disable/remove) menu items. + // Open/Save/Send link depends on whether we're in a link. + var shouldShow = this.onSaveableLink; + this.showItem("context-openlink", shouldShow); + this.showItem("context-sep-open", shouldShow); + this.showItem("context-savelink", shouldShow); + + // Copy depends on whether there is selected text. + // Enabling this context menu item is now done through the global + // command updating system + goUpdateGlobalEditMenuItems(); + + this.showItem("context-copy", this.isContentSelected); + this.showItem("context-selectall", !this.onLink || this.isContentSelected); + if (!this.initedActions) { + let actor = + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal?.getActor( + "ChatAction" + ); + if (actor?.actions) { + this.initActions(actor.actions); + } else { + this.showItem("context-sep-messageactions", false); + } + } + + // Copy email link depends on whether we're on an email link. + this.showItem("context-copyemail", this.onMailtoLink); + + // Copy link location depends on whether we're on a non-mailto link. + this.showItem("context-copylink", this.onLink && !this.onMailtoLink); + this.showItem( + "context-sep-copylink", + this.onLink && this.isContentSelected + ); + }, + + /** + * Adds the given message actions to the context menu. + * + * @param {Array<string>} actions - Array containing the labels for the + * available actions. + */ + initActions(actions) { + this.showItem("context-sep-messageactions", actions.length > 0); + + // Display action menu items. + let sep = document.getElementById("context-sep-messageactions"); + for (let [index, label] of actions.entries()) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", label); + menuitem.addEventListener("command", () => { + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal + ?.getActor("ChatAction") + .sendAsyncMessage("ChatAction:Run", { index }); + }); + sep.parentNode.appendChild(menuitem); + } + this.initedActions = true; + }, + + // Set various context menu attributes based on the state of the world. + setTarget() { + // Initialize contextual info. + this.onLink = nsContextMenu.contentData.context.onLink; + this.linkURL = nsContextMenu.contentData.context.linkURL; + this.linkURI = this.getLinkURI(); + this.linkProtocol = nsContextMenu.contentData.context.linkProtocol; + this.linkText = nsContextMenu.contentData.context.linkTextStr; + this.onMailtoLink = nsContextMenu.contentData.context.onMailtoLink; + this.onSaveableLink = nsContextMenu.contentData.context.onSaveableLink; + }, + + // Open linked-to URL in a new window. + openLink(aURI) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(aURI || this.linkURI, nsContextMenu.contentData.principal); + }, + + // Generate email address and put it on clipboard. + copyEmail() { + // Copy the comma-separated list of email addresses only. + // There are other ways of embedding email addresses in a mailto: + // link, but such complex parsing is beyond us. + var url = this.linkURL; + var qmark = url.indexOf("?"); + var addresses; + + // 7 == length of "mailto:" + addresses = qmark > 7 ? url.substring(7, qmark) : url.substr(7); + + // Let's try to unescape it using a character set + // in case the address is not ASCII. + try { + var characterSet = this.target.ownerDocument.characterSet; + addresses = Services.textToSubURI.unEscapeURIForUI( + characterSet, + addresses + ); + } catch (ex) { + // Do nothing. + } + + var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(addresses); + }, + + // --------- + // Utilities + + // Show/hide one item (specified via name or the item element itself). + showItem(aItemOrId, aShow) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + if (item) { + item.hidden = !aShow; + } + }, + + // Temporary workaround for DOM api not yet implemented by XUL nodes. + cloneNode(aItem) { + // Create another element like the one we're cloning. + var node = document.createXULElement(aItem.tagName); + + // Copy attributes from argument item to the new one. + var attrs = aItem.attributes; + for (var i = 0; i < attrs.length; i++) { + var attr = attrs.item(i); + node.setAttribute(attr.nodeName, attr.nodeValue); + } + + // Voila! + return node; + }, + + getLinkURI() { + try { + return Services.io.newURI(this.linkURL); + } catch (ex) { + // e.g. empty URL string + } + + return null; + }, + + // Get selected text. Only display the first 15 chars. + isTextSelection() { + // Get 16 characters, so that we can trim the selection if it's greater + // than 15 chars + var selectedText = getBrowserSelection(16); + + if (!selectedText) { + return false; + } + + if (selectedText.length > 15) { + selectedText = selectedText.substr(0, 15) + this.ellipsis; + } + + return true; + }, + + // Returns true if anything is selected. + isContentSelection() { + return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed; + }, +}; + +/** + * Gets the selected text in the active browser. Leading and trailing + * whitespace is removed, and consecutive whitespace is replaced by a single + * space. A maximum of 150 characters will be returned, regardless of the value + * of aCharLen. + * + * @param aCharLen + * The maximum number of characters to return. + */ +function getBrowserSelection(aCharLen) { + // selections of more than 150 characters aren't useful + const kMaxSelectionLen = 150; + const charLen = Math.min(aCharLen || kMaxSelectionLen, kMaxSelectionLen); + + var focusedWindow = document.commandDispatcher.focusedWindow; + var selection = focusedWindow.getSelection().toString(); + + if (selection) { + if (selection.length > charLen) { + // only use the first charLen important chars. see bug 221361 + var pattern = new RegExp("^(?:\\s*.){0," + charLen + "}"); + pattern.test(selection); + selection = RegExp.lastMatch; + } + + selection = selection.trim().replace(/\s+/g, " "); + + if (selection.length > charLen) { + selection = selection.substr(0, charLen); + } + } + return selection; +} diff --git a/comm/mail/components/im/content/imStatusSelector.js b/comm/mail/components/im/content/imStatusSelector.js new file mode 100644 index 0000000000..69bbc2776a --- /dev/null +++ b/comm/mail/components/im/content/imStatusSelector.js @@ -0,0 +1,383 @@ +/* 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 { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" +); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var statusSelector = { + observe(aSubject, aTopic, aMsg) { + if (aTopic == "status-changed") { + this.displayCurrentStatus(); + } else if (aTopic == "user-icon-changed") { + this.displayUserIcon(); + } else if (aTopic == "user-display-name-changed") { + this.displayUserDisplayName(); + } + }, + + displayUserIcon() { + let icon = IMServices.core.globalUserStatus.getUserIcon(); + ChatIcons.setUserIconSrc( + document.getElementById("userIcon"), + icon?.spec, + true + ); + }, + + displayUserDisplayName() { + let displayName = IMServices.core.globalUserStatus.displayName; + let elt = document.getElementById("displayName"); + if (displayName) { + elt.removeAttribute("usingDefault"); + } else { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + displayName = bundle.GetStringFromName("displayNameEmptyText"); + elt.setAttribute("usingDefault", displayName); + } + elt.setAttribute("value", displayName); + }, + + displayStatusType(aStatusType) { + document + .getElementById("statusMessageLabel") + .setAttribute("statusType", aStatusType); + let statusString = Status.toLabel(aStatusType); + let statusTypeIcon = document.getElementById("statusTypeIcon"); + statusTypeIcon.setAttribute("status", aStatusType); + statusTypeIcon.setAttribute("tooltiptext", statusString); + return statusString; + }, + + displayCurrentStatus() { + let us = IMServices.core.globalUserStatus; + let status = Status.toAttribute(us.statusType); + let message = status == "offline" ? "" : us.statusText; + let statusMessage = document.getElementById("statusMessageLabel"); + if (!statusMessage) { + // Chat toolbar not in the DOM yet + return; + } + if (message) { + statusMessage.removeAttribute("usingDefault"); + } else { + let statusString = this.displayStatusType(status); + statusMessage.setAttribute("usingDefault", statusString); + message = statusString; + } + statusMessage.setAttribute("value", message); + statusMessage.setAttribute("tooltiptext", message); + }, + + editStatus(aEvent) { + let status = aEvent.target.getAttribute("status"); + if (status == "offline") { + IMServices.core.globalUserStatus.setStatus( + Ci.imIStatusInfo.STATUS_OFFLINE, + "" + ); + } else if (status) { + this.startEditStatus(status); + } + }, + + startEditStatus(aStatusType) { + let currentStatusType = document + .getElementById("statusTypeIcon") + .getAttribute("status"); + if (aStatusType != currentStatusType) { + this._statusTypeBeforeEditing = currentStatusType; + this._statusTypeEditing = aStatusType; + this.displayStatusType(aStatusType); + } + this.statusMessageClick(); + }, + + statusMessageClick() { + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + statusMessage.setAttribute("hidden", "true"); + statusMessageInput.removeAttribute("hidden"); + let statusType = document + .getElementById("statusTypeIcon") + .getAttribute("status"); + if (statusType == "offline" || statusMessage.disabled) { + return; + } + + if (!statusMessageInput.hasAttribute("editing")) { + statusMessageInput.setAttribute("editing", "true"); + statusMessageInput.addEventListener("blur", event => { + this.finishEditStatusMessage(true); + }); + if (statusMessage.hasAttribute("usingDefault")) { + if ( + "_statusTypeBeforeEditing" in this && + this._statusTypeBeforeEditing == "offline" + ) { + statusMessageInput.setAttribute( + "value", + IMServices.core.globalUserStatus.statusText + ); + } else { + statusMessageInput.removeAttribute("value"); + } + } else { + statusMessageInput.setAttribute( + "value", + statusMessage.getAttribute("value") + ); + } + + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + statusMessageInput.setAttribute("spellcheck", "true"); + } else { + statusMessageInput.removeAttribute("spellcheck"); + } + + // force binding attachment by forcing layout + statusMessageInput.getBoundingClientRect(); + statusMessageInput.select(); + } + + this.statusMessageRefreshTimer(); + }, + + statusMessageRefreshTimer() { + const timeBeforeAutoValidate = 20 * 1000; + if ("_stopEditStatusTimeout" in this) { + clearTimeout(this._stopEditStatusTimeout); + } + this._stopEditStatusTimeout = setTimeout( + this.finishEditStatusMessage, + timeBeforeAutoValidate, + true + ); + }, + + statusMessageKeyPress(aEvent) { + if (!this.hasAttribute("editing")) { + if (aEvent.keyCode == aEvent.DOM_VK_DOWN) { + let button = document.getElementById("statusTypeIcon"); + document.getElementById("setStatusTypeMenupopup").openPopup(button); + } + return; + } + + switch (aEvent.keyCode) { + case aEvent.DOM_VK_RETURN: + statusSelector.finishEditStatusMessage(true); + break; + + case aEvent.DOM_VK_ESCAPE: + statusSelector.finishEditStatusMessage(false); + break; + + default: + statusSelector.statusMessageRefreshTimer(); + } + }, + + finishEditStatusMessage(aSave) { + clearTimeout(this._stopEditStatusTimeout); + delete this._stopEditStatusTimeout; + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + statusMessage.removeAttribute("hidden"); + statusMessageInput.toggleAttribute("hidden", "true"); + if (aSave) { + let newStatus = Ci.imIStatusInfo.STATUS_UNKNOWN; + if ("_statusTypeEditing" in this) { + let statusType = this._statusTypeEditing; + if (statusType == "available") { + newStatus = Ci.imIStatusInfo.STATUS_AVAILABLE; + } else if (statusType == "unavailable") { + newStatus = Ci.imIStatusInfo.STATUS_UNAVAILABLE; + } else if (statusType == "offline") { + newStatus = Ci.imIStatusInfo.STATUS_OFFLINE; + } + delete this._statusTypeBeforeEditing; + delete this._statusTypeEditing; + } + // apply the new status only if it is different from the current one + if ( + newStatus != Ci.imIStatusInfo.STATUS_UNKNOWN || + statusMessageInput.value != statusMessageInput.getAttribute("value") + ) { + IMServices.core.globalUserStatus.setStatus( + newStatus, + statusMessageInput.value + ); + } + } else if ("_statusTypeBeforeEditing" in this) { + this.displayStatusType(this._statusTypeBeforeEditing); + delete this._statusTypeBeforeEditing; + delete this._statusTypeEditing; + } + + if (statusMessage.hasAttribute("usingDefault")) { + statusMessage.setAttribute( + "value", + statusMessage.getAttribute("usingDefault") + ); + } + + statusMessageInput.removeAttribute("editing"); + statusMessageInput.removeEventListener("blur", event => { + this.finishEditStatusMessage(true); + }); + + // We need to put the focus back on the label after the textbox + // binding has been detached, otherwise the focus gets lost (it's + // on none of the elements in the document), but before that we + // need to flush the layout. + statusMessageInput.getBoundingClientRect(); + statusMessageInput.focus(); + }, + + userIconClick() { + const nsIFilePicker = Ci.nsIFilePicker; + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + fp.init( + window, + bundle.GetStringFromName("userIconFilePickerTitle"), + nsIFilePicker.modeOpen + ); + fp.appendFilters(nsIFilePicker.filterImages); + fp.open(rv => { + if (rv != nsIFilePicker.returnOK || !fp.file) { + return; + } + IMServices.core.globalUserStatus.setUserIcon(fp.file); + }); + }, + + displayNameClick() { + let displayName = document.getElementById("displayName"); + let displayNameInput = document.getElementById("displayNameInput"); + displayName.setAttribute("hidden", "true"); + displayNameInput.removeAttribute("hidden"); + if (!displayNameInput.hasAttribute("editing")) { + displayNameInput.setAttribute("editing", "true"); + if (displayName.hasAttribute("usingDefault")) { + displayNameInput.removeAttribute("value"); + } else { + displayNameInput.setAttribute( + "value", + displayName.getAttribute("value") + ); + } + displayNameInput.addEventListener("keypress", this.displayNameKeyPress); + displayNameInput.addEventListener("blur", event => { + this.finishEditDisplayName(true); + }); + // force binding attachment by forcing layout + displayNameInput.getBoundingClientRect(); + displayNameInput.select(); + } + + this.displayNameRefreshTimer(); + }, + + _stopEditDisplayNameTimeout: 0, + displayNameRefreshTimer() { + const timeBeforeAutoValidate = 20 * 1000; + clearTimeout(this._stopEditDisplayNameTimeout); + this._stopEditDisplayNameTimeout = setTimeout( + this.finishEditDisplayName, + timeBeforeAutoValidate, + true + ); + }, + + displayNameKeyPress(aEvent) { + switch (aEvent.keyCode) { + case aEvent.DOM_VK_RETURN: + statusSelector.finishEditDisplayName(true); + break; + + case aEvent.DOM_VK_ESCAPE: + statusSelector.finishEditDisplayName(false); + break; + + default: + statusSelector.displayNameRefreshTimer(); + } + }, + + finishEditDisplayName(aSave) { + clearTimeout(this._stopEditDisplayNameTimeout); + let displayName = document.getElementById("displayName"); + let displayNameInput = document.getElementById("displayNameInput"); + displayName.removeAttribute("hidden"); + displayNameInput.toggleAttribute("hidden", "true"); + // Apply the new display name only if it is different from the current one. + if ( + aSave && + displayNameInput.value != displayNameInput.getAttribute("value") + ) { + IMServices.core.globalUserStatus.displayName = displayNameInput.value; + } else if (displayName.hasAttribute("usingDefault")) { + displayName.setAttribute( + "value", + displayName.getAttribute("usingDefault") + ); + } + + displayNameInput.removeAttribute("editing"); + displayNameInput.removeEventListener("keypress", this.displayNameKeyPress); + displayNameInput.removeEventListener("blur", event => { + this.finishEditDisplayName(true); + }); + }, + + init() { + let events = ["status-changed"]; + statusSelector.displayCurrentStatus(); + + if (document.getElementById("displayName")) { + events.push("user-display-name-changed"); + statusSelector.displayUserDisplayName(); + } + + if (document.getElementById("userIcon")) { + events.push("user-icon-changed"); + statusSelector.displayUserIcon(); + } + + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + if (statusMessage && statusMessageInput) { + statusMessage.addEventListener("keypress", this.statusMessageKeyPress); + statusMessageInput.addEventListener( + "keypress", + this.statusMessageKeyPress + ); + } + + for (let event of events) { + Services.obs.addObserver(statusSelector, event); + } + statusSelector._events = events; + + window.addEventListener("unload", statusSelector.unload); + }, + + unload() { + for (let event of statusSelector._events) { + Services.obs.removeObserver(statusSelector, event); + } + }, +}; diff --git a/comm/mail/components/im/content/joinchat.js b/comm/mail/components/im/content/joinchat.js new file mode 100644 index 0000000000..ae4029eb5a --- /dev/null +++ b/comm/mail/components/im/content/joinchat.js @@ -0,0 +1,195 @@ +/* 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 { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var autoJoinPref = "autoJoin"; + +var joinChat = { + onload() { + var accountList = document.getElementById("accountlist"); + for (let acc of IMServices.accounts.getAccounts()) { + if (!acc.connected || !acc.canJoinChat) { + continue; + } + var proto = acc.protocol; + var item = accountList.appendItem(acc.name, acc.id, proto.name); + item.setAttribute("image", ChatIcons.getProtocolIconURI(proto)); + item.setAttribute("class", "menuitem-iconic"); + item.account = acc; + } + if (!accountList.itemCount) { + document + .getElementById("joinChatDialog") + .querySelector("dialog") + .cancelDialog(); + throw new Error("No connected MUC enabled account!"); + } + accountList.selectedIndex = 0; + }, + + onAccountSelect() { + let joinChatGrid = document.getElementById("joinChatGrid"); + while (joinChatGrid.children.length > 3) { + // leave the first 3 cols + joinChatGrid.lastChild.remove(); + } + + let acc = document.getElementById("accountlist").selectedItem.account; + let defaultValues = acc.getChatRoomDefaultFieldValues(); + joinChat._values = defaultValues; + joinChat._fields = []; + joinChat._account = acc; + + let protoId = acc.protocol.id; + document.getElementById("autojoin").hidden = !( + protoId == "prpl-irc" || + protoId == "prpl-jabber" || + protoId == "prpl-gtalk" + ); + + for (let field of acc.getChatRoomFields()) { + let div1 = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + let label = document.createXULElement("label"); + let text = field.label; + let match = /_(.)/.exec(text); + if (match) { + label.setAttribute("accesskey", match[1]); + text = text.replace(/_/, ""); + } + label.setAttribute("value", text); + label.setAttribute("control", "field-" + field.identifier); + label.setAttribute("id", "field-" + field.identifier + "-label"); + div1.appendChild(label); + joinChatGrid.appendChild(div1); + + let div2 = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + let input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.classList.add("input-inline"); + input.setAttribute("id", "field-" + field.identifier); + input.setAttribute( + "aria-labelledby", + "field-" + field.identifier + "-label" + ); + let val = defaultValues.getValue(field.identifier); + if (val) { + input.setAttribute("value", val); + } + if (field.type == Ci.prplIChatRoomField.TYPE_PASSWORD) { + input.setAttribute("type", "password"); + } else if (field.type == Ci.prplIChatRoomField.TYPE_INT) { + input.setAttribute("type", "number"); + input.setAttribute("min", field.min); + input.setAttribute("max", field.max); + } else { + input.setAttribute("type", "text"); + } + div2.appendChild(input); + joinChatGrid.appendChild(div2); + + let div3 = document.querySelector(".optional-col").cloneNode(true); + div3.classList.toggle("required", field.required); + joinChatGrid.appendChild(div3); + + joinChat._fields.push({ field, input }); + } + + window.sizeToContent(); + }, + + join() { + let values = joinChat._values; + for (let field of joinChat._fields) { + let val = field.input.value.trim(); + if (!val && field.field.required) { + field.input.focus(); + // FIXME: why isn't the return false enough? + throw new Error("Some required fields are empty!"); + // return false; + } + if (val) { + values.setValue(field.field.identifier, val); + } + } + let account = joinChat._account; + account.joinChat(values); + + let protoId = account.protocol.id; + if ( + protoId != "prpl-irc" && + protoId != "prpl-jabber" && + protoId != "prpl-gtalk" + ) { + return; + } + + let name; + if (protoId == "prpl-irc") { + name = values.getValue("channel"); + } else { + name = values.getValue("room") + "@" + values.getValue("server"); + } + + let conv = IMServices.conversations.getConversationByNameAndAccount( + name, + account, + true + ); + if (conv) { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mailWindow) { + mailWindow.focus(); + let tabmail = mailWindow.document.getElementById("tabmail"); + tabmail.openTab("chat", { convType: "focus", conv }); + } + } + + if (document.getElementById("autojoin").checked) { + // "nick" for JS-XMPP, "handle" for libpurple prpls. + let nick = values.getValue("nick") || values.getValue("handle"); + if (nick) { + name += "/" + nick; + } + + let prefBranch = Services.prefs.getBranch( + "messenger.account." + account.id + "." + ); + let autojoin = []; + if (prefBranch.prefHasUserValue(autoJoinPref)) { + let prefValue = prefBranch.getStringPref(autoJoinPref); + if (prefValue) { + autojoin = prefValue.split(","); + } + } + + if (!autojoin.includes(name)) { + autojoin.push(name); + prefBranch.setStringPref(autoJoinPref, autojoin.join(",")); + } + } + }, +}; + +document.addEventListener("dialogaccept", joinChat.join); + +window.addEventListener("DOMContentLoaded", event => { + joinChat.onload(); +}); +window.addEventListener("load", event => { + window.sizeToContent(); +}); diff --git a/comm/mail/components/im/content/joinchat.xhtml b/comm/mail/components/im/content/joinchat.xhtml new file mode 100644 index 0000000000..8bd5753e91 --- /dev/null +++ b/comm/mail/components/im/content/joinchat.xhtml @@ -0,0 +1,58 @@ +<?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://messenger/skin/menulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/joinchat.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/joinChat.dtd"> + +<html + id="joinChatDialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + scrolling="false" +> + <head> + <title>&joinChatWindow.title;</title> + <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://messenger/content/chat/joinchat.js" + ></script> + </head> + <body> + <xul:dialog buttons="accept,cancel"> + <div id="joinChatGrid"> + <div> + <xul:label value="&account.label;" control="accountlist" /> + </div> + <div> + <xul:menulist + id="accountlist" + onselect="joinChat.onAccountSelect();" + /> + </div> + <div class="optional-col required">&optional.label;</div> + </div> + <xul:hbox> + <xul:checkbox + id="autojoin" + label="&autojoin.label;" + accesskey="&autojoin.accesskey;" + /> + </xul:hbox> + </xul:dialog> + </body> +</html> diff --git a/comm/mail/components/im/content/toolbarbutton-badge-button.js b/comm/mail/components/im/content/toolbarbutton-badge-button.js new file mode 100644 index 0000000000..def96faf27 --- /dev/null +++ b/comm/mail/components/im/content/toolbarbutton-badge-button.js @@ -0,0 +1,70 @@ +/* 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"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozBadgebutton widget is used to display a chat toolbar button in + * the main Toolbox in the messenger window. It displays icon and label + * for the button. It also shows a badge on top of the chat icon with a number. + * That number is the count of unread messages in the chat. + * + * @augments MozToolbarbutton + */ + class MozBadgebutton extends customElements.get("toolbarbutton") { + static get inheritedAttributes() { + return { + ".toolbarbutton-icon": "src=image", + ".toolbarbutton-text": "value=label,accesskey,crop", + }; + } + + static get markup() { + return ` + <stack> + <html:img class="toolbarbutton-icon" alt="" /> + <html:span class="badgeButton-badge" hidden="hidden"></html:span> + </stack> + <label class="toolbarbutton-text" crop="end" flex="1"></label> + `; + } + + /** + * toolbarbutton overwrites the fragment getter from MozXULElement. + */ + static get fragment() { + return Reflect.get(MozXULElement, "fragment", this); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.setAttribute("is", "toolbarbutton-badge-button"); + this.appendChild(this.constructor.fragment); + + this._badgeCount = 0; + this.initializeAttributeInheritance(); + } + + set badgeCount(count) { + this._badgeCount = count; + let badge = this.querySelector(".badgeButton-badge"); + badge.textContent = count; + badge.hidden = count == 0; + } + + get badgeCount() { + return this._badgeCount; + } + } + + customElements.define("toolbarbutton-badge-button", MozBadgebutton, { + extends: "toolbarbutton", + }); +} diff --git a/comm/mail/components/im/content/verify.js b/comm/mail/components/im/content/verify.js new file mode 100644 index 0000000000..fbe39d6a50 --- /dev/null +++ b/comm/mail/components/im/content/verify.js @@ -0,0 +1,53 @@ +/* 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 verifySession = { + onload() { + this.sessionVerification = + window.arguments[0].wrappedJSObject || window.arguments[0]; + if ( + this.sessionVerification.challengeType !== + Ci.imISessionVerification.CHALLENGE_TEXT + ) { + throw new Error("Unsupported challenge type"); + } + document.l10n.setAttributes( + document.querySelector("title"), + "verify-window-subject-title", + { + subject: this.sessionVerification.subject, + } + ); + document.getElementById("challenge").textContent = + this.sessionVerification.challenge; + if (this.sessionVerification.challengeDescription) { + let description = document.getElementById("challengeDescription"); + description.hidden = false; + description.textContent = this.sessionVerification.challengeDescription; + } + document.addEventListener("dialogaccept", () => { + this.sessionVerification.submitResponse(true); + }); + document.addEventListener("dialogextra2", () => { + this.sessionVerification.submitResponse(false); + document + .getElementById("verifySessionDialog") + .querySelector("dialog") + .acceptDialog(); + }); + document.addEventListener("dialogcancel", () => { + this.sessionVerification.cancel(); + }); + this.sessionVerification.completePromise.catch(() => { + document + .getElementById("verifySessionDialog") + .querySelector("dialog") + .cancelDialog(); + }); + }, +}; + +window.addEventListener("load", event => { + verifySession.onload(); +}); diff --git a/comm/mail/components/im/content/verify.xhtml b/comm/mail/components/im/content/verify.xhtml new file mode 100644 index 0000000000..930ae81e5d --- /dev/null +++ b/comm/mail/components/im/content/verify.xhtml @@ -0,0 +1,46 @@ +<?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/. --> +<html + id="verifySessionDialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + scrolling="false" +> + <head> + <title data-l10n-id="verify-window-title"></title> + <link rel="localization" href="messenger/chat-verifySession.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/verifychat.css" /> + <link + rel="stylesheet" + href="chrome://messenger/skin/shared/grid-layout.css" + /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/verify.js" + ></script> + </head> + <body> + <xul:dialog + buttons="accept,cancel,extra2" + data-l10n-id="verify-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2" + > + <p data-l10n-id="challenge-label"></p> + <p id="challengePresentation"> + <span id="challenge"></span> + <!-- Describes the text content of #challenge in an alternative way. + - E.g. if #challenge is a sequence of emojis, then + - #challengeDescription would be a sequence of emoji names. --> + <span id="challengeDescription" role="note" hidden="hidden"></span> + </p> + </xul:dialog> + </body> +</html> |