Power Chat

Redesigned chatbox for power users — and for those that just want a refresh

  1. // ==UserScript==
  2. // @name Power Chat
  3. // @author commander
  4. // @description Redesigned chatbox for power users — and for those that just want a refresh
  5. // @namespace https://github.com/asger-finding/tanktrouble-userscripts
  6. // @version 0.1.4
  7. // @license GPL-3.0
  8. // @match https://tanktrouble.com/*
  9. // @match https://beta.tanktrouble.com/*
  10. // @exclude *://classic.tanktrouble.com/
  11. // @run-at document-end
  12. // @grant GM_addStyle
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @require https://cdn.jsdelivr.net/npm/match-sorter@6/dist/match-sorter.umd.min.js
  16. // @require https://update.greasyfork.org/scripts/482092/1309109/TankTrouble%20Development%20Library.js
  17. // @noframes
  18. // ==/UserScript==
  19.  
  20. GM_addStyle(`
  21. #chat {
  22. /* Disable drop shadow filter */
  23. filter: none;
  24.  
  25. /* Transform chat location to bottom left */
  26. inset: calc(100% - 30px) auto auto 34px !important;
  27. }
  28.  
  29. /* Reverse chat messages flow */
  30. #chat,
  31. #chat .content,
  32. #chat .body {
  33. display: flex;
  34. flex-direction: column-reverse;
  35. }
  36.  
  37. #chat .body {
  38. align-items: end;
  39. background: #00000014;
  40. border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 0;
  41. border-radius: 3px;
  42. direction: rtl;
  43. margin-bottom: 4px;
  44. margin-top: 0 !important;
  45. mask-image: linear-gradient(to top, rgb(0 0 0) 70%, rgb(0 0 0 / 11%));
  46. overflow: hidden;
  47. padding-right: 10px;
  48. pointer-events: visible;
  49.  
  50. /* Scrollbar */
  51. scrollbar-gutter: stable;
  52. top: 0 !important;
  53. }
  54.  
  55. #chat .content {
  56. position: relative;
  57. width: fit-content !important;
  58. }
  59.  
  60. #chat .status.button {
  61. cursor: initial;
  62. transform: translate(7px, -18px);
  63. z-index: 1;
  64. }
  65.  
  66. #chat form {
  67. background: #ececec;
  68. border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 1pt;
  69. margin-left: 20px;
  70. width: 200px;
  71. }
  72.  
  73. /* Disable chat message sending animation */
  74. #chat form[style*="repeating-linear-gradient"] {
  75. background: #d0d0d0 !important;
  76. }
  77.  
  78. #chat:not(.open) form {
  79. display: none;
  80. }
  81.  
  82. #chat textarea {
  83. font-family: Arial, verdana;
  84. left: 5px;
  85. transition: width 0s !important;
  86. width: calc(100% - 12px);
  87. }
  88.  
  89. #chat .body .chatMessage svg {
  90. border-left: 2px dotted rgb(170 170 170);
  91. padding: 2px 4px 1px;
  92. }
  93.  
  94. #chat .body.dragging {
  95. border: none !important;
  96. margin-left: 20px !important;
  97. }
  98.  
  99. /* Rotate and align the handle to top-right */
  100. .handle.ui-resizable-ne[src*="resizeHandleBottomRight.png"] {
  101. height: 12px !important;
  102. position: absolute;
  103. right: 0;
  104. top: 0;
  105. transform: rotate(-90deg);
  106. width: 12px;
  107. }
  108.  
  109. body:has(#chat .body.ui-resizable-resizing) .ui-resizable-handle.handle.ui-resizable-ne {
  110. display: none !important;
  111. }
  112.  
  113. #chat .body:hover {
  114. overflow-y: scroll;
  115. }
  116.  
  117. #chat .body .chatMessage {
  118. margin-left: ${(/Chrome.*Safari/u).test(navigator.userAgent) ? '3px' : '5px'};
  119. direction: ltr;
  120. }
  121.  
  122. #chat .body::-webkit-scrollbar {
  123. width: 3px;
  124. }
  125.  
  126. #chat .body::-webkit-scrollbar-track {
  127. background: transparent;
  128. }
  129.  
  130. #chat .body::-webkit-scrollbar-thumb {
  131. background: rgb(170 170 170);
  132. }
  133.  
  134. #chat form .autocomplete-dropdown {
  135. background-color: #00ff02;
  136. border-radius: 3px;
  137. bottom: 0;
  138. filter: drop-shadow(0 0 3px rgb(0 0 0 / 70%));
  139. font-family: Arial, verdana;
  140. margin-bottom: 25px;
  141. max-height: 120px;
  142. max-width: 200px;
  143. min-width: 120px;
  144. overflow-y: scroll;
  145. padding: 4px 2px;
  146. position: absolute;
  147. scrollbar-color: #00a902 transparent;
  148. scrollbar-gutter: stable;
  149. scrollbar-width: thin;
  150. white-space: nowrap;
  151. z-index: 999;
  152. }
  153.  
  154. #chat form .autocomplete-dropdown div {
  155. border-bottom: 1pt dotted #00a902;
  156. cursor: pointer;
  157. display: none;
  158. margin-bottom: 2px;
  159. overflow: hidden;
  160. padding: 0 8px 2px 4px;
  161. text-overflow: ellipsis;
  162. }
  163.  
  164. #chat form .autocomplete-dropdown .match {
  165. display: block;
  166. }
  167.  
  168. #chat form .autocomplete-dropdown .match:not(:has(~ .match)) {
  169. border-bottom: none;
  170. padding: 0 8px 0 4px;
  171. }
  172.  
  173. #chat form .autocomplete-dropdown .highlight {
  174. font-weight: bold;
  175. }
  176.  
  177. #chat form .autocomplete-dropdown:hover .highlight {
  178. font-weight: normal;
  179. }
  180.  
  181. #chat form .autocomplete-dropdown div:hover {
  182. font-weight: bold !important;
  183. }
  184.  
  185. #chat form .autocomplete-dropdown:has(div:not(.highlight):hover) > .highlight {
  186. font-weight: normal;
  187. }
  188.  
  189. #chat form .autocomplete-caret-mirror {
  190. background: transparent;
  191. color: transparent;
  192. font-family: Arial, verdana;
  193. font-size: inherit;
  194. font-weight: bold;
  195. height: 0;
  196. margin: 0 0 0 5px;
  197. opacity: 0;
  198. padding: 1px 2px;
  199. pointer-events: none;
  200. z-index: -2147483647;
  201. }
  202. `);
  203.  
  204. /**
  205. * Reconfigure the chat handle to be dragging
  206. * from the south-east direction (down)
  207. * to the north-east direction (up)
  208. */
  209. const changeHandleDirection = async() => {
  210. const { resizable } = $.fn;
  211.  
  212. // Use a regular function to keep context
  213. $.fn.resizable = function(...args) {
  214. const [config] = args;
  215.  
  216. // Reassign the chat handle to be north-east facing
  217. if (config.handles) {
  218. const handle = config.handles.se;
  219. if (handle === TankTrouble.ChatBox.chatBodyResizeHandle) {
  220. handle.removeClass('ui-resizable-se')
  221. .addClass('ui-resizable-ne');
  222.  
  223. config.handles.ne = handle;
  224. delete config.handles.se;
  225.  
  226. // Set a taller chat maxHeight
  227. config.maxHeight = 650;
  228. }
  229. }
  230.  
  231. return resizable.call(this, config);
  232. };
  233.  
  234. await Loader.whenContentInitialized();
  235.  
  236. TankTrouble.ChatBox.chatBodyResizeHandle.detach().insertAfter(TankTrouble.ChatBox.chatBody);
  237. };
  238.  
  239. /**
  240. * Hook message render functions to disable jquery .show() animation and scroll to bottom
  241. * This fixes chat messages not showing up in the reversed chat order or overflowed messages being cleared
  242. */
  243. const fixChatRendering = () => {
  244. Loader.interceptFunction(TankTrouble.ChatBox, '_renderChatMessage', (original, ...args) => {
  245. TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height());
  246.  
  247. // Set animateHeight to false
  248. args[9] = false;
  249. original(...args);
  250. });
  251.  
  252. Loader.interceptFunction(TankTrouble.ChatBox, '_renderSystemMessage', (original, ...args) => {
  253. TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height());
  254.  
  255. // Set animateHeight to false
  256. args[3] = false;
  257. original(...args);
  258. });
  259. };
  260.  
  261. /**
  262. * Prevent TankTrouble from clearing the chat when the client disconnects
  263. * Print message to chat when client switches server to separate conversations
  264. */
  265. const preventServerChangeChatClear = () => {
  266. Loader.interceptFunction(TankTrouble.ChatBox, '_clearChat', (original, ...args) => {
  267. const isUnconnected = ClientManager.getClient().getState() === TTClient.STATES.UNCONNECTED;
  268.  
  269. // Void the call if the client is unconnected
  270. // when the function is invoked
  271. if (isUnconnected) return null;
  272.  
  273. return original(...args);
  274. });
  275.  
  276. Loader.interceptFunction(TankTrouble.ChatBox, '_updateStatusMessageAndAvailability', (original, ...args) => {
  277. const [systemMessageText, guestPlayerIds] = args;
  278.  
  279. // Check for a welcome message. If match.
  280. // print a different system message
  281. if (systemMessageText === 'Welcome to TankTrouble Comms § § ') {
  282. const newServer = ClientManager.getAvailableServers()[ClientManager.multiplayerServerId];
  283. return original(`Connected to ${ newServer.name } ${ guestPlayerIds.length ? '§ ' : '' }`, guestPlayerIds);
  284. }
  285.  
  286. return original(...args);
  287. });
  288. };
  289.  
  290. /**
  291. * Write the chat savestate to storage and return
  292. * @returns Promise for last savestate
  293. */
  294. const initChatSavestate = async() => {
  295. // Initialize dynamic stylesheet
  296. // for user-defined chat width
  297. const inputWidth = new CSSStyleSheet();
  298. inputWidth.insertRule('#chat form { padding-right: 12px !important; }', 0);
  299. inputWidth.insertRule('#chat form, #chat textarea { width: 208px !important; }', 1);
  300. document.adoptedStyleSheets = [inputWidth];
  301.  
  302. // Savestate hooks
  303. Loader.interceptFunction(TankTrouble.ChatBox, 'open', (original, ...args) => {
  304. GM_setValue('chat-open', true);
  305. original(...args);
  306. });
  307. Loader.interceptFunction(TankTrouble.ChatBox, 'close', (original, ...args) => {
  308. GM_setValue('chat-open', false);
  309. original(...args);
  310. });
  311. Loader.interceptFunction(TankTrouble.ChatBox, '_refreshChat', (original, ...args) => {
  312. original(...args);
  313. GM_setValue('chat-width', TankTrouble.ChatBox.chatBody[0].clientWidth);
  314. });
  315.  
  316. // Get savestate
  317. const shouldOpen = await GM_getValue('chat-open', true);
  318. const initialWidth = await GM_getValue('chat-width', 0);
  319.  
  320. Loader.whenContentInitialized().then(() => {
  321. /* eslint-disable prefer-destructuring */
  322. const chatBody = TankTrouble.ChatBox.chatBody[0];
  323. const chatInput = TankTrouble.ChatBox.chatInput[0];
  324. /* eslint-enable prefer-destructuring*/
  325.  
  326. if (shouldOpen) TankTrouble.ChatBox.open();
  327. if (initialWidth !== 0) chatBody.style.width = `${initialWidth}px`;
  328.  
  329. // Create a mutation observer that looks for
  330. // changes in the chatBody's attributes
  331. new MutationObserver(() => {
  332. const width = Number(chatBody.offsetWidth || 220);
  333.  
  334. inputWidth.deleteRule(1);
  335. inputWidth.insertRule(`#chat form, #chat form textarea { width: ${width - 12}px !important; }`, 1);
  336.  
  337. chatInput.dispatchEvent(new InputEvent('input'));
  338. }).observe(chatBody, {
  339. attributes: true,
  340. characterData: false
  341. });
  342. });
  343. };
  344.  
  345. /**
  346. * Add up/down history for sent messages
  347. * @param chatInput Input to target
  348. */
  349. const addInputHistory = chatInput => {
  350. const messages = [];
  351. let currentInputValue = chatInput.value;
  352.  
  353. // Create and initialize chat messages history iterator
  354. let i = messages.length;
  355. const iterator = (function* chatsIterator() {
  356. while (true) {
  357. const incOrDec = (yield messages[i]) === 'prev' ? -1 : 1;
  358. i = Math.min(Math.max(i + incOrDec, 0), messages.length);
  359. }
  360. }(messages));
  361.  
  362. // Initialize the generator
  363. iterator.next();
  364.  
  365. /**
  366. * Check whether or not the input has an empty selection range
  367. * @returns Selection range is 0
  368. */
  369. const isSelectionEmpty = () => chatInput.selectionStart === chatInput.selectionEnd;
  370.  
  371. /** Handle the user triggering a submit keydown event */
  372. const handleSubmit = () => {
  373. if (!chatInput.value) return;
  374.  
  375. messages.push(chatInput.value);
  376. currentInputValue = '';
  377.  
  378. i = messages.length;
  379. };
  380.  
  381. /** Handle the user triggering an arrow up keydown event */
  382. const handleArrowUp = () => {
  383. if (isSelectionEmpty() && chatInput.selectionStart === 0) {
  384. const { value } = iterator.next('prev');
  385. chatInput.value = typeof value === 'undefined' ? '' : value;
  386.  
  387. chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
  388. chatInput.dispatchEvent(new InputEvent('input', { isComposing: true }));
  389. }
  390. };
  391.  
  392. /** Handle the user triggering an arrow down keydown event */
  393. const handleArrowDown = () => {
  394. if (isSelectionEmpty() && chatInput.selectionStart === chatInput.value.length) {
  395. const { value } = iterator.next();
  396. chatInput.value = typeof value === 'undefined' ? currentInputValue : value;
  397.  
  398. chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
  399. chatInput.dispatchEvent(new InputEvent('input', { isComposing: true }));
  400. }
  401. };
  402.  
  403. // If the user is at the top of the history,
  404. // save the chat input value as the "current"
  405. // message whenever there is a change
  406. chatInput.addEventListener('input', ({ inputType }) => {
  407. const isAtEndOfHistory = i === messages.length;
  408. const hasValueChanged = typeof inputType !== 'undefined';
  409. if (isAtEndOfHistory && hasValueChanged) currentInputValue = chatInput.value;
  410. });
  411.  
  412. // Listen for keydown events
  413. // and trigger handlers
  414. chatInput.addEventListener('keydown', ({ key }) => {
  415. switch (key) {
  416. case 'Enter':
  417. handleSubmit();
  418. break;
  419. case 'ArrowUp':
  420. handleArrowUp();
  421. break;
  422. case 'ArrowDown':
  423. handleArrowDown();
  424. break;
  425. default:
  426. break;
  427. }
  428. });
  429. };
  430.  
  431. /**
  432. * Add auto-complete for user mentions when typing @ in the chat input
  433. * @param chatInput Chat input instance
  434. */
  435. const addMentionAutocomplete = chatInput => {
  436. class Dropdown {
  437.  
  438. options = new Map();
  439.  
  440. matches = [];
  441.  
  442. /**
  443. * Setup the dropdown class
  444. * @param input Input to attach to
  445. * @param config Dropdown configuration (allow multiple of the same value, expiry time)
  446. */
  447. constructor(input, config) {
  448. this.input = $(input);
  449. this.wrapper = $('<div class="autocomplete-dropdown" tabindex="-1"></div>').insertAfter(this.input);
  450. this.textareaMirror = $('<div class="autocomplete-caret-mirror"></div>').appendTo(this.wrapper.parent());
  451. this.textareaMirrorInline = $('<span></span>').appendTo(this.textareaMirror);
  452.  
  453. Object.assign(this, {
  454. allowRepeats: false,
  455. autofillLifetime: 10 * 60 * 100,
  456. inputHeight: 18,
  457. ...config
  458. });
  459.  
  460. this.wrapper.insertAfter(this.input);
  461.  
  462.  
  463. this.hide();
  464. }
  465.  
  466. #searchTerm = -1;
  467.  
  468. /**
  469. * Filter the dropdown elements when searchterm is set
  470. * @param term String term to search the dropdown registry for
  471. * @returns term
  472. */
  473. set searchTerm(term) {
  474. if (this.#searchTerm !== term) {
  475. this.#removeExpired();
  476.  
  477. const allSymbols = Array.from(this.options.keys());
  478. this.matches = matchSorter.matchSorter(allSymbols, term, { keys: [symbol => symbol.description] });
  479. for (const symbol of allSymbols) {
  480. const element = this.options.get(symbol).value;
  481.  
  482. element.classList[this.matches.includes(symbol) ? 'add' : 'remove']('match');
  483. }
  484. for (const symbol of this.matches) this.wrapper.append(this.options.get(symbol).value);
  485.  
  486. this.#resetToFirst();
  487. }
  488.  
  489. this.#searchTerm = term;
  490. return term;
  491. }
  492.  
  493. /**
  494. * Getter for `searchTerm`
  495. * @returns `searchTerm`
  496. */
  497. get searchTerm() {
  498. return this.#searchTerm;
  499. }
  500.  
  501. iterator = (function* (options, that) {
  502. let i = 0;
  503. while (true) {
  504. const symbol = that.matches[i];
  505.  
  506. const change = (yield [symbol, options.get(symbol)]) || 0;
  507.  
  508. i = (i = (i + change) % Math.max(that.matches.length, 1)) < 0
  509. ? i + that.matches.length
  510. : i;
  511. }
  512. }(this.options, this));
  513.  
  514. /** Render the dropdown if not already visible */
  515. show() {
  516. if (this.isShowing()) return;
  517.  
  518. this.#resetToFirst();
  519.  
  520. this.wrapper.show();
  521. this.wrapper.scrollTop(0);
  522. }
  523.  
  524. /** Hide the dropdown */
  525. hide() {
  526. this.wrapper.hide();
  527. }
  528.  
  529. /**
  530. * Check if the dropdown is visible
  531. * @returns Is the dropdown showing?
  532. */
  533. isShowing() {
  534. return this.wrapper.is(':visible');
  535. }
  536.  
  537. /**
  538. * Compute dropdown x-shift to textarea value.
  539. *
  540. * Should be called when value changes in the input field
  541. */
  542. update() {
  543. const transformed = this.input.val()
  544. .substr(0, this.input[0].selectionStart);
  545. this.textareaMirrorInline.html(transformed);
  546.  
  547. const rects = this.textareaMirrorInline[0].getBoundingClientRect();
  548. const left = rects.right - rects.x;
  549. this.left = left
  550. + Dropdown.#toNumeric(this.input.css('left'))
  551. + Dropdown.#toNumeric(this.input.css('margin-left'))
  552. + Dropdown.#toNumeric(this.input.css('padding-left'));
  553.  
  554. const isWordWrapped = this.#isWordWrapped();
  555. const leftShift = isWordWrapped ? 0 : Math.max(0, this.left - (this.wrapper.width() / 2));
  556. const bottomShift = this.input.outerHeight() - this.inputHeight;
  557. this.wrapper.css('margin-left', `${leftShift}px`);
  558. this.wrapper.css('margin-bottom', `${bottomShift + 25}px`);
  559.  
  560. if (!this.isShowing()) this.show();
  561. }
  562.  
  563. /**
  564. * Get data for the current position
  565. * @returns Identifier and data for the current dropdown position
  566. */
  567. getCurrent() {
  568. return this.iterator.next(0).value;
  569. }
  570.  
  571. /**
  572. * Add an autocomplete option to the dropdown
  573. * @param option Option as string
  574. * @param submitCallback Event handler for mouseup
  575. * @returns Success in adding option?
  576. */
  577. addOption(option, submitCallback) {
  578. const overrideSymbol = !this.allowRepeats
  579. && Array.from(this.options.keys())
  580. .find(({ description }) => description === option);
  581. const symbolExists = typeof overrideSymbol === 'symbol';
  582.  
  583. if (symbolExists) return false;
  584.  
  585. const symbol = Symbol(option);
  586.  
  587. const element = document.createElement('div');
  588. element.innerText = option;
  589. element.addEventListener('mouseup', evt => submitCallback(evt, evt.target.innerText));
  590.  
  591. const insert = [
  592. symbol,
  593. {
  594. inserted: Date.now(),
  595. lifetime: this.autofillLifetime,
  596. value: element
  597. }
  598. ];
  599.  
  600. this.options.set(...insert);
  601.  
  602. return true;
  603. }
  604.  
  605. /**
  606. * Add an array of text options to the dropdown
  607. * @param options Options as string[]
  608. * @param submitCallback Generalized event handler for mouseup for all options
  609. */
  610. addOptions(options, submitCallback) {
  611. for (const option of options) this.addOption(option, submitCallback);
  612. }
  613.  
  614. /**
  615. * Remove option and corresponding HTMLElement from DOM
  616. * @param symbol Symbol for element to remove
  617. * @returns Was the option deleted?
  618. */
  619. removeOption(symbol) {
  620. this.options.get(symbol)?.value.remove();
  621. this.matches = this.matches.filter(toRemove => toRemove !== symbol);
  622. return this.options.delete(symbol);
  623. }
  624.  
  625. /**
  626. * Clear all options from the dropdown
  627. * @returns Did options clear?
  628. */
  629. clearOptions() {
  630. for (const symbol of this.options.keys()) this.removeOption(symbol);
  631.  
  632. return this.options.size === 0
  633. && this.wrapper.children().length === 0;
  634. }
  635.  
  636. /**
  637. * Navigate position in the dropdown up/down
  638. * @param direction Up/down shift as number
  639. * @returns Identifier for where we navigated to
  640. */
  641. navigate(direction) {
  642. this.wrapper.children().removeClass('highlight');
  643.  
  644. const [symbol, data] = this.iterator.next(direction).value;
  645. if (!symbol) return null;
  646.  
  647. data.value.classList.add('highlight');
  648. data.value.scrollIntoView(false);
  649.  
  650. return symbol;
  651. }
  652.  
  653. /**
  654. * Check if the input wraps to newline
  655. * @returns Whether the input is one or multiple lines
  656. */
  657. #isWordWrapped() {
  658. return this.input.outerHeight() <= this.inputHeight;
  659. }
  660.  
  661. /**
  662. * Reset the position to the
  663. * first item in the dropdown
  664. */
  665. #resetToFirst() {
  666. const symbols = this.matches;
  667. const [currentSymbol] = this.iterator.next(0).value;
  668. const dist = symbols.indexOf(currentSymbol);
  669.  
  670. this.navigate(-dist);
  671. }
  672.  
  673. /**
  674. * Remove expired entries
  675. */
  676. #removeExpired() {
  677. for (const [symbol, value] of this.options.entries()) {
  678. const expiry = value.inserted + value.autofillLifetime;
  679. if (Date.now() > expiry) this.removeOption(symbol);
  680. }
  681. }
  682.  
  683. /**
  684. * Remove all non-numbers from string and return string as number
  685. * @param str String to parse
  686. * @returns String in number format
  687. */
  688. static #toNumeric = str => Number(str.replace(/[^0-9.]/ug, ''));
  689.  
  690. }
  691.  
  692. const dropdown = new Dropdown(chatInput);
  693.  
  694. /**
  695. * Get the word and start/end indexies of the input selectionEnd
  696. * @returns Object with word and range start/end
  697. */
  698. const getIndexiesOfWordInCurrentSelection = () => {
  699. // Separate string by whitespace and
  700. // list indexies for each word in array
  701. const tokenizedQuery = chatInput.value.split(/[\s\n]/u).reduce((acc, word, index) => {
  702. const previous = acc[index - 1];
  703. const start = index === 0 ? index : previous.range[1] + 1;
  704. const end = start + word.length;
  705.  
  706. return acc.concat([ { word, range: [start, end] } ]);
  707. }, []);
  708.  
  709. const currentWord = tokenizedQuery.find(({ range }) => range[0] < chatInput.selectionEnd && range[1] >= chatInput.selectionEnd);
  710.  
  711. return currentWord;
  712. };
  713.  
  714. /**
  715. * Returns the user that the selection is over, from the input value, if prefixed by a @
  716. * @returns Mention username or null
  717. */
  718. const getUserFocusIfMention = () => {
  719. const currentWord = getIndexiesOfWordInCurrentSelection();
  720. const [mentions] = chatInput.value.split(/\s+(?=[^@])/u);
  721. const isUserChat = mentions.startsWith('@');
  722.  
  723. if (currentWord && isUserChat) {
  724. const [, end] = currentWord.range;
  725. return end <= mentions.length ? currentWord : null;
  726. }
  727.  
  728. return null;
  729. };
  730.  
  731. /**
  732. * Handle a dropdown submit event (enter, tab or click)
  733. * by autofilling the value to the input field
  734. * @param evt Event object
  735. * @param username Username to autofill
  736. */
  737. const handleSubmit = (evt, username = dropdown.getCurrent()[0].description) => {
  738. const mention = getUserFocusIfMention();
  739. if (mention === null) return;
  740.  
  741. const [start, end] = mention.range;
  742. if (username) {
  743. const before = chatInput.value.slice(0, start);
  744. const after = chatInput.value.substring(end, chatInput.value.length);
  745.  
  746. const insertSpaceAfter = !after.startsWith(' ');
  747.  
  748. const beforeValue = `${ before }@${ username }${ insertSpaceAfter ? ' ' : '' }`;
  749. const cursorPosition = [beforeValue.length + 1, beforeValue.length + 1];
  750. chatInput.value = `${ beforeValue }${ after }`;
  751.  
  752. chatInput.setSelectionRange(...cursorPosition);
  753. }
  754.  
  755. evt.preventDefault();
  756.  
  757. chatInput.dispatchEvent(new InputEvent('input'));
  758. };
  759.  
  760. /**
  761. * Event handler for TTClient.EVENTS.GAME_LIST_CHANGED
  762. */
  763. const handleGameListChanged = () => {
  764. const gameStates = ClientManager.getClient().getAvailableGameStates();
  765.  
  766. for (const gameState of gameStates) {
  767. const playerStates = gameState.getPlayerStates();
  768.  
  769. for (const player of playerStates) {
  770. const playerId = player.getPlayerId();
  771.  
  772. Backend.getInstance().getPlayerDetails(result => {
  773. if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit);
  774. }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
  775. }
  776. }
  777. };
  778.  
  779. /**
  780. * Event handler for received chat messages
  781. * @param data Event data
  782. */
  783. const handleNewChatMessage = data => {
  784. const involvedPlayerIds = data.involvedPlayerIds || [...data.getFrom() || [], ...data.getTo() || []];
  785. const loggedIn = Users.getAllPlayerIds();
  786. const foreignPlayerIds = involvedPlayerIds.filter(playerId => !loggedIn.includes(playerId));
  787.  
  788. for (const playerId of foreignPlayerIds) {
  789. Backend.getInstance().getPlayerDetails(result => {
  790. if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit);
  791. }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
  792. }
  793. };
  794.  
  795. chatInput.addEventListener('input', ({ isComposing }) => {
  796. if (isComposing) return;
  797.  
  798. const userFocus = getUserFocusIfMention();
  799. if (userFocus === null) {
  800. dropdown.hide();
  801. return;
  802. }
  803.  
  804. dropdown.searchTerm = userFocus.word.replace(/^@/u, '');
  805. if (!dropdown.matches.length) {
  806. dropdown.hide();
  807. return;
  808. }
  809.  
  810. // Show UI
  811. dropdown.show();
  812. dropdown.update();
  813. });
  814.  
  815. // eslint-disable-next-line complexity
  816. chatInput.addEventListener('keydown', evt => {
  817. const userFocus = getUserFocusIfMention();
  818. if (userFocus === null) return;
  819.  
  820. dropdown.searchTerm = userFocus.word.replace(/^@/u, '');
  821. if (!dropdown.matches.length) return;
  822.  
  823. switch (evt.key) {
  824. case 'Enter':
  825. case 'Tab':
  826. handleSubmit(evt);
  827. break;
  828. case 'ArrowUp':
  829. dropdown.navigate(-1);
  830. evt.preventDefault();
  831. break;
  832. case 'ArrowDown':
  833. dropdown.navigate(1);
  834. evt.preventDefault();
  835. break;
  836. default:
  837. break;
  838. }
  839. }, false);
  840.  
  841. /**
  842. * State change event handler
  843. * @param _self Self reference
  844. * @param _oldState Old client state
  845. * @param newState New client state
  846. */
  847. const clientStateEventHandler = (_self, _oldState, newState) => {
  848. switch (newState) {
  849. case TTClient.STATES.UNCONNECTED:
  850. dropdown.clearOptions();
  851. break;
  852. default:
  853. break;
  854. }
  855. };
  856.  
  857. /**
  858. * Event handler for new chat messages
  859. * @param _self Self reference
  860. * @param evt Event type
  861. * @param data Event data
  862. */
  863. // eslint-disable-next-line complexity
  864. const clientEventHandler = (_self, evt, data) => {
  865. switch (evt) {
  866. case TTClient.EVENTS.GAME_LIST_CHANGED:
  867. handleGameListChanged();
  868. break;
  869. case TTClient.EVENTS.USER_CHAT_POSTED:
  870. if (data) handleNewChatMessage(data);
  871. break;
  872. case TTClient.EVENTS.GLOBAL_CHAT_POSTED:
  873. case TTClient.EVENTS.CHAT_POSTED:
  874. if (data) handleNewChatMessage(data);
  875. break;
  876. case TTClient.EVENTS.SYSTEM_CHAT_POSTED:
  877. case TTClient.EVENTS.PLAYERS_BANNED:
  878. case TTClient.EVENTS.PLAYERS_UNBANNED:
  879. if (data) handleNewChatMessage(data);
  880. break;
  881. default:
  882. break;
  883. }
  884. };
  885.  
  886. ClientManager.getClient().addStateChangeListener(clientStateEventHandler, this);
  887. ClientManager.getClient().addEventListener(clientEventHandler, this);
  888. };
  889.  
  890. changeHandleDirection();
  891. fixChatRendering();
  892. initChatSavestate();
  893.  
  894. Loader.whenContentInitialized().then(() => {
  895. // eslint-disable-next-line prefer-destructuring
  896. const chatInput = TankTrouble.ChatBox.chatInput[0];
  897.  
  898. preventServerChangeChatClear();
  899. addMentionAutocomplete(chatInput);
  900. addInputHistory(chatInput);
  901.  
  902. // Allow more characters in the chat input
  903. chatInput.setAttribute('maxlength', '255');
  904. });