/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /* global MozElements */ /* global MozXULElement */ /* global getBrowser */ // Wrap in a block to prevent leaking to window scope. { var { IMServices } = ChromeUtils.importESModule( "resource:///modules/IMServices.sys.mjs" ); const { ChatIcons } = ChromeUtils.importESModule( "resource:///modules/chatIcons.sys.mjs" ); const LazyModules = {}; ChromeUtils.defineESModuleGetters(LazyModules, { Status: "resource:///modules/imStatusUtils.sys.mjs", }); /** * The MozChatTooltip widget implements a custom tooltip for chat. This tooltip * is used to display a rich tooltip when you mouse over contacts, channels * etc. in the chat view. * * @augments {XULPopupElement} */ class MozChatTooltip extends MozElements.MozElementMixin(XULPopupElement) { static get inheritedAttributes() { return { ".displayName": "value=displayname" }; } constructor() { super(); this._buddy = null; this.observer = { // @see {nsIObserver} observe: (subject, topic, data) => { if ( subject == this.buddy && (topic == "account-buddy-status-changed" || topic == "account-buddy-status-detail-changed" || topic == "account-buddy-display-name-changed" || topic == "account-buddy-icon-changed") ) { this.updateTooltipFromBuddy(this.buddy); } else if ( topic == "user-info-received" && data == this.observedUserInfo ) { this.updateTooltipInfo( subject.QueryInterface(Ci.nsISimpleEnumerator) ); } }, QueryInterface: ChromeUtils.generateQI([ "nsIObserver", "nsISupportsWeakReference", ]), }; this.addEventListener("popupshowing", event => { if (!this._onPopupShowing()) { event.preventDefault(); } }); this.addEventListener("popuphiding", () => { this.buddy = null; if ("observedUserInfo" in this && this.observedUserInfo) { Services.obs.removeObserver(this.observer, "user-info-received"); delete this.observedUserInfo; } }); } _onPopupShowing() { // No tooltip for elements that have already been removed. if (!this.triggerNode.parentNode) { return false; } let showHTMLTooltip = false; // Reset tooltip. const largeTooltip = this.querySelector(".largeTooltip"); largeTooltip.hidden = false; this.removeAttribute("label"); const htmlTooltip = this.querySelector(".htmlTooltip"); htmlTooltip.hidden = true; this.hasBestAvatar = false; // We have a few cases that have special behavior. These are richlistitems // and have tooltip="". const item = this.triggerNode.closest( `[tooltip="${this.id}"] richlistitem` ); // No tooltip on search results if (item?.hasAttribute("is-search-result")) { return false; } // No tooltip on the group headers if (item && item.matches(`:scope[is="chat-group-richlistitem"]`)) { return false; } if (item && item.matches(`:scope[is="chat-imconv-richlistitem"]`)) { return this.updateTooltipFromConversation(item.conv); } if (item && item.matches(`:scope[is="chat-contact-richlistitem"]`)) { return this.updateTooltipFromBuddy( item.contact.preferredBuddy.preferredAccountBuddy ); } if (item) { const contactlistbox = document.getElementById("contactlistbox"); const conv = contactlistbox.selectedItem.conv; return this.updateTooltipFromParticipant( item.chatBuddy.name, conv, item.chatBuddy ); } // Tooltips are also used for the chat content, where we need to do // some more general checks. const elt = this.triggerNode; const classList = elt.classList; // ib-sender nicks are handled with _originalMsg if possible if (classList.contains("ib-nick") || classList.contains("ib-person")) { const conv = getBrowser()._conv; if (conv.isChat) { return this.updateTooltipFromParticipant(elt.textContent, conv); } if (!conv.isChat && elt.textContent == conv.name) { return this.updateTooltipFromConversation(conv); } } let sender = elt.textContent; let overrideAvatar = undefined; // Are we over a message? for (let node = elt; node; node = node.parentNode) { if (!node._originalMsg) { continue; } // Nick, build tooltip with original who information from message if (classList.contains("ib-sender")) { sender = node._originalMsg.who; overrideAvatar = node._originalMsg.iconURL; break; } // It's a message, so add a date/time tooltip. const date = new Date(node._originalMsg.time * 1000); let text; if (new Date().toDateString() == date.toDateString()) { const dateTimeFormatter = new Services.intl.DateTimeFormat( undefined, { timeStyle: "medium", } ); text = dateTimeFormatter.format(date); } else { const dateTimeFormatter = new Services.intl.DateTimeFormat( undefined, { dateStyle: "short", timeStyle: "medium", } ); text = dateTimeFormatter.format(date); } // Setting the attribute on this node means that if the element // we are pointing at carries a title set by the prpl, // that title won't be overridden. node.setAttribute("title", text); showHTMLTooltip = true; break; } if (classList.contains("ib-sender")) { const conv = getBrowser()._conv; if (conv.isChat) { return this.updateTooltipFromParticipant( sender, conv, undefined, overrideAvatar ); } if (!conv.isChat && elt.textContent == conv.name) { return this.updateTooltipFromConversation(conv, overrideAvatar); } } largeTooltip.hidden = true; // Show the title in the tooltip if (showHTMLTooltip) { let content = this.triggerNode.getAttribute("title"); if (!content) { const closestTitle = this.triggerNode.closest("[title]"); if (closestTitle) { content = closestTitle.getAttribute("title"); } } if (!content) { return false; } htmlTooltip.textContent = content; htmlTooltip.hidden = false; return true; } return false; } connectedCallback() { if (this.delayConnectedCallback()) { return; } this.textContent = ""; MozXULElement.insertFTLIfNeeded("chat/imtooltip.ftl"); this.appendChild( MozXULElement.parseXULToFragment(` `) ); this.initializeAttributeInheritance(); } set buddy(val) { if (val == this._buddy) { return; } if (!val) { this._buddy.buddy.removeObserver(this.observer); } else { val.buddy.addObserver(this.observer); } this._buddy = val; } get buddy() { return this._buddy; } get table() { if (!("_table" in this)) { this._table = this.querySelector(".tooltipTable"); } return this._table; } setMessage(aMessage, noTopic = false) { const msg = this.querySelector(".statusMessage"); msg.value = aMessage; msg.toggleAttribute("noTopic", noTopic); } reset() { while (this.table.hasChildNodes()) { this.table.lastChild.remove(); } } /** * Add a row to the tooltip's table * * @param {string} aLabel - Label for the table row. * @param {string} aValue - Value for the table row. * @param {{label: boolean, value: boolean}} [l10nIds] - Treat the label * and value as l10n IDs */ addRow(aLabel, aValue, l10nIds = { label: false, value: false }) { let description; let row = [...this.table.querySelectorAll("tr")].find(tr => { const th = tr.querySelector("th"); if (l10nIds?.label) { return th.dataset.l10nId == aLabel; } return th.textContent == aLabel; }); if (!row) { // Create a new row for this label. row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr"); const th = document.createElementNS( "http://www.w3.org/1999/xhtml", "th" ); if (l10nIds?.label) { document.l10n.setAttributes(th, aLabel); } else { th.textContent = aLabel; } th.setAttribute("valign", "top"); row.appendChild(th); description = document.createElementNS( "http://www.w3.org/1999/xhtml", "td" ); row.appendChild(description); this.table.appendChild(row); } else { // Row with this label already exists - just update. description = row.querySelector("td"); } if (l10nIds?.value) { document.l10n.setAttributes(description, aValue); } else { description.textContent = aValue; } } addSeparator() { if (this.table.hasChildNodes()) { const lastElement = this.table.lastElementChild; lastElement.querySelector("th").classList.add("chatTooltipSeparator"); lastElement.querySelector("td").classList.add("chatTooltipSeparator"); } } requestBuddyInfo(aAccount, aObservedName) { // Libpurple prpls don't necessarily return data in response to // requestBuddyInfo that is suitable for displaying inside a // tooltip (e.g. too many objects, or and tags), // so we only use it for JavaScript prpls. // This is a terrible, terrible hack to work around the fact that // ClassInfo.implementationLanguage has gone. if (!aAccount.prplAccount || !aAccount.prplAccount.wrappedJSObject) { return; } this.observedUserInfo = aObservedName; Services.obs.addObserver(this.observer, "user-info-received"); aAccount.requestBuddyInfo(aObservedName); } /** * Sets the shown user icon. * * @param {string|null} iconURI - The image uri to show, or "" to use the * fallback, or null to hide the icon. * @param {boolean} useFallback - True if the "fallback" icon should be shown * if iconUri isn't provided. */ setUserIcon(iconUri, useFalback) { ChatIcons.setUserIconSrc( this.querySelector(".userIcon"), iconUri, useFalback ); } setProtocolIcon(protocol) { this.querySelector(".protoIcon").setAttribute( "src", ChatIcons.getProtocolIconURI(protocol) ); } setStatusIcon(statusName) { this.querySelector(".statusTypeIcon").setAttribute( "src", ChatIcons.getStatusIconURI(statusName) ); ChatIcons.setProtocolIconOpacity( this.querySelector(".protoIcon"), statusName ); } /** * Regenerate the tooltip based on a buddy. * * @param {prplIAccountBuddy} aBuddy - The buddy to generate the conversation. * @param {IMConversation} [aConv] - A conversation associated with this buddy. * @param {string} [overrideAvatar] - URL for the user avatar to use * instead. */ updateTooltipFromBuddy(aBuddy, aConv, overrideAvatar) { this.buddy = aBuddy; this.reset(); const name = aBuddy.userName; const displayName = aBuddy.displayName; this.setAttribute("displayname", displayName); const account = aBuddy.account; this.setProtocolIcon(account.protocol); // If a conversation is provided, use the icon from it. Otherwise, use the // buddy icon filename. if (overrideAvatar) { this.setUserIcon(overrideAvatar, true); this.hasBestAvatar = true; } else if (aConv && !aConv.isChat) { this.setUserIcon(aConv.convIconFilename, true); this.hasBestAvatar = true; } else { this.setUserIcon(aBuddy.buddyIconFilename, true); } const statusType = aBuddy.statusType; this.setStatusIcon(LazyModules.Status.toAttribute(statusType)); this.setMessage( LazyModules.Status.toLabel(statusType, aBuddy.statusText) ); if (displayName != name) { this.addRow("buddy-username", name, { label: true }); } this.addRow("buddy-account", account.name, { label: true }); if (aBuddy.canVerifyIdentity) { const identityStatus = aBuddy.identityVerified ? "chat-buddy-identity-status-verified" : "chat-buddy-identity-status-unverified"; this.addRow("chat-buddy-identity-status", identityStatus, { label: true, value: true, }); } // Add encryption status. if (this.triggerNode.classList.contains("message-encrypted")) { this.addRow("encryption-tag", "message-status", { label: true, value: true, }); } this.requestBuddyInfo(account, aBuddy.normalizedName); const tooltipInfo = aBuddy.getTooltipInfo(); if (tooltipInfo) { this.updateTooltipInfo(tooltipInfo); } return true; } updateTooltipInfo(aTooltipInfo) { for (const elt of aTooltipInfo) { switch (elt.type) { case Ci.prplITooltipInfo.pair: case Ci.prplITooltipInfo.sectionHeader: this.addRow(elt.label, elt.value); break; case Ci.prplITooltipInfo.sectionBreak: this.addSeparator(); break; case Ci.prplITooltipInfo.status: { const statusType = parseInt(elt.label); this.setStatusIcon(LazyModules.Status.toAttribute(statusType)); this.setMessage(LazyModules.Status.toLabel(statusType, elt.value)); break; } case Ci.prplITooltipInfo.icon: if (!this.hasBestAvatar) { this.setUserIcon(elt.value); } break; } } } /** * Regenerate the tooltip based on a conversation. * * @param {IMConversation} aConv - The conversation to generate the tooltip from. * @param {string} [overrideAvatar] - URL for the user avatar to use * instead if the conversation is a direct conversation. */ updateTooltipFromConversation(aConv, overrideAvatar) { if (!aConv.isChat && aConv.buddy) { return this.updateTooltipFromBuddy(aConv.buddy, aConv, overrideAvatar); } this.reset(); this.setAttribute("displayname", aConv.name); const account = aConv.account; this.setProtocolIcon(account.protocol); if (overrideAvatar && !aConv.isChat) { this.setUserIcon(overrideAvatar, true); this.hasBestAvatar = true; } else { // Set the icon, potentially showing a fallback icon if this is an IM. this.setUserIcon(aConv.convIconFilename, !aConv.isChat); } if (aConv.isChat) { if (!account.connected || aConv.left) { this.setStatusIcon("chat-left"); } else { this.setStatusIcon("chat"); } const topic = aConv.topic; const noTopic = !topic; this.setMessage(topic || aConv.noTopicString, noTopic); } else { this.setStatusIcon("unknown"); this.setMessage(LazyModules.Status.toLabel("unknown")); // Last ditch attempt to get some tooltip info. This call relies on // the account's requestBuddyInfo implementation working correctly // with aConv.normalizedName. this.requestBuddyInfo(account, aConv.normalizedName); } this.addRow("buddy-account", account.name, { label: true }); return true; } /** * Set the tooltip details based on a conversation participant. * * @param {string} aNick - Nick of the user this tooltip is for. * @param {prplIConversation} aConv - Conversation this tooltip is shown * in. * @param {prplIConvChatBuddy} [aParticipant] - Participant to use instead * of looking it up in the conversation by the passed nick. * @param {string} [overrideAvatar] - URL for the user avatar to use * instead. */ updateTooltipFromParticipant(aNick, aConv, aParticipant, overrideAvatar) { if (!aConv.target) { return false; // We're viewing a log. } if (!aParticipant) { aParticipant = aConv.target.getParticipant(aNick); } const account = aConv.account; const normalizedNick = aConv.target.getNormalizedChatBuddyName(aNick); // To try to ensure that we aren't misidentifying a nick with a // contact, we require at least that the normalizedChatBuddyName of // the nick is normalized like a normalizedName for contacts. if (normalizedNick == account.normalize(normalizedNick)) { const accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount( normalizedNick, account ); if (accountBuddy) { return this.updateTooltipFromBuddy( accountBuddy, aConv, overrideAvatar ); } } this.reset(); this.setAttribute("displayname", aNick); this.setProtocolIcon(account.protocol); this.setStatusIcon("unknown"); this.setMessage(LazyModules.Status.toLabel("unknown")); this.setUserIcon(overrideAvatar ?? aParticipant?.buddyIconFilename, true); if (overrideAvatar) { this.hasBestAvatar = true; } if (aParticipant.canVerifyIdentity) { const identityStatus = aParticipant.identityVerified ? "chat-buddy-identity-status-verified" : "chat-buddy-identity-status-unverified"; this.addRow("chat-buddy-identity-status", identityStatus, { label: true, value: true, }); } this.requestBuddyInfo(account, normalizedNick); return true; } } customElements.define("chat-tooltip", MozChatTooltip, { extends: "tooltip" }); }