Power Chat

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

// ==UserScript==
// @name        Power Chat
// @author      commander
// @description Redesigned chatbox for power users — and for those that just want a refresh
// @namespace   https://github.com/asger-finding/tanktrouble-userscripts
// @version     0.1.4
// @license     GPL-3.0
// @match       https://tanktrouble.com/*
// @match       https://beta.tanktrouble.com/*
// @exclude     *://classic.tanktrouble.com/
// @run-at      document-end
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @require     https://cdn.jsdelivr.net/npm/match-sorter@6/dist/match-sorter.umd.min.js
// @require     https://update.greasyfork.org/scripts/482092/1309109/TankTrouble%20Development%20Library.js
// @noframes
// ==/UserScript==

GM_addStyle(`
#chat {
	/* Disable drop shadow filter */
	filter: none;

	/* Transform chat location to bottom left */
	inset: calc(100% - 30px) auto auto 34px !important;
}

/* Reverse chat messages flow */
#chat,
#chat .content,
#chat .body {
	display: flex;
	flex-direction: column-reverse;
}

#chat .body {
	align-items: end;
	background: #00000014;
	border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 0;
	border-radius: 3px;
	direction: rtl;
	margin-bottom: 4px;
	margin-top: 0 !important;
	mask-image: linear-gradient(to top, rgb(0 0 0) 70%, rgb(0 0 0 / 11%));
	overflow: hidden;
	padding-right: 10px;
	pointer-events: visible;

	/* Scrollbar */
	scrollbar-gutter: stable;
	top: 0 !important;
}

#chat .content {
	position: relative;
	width: fit-content !important;
}

#chat .status.button {
	cursor: initial;
	transform: translate(7px, -18px);
	z-index: 1;
}

#chat form {
	background: #ececec;
	border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 1pt;
	margin-left: 20px;
	width: 200px;
}

/* Disable chat message sending animation */
#chat form[style*="repeating-linear-gradient"] {
	background: #d0d0d0 !important;
}

#chat:not(.open) form {
	display: none;
}

#chat textarea {
	font-family: Arial, verdana;
	left: 5px;
	transition: width 0s !important;
	width: calc(100% - 12px);
}

#chat .body .chatMessage svg {
	border-left: 2px dotted rgb(170 170 170);
	padding: 2px 4px 1px;
}

#chat .body.dragging {
	border: none !important;
	margin-left: 20px !important;
}

/* Rotate and align the handle to top-right */
.handle.ui-resizable-ne[src*="resizeHandleBottomRight.png"] {
	height: 12px !important;
	position: absolute;
	right: 0;
	top: 0;
	transform: rotate(-90deg);
	width: 12px;
}

body:has(#chat .body.ui-resizable-resizing) .ui-resizable-handle.handle.ui-resizable-ne {
	display: none !important;
}

#chat .body:hover {
	overflow-y: scroll;
}

#chat .body .chatMessage {
	margin-left: ${(/Chrome.*Safari/u).test(navigator.userAgent) ? '3px' : '5px'};
	direction: ltr;
}

#chat .body::-webkit-scrollbar {
	width: 3px;
}

#chat .body::-webkit-scrollbar-track {
	background: transparent;
}

#chat .body::-webkit-scrollbar-thumb {
	background: rgb(170 170 170);
}

#chat form .autocomplete-dropdown {
	background-color: #00ff02;
	border-radius: 3px;
	bottom: 0;
	filter: drop-shadow(0 0 3px rgb(0 0 0 / 70%));
	font-family: Arial, verdana;
	margin-bottom: 25px;
	max-height: 120px;
	max-width: 200px;
	min-width: 120px;
	overflow-y: scroll;
	padding: 4px 2px;
	position: absolute;
	scrollbar-color: #00a902 transparent;
	scrollbar-gutter: stable;
	scrollbar-width: thin;
	white-space: nowrap;
	z-index: 999;
}

#chat form .autocomplete-dropdown div {
	border-bottom: 1pt dotted #00a902;
	cursor: pointer;
	display: none;
	margin-bottom: 2px;
	overflow: hidden;
	padding: 0 8px 2px 4px;
	text-overflow: ellipsis;
}

#chat form .autocomplete-dropdown .match {
	display: block;
}

#chat form .autocomplete-dropdown .match:not(:has(~ .match)) {
	border-bottom: none;
	padding: 0 8px 0 4px;
}

#chat form .autocomplete-dropdown .highlight {
	font-weight: bold;
}

#chat form .autocomplete-dropdown:hover .highlight {
	font-weight: normal;
}

#chat form .autocomplete-dropdown div:hover {
	font-weight: bold !important;
}

#chat form .autocomplete-dropdown:has(div:not(.highlight):hover) > .highlight {
	font-weight: normal;
}

#chat form .autocomplete-caret-mirror {
	background: transparent;
	color: transparent;
	font-family: Arial, verdana;
	font-size: inherit;
	font-weight: bold;
	height: 0;
	margin: 0 0 0 5px;
	opacity: 0;
	padding: 1px 2px;
	pointer-events: none;
	z-index: -2147483647;
}
`);

