Safe Space Racing NT

Replaces the Race Track with a Typing Test Lobby Chatroom. Choose which users to mute and block, it's your "safe space".

目前為 2021-12-28 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Safe Space Racing NT
// @version      0.1.0
// @description  Replaces the Race Track with a Typing Test Lobby Chatroom. Choose which users to mute and block, it's your "safe space".
// @author       Nate Dogg
// @match        *://*.nitrotype.com/race
// @match        *://*.nitrotype.com/race/*
// @match        *://*.nitrotype.com/profile
// @icon         data:image/png;base64,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
// @grant        none
// @require      https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.0-rc.3/dexie.min.js
// @namespace https://greasyfork.org/users/805959
// ==/UserScript==
 
/* globals Dexie */
 
/////////////
//  Utils  //
/////////////
 
/** Finds the React Component from given dom. */
const findReact = (dom, traverseUp = 0) => {
	const key = Object.keys(dom).find((key) => key.startsWith("__reactFiber$"))
	const domFiber = dom[key]
	if (domFiber == null) return null
	const getCompFiber = (fiber) => {
		let parentFiber = fiber?.return
		while (typeof parentFiber?.type == "string") {
			parentFiber = parentFiber?.return
		}
		return parentFiber
	}
	let compFiber = getCompFiber(domFiber)
	for (let i = 0; i < traverseUp && compFiber; i++) {
		compFiber = getCompFiber(compFiber)
	}
	return compFiber?.stateNode
}
 
/** Console logging with some prefixing. */
const logging = (() => {
	const logPrefix = (prefix = "") => {
		const formatMessage = `%c[Nitro Type Safe Space]${prefix ? `%c[${prefix}]` : ""}`
		let args = [console, `${formatMessage}%c`, "background-color: #D62F3A; color: #fff; font-weight: bold"]
		if (prefix) {
			args = args.concat("background-color: #4f505e; color: #fff; font-weight: bold")
		}
		return args.concat("color: unset")
	}
	return {
		info: (prefix) => Function.prototype.bind.apply(console.info, logPrefix(prefix)),
		warn: (prefix) => Function.prototype.bind.apply(console.warn, logPrefix(prefix)),
		error: (prefix) => Function.prototype.bind.apply(console.error, logPrefix(prefix)),
		log: (prefix) => Function.prototype.bind.apply(console.log, logPrefix(prefix)),
		debug: (prefix) => Function.prototype.bind.apply(console.debug, logPrefix(prefix)),
	}
})()
 
// Config storage
const db = new Dexie("NTSafeSpace")
db.version(1).stores({
	users: "id, &username, team, displayName, status",
})
db.open().catch(function (e) {
	logging.error("Init")("Failed to open up the config database")
})
 
/////////////////////
//  Settings Page  //
/////////////////////
 
if (window.location.pathname === "/profile") {
	//////////////////
	//  Components  //
	//////////////////
 
	const safeSpaceSettingRoot = document.createElement("div")
	safeSpaceSettingRoot.classList.add("g-b", "g-b--9of12")
	safeSpaceSettingRoot.innerHTML = `
		<h2 class="tbs">Nitro Type Safe Space Settings</h2>
		<p class="tc-ts">Manage settings from this Userscript.</p>
		<p class="input-label">Mute/Blocked Users<p>
		<table class="table table--selectable table--striped">
			<thead class="table-head">
				<tr class="table-row">
					<th scope="col" class="table-cell table-cell--racer">Racer</th>
					<th scope="col" class="table-cell table-cell--status">Status</th>
					<th scope="col" class="table-cell table-cell--remove" style="width: 90px">Remove?</th>
				</tr>
			</thead>
			<tbody class="table-body">
			</tbody>
		</table>`
 
	const userTableBody = safeSpaceSettingRoot.querySelector("tbody.table-body")
 
	const userRow = document.createElement("tr")
	userRow.classList.add("table-row")
	userRow.innerHTML = `
		<td class="table-cell table-cell--racer">
			<div class="bucket bucket--s bucket--c">
				<div class="bucket-media bucket-media--w90">
					<img class="img--noMax db">
				</div>
				<div class="bucket-content">
					<div class="df df--align-center">
						<div class="prxxs"><img alt="Nitro Gold" class="icon icon-nt-gold-s" src="/dist/site/images/themes/profiles/gold/nt-gold-icon-xl.png"></div>
						<div class="prxxs df df--align-center">
							<a class="link link--bare mrxxs twb" style="color: rgb(253, 182, 77);"></a>
							<span class="type-ellip type-gold tss"></span>
						</div>
					</div>
					<div class="tsi tc-lemon tsxs"></div>
				</div>
			</div>
		</td>
		<td class="table-cell table-cell--status">
			<select class="input-select">
				<option value="MUTE">Muted</option>
				<option value="BLOCK">Blocked</option>
			</select>
		</td>
		<td class="table-cell table-cell--remove tar prs">
			<button title="Remove Block/Mute User" type="button" class="btn btn--negative">
				<svg class="icon icon-x--s"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-x"></use></svg>
			</button>
		</td>`
 
	const handleRowClick = (e) => {
		const row = e.target.closest(".table-row"),
			input = e.target.closest("a, button, select"),
			userID = row && !input ? parseInt(row.dataset.user, 10) : null
		if (userID !== null && !isNaN(userID)) {
			db.users.get(userID).then((user) => {
				window.location.href = `/racer/${user.username}`
			})
		}
	}
 
	const handleStatusUpdateChange = (e) => {
		const targetElement = e.target.closest("select"),
			row = e.target.closest(".table-row"),
			userID = row ? parseInt(row.dataset.user, 10) : null
		if (userID !== null && !isNaN(userID)) {
			db.users.update(userID, { status: targetElement.value })
		}
	}
 
	const handleRemoveButtonClick = (e) => {
		const row = e.target.closest(".table-row"),
			userID = row ? parseInt(row.dataset.user, 10) : null
		if (userID !== null && !isNaN(userID)) {
			db.users.delete(userID).then(() => row.remove())
		}
	}
 
	db.users.count().then((total) => {
		if (total === 0) {
			const emptyRow = document.createElement("tr")
			emptyRow.classList.add("table-row")
			emptyRow.innerHTML = `<td class="table-cell" colspan="3">No racers found</td>`
			userTableBody.append(emptyRow)
			userTableBody.parentNode.classList.remove("table--selectable")
			return
		}
 
		const rowFragment = document.createDocumentFragment()
		db.users
			.each((userData) => {
				const row = userRow.cloneNode(true),
					carImage = row.querySelector("img.img--noMax"),
					teamLink = row.querySelector("a.link"),
					racerName = row.querySelector(".type-ellip"),
					statusSelect = row.querySelector("select"),
					removeButton = row.querySelector("button"),
					displayName = userData.displayName || userData.username
 
				row.dataset.user = userData.id
				row.addEventListener("click", handleRowClick)
 
				carImage.src = userData.carImgSrc
				carImage.alt = `${displayName}'s car`
 
				teamLink.parentNode.title = displayName
				racerName.textContent = `${userData.team ? " " : ""}${displayName}`
				row.querySelector(".tsi").textContent = `"${userData.title}"`
 
				if (!userData.team) {
					teamLink.remove()
				} else {
					teamLink.textContent = `[${userData.team}]`
					teamLink.href = `/team/${userData.team}`
					teamLink.style.color = `#${userData.teamColor}`
				}
 
				if (!userData.isGold) {
					row.querySelector(".icon-nt-gold-s").parentNode.remove()
					racerName.classList.remove("type-gold")
				}
 
				statusSelect.value = userData.status
				statusSelect.addEventListener("change", handleStatusUpdateChange)
 
				removeButton.addEventListener("click", handleRemoveButtonClick)
 
				rowFragment.append(row)
			})
			.then(() => {
				userTableBody.append(rowFragment)
			})
	})
 
	/////////////
	//  Final  //
	/////////////
 
	/** Mutation observer to check whether setting page has loaded. */
	const settingPageObserver = new MutationObserver(([mutation], observer) => {
		const sideMenu = mutation.target.querySelector(".has-btn"),
			originalSettingRoot = mutation.target.querySelector(".g-b.g-b--9of12")
		if (sideMenu && originalSettingRoot) {
			observer.disconnect()
 
			const menuSafeSpaceButton = document.createElement("button")
			menuSafeSpaceButton.classList.add("btn", "btn--fw")
			menuSafeSpaceButton.textContent = "Nitro Type Safe Space"
			menuSafeSpaceButton.addEventListener("click", (e) => {
				const currentActiveButton = sideMenu.querySelector(".btn.is-active")
				if (currentActiveButton) {
					currentActiveButton.classList.remove("is-active")
				}
				menuSafeSpaceButton.classList.add("is-active")
				originalSettingRoot.replaceWith(safeSpaceSettingRoot)
			})
 
			const handleOriginalMenuButtonClick = (e) => {
				menuSafeSpaceButton.classList.remove("is-active")
				safeSpaceSettingRoot.replaceWith(originalSettingRoot)
			}
			sideMenu.querySelectorAll(".btn").forEach((node) => {
				node.addEventListener("click", handleOriginalMenuButtonClick)
			})
 
			sideMenu.append(menuSafeSpaceButton)
		}
	})
	settingPageObserver.observe(document.querySelector("main.structure-content"), { childList: true })
 
	return
}
 
