您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Replaces the Race Track with a Typing Test Lobby Chatroom. Choose which users to mute and block, it's your "safe space".
// ==UserScript== // @name Nitro Type - Safe Space // @version 0.6.1 // @description Replaces the Race Track with a Typing Test Lobby Chatroom. Choose which users to mute and block, it's your "safe space". // @author Toonidy // @match *://*.nitrotype.com/race // @match *://*.nitrotype.com/race/* // @match *://*.nitrotype.com/profile // @icon https://i.ibb.co/YRs06pc/toonidy-userscript.png // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js#sha512-ybuxSW2YL5rQG/JjACOUKLiosgV80VUfJWs4dOpmSWZEGwdfdsy2ldvDSQ806dDXGmg9j/csNycIbqsrcqW6tQ== // @require https://greasyfork.org/scripts/443718-nitro-type-userscript-utils/code/Nitro%20Type%20Userscript%20Utils.js?version=1042360 // @license MIT // @namespace https://greasyfork.org/users/858426 // ==/UserScript== /* globals Dexie findReact createLogger */ const logging = createLogger("Nitro Type Safe Space") // 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", e) }) ///////////////////// // 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", () => { const currentActiveButton = sideMenu.querySelector(".btn.is-active") if (currentActiveButton) { currentActiveButton.classList.remove("is-active") } menuSafeSpaceButton.classList.add("is-active") originalSettingRoot.replaceWith(safeSpaceSettingRoot) }) const handleOriginalMenuButtonClick = () => { 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 } if (!raceObj.props.user.loggedIn) { logging.error("Init")("Safe Space is not available for Guest Racing") return } ////////////// // Styles // ////////////// const style = document.createElement("style") style.appendChild( document.createTextNode(` :root { --chat-contacts-width: 265px; } .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: 8px; top: 8px; bottom: 8px; right: 633px; display: flex; flex-direction: column; border-radius: 8px; color: #eee; background-color: #303030; transition: 0.3s right ease; } .nt-safe-space-chat-contacts-hidden .nt-safe-space-info { right: calc(617px - var(--chat-contacts-width)); } .nt-safe-space-info-status { display: flex; flex-direction: column; flex-grow: 1; justify-content: center; align-items: center; } .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; } .nt-safe-space-info-status-wampus { display: flex; justify-content: center; margin-top: 1rem; } .nt-safe-space-info-status-wampus img { width: 100px; height: 64px; } .nt-safe-space-info-footer { position: relative; height: 138px; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } .nt-safe-space-info-footer .nt-safe-space-contact-item { position: absolute; right: 8px; bottom: 8px; padding: 8px; border-radius: 8px; } /* Chat */ .nt-safe-space-chat { position: absolute; left: 400px; right: 8px; top: 8px; bottom: 8px; z-index: 5; display: flex; border-radius: 8px; overflow: hidden; transition: 0.3s left ease; } .nt-safe-space-chat-contacts-hidden .nt-safe-space-chat { left: calc(415px + var(--chat-contacts-width)); } /* Chat Contacts */ .nt-safe-space-contacts { display: flex; flex-direction: column; width: var(--chat-contacts-width); border-top-left-radius: 8px; border-bottom-left-radius: 8px; border-right-width: 1px; border-right-style: solid; border-right-color: #34344a; background-color: #0b0b10; color: #fff; transition-duration: 0.3s; transition-property: width, border-right-width; transition-timing-function: ease; overflow: hidden; } .nt-safe-space-chat-contacts-hidden .nt-safe-space-contacts { border-right-width: 0px; width: 0px; } .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: 210px; transition: height 0.2s ease; } .nt-safe-space-chatroom.hide-reply-options .nt-safe-space-chatroom-messages { height: 344px; } .nt-safe-space-chatroom.disable-reply .nt-safe-space-chatroom-messages { height: 384px; } .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; 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.hide-reply-options .nt-safe-space-chatroom-reply-toolbar-option { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } .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) const root = document.createElement("div") root.classList.add("nt-safe-space-root", "nt-safe-space-chat-contacts-hidden") ////////////////// // Components // ////////////////// /** Display a chatroom with messages. */ const ChatRoom = ((safeSpaceContainer, 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 = [], userData = {}, 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.parentNode.classList.remove("hide-reply-options") } else { chatMessages.parentNode.parentNode.classList.add("hide-reply-options") } } const toggleChat = (show) => { if (show) { chatMessages.parentNode.parentNode.classList.remove("disable-reply") } else { chatMessages.parentNode.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").remove() } 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 || isMe) { 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) userData[userID] = user if (!isMe) { chatContacts.appendChild(newRacerElement) racerCount++ safeSpaceContainer.classList.remove("nt-safe-space-chat-contacts-hidden") } return newRacerElement } const removeRacer = (userID) => { const contact = document.getElementById(`${racerContactIDPrefix}${userID}`) if (!contact || contact.classList.contains("is-me") || userID === currentUserID) { return } contact.remove() racerCount-- if (racerCount === 0) { safeSpaceContainer.classList.add("nt-safe-space-chat-contacts-hidden") } root.querySelectorAll(".nt-safe-space-contact-item").forEach((node, i) => { if (i % 2 !== 0) { node.classList.add("alt-row") } else { node.classList.remove("alt-row") } }) } const getRacer = (userID) => { return userData[userID] } 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, getRacer, 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") } originalChatObj = chatObj raceKeyboardObj = kbObj 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 = document.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) }, } })(root, 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-safe-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 class="nt-safe-space-info-status-wampus"><img src="/images/loot/sticker_1630508306.png" alt="Laughing Wampus" /></div> </div> <div class="nt-safe-space-info-footer"> </div>` const status = root.querySelector(".nt-safe-space-info-status"), statusTitle = root.querySelector(".nt-safe-space-info-status-title"), statusSubTitle = root.querySelector(".nt-safe-space-info-status-subtitle"), statusWampus = root.querySelector(".nt-safe-space-info-status-wampus"), statusFooter = root.querySelector(".nt-safe-space-info-footer") statusSubTitle.remove() statusWampus.remove() const updateStatusTitle = (text) => { statusTitle.textContent = text } const updateStatusSubTitle = (text) => { if (text) { statusSubTitle.textContent = text if (statusWampus.isConnected) { statusWampus.before(statusSubTitle) } else { status.append(statusSubTitle) } } else { statusSubTitle.remove() } } const updatePlayer = (node) => { statusFooter.append(node) } const toggleWampus = (show) => { if (show) { status.append(statusWampus) } else { statusWampus.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, updatePlayer, toggleWampus, 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, isWampusRace = false const server = raceObj.server, currentUserID = raceObj.props.user.userID, friendIDs = raceObj.props.friendIDs, stickerList = raceObj.stickers, chatTextList = raceObj.props.chatTexts /** Reload next race earlier than usual. */ const requestNextRaceASAP = (e) => { ChatRoom.addMessage("system", ChatRoom.systemUser, "No don't leave me :(") if (canReloadRace) { InfoSection.updateStatusSubTitle("Starting new race...") raceObj.raceAgain(e) return } reloadRaceRequested = true InfoSection.updateStatusSubTitle("Loading new race, please wait...") } /** Key Event handler to allow early race reloading. */ const nextRaceASAPKeyHandler = (e) => { if (e.key === "Enter") { window.removeEventListener("keypress", nextRaceASAPKeyHandler) requestNextRaceASAP(e) } } // Setup User's stickers let userStickers = raceObj.userStickers .filter((s) => s.equipped) .sort((a, b) => a.equipped - b.equipped) .map((s) => s.lootID) if (userStickers.length === 0) { userStickers = raceObj.props.lootConfig.sticker.defaults } ChatRoom.assignStickers( userStickers.map((id) => { const s = raceObj.props.loot.find((s) => s.lootID === id) return { id, name: s.name, src: s.options.src, } }) ) // Track Speed Range and Race Mode server.on("setup", (e) => { if (typeof e.trackLeader === "string" && e.trackLeader !== "") { if (e.trackLeader === raceObj.props.user.username) { InfoSection.updateStatusTitle("Creating Friendly Typing Test") } else { InfoSection.updateStatusTitle("Joining Friendly Typing Test") } } let subTitle = "" if ((typeof e.trackLeader !== "string" || e.trackLeader !== raceObj.props.user.username) && e.scores && e.scores.length === 2) { const [from, to] = e.scores subTitle = `Speed Range: ${from} WPM - ${to} WPM` } else { subTitle = `${e.scoringMode.toUpperCase()} mode` server.on("settings", (e) => { InfoSection.updateStatusSubTitle(`${e.scoringMode.toUpperCase()} mode`) }) } if (subTitle) { InfoSection.updateStatusSubTitle(subTitle) } }) // Track Race Status server.on("status", (e) => { const raceStatus = e.status if (raceStatus === "countdown") { logging.info("Racing")("Start countdown") if (isWampusRace) { InfoSection.toggleWampus(false) } 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 (user.robot) { if (user.profile.specialRobot === "wampus") { isWampusRace = true InfoSection.toggleWampus(true) } return } ChatRoom.getChatUser(user.userID).then((data) => { if (!data || data.status !== "BLOCK") { const chatNode = ChatRoom.addRacer(user, data?.status) if (user.userID === currentUserID) { InfoSection.updatePlayer(chatNode) } 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 Players Leaving (Friend Race) server.on("left", (e) => { if (!e) { return } ChatRoom.getChatUser(e).then((data) => { if (data?.status !== "BLOCK") { const user = ChatRoom.getRacer(e) if (user) { ChatRoom.addMessage("system", user, "Has left the chatroom =(") } } }) ChatRoom.removeRacer(e) }) // 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.updateStatusSubTitle("Starting new race...") raceObj.raceAgain(e) } else { canReloadRace = true } } }) }) /** 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)), time = ((completeStamp - startStamp) / 1e3).toFixed(2), acc = ((1 - errors / (typed - skipped)) * 100).toFixed(2), points = Math.round((100 + wpm / 2) * (1 - errors / (typed - skipped))), 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 | ${time} secs`) logging.info("Finish")("Display Alternative Result Screen") break } } }) /** Mutation observer to track whether last letter was typed (just finished race). */ const lastLetterObserver = new MutationObserver(([mutation], observer) => { if (mutation.target.classList.contains("is-correct")) { observer.disconnect() window.addEventListener("keypress", nextRaceASAPKeyHandler) InfoSection.updateStatusTitle("Finished") if (isWampusRace) { InfoSection.toggleWampus(true) } ChatRoom.addMessage("system", ChatRoom.systemUser, "Done! Time to review your result :)") resultObserver.observe(raceContainer, { childList: true }) } }) ///////////// // Final // ///////////// // Hide chat (Nitro Type will break if the DOM element is removed) const registerChatNode = (node, inputNode) => { if (!node?.classList?.contains("raceChat")) { if (node !== null) { logging.warn("Init")("Invalid node element for registering chat.") } return } const raceKeyboardObj = findReact(inputNode), originalChatObj = findReact(node) node.style.display = "none" if (raceKeyboardObj && originalChatObj) { ChatRoom.enableKeyListener(raceKeyboardObj, originalChatObj) } else { logging.warn("Init")("Unable to overwrite chat system") } } const typingInputObserver = new MutationObserver((mutations, observer) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.classList?.contains("dash-copy-input")) { observer.disconnect() const raceChatNode = raceContainer.querySelector(".raceChat") if (raceChatNode) { registerChatNode(raceChatNode, node) } else { logging.warn("Init")("Unable to overwrite chat system") } break } } } }) const raceChatNode = raceContainer.querySelector(".raceChat"), typingInputNode = raceContainer.querySelector(".dash-copy-input") if (raceChatNode && typingInputNode) { registerChatNode(raceChatNode, typingInputNode) } else { typingInputObserver.observe(raceContainer.querySelector(".dash-center"), { childList: true }) } // Setup Race Track root.append(InfoSection.root, ChatRoom.root) // Replace Race Track canvasTrack.replaceWith(root) logging.info("Init")("Race Track has been updated") }