diff options
Diffstat (limited to 'comm/mail/components/im/content/chat-conversation.js')
-rw-r--r-- | comm/mail/components/im/content/chat-conversation.js | 1760 |
1 files changed, 1760 insertions, 0 deletions
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); +} |