inputKeyPress()

in mail/components/im/content/chat-conversation.js [631:994]


    inputKeyPress(event) {
      const 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))
      ) {
        const 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.
      const 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"
          const pos = this.inputBox.selectionStart;
          const suffix = ": ";
          if (
            pos > suffix.length &&
            text.substring(pos - suffix.length, pos) == suffix
          ) {
            const completions = Array.from(this.buddies.keys());
            // Check if the preceding words are a sequence of nick completions.
            const 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
        );
        const 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.
        const completingCommand = isFirstWord && word[0] == "/";
        if (completingCommand) {
          for (const 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.
            const 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());

          const outgoingNick = this._conv.nick;
          completions = completions.filter(c => c != outgoingNick);

          // Check if the preceding words are a sequence of nick completions.
          const wordStart = this.inputBox.selectionStart - word.length;
          if (wordStart > 2) {
            const separator = text.substring(wordStart - 2, wordStart);
            if (separator == ": " || separator == ", ") {
              const 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.
        const wholeWord = text.substring(
          this.inputBox.selectionStart - word.length
        );
        for (const completion of matchingCompletions) {
          if (wholeWord.lastIndexOf(completion, 0) == 0) {
            const moveCursor = completion.length - word.length;
            this.inputBox.selectionStart += moveCursor;
            const 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.
        const firstCompletion = this._completions[0];
        const lastCompletion = this._completions.slice(-1)[0];

        let preferredNick = false;
        if (this._conv.isChat && !completingCommand) {
          // If there are active nicks, prefer those.
          const 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) {
          const 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);
          }
        }

        const 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.
          const 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");
      }
    }