/* 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 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); }