///////////////////
//  Racing Page  //
///////////////////
 
if (window.location.pathname === "/race" || window.location.pathname.startsWith("/race/")) {
	const raceContainer = document.getElementById("raceContainer"),
		canvasTrack = raceContainer?.querySelector("canvas"),
		raceObj = raceContainer ? findReact(raceContainer) : null
	if (!raceContainer || !canvasTrack || !raceObj) {
		logging.error("Init")("Could not find the race track")
		return
	}
 
	//////////////
	//  Styles  //
	//////////////
 
	const style = document.createElement("style")
	style.appendChild(
		document.createTextNode(`
.nt-safe-space-root {
	position: relative;
	box-sizing: border-box;
	width: 1024px;
	height: 400px;
	background-color: #202020;
}
 
/* Some Overrides */
.race-results {
	z-index: 6;
}
 
/* Info Section */
.nt-safe-space-info {
	position: absolute;
	left: 14px;
	top: 14px;
	bottom: 14px;
	right: 619px;
	display: flex;
	border-radius: 8px;
	color: #eee;
	background-color: #303030;
}
.nt-sace-space-info-status {
	margin: auto;
}
.nt-safe-space-info-status-title {
	font-size: 24px;
	font-weight: 600;
	text-align: center;
	margin-bottom: 14px;
}
.nt-safe-space-info-status-subtitle {
	font-size: 14px;
	text-align: center;
}
 
/* Chat */
.nt-safe-space-chat {
	position: absolute;
	left: 415px;
	right: 14px;
	z-index: 5;
	top: 14px;
	bottom: 14px;
	display: flex;
	border-radius: 8px;
	overflow: hidden;
}
 
/* Chat Contacts */
.nt-safe-space-contacts {
	display: flex;
	flex-direction: column;
	width: 250px;
	border-top-left-radius: 8px;
	border-bottom-left-radius: 8px;
	border-right: 1px solid #34344a;
	background-color: #0b0b10;
	color: #fff;
}
.nt-safe-space-contact-item {
	padding: 2px 8px;
	border-bottom: 1px solid #20202e;
	background-color: #111218;
}
.nt-safe-space-contact-item:hover {
	background-color: #181822;
}
.nt-safe-space-contact-item:first-of-type {
	padding-top: 8px;
}
.nt-safe-space-contact-item:nth-child(4) {
	padding-bottom: 8px;
	border-bottom: 0;
}
.nt-safe-space-contact-item.alt-row {
	background-color: #181a22;
}
.nt-safe-space-contact-item.alt-row:hover {
	background-color: #20212c;
}
.nt-safe-space-contact-item-body {
	display: flex;
	justify-content: space-between;
	align-items: center;
}
.nt-safe-space-contact-player {
	display: flex;
	align-items: center;
	flex-grow: 1;
}
.nt-safe-space-contact-avatar  {
	display: flex;
	width: 64px;
	height: 64px;
	overflow: hidden;
	margin-right: 4px;
}
.nt-safe-space-contact-avatar img {
	margin: auto;
	max-width: 100%;
	max-height: 100%;
}
.nt-safe-space-contact-speech-bubble {
	position: relative;
	background: #fff;
	border-radius: 8px;
	padding: 4px;
	margin-left: 10px;
	transition: opacity 0.2s ease;
	opacity: 1;
}
.nt-safe-space-contact-speech-bubble.nt-safe-space-hidden {
	opacity: 0;
}
.nt-safe-space-contact-speech-bubble:after {
	content: '';
	position: absolute;
	left: 0;
	top: 50%;
	width: 0;
	height: 0;
	border: 10px solid transparent;
	border-right-color: #fff;
	border-left: 0;
	margin-top: -10px;
	margin-left: -10px;
}
.nt-safe-space-contact-speech-bubble-img {
	background-repeat: no-repeat;
	background-size: contain;
	background-position: center;
	width: 48px;
	height: 48px;
}
.nt-safe-space-contact-item-name {
	display: flex;
	align-items: center;
	font-size: 12px;
	font-weight: 600;
	margin-bottom: 4px;
}
.nt-safe-space-contact-menu {
	display: flex;
	flex-direction: column;
	font-size: 10px;
}
.nt-safe-space-contact-menu-item {
	display: flex;
	align-items: center;
	padding: 4px;
	margin-bottom: 2px;
	border-radius: 4px;
	width: 80px;
	cursor: pointer;
}
.nt-safe-space-contact-menu-item:hover {
	background-color: rgba(255, 255, 255, 0.1);
}
.nt-safe-space-contact-menu-icon {
	margin-right: 8px;
}
 
/* Chat Messages Container */
.nt-safe-space-chatroom {
	flex-grow: 1;
	background-color: #20222e;
	background-image: url(/dist/site/images/backgrounds/bg-noise.png)
}
.nt-safe-space-chatroom-messages {
	position: relative;
	height: 198px;
	transition: height 0.2s ease;
}
.nt-safe-space-chatroom-messages.hide-reply-options {
	height: 332px;
}
.nt-safe-space-chatroom-messages.disable-reply {
	height: 372px;
}
.nt-safe-space-chatroom-messages-scrollable {
	position: absolute;
	left: 8px;
	right: 8px;
	top: 8px;
	bottom: 8px;
	display: flex;
	flex-direction: column;
	overflow-y: auto;
	scrollbar-face-color: #fff;
	scrollbar-track-color: #000;
	color: #eee;
	font-size: 12px;
}
.nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar {
	width: 4px;
	height: 4px;
}
.nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar-thumb {
	background: #fff;
}
.nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar-track {
	background: #000;
}
 
/* Chat Message Item */
.nt-safe-space-chatroom-message {
	margin-top: auto;
	margin-bottom: 16px;
}
.nt-safe-space-chatroom-message:last-of-type {
	margin-bottom: unset;
}
.nt-safe-space-chatroom-message-heading, .nt-safe-space-chatroom-message-body  {
	display: flex;
	align-items: center;
}
.nt-safe-space-chatroom-message-heading {
	margin-bottom: 4px;
	font-weight: 600;
}
.nt-safe-space-chatroom-message-body {
	display: inline-flex;
	border-radius: 8px;
	padding-top: 4px;
	padding-bottom: 4px;
	padding-left: 8px;
	padding-right: 8px;
	background-color: rgba(255, 255, 255, 0.1);
}
.nt-safe-space-chatroom-message-team,
.nt-safe-space-chatroom-message-name {
	margin-right: 0.5ch;
}
.nt-safe-space-chatroom-message-name.nt-gold-user,
.nt-safe-space-contact-item-name.nt-gold-user {
	color: #E0BB2F;
}
.nt-safe-space-chatroom-message-heading svg.icon,
.nt-safe-space-contact-item svg.icon {
	margin-right: 0.2ch;
}
.nt-safe-space-chatroom-message-body .nt-safe-space-chatroom-message-text.system-message {
	font-style: italic;
}
.nt-safe-space-chatroom-message-body .nt-safe-space-chatroom-mesasge-img {
	background-repeat: no-repeat;
	background-size: contain;
	background-position: center;
	width: 48px;
	height: 48px;
	margin-left: 1ch;
}
.nt-safe-space-chatroom-message-time {
	font-size: 10px;
	margin-top: 2px;
}
.nt-safe-space-chatroom-message.is-me {
	display: flex;
	flex-direction: column;
}
.nt-safe-space-chatroom-message.is-me,
.nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-heading,
.nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-body {
	margin-left: auto;
}
.nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-time {
	text-align: right;
}
 
/* Chat Reply */
.nt-safe-space-chatroom-reply {
	height: 176px;
}
.nt-safe-space-chatroom-reply-toolbar {
	background-color: #093c60;
	padding: 2px;
}
.nt-safe-space-chatroom-reply-toolbar.friend-race {
	display: grid;
	grid-template-columns: 1fr 1fr;
	gap: 2px;
}
.nt-safe-space-chatroom-reply-toolbar-option {
	position: relative;
	border-radius: 4px;
	padding: 8px;
	width: 100%;
	color: #fff;
	transition: background-color 0.2s ease;
	background-color: rgba(0, 0, 0, 0.1);
}
.nt-safe-space-chatroom-reply-toolbar-option:hover {
	background-color: rgba(0, 0, 0, 0.2);
}
.nt-safe-space-chatroom-reply-toolbar-option.selected {
	background-color: rgba(0, 0, 0, 0.3);
}
.nt-safe-space-chatroom-reply-toolbar-option svg {
	margin: 0 auto;
	width: 20px;
	height: 20px;
}
.nt-safe-space-chatroom-reply-options {
	display: grid;
	grid-template-columns: 1fr 1fr 1fr 1fr;
	grid-template-rows: 1fr 1fr;
	gap: 2px;
	padding: 2px;
	background: linear-gradient(to bottom, #167ac3 30%, #1C99F4 100%);
}
.nt-safe-space-chatroom-reply-sticker {
	position: relative;
	display: flex;
	align-items: center;
	justify-content: center;
	border-radius: 4px;
	padding: 8px;
	background-color: rgba(0, 0, 0, 0.3);
	transition: background-color 0.2s ease;
	cursor: pointer;
}
.nt-safe-space-chatroom-reply-sticker:hover{
	background-color: #eee;
}
.nt-safe-space-chatroom-reply-sticker.nt-space-space-activated {
	background-color: #fff;
}
.nt-safe-space-chatroom-reply-sticker-img {
	background-repeat: no-repeat;
	background-size: contain;
	background-position: center;
	transition: background-image 0.2s ease;
	width: 48px;
	height: 48px;
}
.nt-safe-space-chatroom-reply-sticker-shortcut, .nt-safe-space-chatroom-reply-toolbar-option-shortcut {
	position: absolute;
	right: 0;
	top: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 16px;
	height: 16px;
	border-bottom-left-radius: 4px;
	background-color: rgba(0, 0, 0, 0.3);
	color: #fff;
	font-size: 12px;
}`)
	)
	document.head.appendChild(style)
 
	//////////////////
	//  Components  //
	//////////////////
 
	/** Display a chatroom with messages. */
	const ChatRoom = ((raceObj, db) => {
		const racerContactIDPrefix = "ntSafeSpaceRacer_",
			friendIconSVG = `<svg class="icon icon-friends-s tc-lemon"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-friends"></use></svg>`,
			smileyIconSVG = `<svg class="icon icon-smiley-l"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-smiley"></use></svg>`,
			chatIconSVG = `<svg class="icon icon-chat"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-chat"></use></svg>`,
			blockIconSVG = `<svg class="icon icon-lock-outline"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-lock-outline"></use></svg>`,
			muteIconSVG = `<svg class="icon icon-eye"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-eye"></use></svg>`,
			root = document.createElement("div"),
			systemUser = {
				userID: 0,
				profile: {
					displayName: "Typing Test Instructor",
					username: "Typing Test Instructor",
					tag: null,
					tagColor: null,
					membership: "normal",
				},
			}
		let userStickers = [],
			isStickers = true,
			isFriendRace = raceObj.state.friendsRace,
			racerCount = 0,
			userSpeechBubbleTimer = {},
			chatButtonTimer = [],
			updatingDB = [],
			raceKeyboardObj,
			originalChatObj
 
		root.classList.add("nt-safe-space-chat")
		root.innerHTML = `
			<div class="nt-safe-space-contacts"></div>
			<div class="nt-safe-space-chatroom">
				<div class="nt-safe-space-chatroom-messages">
					<div class="nt-safe-space-chatroom-messages-scrollable"></div>
				</div>
				<div class="nt-safe-space-chatroom-reply">
					<div class="nt-safe-space-chatroom-reply-toolbar friend-race">
						<button class="nt-safe-space-chatroom-reply-toolbar-option option-sticker selected" type="button" title="Send Sticker">
							${smileyIconSVG}
							<div class="nt-safe-space-chatroom-reply-toolbar-option-shortcut">S</div>
						</button>
						<button class="nt-safe-space-chatroom-reply-toolbar-option option-chat" type="button" title="Send Chat Message">
							${chatIconSVG}
							<div class="nt-safe-space-chatroom-reply-toolbar-option-shortcut">C</div>
						</button>
					</div>
					<div class="nt-safe-space-chatroom-reply-options">
						${Array.from(Array(8).keys())
							.map(
								(i) =>
									`<button class="nt-safe-space-chatroom-reply-sticker" type="button" data-stickerindex="${i}">
							<div class="nt-safe-space-chatroom-reply-sticker-img"></div>
							<div class="nt-safe-space-chatroom-reply-sticker-shortcut">${i + 1}</div>
						</button>`
							)
							.join("")}
					</div>
				</div>
			</div>`
 
		const handleNoHighlightButtonMouseDown = (e) => {
			e.preventDefault()
		}
 
		const handleChatOptionButtonClick = (e) => {
			e.preventDefault()
			const targetElement = e.target.closest(".nt-safe-space-chatroom-reply-toolbar-option")
			if (targetElement.classList.contains("selected")) {
				targetElement.classList.remove("selected")
				toggleChatOptions(false)
				return
			}
			toggleChatOptions(true)
			isStickers = targetElement.classList.contains("option-sticker")
			refreshChatOptions()
			root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((optionElement) => {
				optionElement.classList.remove("selected")
			})
			targetElement.classList.add("selected")
		}
 
		const handleChatSendButtonClick = (e) => {
			e.preventDefault()
			const targetElement = e.target.closest(".nt-safe-space-chatroom-reply-sticker"),
				index = targetElement ? parseInt(targetElement.dataset.stickerindex, 10) : null
			if (index === null || isNaN(index)) {
				return
			}
			if (isStickers) {
				originalChatObj.sendMessage(userStickers[index].id, userStickers[index].src, "sticker")
			} else {
				originalChatObj.sendMessage(index, raceObj.props.chatTexts[index], "text")
			}
			flashChatButton(index)
		}
 
		root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((node) => {
			node.addEventListener("click", handleChatOptionButtonClick)
			node.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
		})
		root.querySelectorAll(".nt-safe-space-chatroom-reply-sticker").forEach((node) => {
			node.addEventListener("click", handleChatSendButtonClick)
			node.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
		})
 
		const buttonSticker = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.option-sticker"),
			buttonChat = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.option-chat")
 
		if (!isFriendRace) {
			root.querySelector(".nt-safe-space-chatroom-reply-toolbar").classList.remove("friend-race")
			buttonChat.remove()
		}
 
		const chatMessages = root.querySelector(".nt-safe-space-chatroom-messages-scrollable")
 
		const refreshChatOptions = () => {
			root.querySelectorAll(".nt-safe-space-chatroom-reply-sticker-img").forEach((stickerItemContainer, i) => {
				if (isStickers) {
					if (userStickers[i]) {
						stickerItemContainer.parentNode.title = userStickers[i].name
						stickerItemContainer.style.backgroundImage = `url(${userStickers[i].src})`
					} else {
						stickerItemContainer.parentNode.title = ""
						stickerItemContainer.parentNode.style.display = "none"
					}
				} else {
					stickerItemContainer.style.backgroundImage = `url(/dist/site/images/chat/canned/chat_${i}.png)`
					stickerItemContainer.parentNode.title = raceObj.props.chatTexts[i]
					stickerItemContainer.parentNode.style.display = ""
				}
			})
		}
 
		const toggleChatOptions = (show) => {
			if (show) {
				chatMessages.parentNode.classList.remove("hide-reply-options")
			} else {
				chatMessages.parentNode.classList.add("hide-reply-options")
			}
		}
 
		const toggleChat = (show) => {
			if (show) {
				chatMessages.parentNode.classList.remove("disable-reply")
			} else {
				chatMessages.parentNode.classList.add("disable-reply")
			}
		}
 
		const flashChatButton = (index) => {
			const button = root.querySelector(`.nt-safe-space-chatroom-reply-sticker[data-stickerindex="${index}"]`)
			if (button) {
				if (chatButtonTimer[index]) {
					clearTimeout(chatButtonTimer[index])
				}
				button.classList.add("nt-space-space-activated")
				chatButtonTimer[index] = setTimeout(() => {
					button.classList.remove("nt-space-space-activated")
				}, 5e2)
			}
		}
 
		const addRacer = (user, status) => {
			const { userID } = user,
				{ tag, tagColor, displayName, username, carID, carHueAngle } = user.profile,
				isMe = userID == currentUserID,
				isGold = user.profile.membership === "gold",
				isFriend = friendIDs && friendIDs.includes(userID) ? true : user.isFriend,
				imgCarSrc = raceObj.props.getCarUrl(carID, false, carHueAngle, false),
				newRacerElement = chatContactTemplate.cloneNode(true),
				newRacerTeamNode = newRacerElement.querySelector(".nt-safe-space-chatroom-message-team"),
				newRacerNameNode = newRacerElement.querySelector(".nt-safe-space-chatroom-message-name"),
				newRacerAvatarNode = newRacerElement.querySelector(".nt-safe-space-contact-avatar img"),
				newMuteButton = newRacerElement.querySelector(".nt-safe-space-btn-mute"),
				newBlockButton = newRacerElement.querySelector(".nt-safe-space-btn-block")
			newRacerElement.id = `${racerContactIDPrefix}${userID}`
			newRacerElement.dataset.user = userID
			newRacerNameNode.textContent = displayName || username
			newRacerAvatarNode.src = imgCarSrc
			newRacerAvatarNode.alt = `${displayName || username}'s car`
			if (isMe) {
				newRacerElement.classList.add("is-me")
				newRacerElement.querySelector(".nt-safe-space-contact-menu").innerHTML = "That's me :)"
			}
			if (tag) {
				newRacerTeamNode.textContent = `[${tag}]`
				newRacerTeamNode.style.color = `#${tagColor}`
			} else {
				newRacerTeamNode.remove()
			}
			if (!isGold) {
				newRacerNameNode.classList.remove("nt-gold-user")
				newRacerElement.querySelector(".icon-nt-gold-s").remove()
			}
			if (!isFriend) {
				newRacerElement.querySelector(".nt-safe-space-chatroom-message-friend").remove()
			}
			if (racerCount % 2 !== 0) {
				newRacerElement.classList.add("alt-row")
			}
			if (status === "MUTE") {
				newMuteButton.classList.remove("nt-safe-space-btn-mute")
				newMuteButton.classList.add("nt-safe-space-btn-unmute")
				newMuteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Unmute"
			}
			newMuteButton.addEventListener("click", handleContactOptionButtonClick)
			newBlockButton.addEventListener("click", handleContactOptionButtonClick)
			newMuteButton.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
			newBlockButton.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
 
			chatContacts.appendChild(newRacerElement)
			racerCount++
		}
 
		const removeRacer = (userID) => {
			const contact = document.getElementById(`${racerContactIDPrefix}${userID}`)
			if (!contact) {
				return
			}
			contact.remove()
		}
 
		const updateUser = (userID, status, user) => {
			if (updatingDB.includes(userID)) {
				return
			}
			user = user || raceObj.state.racers.find((r) => r.userID === userID)
			if (!user) {
				logging.warn("Chat")("User not found for sync", userID)
				return
			}
			const { tag, tagColor, displayName, username, title, membership, carID, carHueAngle } = user.profile,
				carImgSrc = raceObj.props.getCarUrl(carID, false, carHueAngle, false)
			updatingDB = updatingDB.concat(userID)
			return db.users
				.put({
					id: userID,
					username,
					displayName,
					isGold: membership === "gold",
					title,
					team: tag,
					teamColor: tagColor,
					carID: tagColor,
					carHueAngle: tagColor,
					carImgSrc,
					status,
				})
				.then(() => {
					updatingDB = updatingDB.filter((uid) => uid !== userID)
					return true
				})
		}
 
		const muteUser = (userID) => {
			const user = raceObj.state.racers.find((r) => r.userID === userID)
			if (!user) {
				logging.warn("Chat")("Muting user not found", userID)
				return
			}
			updateUser(userID, "MUTE").then(() => {
				addMessage("system", user, "Has been muted =)")
				const muteButton = root.querySelector(`#${racerContactIDPrefix}${userID} .nt-safe-space-btn-mute`)
				muteButton.classList.remove("nt-safe-space-btn-mute")
				muteButton.classList.add("nt-safe-space-btn-unmute")
				muteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Unmute"
			})
		}
 
		const unmuteUser = (userID) => {
			if (updatingDB.includes(userID)) {
				return
			}
			const user = raceObj.state.racers.find((r) => r.userID === userID)
			if (!user) {
				logging.warn("Chat")("Muting user not found", userID)
				return
			}
			updatingDB = updatingDB.concat(userID)
			db.users.delete(user.userID).then(() => {
				addMessage("system", user, "Has been unmuted =)")
				updatingDB = updatingDB.filter((uid) => uid !== userID)
				const muteButton = root.querySelector(`#${racerContactIDPrefix}${userID} .nt-safe-space-btn-unmute`)
				muteButton.classList.remove("nt-safe-space-btn-unmute")
				muteButton.classList.add("nt-safe-space-btn-mute")
				muteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Mute"
			})
		}
 
		const blockUser = (userID) => {
			const user = raceObj.state.racers.find((r) => r.userID === userID)
			if (!user) {
				logging.warn("Chat")("Muting user not found", userID)
				return
			}
			updateUser(userID, "BLOCK").then(() => {
				addMessage("system", user, "Has been blocked =)")
				removeRacer(userID)
			})
		}
 
		// Chat Contact Template
		const chatContacts = root.querySelector(".nt-safe-space-contacts"),
			chatContactTemplate = document.createElement("div")
		chatContactTemplate.classList.add("nt-safe-space-contact-item")
		chatContactTemplate.innerHTML = `
			<div class="nt-safe-space-contact-item-name">
				<img class="icon icon-nt-gold-s" src="/dist/site/images/themes/profiles/gold/nt-gold-icon-xl.png" alt="Nitro Gold">
				<span class="nt-safe-space-chatroom-message-team"></span>
				<span class="nt-safe-space-chatroom-message-name nt-gold-user"></span>
				<span class="nt-safe-space-chatroom-message-friend">${friendIconSVG}</span>
			</div>
			<div class="nt-safe-space-contact-item-body">
				<div class="nt-safe-space-contact-player">
					<div class="nt-safe-space-contact-avatar">
						<img />
					</div>
					<div class="nt-safe-space-contact-speech-bubble nt-safe-space-hidden">
						<div class="nt-safe-space-contact-speech-bubble-img"></div>
					</div>
				</div>
				<div class="nt-safe-space-contact-menu">
					<button class="nt-safe-space-contact-menu-item nt-safe-space-btn nt-safe-space-btn-mute">
						<span class="nt-safe-space-contact-menu-icon">${muteIconSVG}</span>
						<span class="nt-safe-space-contact-menu-label">Mute</span>
					</button>
					<button class="nt-safe-space-contact-menu-item nt-safe-space-btn nt-safe-space-btn-block">
						<span class="nt-safe-space-contact-menu-icon">${blockIconSVG}</span>
						<span class="nt-safe-space-contact-menu-label">Block</span>
					</button>
				 </div>
			</div>`
 
		const handleContactOptionButtonClick = (e) => {
			e.preventDefault()
			const targetElement = e.target.closest(".nt-safe-space-btn"),
				userContact = e.target.closest(".nt-safe-space-contact-item"),
				targetUserID = parseInt(userContact?.dataset.user, 10)
			if (!targetUserID) {
				return
			}
			if (targetElement.classList.contains("nt-safe-space-btn-mute")) {
				muteUser(targetUserID)
			} else if (targetElement.classList.contains("nt-safe-space-btn-unmute")) {
				unmuteUser(targetUserID)
			} else if (targetElement.classList.contains("nt-safe-space-btn-block")) {
				blockUser(targetUserID)
			}
		}
 
		// Chat Message Template
		const chatMessageTemplate = document.createElement("div")
		chatMessageTemplate.classList.add("nt-safe-space-chatroom-message")
		chatMessageTemplate.innerHTML = `
			<div class="nt-safe-space-chatroom-message-heading">
				<img class="icon icon-nt-gold-s" src="/dist/site/images/themes/profiles/gold/nt-gold-icon-xl.png" alt="Nitro Gold">
				<span class="nt-safe-space-chatroom-message-team"></span>
				<span class="nt-safe-space-chatroom-message-name nt-gold-user"></span>
				<span class="nt-safe-space-chatroom-message-friend">${friendIconSVG}</span>
				<div class="nt-safe-space-chatroom-message-time"></div>
			</div>
			<div class="nt-safe-space-chatroom-message-body">
				<span class="nt-safe-space-chatroom-message-text"></span>
				<div class="nt-safe-space-chatroom-mesasge-img"></div>
			</div>`
 
		const chatNameTemplate = chatMessageTemplate
			.querySelector(".nt-safe-space-chatroom-message-heading")
			.cloneNode(true)
		chatNameTemplate.querySelector(".nt-safe-space-chatroom-message-time").remove()
 
		// Setup Custom Sticker Shortcut Handler
		const handleKeyPress = (t, n) => {
			if (t !== "keydown") {
				return false
			}
			let selectedButton
			const { key } = n
 
			if (key.toLowerCase() === "s") {
				selectedButton = buttonSticker
			} else if (key.toLowerCase() === "c" && isFriendRace) {
				selectedButton = buttonChat
			}
			if (selectedButton) {
				if (selectedButton.classList.contains("selected")) {
					selectedButton.classList.remove("selected")
					toggleChatOptions(false)
					return false
				}
				toggleChatOptions(true)
				isStickers = selectedButton.classList.contains("option-sticker")
				refreshChatOptions()
				root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((optionElement) => {
					optionElement.classList.remove("selected")
				})
				selectedButton.classList.add("selected")
				return false
			}
 
			// Handle Chat Send (if the menu is opened)
			const selected = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.selected")
			if (!selected) {
				return false
			}
			if (/^[1-8]$/.test(key) && raceKeyboardObj) {
				const index = parseInt(key - 1, 10)
				if (isStickers) {
					originalChatObj.sendMessage(userStickers[index].id, userStickers[index].src, "sticker")
				} else {
					originalChatObj.sendMessage(index, raceObj.props.chatTexts[index], "text")
				}
				flashChatButton(index)
				return false
			}
		}
 
		const addMessage = (type, user, message, imgSrc) => {
			const { userID } = user,
				{ tag, tagColor, displayName, username } = user.profile,
				isMe = userID == currentUserID,
				isGold = user.profile.membership === "gold",
				isFriend = friendIDs && friendIDs.includes(userID) ? true : user.isFriend,
				newMessageElement = chatMessageTemplate.cloneNode(true),
				stamp = new Date(),
				newMessageTeamNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-team"),
				newMessageNameNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-name"),
				newMessageTextNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-text"),
				newMessageImageNode = newMessageElement.querySelector(".nt-safe-space-chatroom-mesasge-img")
			newMessageElement.querySelector(
				".nt-safe-space-chatroom-message-time"
			).textContent = `- ${stamp.toLocaleTimeString("en-US")}`
			newMessageElement.dataset.user = userID
			newMessageNameNode.textContent = displayName || username
			newMessageTextNode.textContent = message
			if (isMe) {
				newMessageElement.classList.add("is-me")
			}
			if (tag) {
				newMessageTeamNode.textContent = `[${tag}]`
				newMessageTeamNode.style.color = `#${tagColor}`
			} else {
				newMessageTeamNode.remove()
			}
			if (!isGold) {
				newMessageNameNode.classList.remove("nt-gold-user")
				newMessageElement.querySelector(".icon-nt-gold-s").remove()
			}
			if (!isFriend) {
				newMessageElement.querySelector(".nt-safe-space-chatroom-message-friend").remove()
			}
			if (type === "system") {
				newMessageTextNode.classList.add("system-message")
			}
			if (imgSrc) {
				newMessageImageNode.style.backgroundImage = `url(${imgSrc})`
			} else {
				newMessageImageNode.remove()
			}
 
			chatMessages.appendChild(newMessageElement)
			chatMessages.scrollTop = chatMessages.scrollHeight
		}
 
		// Return Chat component
		return {
			root: root,
			systemUser,
			addRacer,
			removeRacer,
			addMessage,
			updateUser,
			assignStickers: (stickers = []) => {
				userStickers = stickers
				refreshChatOptions()
			},
			enableKeyListener: (kbObj, chatObj) => {
				if (!kbObj) {
					throw new Error("Keyboard React Object is required")
				}
				if (!chatObj) {
					throw new Error("Chat React Object is required")
				}
				raceKeyboardObj = kbObj
				originalChatObj = chatObj
				raceKeyboardObj.input.initialize({
					boundElement: raceKeyboardObj.typingInputRef.current,
					keyHandler: (t, n) => {
						let continueEvent = true
						if (!raceKeyboardObj.props.started) {
							continueEvent = handleKeyPress(t, n)
						}
						if (continueEvent) {
							raceKeyboardObj.handleKeyPress(t, n)
						}
					},
				})
			},
			disableChat: () => {
				toggleChat(false)
				if (raceKeyboardObj) {
					raceKeyboardObj.input.initialize({
						boundElement: raceKeyboardObj.typingInputRef.current,
						keyHandler: raceKeyboardObj.handleKeyPress,
					})
				}
			},
			displaySpeechBubble: (userID, imgSrc) => {
				const speechBubble = chatContacts.querySelector(
					`#${racerContactIDPrefix}${userID} .nt-safe-space-contact-speech-bubble-img`
				)
				if (speechBubble) {
					if (userSpeechBubbleTimer[userID]) {
						clearTimeout(userSpeechBubbleTimer[userID])
					}
					speechBubble.style.backgroundImage = `url(${imgSrc})`
					speechBubble.parentNode.classList.remove("nt-safe-space-hidden")
					userSpeechBubbleTimer[userID] = setTimeout(() => {
						speechBubble.parentNode.classList.add("nt-safe-space-hidden")
						userSpeechBubbleTimer[userID] = null
					}, 4e3)
				}
			},
			getChatUser: (userID) => {
				return db.users.get(userID)
			},
		}
	})(raceObj, db)
 
	/** Displays Information about Race Status and Results. */
	const InfoSection = (() => {
		const root = document.createElement("div")
		root.classList.add("nt-safe-space-info")
		root.innerHTML = `
		<div class="nt-sace-space-info-status">
			<div class="nt-safe-space-info-status-title">Setting up Typing Test</div>
			<div class="nt-safe-space-info-status-subtitle"></div>
		</div>`
 
		const status = root.querySelector(".nt-sace-space-info-status"),
			statusTitle = root.querySelector(".nt-safe-space-info-status-title"),
			statusSubTitle = root.querySelector(".nt-safe-space-info-status-subtitle")
 
		statusSubTitle.remove()
 
		const updateStatusTitle = (text) => {
			statusTitle.textContent = text
		}
 
		const updateStatusSubTitle = (text) => {
			statusSubTitle.textContent = text
			if (text) {
				status.append(statusSubTitle)
			} else {
				statusSubTitle.remove()
			}
		}
 
		const COUNTDOWN_STATES = [
			["Get Ready!", "It's Typing Test Time! Get ready..."],
			["3..."],
			["2..."],
			["1..."],
			["Let's Go!", "Go go go! GLHF!"],
		]
		let countdownTimer,
			lastCountdown = 0
 
		const updateText = (state, chat) => {
			let [status, systemChatMessage] = COUNTDOWN_STATES[state]
			systemChatMessage = systemChatMessage || status
			chat.addMessage("system", chat.systemUser, systemChatMessage)
			updateStatusTitle(status)
		}
 
		return {
			root,
			updateStatusTitle,
			updateStatusSubTitle,
			startCountdown: (chat) => {
				if (countdownTimer) {
					logging.warn("Status")("You can only initiate countdown once")
					return
				}
				lastCountdown = 0
				updateText(lastCountdown, chat)
				countdownTimer = setInterval(() => {
					if (lastCountdown + 1 < COUNTDOWN_STATES.length - 1) {
						updateText(++lastCountdown, chat)
					}
				}, 1e3)
			},
			stopCountdown: (chat) => {
				clearTimeout(countdownTimer)
				lastCountdown = COUNTDOWN_STATES.length - 1
				updateText(lastCountdown, chat)
			},
		}
	})()
 
	////////////////////////
	//  Backend Handling  //
	////////////////////////
 
	let disqualifiedUsers = [],
		reloadRaceRequested = false,
		canReloadRace = false,
		typingTestLoadingProgress = 0.0
 
	const server = raceObj.server,
		currentUserID = raceObj.props.user.userID,
		friendIDs = raceObj.props.friendIDs,
		stickerList = raceObj.stickers,
		chatTextList = raceObj.props.chatTexts
 
	/** Key Event handler to allow early race reloading. */
	const nextRaceASAPKeyHandler = (e) => {
		if (e.key === "Enter") {
			window.removeEventListener("keypress", nextRaceASAPKeyHandler)
			ChatRoom.addMessage("system", ChatRoom.systemUser, "No don't leave me :(")
			if (canReloadRace) {
				InfoSection.updateStatusTitle("Starting new race...")
				raceObj.raceAgain(e)
				return
			}
			reloadRaceRequested = true
			InfoSection.updateStatusTitle("Starting new race...")
		}
	}
 
	// Setup User's stickers
	ChatRoom.assignStickers(
		raceObj.userStickers
			.filter((s) => s.equipped)
			.map((s) => ({
				id: s.lootID,
				name: s.name,
				src: s.options.src,
			}))
	)
 
	// Track Race Status
	server.on("status", (e) => {
		const raceStatus = e.status
		if (raceStatus === "countdown") {
			logging.info("Racing")("Start countdown")
			InfoSection.updateStatusSubTitle(``)
			InfoSection.startCountdown(ChatRoom)
		} else if (raceStatus === "racing") {
			logging.info("Racing")("Start racing")
			InfoSection.stopCountdown(ChatRoom)
			ChatRoom.disableChat()
 
			const lastLetter = raceContainer.querySelector(
				".dash-copy .dash-word:last-of-type .dash-letter:nth-last-of-type(2)"
			)
			if (lastLetter) {
				lastLetterObserver.observe(lastLetter, { attributes: true })
			} else {
				logging.warn("Init")("Unable to setup finish race tracker")
			}
		}
	})
 
	// Track New Racers
	server.on("joined", (user) => {
		if (!raceObj.state.friendsRace) {
			typingTestLoadingProgress = Math.min(0.99, raceObj.state.racers.length / 5.0) * 100
			InfoSection.updateStatusSubTitle(`Loading test... ${typingTestLoadingProgress.toFixed(2)}%`)
		}
		if (user.robot) {
			return
		}
		ChatRoom.getChatUser(user.userID).then((data) => {
			if (!data || data.status !== "BLOCK") {
				ChatRoom.addRacer(user, data?.status)
				ChatRoom.addMessage("system", user, "Has joined the chatroom")
			}
			if (data?.status === "MUTE") {
				ChatRoom.addMessage("system", user, "Has been muted =)")
			}
			if (data?.status === "BLOCK") {
				logging.info("Chat")("This user is blocked", JSON.stringify(user))
			}
			if (data) {
				ChatRoom.updateUser(user.userID, data.status, user).then(() => {
					logging.info("Chat")(`Sync user details (${data.status})`, JSON.stringify(user))
				})
			}
		})
	})
 
	// Track New Chat Messages
	server.on("chat", (e) => {
		const user = raceObj.state.racers.find((r) => r.userID === e.from)
		if (!user) {
			logging.warn("Chat")("Received message from unknown user", JSON.stringify(e))
			return
		}
		ChatRoom.getChatUser(user.userID).then((data) => {
			let message, imgSrc
			if (e.chatType === "sticker" && stickerList) {
				const sticker = stickerList.find((s) => s.lootID === e.chatID)
				if (sticker) {
					message = sticker.name
					imgSrc = sticker.options.src
				}
			} else if (e.chatType === "text" && chatTextList) {
				message = chatTextList[e.chatID]
				imgSrc = `/dist/site/images/chat/canned/chat_${e.chatID}.png`
			} else {
				message = "???"
			}
			if (!data || !["MUTE", "BLOCK"].includes(data.status)) {
				ChatRoom.addMessage("msg", user, message, imgSrc)
				ChatRoom.displaySpeechBubble(user.userID, imgSrc)
			} else {
				logging.info("Chat")(`${data.status} message received`, JSON.stringify({ ...e, message, imgSrc }))
			}
		})
	})
 
	// Track Racing Updates for disqualify and completion
	server.on("update", (e) => {
		e?.racers?.forEach((user) => {
			if (!user.robot && user.disqualified && !disqualifiedUsers.includes(user.userID)) {
				disqualifiedUsers = disqualifiedUsers.concat(user.userID)
				ChatRoom.getChatUser(user.userID).then((data) => {
					if (data?.status !== "BLOCK") {
						ChatRoom.addMessage("system", user, "Has left the chatroom =(")
					}
				})
			}
			if (
				user.userID === currentUserID &&
				user.progress.completeStamp > 0 &&
				user.profile &&
				!canReloadRace &&
				!raceContainer.querySelector(".race-results")
			) {
				if (reloadRaceRequested) {
					InfoSection.updateStatusTitle("Starting new race...")
					raceObj.raceAgain(e)
				} else {
					canReloadRace = true
				}
			}
		})
	})
 
	/*
	// Track Players Leaving (Friend Race?)
	// This doesn't work, Nitro Type doesn't put in the player that left
	server.on("left", (e) => {
		logging.debug("Test")("Left Payload", e)
	})
	*/
 
	/** Rank suffixes for Race Result. */
	const RANK_SUFFIX = ["st", "nd", "rd"]
 
	/** Mutation obverser to track whether results screen showed up. */
	const resultObserver = new MutationObserver(([mutation], observer) => {
		for (const newNode of mutation.addedNodes) {
			if (newNode.classList.contains("race-results")) {
				observer.disconnect()
				window.removeEventListener("keypress", nextRaceASAPKeyHandler)
 
				const currentUserResult = raceObj.state.racers.find((r) => r.userID === currentUserID)
				if (
					!currentUserResult ||
					!currentUserResult.progress ||
					typeof currentUserResult.place === "undefined"
				) {
					logging.warn("Finish")("Unable to find race results")
					return
				}
 
				const resultMain = raceContainer.querySelector(".raceResults"),
					resultContainer = resultMain.parentNode,
					obj = resultContainer ? findReact(resultContainer) : null
				if (!resultContainer || !obj) {
					logging.warn("Finish")("Unable to hide result screen by default")
					return
				}
				resultMain.style.marginLeft = "-10000px"
				resultContainer.classList.add("is-minimized", "has-minimized")
				obj.state.isHidden = true
				obj.state.hasMinimized = true
 
				setTimeout(() => {
					resultMain.style.marginLeft = ""
				}, 500)
 
				const { typed, skipped, startStamp, completeStamp, errors } = currentUserResult.progress,
					wpm = Math.round((typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4)),
					acc = ((1 - errors / (typed - skipped)) * 100).toFixed(2),
					points = Math.round((100 + wpm / 2) * (1 - errors / typed)),
					place = currentUserResult.place,
					rankSuffix = place >= 1 && place <= 3 ? RANK_SUFFIX[place - 1] : "th"
				InfoSection.updateStatusTitle("Race Results")
				InfoSection.updateStatusSubTitle(`${place}${rankSuffix} | ${acc}% Acc | ${wpm} WPM | ${points} points`)
 
				logging.info("Finish")("Display Alternative Result Screen")
				break
			}
		}
	})
 
	/** Mutation observer to track if racer finished. */
	const lastLetterObserver = new MutationObserver(([mutation], observer) => {
		if (mutation.target.classList.contains("is-correct")) {
			observer.disconnect()
			window.addEventListener("keypress", nextRaceASAPKeyHandler)
			InfoSection.updateStatusTitle("Finished")
			ChatRoom.addMessage("system", ChatRoom.systemUser, "Done! Time to review your result :)")
			resultObserver.observe(raceContainer, { childList: true })
		}
	})
 
	/////////////
	//  Final  //
	/////////////
 
	// Remove chat
	const chatBubbleObserver = new MutationObserver(([mutation], observer) => {
		for (const node of mutation.addedNodes) {
			if (node.classList.contains("raceChat")) {
				observer.disconnect()
				const raceKeyboardObj = findReact(raceContainer.querySelector(".dash-copy-input")),
					originalChatObj = findReact(node)
				node.style.display = "none"
				if (raceKeyboardObj && originalChatObj) {
					ChatRoom.enableKeyListener(raceKeyboardObj, originalChatObj)
				} else {
					logging.warn("Init")("Unable to overwrite chat system")
				}
				break
			}
		}
	})
	chatBubbleObserver.observe(raceContainer, { childList: true })
 
	// Setup Race Track
	const root = document.createElement("div")
	root.classList.add("nt-safe-space-root")
	root.append(InfoSection.root, ChatRoom.root)
 
	// Replace Race Track
	canvasTrack.replaceWith(root)
 
	logging.info("Init")("Race Track has been updated")
}