/**
 * Reconfigure the chat handle to be dragging
 * from the south-east direction (down)
 * to the north-east direction (up)
 */
const changeHandleDirection = async() => {
	const { resizable } = $.fn;

	// Use a regular function to keep context
	$.fn.resizable = function(...args) {
		const [config] = args;

		// Reassign the chat handle to be north-east facing
		if (config.handles) {
			const handle = config.handles.se;
			if (handle === TankTrouble.ChatBox.chatBodyResizeHandle) {
				handle.removeClass('ui-resizable-se')
					.addClass('ui-resizable-ne');

				config.handles.ne = handle;
				delete config.handles.se;

				// Set a taller chat maxHeight
				config.maxHeight = 650;
			}
		}

		return resizable.call(this, config);
	};

	await Loader.whenContentInitialized();

	TankTrouble.ChatBox.chatBodyResizeHandle.detach().insertAfter(TankTrouble.ChatBox.chatBody);
};

/**
 * Hook message render functions to disable jquery .show() animation and scroll to bottom
 * This fixes chat messages not showing up in the reversed chat order or overflowed messages being cleared
 */
const fixChatRendering = () => {
	Loader.interceptFunction(TankTrouble.ChatBox, '_renderChatMessage', (original, ...args) => {
		TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height());

		// Set animateHeight to false
		args[9] = false;
		original(...args);
	});

	Loader.interceptFunction(TankTrouble.ChatBox, '_renderSystemMessage', (original, ...args) => {
		TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height());

		// Set animateHeight to false
		args[3] = false;
		original(...args);
	});
};

/**
 * Prevent TankTrouble from clearing the chat when the client disconnects
 * Print message to chat when client switches server to separate conversations
 */
const preventServerChangeChatClear = () => {
	Loader.interceptFunction(TankTrouble.ChatBox, '_clearChat', (original, ...args) => {
		const isUnconnected = ClientManager.getClient().getState() === TTClient.STATES.UNCONNECTED;

		// Void the call if the client is unconnected
		// when the function is invoked
		if (isUnconnected) return null;

		return original(...args);
	});

	Loader.interceptFunction(TankTrouble.ChatBox, '_updateStatusMessageAndAvailability', (original, ...args) => {
		const [systemMessageText, guestPlayerIds] = args;

		// Check for a welcome message. If match.
		// print a different system message
		if (systemMessageText === 'Welcome to TankTrouble Comms § § ') {
			const newServer = ClientManager.getAvailableServers()[ClientManager.multiplayerServerId];
			return original(`Connected to ${ newServer.name } ${ guestPlayerIds.length ? '§ ' : '' }`, guestPlayerIds);
		}

		return original(...args);
	});
};

/**
 * Write the chat savestate to storage and return
 * @returns Promise for last savestate
 */
