Power Chat

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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');
});