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