const initChatSavestate = async() => {
	// Initialize dynamic stylesheet
	// for user-defined chat width
	const inputWidth = new CSSStyleSheet();
	inputWidth.insertRule('#chat form { padding-right: 12px !important; }', 0);
	inputWidth.insertRule('#chat form, #chat textarea { width: 208px !important; }', 1);
	document.adoptedStyleSheets = [inputWidth];

	// Savestate hooks
	Loader.interceptFunction(TankTrouble.ChatBox, 'open', (original, ...args) => {
		GM_setValue('chat-open', true);
		original(...args);
	});
	Loader.interceptFunction(TankTrouble.ChatBox, 'close', (original, ...args) => {
		GM_setValue('chat-open', false);
		original(...args);
	});
	Loader.interceptFunction(TankTrouble.ChatBox, '_refreshChat', (original, ...args) => {
		original(...args);
		GM_setValue('chat-width', TankTrouble.ChatBox.chatBody[0].clientWidth);
	});

	// Get savestate
	const shouldOpen = await GM_getValue('chat-open', true);
	const initialWidth = await GM_getValue('chat-width', 0);

	Loader.whenContentInitialized().then(() => {
		/* eslint-disable prefer-destructuring */
		const chatBody = TankTrouble.ChatBox.chatBody[0];
		const chatInput = TankTrouble.ChatBox.chatInput[0];
		/* eslint-enable prefer-destructuring*/

		if (shouldOpen) TankTrouble.ChatBox.open();
		if (initialWidth !== 0) chatBody.style.width = `${initialWidth}px`;

		// Create a mutation observer that looks for
		// changes in the chatBody's attributes
		new MutationObserver(() => {
			const width = Number(chatBody.offsetWidth || 220);

			inputWidth.deleteRule(1);
			inputWidth.insertRule(`#chat form, #chat form textarea { width: ${width - 12}px !important; }`, 1);

			chatInput.dispatchEvent(new InputEvent('input'));
		}).observe(chatBody, {
			attributes: true,
			characterData: false
		});
	});
};

/**
 * Add up/down history for sent messages
 * @param chatInput Input to target
 */
const addInputHistory = chatInput => {
	const messages = [];
	let currentInputValue = chatInput.value;

	// Create and initialize chat messages history iterator
	let i = messages.length;
	const iterator = (function* chatsIterator() {
		while (true) {
			const incOrDec = (yield messages[i]) === 'prev' ? -1 : 1;
			i = Math.min(Math.max(i + incOrDec, 0), messages.length);
		}
	}(messages));

	// Initialize the generator
	iterator.next();

	/**
	 * Check whether or not the input has an empty selection range
	 * @returns Selection range is 0
	 */
	const isSelectionEmpty = () => chatInput.selectionStart === chatInput.selectionEnd;

	/** Handle the user triggering a submit keydown event */
	const handleSubmit = () => {
		if (!chatInput.value) return;

		messages.push(chatInput.value);
		currentInputValue = '';

		i = messages.length;
	};

	/** Handle the user triggering an arrow up keydown event */
	const handleArrowUp = () => {
		if (isSelectionEmpty() && chatInput.selectionStart === 0) {
			const { value } = iterator.next('prev');
			chatInput.value = typeof value === 'undefined' ? '' : value;

			chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
			chatInput.dispatchEvent(new InputEvent('input', { isComposing: true }));
		}
	};

	/** Handle the user triggering an arrow down keydown event */
	const handleArrowDown = () => {
		if (isSelectionEmpty() && chatInput.selectionStart === chatInput.value.length) {
			const { value } = iterator.next();
			chatInput.value = typeof value === 'undefined' ? currentInputValue : value;

			chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
			chatInput.dispatchEvent(new InputEvent('input', { isComposing: true }));
		}
	};

	// If the user is at the top of the history,
	// save the chat input value as the "current"
	// message whenever there is a change
	chatInput.addEventListener('input', ({ inputType }) => {
		const isAtEndOfHistory = i === messages.length;
		const hasValueChanged = typeof inputType !== 'undefined';
		if (isAtEndOfHistory && hasValueChanged) currentInputValue = chatInput.value;
	});

	// Listen for keydown events
	// and trigger handlers
	chatInput.addEventListener('keydown', ({ key }) => {
		switch (key) {
		case 'Enter':
			handleSubmit();
			break;
		case 'ArrowUp':
			handleArrowUp();
			break;
		case 'ArrowDown':
			handleArrowDown();
			break;
		default:
			break;
		}
	});
};

/**
 * Add auto-complete for user mentions when typing @ in the chat input
 * @param chatInput Chat input instance
 */
