"use strict"; const $ = require("jquery"); const fuzzy = require("fuzzy"); const Mousetrap = require("mousetrap"); const {Textcomplete, Textarea} = require("textcomplete"); const emojiMap = require("./libs/simplemap.json"); const options = require("./options"); const constants = require("./constants"); const input = $("#input"); let textcomplete; let enabled = false; module.exports = { enable: enableAutocomplete, disable: () => { if (enabled) { input.off("input.tabcomplete"); Mousetrap(input.get(0)).off("tab", "keydown"); textcomplete.destroy(); enabled = false; } }, }; const chat = $("#chat"); const sidebar = $("#sidebar"); const emojiSearchTerms = Object.keys(emojiMap); const emojiStrategy = { id: "emoji", match: /\B:([-+\w:?]{2,}):?$/, search(term, callback) { // Trim colon from the matched term, // as we are unable to get a clean string from match regex term = term.replace(/:$/, ""), callback(fuzzyGrep(term, emojiSearchTerms)); }, template([string, original]) { return `${emojiMap[original]} ${string}`; }, replace([, original]) { return emojiMap[original]; }, index: 1, }; const nicksStrategy = { id: "nicks", match: /\B(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/, search(term, callback) { term = term.slice(1); if (term[0] === "@") { callback(completeNicks(term.slice(1), true) .map((val) => ["@" + val[0], "@" + val[1]])); } else { callback(completeNicks(term, true)); } }, template([string]) { return string; }, replace([, original], position = 1) { // If no postfix specified, return autocompleted nick as-is if (!options.settings.nickPostfix) { return original; } // If there is whitespace in the input already, append space to nick if (position > 0 && /\s/.test(input.val())) { return original + " "; } // If nick is first in the input, append specified postfix return original + options.settings.nickPostfix; }, index: 1, }; const chanStrategy = { id: "chans", match: /\B((#|\+|&|![A-Z0-9]{5})([^\x00\x0A\x0D\x20\x2C\x3A]+(:[^\x00\x0A\x0D\x20\x2C\x3A]*)?)?)$/, search(term, callback, match) { callback(completeChans(match[0])); }, template([string]) { return string; }, replace([, original]) { return original; }, index: 1, }; const commandStrategy = { id: "commands", match: /^\/(\w*)$/, search(term, callback) { callback(completeCommands("/" + term)); }, template([string]) { return string; }, replace([, original]) { return original; }, index: 1, }; const foregroundColorStrategy = { id: "foreground-colors", match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/, search(term, callback) { term = term.toLowerCase(); const matchingColorCodes = constants.colorCodeMap .filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1])) .map((i) => { if (fuzzy.test(term, i[1])) { return [i[0], fuzzy.match(term, i[1], { pre: "", post: "", }).rendered]; } return i; }); callback(matchingColorCodes); }, template(value) { return `${value[1]}`; }, replace(value) { return "\x03" + value[0]; }, index: 1, }; const backgroundColorStrategy = { id: "background-colors", match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/, search(term, callback, match) { term = term.toLowerCase(); const matchingColorCodes = constants.colorCodeMap .filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1])) .map((pair) => { if (fuzzy.test(term, pair[1])) { return [pair[0], fuzzy.match(term, pair[1], { pre: "", post: "", }).rendered]; } return pair; }) .map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`... callback(matchingColorCodes); }, template(value) { return `${value[1]}`; }, replace(value) { return "\x03$1," + value[0]; }, index: 2, }; function enableAutocomplete() { enabled = true; let tabCount = 0; let lastMatch = ""; let currentMatches = []; input.on("input.tabcomplete", () => { tabCount = 0; currentMatches = []; lastMatch = ""; }); Mousetrap(input.get(0)).bind("tab", (e) => { if (input.data("autocompleting")) { return; } e.preventDefault(); const text = input.val(); if (input.get(0).selectionStart !== text.length) { return; } if (tabCount === 0) { lastMatch = text.split(/\s/).pop(); if (lastMatch.length === 0) { return; } currentMatches = completeNicks(lastMatch, false); if (currentMatches.length === 0) { return; } } const position = input.get(0).selectionStart - lastMatch.length; const newMatch = nicksStrategy.replace([0, currentMatches[tabCount % currentMatches.length]], position); input.val(text.substr(0, position) + newMatch); lastMatch = newMatch; tabCount++; }, "keydown"); const editor = new Textarea(input.get(0)); textcomplete = new Textcomplete(editor, { dropdown: { className: "textcomplete-menu", placement: "top", }, }); textcomplete.register([ emojiStrategy, nicksStrategy, chanStrategy, commandStrategy, foregroundColorStrategy, backgroundColorStrategy, ]); // Activate the first item by default // https://github.com/yuku-t/textcomplete/issues/93 textcomplete.on("rendered", () => { if (textcomplete.dropdown.items.length > 0) { textcomplete.dropdown.items[0].activate(); } }); textcomplete.on("show", () => { input.data("autocompleting", true); }); textcomplete.on("hidden", () => { input.data("autocompleting", false); }); } function fuzzyGrep(term, array) { const results = fuzzy.filter( term, array, { pre: "", post: "", } ); return results.map((el) => [el.string, el.original]); } function rawNicks() { const chan = chat.find(".active"); const users = chan.find(".userlist"); // If this channel has a list of nicks, just return it if (users.length > 0) { return users.data("nicks"); } const me = $("#nick-value").text(); const otherUser = chan.attr("aria-label"); // If this is a query, add their name to autocomplete if (me !== otherUser && chan.data("type") === "query") { return [otherUser, me]; } // Return our own name by default for anything that isn't a channel or query return [me]; } function completeNicks(word, isFuzzy) { const users = rawNicks(); word = word.toLowerCase(); if (isFuzzy) { return fuzzyGrep(word, users); } return $.grep( users, (w) => !w.toLowerCase().indexOf(word) ); } function completeCommands(word) { const words = constants.commands.slice(); return fuzzyGrep(word, words); } function completeChans(word) { const words = []; sidebar.find(".chan.active") .parent() .find(".chan") .each(function() { const self = $(this); if (!self.hasClass("lobby")) { words.push(self.attr("aria-label")); } }); return fuzzyGrep(word, words); }