const addMentionAutocomplete = chatInput => {
	class Dropdown {

		options = new Map();

		matches = [];

		/**
		 * Setup the dropdown class
		 * @param input Input to attach to
		 * @param config Dropdown configuration (allow multiple of the same value, expiry time)
		 */
		constructor(input, config) {
			this.input = $(input);
			this.wrapper = $('<div class="autocomplete-dropdown" tabindex="-1"></div>').insertAfter(this.input);
			this.textareaMirror = $('<div class="autocomplete-caret-mirror"></div>').appendTo(this.wrapper.parent());
			this.textareaMirrorInline = $('<span></span>').appendTo(this.textareaMirror);

			Object.assign(this, {
				allowRepeats: false,
				autofillLifetime: 10 * 60 * 100,
				inputHeight: 18,
				...config
			});

			this.wrapper.insertAfter(this.input);


			this.hide();
		}

		#searchTerm = -1;

		/**
		 * Filter the dropdown elements when searchterm is set
		 * @param term String term to search the dropdown registry for
		 * @returns term
		 */
		set searchTerm(term) {
			if (this.#searchTerm !== term) {
				this.#removeExpired();

				const allSymbols = Array.from(this.options.keys());
				this.matches = matchSorter.matchSorter(allSymbols, term, { keys: [symbol => symbol.description] });
				for (const symbol of allSymbols) {
					const element = this.options.get(symbol).value;

					element.classList[this.matches.includes(symbol) ? 'add' : 'remove']('match');
				}
				for (const symbol of this.matches) this.wrapper.append(this.options.get(symbol).value);

				this.#resetToFirst();
			}

			this.#searchTerm = term;
			return term;
		}

		/**
		 * Getter for `searchTerm`
		 * @returns `searchTerm`
		 */
		get searchTerm() {
			return this.#searchTerm;
		}

		iterator = (function* (options, that) {
			let i = 0;
			while (true) {
				const symbol = that.matches[i];

				const change = (yield [symbol, options.get(symbol)]) || 0;

				i = (i = (i + change) % Math.max(that.matches.length, 1)) < 0
					? i + that.matches.length
					: i;
			}
		}(this.options, this));

		/** Render the dropdown if not already visible */
		show() {
			if (this.isShowing()) return;

			this.#resetToFirst();

			this.wrapper.show();
			this.wrapper.scrollTop(0);
		}

		/** Hide the dropdown */
		hide() {
			this.wrapper.hide();
		}

		/**
		 * Check if the dropdown is visible
		 * @returns Is the dropdown showing?
		 */
		isShowing() {
			return this.wrapper.is(':visible');
		}

		/**
		 * Compute dropdown x-shift to textarea value.
		 * 
		 * Should be called when value changes in the input field
		 */
		update() {
			const transformed = this.input.val()
				.substr(0, this.input[0].selectionStart);
			this.textareaMirrorInline.html(transformed);

			const rects = this.textareaMirrorInline[0].getBoundingClientRect();
			const left = rects.right - rects.x;
			this.left = left
				+ Dropdown.#toNumeric(this.input.css('left'))
				+ Dropdown.#toNumeric(this.input.css('margin-left'))
				+ Dropdown.#toNumeric(this.input.css('padding-left'));

			const isWordWrapped = this.#isWordWrapped();
			const leftShift = isWordWrapped ? 0 : Math.max(0, this.left - (this.wrapper.width() / 2));
			const bottomShift = this.input.outerHeight() - this.inputHeight;
			this.wrapper.css('margin-left', `${leftShift}px`);
			this.wrapper.css('margin-bottom', `${bottomShift + 25}px`);

			if (!this.isShowing()) this.show();
		}

		/**
		 * Get data for the current position
		 * @returns Identifier and data for the current dropdown position
		 */
		getCurrent() {
			return this.iterator.next(0).value;
		}

		/**
		 * Add an autocomplete option to the dropdown
		 * @param option Option as string
		 * @param submitCallback Event handler for mouseup
		 * @returns Success in adding option?
		 */
		addOption(option, submitCallback) {
			const overrideSymbol = !this.allowRepeats
				&& Array.from(this.options.keys())
					.find(({ description }) => description === option);
			const symbolExists = typeof overrideSymbol === 'symbol';

			if (symbolExists) return false;

			const symbol = Symbol(option);

			const element = document.createElement('div');
			element.innerText = option;
			element.addEventListener('mouseup', evt => submitCallback(evt, evt.target.innerText));

			const insert = [
				symbol,
				{
					inserted: Date.now(),
					lifetime: this.autofillLifetime,
					value: element
				}
			];

			this.options.set(...insert);

			return true;
		}

		/**
		 * Add an array of text options to the dropdown
		 * @param options Options as string[]
		 * @param submitCallback Generalized event handler for mouseup for all options
		 */
		addOptions(options, submitCallback) {
			for (const option of options) this.addOption(option, submitCallback);
		}

		/**
		 * Remove option and corresponding HTMLElement from DOM
		 * @param symbol Symbol for element to remove
		 * @returns Was the option deleted?
		 */
		removeOption(symbol) {
			this.options.get(symbol)?.value.remove();
			this.matches = this.matches.filter(toRemove => toRemove !== symbol);
			return this.options.delete(symbol);
		}

		/**
		 * Clear all options from the dropdown
		 * @returns Did options clear?
		 */
		clearOptions() {
			for (const symbol of this.options.keys()) this.removeOption(symbol);

			return this.options.size === 0
				&& this.wrapper.children().length === 0;
		}

		/**
		 * Navigate position in the dropdown up/down
		 * @param direction Up/down shift as number
		 * @returns Identifier for where we navigated to
		 */
		navigate(direction) {
			this.wrapper.children().removeClass('highlight');

			const [symbol, data] = this.iterator.next(direction).value;
			if (!symbol) return null;

			data.value.classList.add('highlight');
			data.value.scrollIntoView(false);

			return symbol;
		}

		/**
		 * Check if the input wraps to newline
		 * @returns Whether the input is one or multiple lines
		 */
		#isWordWrapped() {
			return this.input.outerHeight() <= this.inputHeight;
		}

		/**
		 * Reset the position to the
		 * first item in the dropdown
		 */
		#resetToFirst() {
			const symbols = this.matches;
			const [currentSymbol] = this.iterator.next(0).value;
			const dist = symbols.indexOf(currentSymbol);

			this.navigate(-dist);
		}

		/**
		 * Remove expired entries
		 */
		#removeExpired() {
			for (const [symbol, value] of this.options.entries()) {
				const expiry = value.inserted + value.autofillLifetime;
				if (Date.now() > expiry) this.removeOption(symbol);
			}
		}

		/**
		 * Remove all non-numbers from string and return string as number
		 * @param str String to parse
		 * @returns String in number format
		 */
		static #toNumeric = str => Number(str.replace(/[^0-9.]/ug, ''));

	}

	const dropdown = new Dropdown(chatInput);

	/**
	 * Get the word and start/end indexies of the input selectionEnd
	 * @returns Object with word and range start/end 
	 */
	const getIndexiesOfWordInCurrentSelection = () => {
		// Separate string by whitespace and
		// list indexies for each word in array
		const tokenizedQuery = chatInput.value.split(/[\s\n]/u).reduce((acc, word, index) => {
			const previous = acc[index - 1];
			const start = index === 0 ? index : previous.range[1] + 1;
			const end = start + word.length;

			return acc.concat([ { word, range: [start, end] } ]);
		}, []);

		const currentWord = tokenizedQuery.find(({ range }) => range[0] < chatInput.selectionEnd && range[1] >= chatInput.selectionEnd);

		return currentWord;
	};

	/**
	 * Returns the user that the selection is over, from the input value, if prefixed by a @
	 * @returns Mention username or null
	 */
	const getUserFocusIfMention = () => {
		const currentWord = getIndexiesOfWordInCurrentSelection();
		const [mentions] = chatInput.value.split(/\s+(?=[^@])/u);
		const isUserChat = mentions.startsWith('@');

		if (currentWord && isUserChat) {
			const [, end] = currentWord.range;
			return end <= mentions.length ? currentWord : null;
		}

		return null;
	};

	/**
	 * Handle a dropdown submit event (enter, tab or click)
	 * by autofilling the value to the input field
	 * @param evt Event object
	 * @param username Username to autofill
	 */
	const handleSubmit = (evt, username = dropdown.getCurrent()[0].description) => {
		const mention = getUserFocusIfMention();
		if (mention === null) return;

		const [start, end] = mention.range;
		if (username) {
			const before = chatInput.value.slice(0, start);
			const after = chatInput.value.substring(end, chatInput.value.length);

			const insertSpaceAfter = !after.startsWith(' ');

			const beforeValue = `${ before }@${ username }${ insertSpaceAfter ? ' ' : '' }`;
			const cursorPosition = [beforeValue.length + 1, beforeValue.length + 1];
			chatInput.value = `${ beforeValue }${ after }`;

			chatInput.setSelectionRange(...cursorPosition);
		}

		evt.preventDefault();

		chatInput.dispatchEvent(new InputEvent('input'));
	};

	/**
	 * Event handler for TTClient.EVENTS.GAME_LIST_CHANGED
	 */
	const handleGameListChanged = () => {
		const gameStates = ClientManager.getClient().getAvailableGameStates();

		for (const gameState of gameStates) {
			const playerStates = gameState.getPlayerStates();

			for (const player of playerStates) {
				const playerId = player.getPlayerId();

				Backend.getInstance().getPlayerDetails(result => {
					if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit);
				}, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
			}
		}
	};

	/**
	 * Event handler for received chat messages
	 * @param data Event data
	 */
	const handleNewChatMessage = data => {
		const involvedPlayerIds = data.involvedPlayerIds || [...data.getFrom() || [], ...data.getTo() || []];
		const loggedIn = Users.getAllPlayerIds();
		const foreignPlayerIds = involvedPlayerIds.filter(playerId => !loggedIn.includes(playerId));

		for (const playerId of foreignPlayerIds) {
			Backend.getInstance().getPlayerDetails(result => {
				if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit);
			}, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
		}
	};

	chatInput.addEventListener('input', ({ isComposing }) => {
		if (isComposing) return;

		const userFocus = getUserFocusIfMention();
		if (userFocus === null) {
			dropdown.hide();
			return;
		}

		dropdown.searchTerm = userFocus.word.replace(/^@/u, '');
		if (!dropdown.matches.length) {
			dropdown.hide();
			return;
		}

		// Show UI
		dropdown.show();
		dropdown.update();
	});

	// eslint-disable-next-line complexity
	chatInput.addEventListener('keydown', evt => {
		const userFocus = getUserFocusIfMention();
		if (userFocus === null) return;

		dropdown.searchTerm = userFocus.word.replace(/^@/u, '');
		if (!dropdown.matches.length) return;

		switch (evt.key) {
		case 'Enter':
		case 'Tab':
			handleSubmit(evt);
			break;
		case 'ArrowUp':
			dropdown.navigate(-1);
			evt.preventDefault();
			break;
		case 'ArrowDown':
			dropdown.navigate(1);
			evt.preventDefault();
			break;
		default:
			break;
		}
	}, false);

	/**
	 * State change event handler
	 * @param _self Self reference
	 * @param _oldState Old client state
	 * @param newState New client state
	 */
	const clientStateEventHandler = (_self, _oldState, newState) => {
		switch (newState) {
		case TTClient.STATES.UNCONNECTED:
			dropdown.clearOptions();
			break;
		default:
			break;
		}
	};

	/**
	 * Event handler for new chat messages
	 * @param _self Self reference
	 * @param evt Event type
	 * @param data Event data
	 */
	// eslint-disable-next-line complexity
	const clientEventHandler = (_self, evt, data) => {
		switch (evt) {
		case TTClient.EVENTS.GAME_LIST_CHANGED:
			handleGameListChanged();
			break;
		case TTClient.EVENTS.USER_CHAT_POSTED:
			if (data) handleNewChatMessage(data);
			break;
		case TTClient.EVENTS.GLOBAL_CHAT_POSTED:
		case TTClient.EVENTS.CHAT_POSTED:
			if (data) handleNewChatMessage(data);
			break;
		case TTClient.EVENTS.SYSTEM_CHAT_POSTED:
		case TTClient.EVENTS.PLAYERS_BANNED:
		case TTClient.EVENTS.PLAYERS_UNBANNED:
			if (data) handleNewChatMessage(data);
			break;
		default:
			break;
		}
	};

	ClientManager.getClient().addStateChangeListener(clientStateEventHandler, this);
	ClientManager.getClient().addEventListener(clientEventHandler, this);
};

changeHandleDirection();
fixChatRendering();
initChatSavestate();

Loader.whenContentInitialized().then(() => {
	// eslint-disable-next-line prefer-destructuring
	const chatInput = TankTrouble.ChatBox.chatInput[0];

	preventServerChangeChatClear();
	addMentionAutocomplete(chatInput);
	addInputHistory(chatInput);

	// Allow more characters in the chat input
	chatInput.setAttribute('maxlength', '255');
});