Nitro Type - Safe Space

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

  1. // ==UserScript==
  2. // @name Nitro Type - Safe Space
  3. // @version 0.6.1
  4. // @description Replaces the Race Track with a Typing Test Lobby Chatroom. Choose which users to mute and block, it's your "safe space".
  5. // @author Toonidy
  6. // @match *://*.nitrotype.com/race
  7. // @match *://*.nitrotype.com/race/*
  8. // @match *://*.nitrotype.com/profile
  9. // @icon https://i.ibb.co/YRs06pc/toonidy-userscript.png
  10. // @grant none
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js#sha512-ybuxSW2YL5rQG/JjACOUKLiosgV80VUfJWs4dOpmSWZEGwdfdsy2ldvDSQ806dDXGmg9j/csNycIbqsrcqW6tQ==
  12. // @require https://greasyfork.org/scripts/443718-nitro-type-userscript-utils/code/Nitro%20Type%20Userscript%20Utils.js?version=1042360
  13. // @license MIT
  14. // @namespace https://greasyfork.org/users/858426
  15. // ==/UserScript==
  16. /* globals Dexie findReact createLogger */
  17. const logging = createLogger("Nitro Type Safe Space")
  18. // Config storage
  19. const db = new Dexie("NTSafeSpace")
  20. db.version(1).stores({
  21. users: "id, &username, team, displayName, status",
  22. })
  23. db.open().catch(function (e) {
  24. logging.error("Init")("Failed to open up the config database", e)
  25. })
  26. /////////////////////
  27. // Settings Page //
  28. /////////////////////
  29. if (window.location.pathname === "/profile") {
  30. //////////////////
  31. // Components //
  32. //////////////////
  33. const safeSpaceSettingRoot = document.createElement("div")
  34. safeSpaceSettingRoot.classList.add("g-b", "g-b--9of12")
  35. safeSpaceSettingRoot.innerHTML = `
  36. <h2 class="tbs">Nitro Type Safe Space Settings</h2>
  37. <p class="tc-ts">Manage settings from this Userscript.</p>
  38. <p class="input-label">Mute/Blocked Users<p>
  39. <table class="table table--selectable table--striped">
  40. <thead class="table-head">
  41. <tr class="table-row">
  42. <th scope="col" class="table-cell table-cell--racer">Racer</th>
  43. <th scope="col" class="table-cell table-cell--status">Status</th>
  44. <th scope="col" class="table-cell table-cell--remove" style="width: 90px">Remove?</th>
  45. </tr>
  46. </thead>
  47. <tbody class="table-body">
  48. </tbody>
  49. </table>`
  50. const userTableBody = safeSpaceSettingRoot.querySelector("tbody.table-body")
  51. const userRow = document.createElement("tr")
  52. userRow.classList.add("table-row")
  53. userRow.innerHTML = `
  54. <td class="table-cell table-cell--racer">
  55. <div class="bucket bucket--s bucket--c">
  56. <div class="bucket-media bucket-media--w90">
  57. <img class="img--noMax db">
  58. </div>
  59. <div class="bucket-content">
  60. <div class="df df--align-center">
  61. <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>
  62. <div class="prxxs df df--align-center">
  63. <a class="link link--bare mrxxs twb" style="color: rgb(253, 182, 77);"></a>
  64. <span class="type-ellip type-gold tss"></span>
  65. </div>
  66. </div>
  67. <div class="tsi tc-lemon tsxs"></div>
  68. </div>
  69. </div>
  70. </td>
  71. <td class="table-cell table-cell--status">
  72. <select class="input-select">
  73. <option value="MUTE">Muted</option>
  74. <option value="BLOCK">Blocked</option>
  75. </select>
  76. </td>
  77. <td class="table-cell table-cell--remove tar prs">
  78. <button title="Remove Block/Mute User" type="button" class="btn btn--negative">
  79. <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>
  80. </button>
  81. </td>`
  82. const handleRowClick = (e) => {
  83. const row = e.target.closest(".table-row"),
  84. input = e.target.closest("a, button, select"),
  85. userID = row && !input ? parseInt(row.dataset.user, 10) : null
  86. if (userID !== null && !isNaN(userID)) {
  87. db.users.get(userID).then((user) => {
  88. window.location.href = `/racer/${user.username}`
  89. })
  90. }
  91. }
  92. const handleStatusUpdateChange = (e) => {
  93. const targetElement = e.target.closest("select"),
  94. row = e.target.closest(".table-row"),
  95. userID = row ? parseInt(row.dataset.user, 10) : null
  96. if (userID !== null && !isNaN(userID)) {
  97. db.users.update(userID, { status: targetElement.value })
  98. }
  99. }
  100. const handleRemoveButtonClick = (e) => {
  101. const row = e.target.closest(".table-row"),
  102. userID = row ? parseInt(row.dataset.user, 10) : null
  103. if (userID !== null && !isNaN(userID)) {
  104. db.users.delete(userID).then(() => row.remove())
  105. }
  106. }
  107. db.users.count().then((total) => {
  108. if (total === 0) {
  109. const emptyRow = document.createElement("tr")
  110. emptyRow.classList.add("table-row")
  111. emptyRow.innerHTML = `<td class="table-cell" colspan="3">No racers found</td>`
  112. userTableBody.append(emptyRow)
  113. userTableBody.parentNode.classList.remove("table--selectable")
  114. return
  115. }
  116. const rowFragment = document.createDocumentFragment()
  117. db.users
  118. .each((userData) => {
  119. const row = userRow.cloneNode(true),
  120. carImage = row.querySelector("img.img--noMax"),
  121. teamLink = row.querySelector("a.link"),
  122. racerName = row.querySelector(".type-ellip"),
  123. statusSelect = row.querySelector("select"),
  124. removeButton = row.querySelector("button"),
  125. displayName = userData.displayName || userData.username
  126. row.dataset.user = userData.id
  127. row.addEventListener("click", handleRowClick)
  128. carImage.src = userData.carImgSrc
  129. carImage.alt = `${displayName}'s car`
  130. teamLink.parentNode.title = displayName
  131. racerName.textContent = `${userData.team ? " " : ""}${displayName}`
  132. row.querySelector(".tsi").textContent = `"${userData.title}"`
  133. if (!userData.team) {
  134. teamLink.remove()
  135. } else {
  136. teamLink.textContent = `[${userData.team}]`
  137. teamLink.href = `/team/${userData.team}`
  138. teamLink.style.color = `#${userData.teamColor}`
  139. }
  140. if (!userData.isGold) {
  141. row.querySelector(".icon-nt-gold-s").parentNode.remove()
  142. racerName.classList.remove("type-gold")
  143. }
  144. statusSelect.value = userData.status
  145. statusSelect.addEventListener("change", handleStatusUpdateChange)
  146. removeButton.addEventListener("click", handleRemoveButtonClick)
  147. rowFragment.append(row)
  148. })
  149. .then(() => {
  150. userTableBody.append(rowFragment)
  151. })
  152. })
  153. /////////////
  154. // Final //
  155. /////////////
  156. /** Mutation observer to check whether setting page has loaded. */
  157. const settingPageObserver = new MutationObserver(([mutation], observer) => {
  158. const sideMenu = mutation.target.querySelector(".has-btn"),
  159. originalSettingRoot = mutation.target.querySelector(".g-b.g-b--9of12")
  160. if (sideMenu && originalSettingRoot) {
  161. observer.disconnect()
  162. const menuSafeSpaceButton = document.createElement("button")
  163. menuSafeSpaceButton.classList.add("btn", "btn--fw")
  164. menuSafeSpaceButton.textContent = "Nitro Type Safe Space"
  165. menuSafeSpaceButton.addEventListener("click", () => {
  166. const currentActiveButton = sideMenu.querySelector(".btn.is-active")
  167. if (currentActiveButton) {
  168. currentActiveButton.classList.remove("is-active")
  169. }
  170. menuSafeSpaceButton.classList.add("is-active")
  171. originalSettingRoot.replaceWith(safeSpaceSettingRoot)
  172. })
  173. const handleOriginalMenuButtonClick = () => {
  174. menuSafeSpaceButton.classList.remove("is-active")
  175. safeSpaceSettingRoot.replaceWith(originalSettingRoot)
  176. }
  177. sideMenu.querySelectorAll(".btn").forEach((node) => {
  178. node.addEventListener("click", handleOriginalMenuButtonClick)
  179. })
  180. sideMenu.append(menuSafeSpaceButton)
  181. }
  182. })
  183. settingPageObserver.observe(document.querySelector("main.structure-content"), { childList: true })
  184. return
  185. }
  186. ///////////////////
  187. // Racing Page //
  188. ///////////////////
  189. if (window.location.pathname === "/race" || window.location.pathname.startsWith("/race/")) {
  190. const raceContainer = document.getElementById("raceContainer"),
  191. canvasTrack = raceContainer?.querySelector("canvas"),
  192. raceObj = raceContainer ? findReact(raceContainer) : null
  193. if (!raceContainer || !canvasTrack || !raceObj) {
  194. logging.error("Init")("Could not find the race track")
  195. return
  196. }
  197. if (!raceObj.props.user.loggedIn) {
  198. logging.error("Init")("Safe Space is not available for Guest Racing")
  199. return
  200. }
  201. //////////////
  202. // Styles //
  203. //////////////
  204. const style = document.createElement("style")
  205. style.appendChild(
  206. document.createTextNode(`
  207. :root {
  208. --chat-contacts-width: 265px;
  209. }
  210. .nt-safe-space-root {
  211. position: relative;
  212. box-sizing: border-box;
  213. width: 1024px;
  214. height: 400px;
  215. background-color: #202020;
  216. }
  217. /* Some Overrides */
  218. .race-results {
  219. z-index: 6;
  220. }
  221. /* Info Section */
  222. .nt-safe-space-info {
  223. position: absolute;
  224. left: 8px;
  225. top: 8px;
  226. bottom: 8px;
  227. right: 633px;
  228. display: flex;
  229. flex-direction: column;
  230. border-radius: 8px;
  231. color: #eee;
  232. background-color: #303030;
  233. transition: 0.3s right ease;
  234. }
  235. .nt-safe-space-chat-contacts-hidden .nt-safe-space-info {
  236. right: calc(617px - var(--chat-contacts-width));
  237. }
  238. .nt-safe-space-info-status {
  239. display: flex;
  240. flex-direction: column;
  241. flex-grow: 1;
  242. justify-content: center;
  243. align-items: center;
  244. }
  245. .nt-safe-space-info-status-title {
  246. font-size: 24px;
  247. font-weight: 600;
  248. text-align: center;
  249. margin-bottom: 14px;
  250. }
  251. .nt-safe-space-info-status-subtitle {
  252. font-size: 14px;
  253. text-align: center;
  254. }
  255. .nt-safe-space-info-status-wampus {
  256. display: flex;
  257. justify-content: center;
  258. margin-top: 1rem;
  259. }
  260. .nt-safe-space-info-status-wampus img {
  261. width: 100px;
  262. height: 64px;
  263. }
  264. .nt-safe-space-info-footer {
  265. position: relative;
  266. height: 138px;
  267. border-bottom-left-radius: 8px;
  268. border-bottom-right-radius: 8px;
  269. }
  270. .nt-safe-space-info-footer .nt-safe-space-contact-item {
  271. position: absolute;
  272. right: 8px;
  273. bottom: 8px;
  274. padding: 8px;
  275. border-radius: 8px;
  276. }
  277. /* Chat */
  278. .nt-safe-space-chat {
  279. position: absolute;
  280. left: 400px;
  281. right: 8px;
  282. top: 8px;
  283. bottom: 8px;
  284. z-index: 5;
  285. display: flex;
  286. border-radius: 8px;
  287. overflow: hidden;
  288. transition: 0.3s left ease;
  289. }
  290. .nt-safe-space-chat-contacts-hidden .nt-safe-space-chat {
  291. left: calc(415px + var(--chat-contacts-width));
  292. }
  293. /* Chat Contacts */
  294. .nt-safe-space-contacts {
  295. display: flex;
  296. flex-direction: column;
  297. width: var(--chat-contacts-width);
  298. border-top-left-radius: 8px;
  299. border-bottom-left-radius: 8px;
  300. border-right-width: 1px;
  301. border-right-style: solid;
  302. border-right-color: #34344a;
  303. background-color: #0b0b10;
  304. color: #fff;
  305. transition-duration: 0.3s;
  306. transition-property: width, border-right-width;
  307. transition-timing-function: ease;
  308. overflow: hidden;
  309. }
  310. .nt-safe-space-chat-contacts-hidden .nt-safe-space-contacts {
  311. border-right-width: 0px;
  312. width: 0px;
  313. }
  314. .nt-safe-space-contact-item {
  315. padding: 2px 8px;
  316. border-bottom: 1px solid #20202e;
  317. background-color: #111218;
  318. }
  319. .nt-safe-space-contact-item:hover {
  320. background-color: #181822;
  321. }
  322. .nt-safe-space-contact-item:first-of-type {
  323. padding-top: 8px;
  324. }
  325. .nt-safe-space-contact-item:nth-child(4) {
  326. padding-bottom: 8px;
  327. border-bottom: 0;
  328. }
  329. .nt-safe-space-contact-item.alt-row {
  330. background-color: #181a22;
  331. }
  332. .nt-safe-space-contact-item.alt-row:hover {
  333. background-color: #20212c;
  334. }
  335. .nt-safe-space-contact-item-body {
  336. display: flex;
  337. justify-content: space-between;
  338. align-items: center;
  339. }
  340. .nt-safe-space-contact-player {
  341. display: flex;
  342. align-items: center;
  343. flex-grow: 1;
  344. }
  345. .nt-safe-space-contact-avatar {
  346. display: flex;
  347. width: 64px;
  348. height: 64px;
  349. overflow: hidden;
  350. margin-right: 4px;
  351. }
  352. .nt-safe-space-contact-avatar img {
  353. margin: auto;
  354. max-width: 100%;
  355. max-height: 100%;
  356. }
  357. .nt-safe-space-contact-speech-bubble {
  358. position: relative;
  359. background: #fff;
  360. border-radius: 8px;
  361. padding: 4px;
  362. margin-left: 10px;
  363. transition: opacity 0.2s ease;
  364. opacity: 1;
  365. }
  366. .nt-safe-space-contact-speech-bubble.nt-safe-space-hidden {
  367. opacity: 0;
  368. }
  369. .nt-safe-space-contact-speech-bubble:after {
  370. content: '';
  371. position: absolute;
  372. left: 0;
  373. top: 50%;
  374. width: 0;
  375. height: 0;
  376. border: 10px solid transparent;
  377. border-right-color: #fff;
  378. border-left: 0;
  379. margin-top: -10px;
  380. margin-left: -10px;
  381. }
  382. .nt-safe-space-contact-speech-bubble-img {
  383. background-repeat: no-repeat;
  384. background-size: contain;
  385. background-position: center;
  386. width: 48px;
  387. height: 48px;
  388. }
  389. .nt-safe-space-contact-item-name {
  390. display: flex;
  391. align-items: center;
  392. font-size: 12px;
  393. font-weight: 600;
  394. margin-bottom: 4px;
  395. }
  396. .nt-safe-space-contact-menu {
  397. display: flex;
  398. flex-direction: column;
  399. font-size: 10px;
  400. }
  401. .nt-safe-space-contact-menu-item {
  402. display: flex;
  403. align-items: center;
  404. padding: 4px;
  405. margin-bottom: 2px;
  406. border-radius: 4px;
  407. width: 80px;
  408. cursor: pointer;
  409. }
  410. .nt-safe-space-contact-menu-item:hover {
  411. background-color: rgba(255, 255, 255, 0.1);
  412. }
  413. .nt-safe-space-contact-menu-icon {
  414. margin-right: 8px;
  415. }
  416. /* Chat Messages Container */
  417. .nt-safe-space-chatroom {
  418. flex-grow: 1;
  419. background-color: #20222e;
  420. background-image: url(/dist/site/images/backgrounds/bg-noise.png)
  421. }
  422. .nt-safe-space-chatroom-messages {
  423. position: relative;
  424. height: 210px;
  425. transition: height 0.2s ease;
  426. }
  427. .nt-safe-space-chatroom.hide-reply-options .nt-safe-space-chatroom-messages {
  428. height: 344px;
  429. }
  430. .nt-safe-space-chatroom.disable-reply .nt-safe-space-chatroom-messages {
  431. height: 384px;
  432. }
  433. .nt-safe-space-chatroom-messages-scrollable {
  434. position: absolute;
  435. left: 8px;
  436. right: 8px;
  437. top: 8px;
  438. bottom: 8px;
  439. display: flex;
  440. flex-direction: column;
  441. overflow-y: auto;
  442. scrollbar-face-color: #fff;
  443. scrollbar-track-color: #000;
  444. color: #eee;
  445. font-size: 12px;
  446. }
  447. .nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar {
  448. width: 4px;
  449. height: 4px;
  450. }
  451. .nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar-thumb {
  452. background: #fff;
  453. }
  454. .nt-safe-space-chatroom-messages-scrollable::-webkit-scrollbar-track {
  455. background: #000;
  456. }
  457. /* Chat Message Item */
  458. .nt-safe-space-chatroom-message {
  459. margin-top: auto;
  460. margin-bottom: 16px;
  461. }
  462. .nt-safe-space-chatroom-message:last-of-type {
  463. margin-bottom: unset;
  464. }
  465. .nt-safe-space-chatroom-message-heading, .nt-safe-space-chatroom-message-body {
  466. display: flex;
  467. align-items: center;
  468. }
  469. .nt-safe-space-chatroom-message-heading {
  470. margin-bottom: 4px;
  471. font-weight: 600;
  472. }
  473. .nt-safe-space-chatroom-message-body {
  474. display: inline-flex;
  475. border-radius: 8px;
  476. padding-top: 4px;
  477. padding-bottom: 4px;
  478. padding-left: 8px;
  479. padding-right: 8px;
  480. background-color: rgba(255, 255, 255, 0.1);
  481. }
  482. .nt-safe-space-chatroom-message-team,
  483. .nt-safe-space-chatroom-message-name {
  484. margin-right: 0.5ch;
  485. }
  486. .nt-safe-space-chatroom-message-name.nt-gold-user,
  487. .nt-safe-space-contact-item-name.nt-gold-user {
  488. color: #E0BB2F;
  489. }
  490. .nt-safe-space-chatroom-message-heading svg.icon,
  491. .nt-safe-space-contact-item svg.icon {
  492. margin-right: 0.2ch;
  493. }
  494. .nt-safe-space-chatroom-message-body .nt-safe-space-chatroom-message-text.system-message {
  495. font-style: italic;
  496. }
  497. .nt-safe-space-chatroom-message-body .nt-safe-space-chatroom-mesasge-img {
  498. background-repeat: no-repeat;
  499. background-size: contain;
  500. background-position: center;
  501. width: 48px;
  502. height: 48px;
  503. margin-left: 1ch;
  504. }
  505. .nt-safe-space-chatroom-message-time {
  506. font-size: 10px;
  507. margin-top: 2px;
  508. }
  509. .nt-safe-space-chatroom-message.is-me {
  510. display: flex;
  511. flex-direction: column;
  512. }
  513. .nt-safe-space-chatroom-message.is-me,
  514. .nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-heading,
  515. .nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-body {
  516. margin-left: auto;
  517. }
  518. .nt-safe-space-chatroom-message.is-me .nt-safe-space-chatroom-message-time {
  519. text-align: right;
  520. }
  521. /* Chat Reply */
  522. .nt-safe-space-chatroom-reply {
  523. height: 176px;
  524. }
  525. .nt-safe-space-chatroom-reply-toolbar {
  526. background-color: #093c60;
  527. padding: 2px;
  528. }
  529. .nt-safe-space-chatroom-reply-toolbar.friend-race {
  530. display: grid;
  531. grid-template-columns: 1fr 1fr;
  532. gap: 2px;
  533. }
  534. .nt-safe-space-chatroom-reply-toolbar-option {
  535. position: relative;
  536. padding: 8px;
  537. width: 100%;
  538. color: #fff;
  539. transition: background-color 0.2s ease;
  540. background-color: rgba(0, 0, 0, 0.1);
  541. }
  542. .nt-safe-space-chatroom-reply-toolbar-option:hover {
  543. background-color: rgba(0, 0, 0, 0.2);
  544. }
  545. .nt-safe-space-chatroom-reply-toolbar-option.selected {
  546. background-color: rgba(0, 0, 0, 0.3);
  547. }
  548. .nt-safe-space-chatroom.hide-reply-options .nt-safe-space-chatroom-reply-toolbar-option {
  549. border-bottom-left-radius: 4px;
  550. border-bottom-right-radius: 4px;
  551. }
  552. .nt-safe-space-chatroom-reply-toolbar-option svg {
  553. margin: 0 auto;
  554. width: 20px;
  555. height: 20px;
  556. }
  557. .nt-safe-space-chatroom-reply-options {
  558. display: grid;
  559. grid-template-columns: 1fr 1fr 1fr 1fr;
  560. grid-template-rows: 1fr 1fr;
  561. gap: 2px;
  562. padding: 2px;
  563. background: linear-gradient(to bottom, #167ac3 30%, #1C99F4 100%);
  564. }
  565. .nt-safe-space-chatroom-reply-sticker {
  566. position: relative;
  567. display: flex;
  568. align-items: center;
  569. justify-content: center;
  570. border-radius: 4px;
  571. padding: 8px;
  572. background-color: rgba(0, 0, 0, 0.3);
  573. transition: background-color 0.2s ease;
  574. cursor: pointer;
  575. }
  576. .nt-safe-space-chatroom-reply-sticker:hover{
  577. background-color: #eee;
  578. }
  579. .nt-safe-space-chatroom-reply-sticker.nt-space-space-activated {
  580. background-color: #fff;
  581. }
  582. .nt-safe-space-chatroom-reply-sticker-img {
  583. background-repeat: no-repeat;
  584. background-size: contain;
  585. background-position: center;
  586. transition: background-image 0.2s ease;
  587. width: 48px;
  588. height: 48px;
  589. }
  590. .nt-safe-space-chatroom-reply-sticker-shortcut, .nt-safe-space-chatroom-reply-toolbar-option-shortcut {
  591. position: absolute;
  592. right: 0;
  593. top: 0;
  594. display: flex;
  595. align-items: center;
  596. justify-content: center;
  597. width: 16px;
  598. height: 16px;
  599. border-bottom-left-radius: 4px;
  600. background-color: rgba(0, 0, 0, 0.3);
  601. color: #fff;
  602. font-size: 12px;
  603. }`)
  604. )
  605. document.head.appendChild(style)
  606. const root = document.createElement("div")
  607. root.classList.add("nt-safe-space-root", "nt-safe-space-chat-contacts-hidden")
  608. //////////////////
  609. // Components //
  610. //////////////////
  611. /** Display a chatroom with messages. */
  612. const ChatRoom = ((safeSpaceContainer, raceObj, db) => {
  613. const racerContactIDPrefix = "ntSafeSpaceRacer_",
  614. 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>`,
  615. 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>`,
  616. 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>`,
  617. 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>`,
  618. 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>`,
  619. root = document.createElement("div"),
  620. systemUser = {
  621. userID: 0,
  622. profile: {
  623. displayName: "Typing Test Instructor",
  624. username: "Typing Test Instructor",
  625. tag: null,
  626. tagColor: null,
  627. membership: "normal",
  628. },
  629. }
  630. let userStickers = [],
  631. userData = {},
  632. isStickers = true,
  633. isFriendRace = raceObj.state.friendsRace,
  634. racerCount = 0,
  635. userSpeechBubbleTimer = {},
  636. chatButtonTimer = [],
  637. updatingDB = [],
  638. raceKeyboardObj,
  639. originalChatObj
  640. root.classList.add("nt-safe-space-chat")
  641. root.innerHTML = `
  642. <div class="nt-safe-space-contacts"></div>
  643. <div class="nt-safe-space-chatroom">
  644. <div class="nt-safe-space-chatroom-messages">
  645. <div class="nt-safe-space-chatroom-messages-scrollable"></div>
  646. </div>
  647. <div class="nt-safe-space-chatroom-reply">
  648. <div class="nt-safe-space-chatroom-reply-toolbar friend-race">
  649. <button class="nt-safe-space-chatroom-reply-toolbar-option option-sticker selected" type="button" title="Send Sticker">
  650. ${smileyIconSVG}
  651. <div class="nt-safe-space-chatroom-reply-toolbar-option-shortcut">S</div>
  652. </button>
  653. <button class="nt-safe-space-chatroom-reply-toolbar-option option-chat" type="button" title="Send Chat Message">
  654. ${chatIconSVG}
  655. <div class="nt-safe-space-chatroom-reply-toolbar-option-shortcut">C</div>
  656. </button>
  657. </div>
  658. <div class="nt-safe-space-chatroom-reply-options">
  659. ${Array.from(Array(8).keys())
  660. .map(
  661. (i) =>
  662. `<button class="nt-safe-space-chatroom-reply-sticker" type="button" data-stickerindex="${i}">
  663. <div class="nt-safe-space-chatroom-reply-sticker-img"></div>
  664. <div class="nt-safe-space-chatroom-reply-sticker-shortcut">${i + 1}</div>
  665. </button>`
  666. )
  667. .join("")}
  668. </div>
  669. </div>
  670. </div>`
  671. const handleNoHighlightButtonMouseDown = (e) => {
  672. e.preventDefault()
  673. }
  674. const handleChatOptionButtonClick = (e) => {
  675. e.preventDefault()
  676. const targetElement = e.target.closest(".nt-safe-space-chatroom-reply-toolbar-option")
  677. if (targetElement.classList.contains("selected")) {
  678. targetElement.classList.remove("selected")
  679. toggleChatOptions(false)
  680. return
  681. }
  682. toggleChatOptions(true)
  683. isStickers = targetElement.classList.contains("option-sticker")
  684. refreshChatOptions()
  685. root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((optionElement) => {
  686. optionElement.classList.remove("selected")
  687. })
  688. targetElement.classList.add("selected")
  689. }
  690. const handleChatSendButtonClick = (e) => {
  691. e.preventDefault()
  692. const targetElement = e.target.closest(".nt-safe-space-chatroom-reply-sticker"),
  693. index = targetElement ? parseInt(targetElement.dataset.stickerindex, 10) : null
  694. if (index === null || isNaN(index)) {
  695. return
  696. }
  697. if (isStickers) {
  698. originalChatObj.sendMessage(userStickers[index].id, userStickers[index].src, "sticker")
  699. } else {
  700. originalChatObj.sendMessage(index, raceObj.props.chatTexts[index], "text")
  701. }
  702. flashChatButton(index)
  703. }
  704. root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((node) => {
  705. node.addEventListener("click", handleChatOptionButtonClick)
  706. node.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
  707. })
  708. root.querySelectorAll(".nt-safe-space-chatroom-reply-sticker").forEach((node) => {
  709. node.addEventListener("click", handleChatSendButtonClick)
  710. node.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
  711. })
  712. const buttonSticker = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.option-sticker"),
  713. buttonChat = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.option-chat")
  714. if (!isFriendRace) {
  715. root.querySelector(".nt-safe-space-chatroom-reply-toolbar").classList.remove("friend-race")
  716. buttonChat.remove()
  717. }
  718. const chatMessages = root.querySelector(".nt-safe-space-chatroom-messages-scrollable")
  719. const refreshChatOptions = () => {
  720. root.querySelectorAll(".nt-safe-space-chatroom-reply-sticker-img").forEach((stickerItemContainer, i) => {
  721. if (isStickers) {
  722. if (userStickers[i]) {
  723. stickerItemContainer.parentNode.title = userStickers[i].name
  724. stickerItemContainer.style.backgroundImage = `url(${userStickers[i].src})`
  725. } else {
  726. stickerItemContainer.parentNode.title = ""
  727. stickerItemContainer.parentNode.style.display = "none"
  728. }
  729. } else {
  730. stickerItemContainer.style.backgroundImage = `url(/dist/site/images/chat/canned/chat_${i}.png)`
  731. stickerItemContainer.parentNode.title = raceObj.props.chatTexts[i]
  732. stickerItemContainer.parentNode.style.display = ""
  733. }
  734. })
  735. }
  736. const toggleChatOptions = (show) => {
  737. if (show) {
  738. chatMessages.parentNode.parentNode.classList.remove("hide-reply-options")
  739. } else {
  740. chatMessages.parentNode.parentNode.classList.add("hide-reply-options")
  741. }
  742. }
  743. const toggleChat = (show) => {
  744. if (show) {
  745. chatMessages.parentNode.parentNode.classList.remove("disable-reply")
  746. } else {
  747. chatMessages.parentNode.parentNode.classList.add("disable-reply")
  748. }
  749. }
  750. const flashChatButton = (index) => {
  751. const button = root.querySelector(`.nt-safe-space-chatroom-reply-sticker[data-stickerindex="${index}"]`)
  752. if (button) {
  753. if (chatButtonTimer[index]) {
  754. clearTimeout(chatButtonTimer[index])
  755. }
  756. button.classList.add("nt-space-space-activated")
  757. chatButtonTimer[index] = setTimeout(() => {
  758. button.classList.remove("nt-space-space-activated")
  759. }, 5e2)
  760. }
  761. }
  762. const addRacer = (user, status) => {
  763. const { userID } = user,
  764. { tag, tagColor, displayName, username, carID, carHueAngle } = user.profile,
  765. isMe = userID == currentUserID,
  766. isGold = user.profile.membership === "gold",
  767. isFriend = friendIDs && friendIDs.includes(userID) ? true : user.isFriend,
  768. imgCarSrc = raceObj.props.getCarUrl(carID, false, carHueAngle, false),
  769. newRacerElement = chatContactTemplate.cloneNode(true),
  770. newRacerTeamNode = newRacerElement.querySelector(".nt-safe-space-chatroom-message-team"),
  771. newRacerNameNode = newRacerElement.querySelector(".nt-safe-space-chatroom-message-name"),
  772. newRacerAvatarNode = newRacerElement.querySelector(".nt-safe-space-contact-avatar img"),
  773. newMuteButton = newRacerElement.querySelector(".nt-safe-space-btn-mute"),
  774. newBlockButton = newRacerElement.querySelector(".nt-safe-space-btn-block")
  775. newRacerElement.id = `${racerContactIDPrefix}${userID}`
  776. newRacerElement.dataset.user = userID
  777. newRacerNameNode.textContent = displayName || username
  778. newRacerAvatarNode.src = imgCarSrc
  779. newRacerAvatarNode.alt = `${displayName || username}'s car`
  780. if (isMe) {
  781. newRacerElement.classList.add("is-me")
  782. newRacerElement.querySelector(".nt-safe-space-contact-menu").remove()
  783. }
  784. if (tag) {
  785. newRacerTeamNode.textContent = `[${tag}]`
  786. newRacerTeamNode.style.color = `#${tagColor}`
  787. } else {
  788. newRacerTeamNode.remove()
  789. }
  790. if (!isGold) {
  791. newRacerNameNode.classList.remove("nt-gold-user")
  792. newRacerElement.querySelector(".icon-nt-gold-s").remove()
  793. }
  794. if (!isFriend) {
  795. newRacerElement.querySelector(".nt-safe-space-chatroom-message-friend").remove()
  796. }
  797. if (racerCount % 2 !== 0 || isMe) {
  798. newRacerElement.classList.add("alt-row")
  799. }
  800. if (status === "MUTE") {
  801. newMuteButton.classList.remove("nt-safe-space-btn-mute")
  802. newMuteButton.classList.add("nt-safe-space-btn-unmute")
  803. newMuteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Unmute"
  804. }
  805. newMuteButton.addEventListener("click", handleContactOptionButtonClick)
  806. newBlockButton.addEventListener("click", handleContactOptionButtonClick)
  807. newMuteButton.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
  808. newBlockButton.addEventListener("mousedown", handleNoHighlightButtonMouseDown)
  809. userData[userID] = user
  810. if (!isMe) {
  811. chatContacts.appendChild(newRacerElement)
  812. racerCount++
  813. safeSpaceContainer.classList.remove("nt-safe-space-chat-contacts-hidden")
  814. }
  815. return newRacerElement
  816. }
  817. const removeRacer = (userID) => {
  818. const contact = document.getElementById(`${racerContactIDPrefix}${userID}`)
  819. if (!contact || contact.classList.contains("is-me") || userID === currentUserID) {
  820. return
  821. }
  822. contact.remove()
  823. racerCount--
  824. if (racerCount === 0) {
  825. safeSpaceContainer.classList.add("nt-safe-space-chat-contacts-hidden")
  826. }
  827. root.querySelectorAll(".nt-safe-space-contact-item").forEach((node, i) => {
  828. if (i % 2 !== 0) {
  829. node.classList.add("alt-row")
  830. } else {
  831. node.classList.remove("alt-row")
  832. }
  833. })
  834. }
  835. const getRacer = (userID) => {
  836. return userData[userID]
  837. }
  838. const updateUser = (userID, status, user) => {
  839. if (updatingDB.includes(userID)) {
  840. return
  841. }
  842. user = user || raceObj.state.racers.find((r) => r.userID === userID)
  843. if (!user) {
  844. logging.warn("Chat")("User not found for sync", userID)
  845. return
  846. }
  847. const { tag, tagColor, displayName, username, title, membership, carID, carHueAngle } = user.profile,
  848. carImgSrc = raceObj.props.getCarUrl(carID, false, carHueAngle, false)
  849. updatingDB = updatingDB.concat(userID)
  850. return db.users
  851. .put({
  852. id: userID,
  853. username,
  854. displayName,
  855. isGold: membership === "gold",
  856. title,
  857. team: tag,
  858. teamColor: tagColor,
  859. carID: tagColor,
  860. carHueAngle: tagColor,
  861. carImgSrc,
  862. status,
  863. })
  864. .then(() => {
  865. updatingDB = updatingDB.filter((uid) => uid !== userID)
  866. return true
  867. })
  868. }
  869. const muteUser = (userID) => {
  870. const user = raceObj.state.racers.find((r) => r.userID === userID)
  871. if (!user) {
  872. logging.warn("Chat")("Muting user not found", userID)
  873. return
  874. }
  875. updateUser(userID, "MUTE").then(() => {
  876. addMessage("system", user, "Has been muted =)")
  877. const muteButton = root.querySelector(`#${racerContactIDPrefix}${userID} .nt-safe-space-btn-mute`)
  878. muteButton.classList.remove("nt-safe-space-btn-mute")
  879. muteButton.classList.add("nt-safe-space-btn-unmute")
  880. muteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Unmute"
  881. })
  882. }
  883. const unmuteUser = (userID) => {
  884. if (updatingDB.includes(userID)) {
  885. return
  886. }
  887. const user = raceObj.state.racers.find((r) => r.userID === userID)
  888. if (!user) {
  889. logging.warn("Chat")("Muting user not found", userID)
  890. return
  891. }
  892. updatingDB = updatingDB.concat(userID)
  893. db.users.delete(user.userID).then(() => {
  894. addMessage("system", user, "Has been unmuted =)")
  895. updatingDB = updatingDB.filter((uid) => uid !== userID)
  896. const muteButton = root.querySelector(`#${racerContactIDPrefix}${userID} .nt-safe-space-btn-unmute`)
  897. muteButton.classList.remove("nt-safe-space-btn-unmute")
  898. muteButton.classList.add("nt-safe-space-btn-mute")
  899. muteButton.querySelector(".nt-safe-space-contact-menu-label").textContent = "Mute"
  900. })
  901. }
  902. const blockUser = (userID) => {
  903. const user = raceObj.state.racers.find((r) => r.userID === userID)
  904. if (!user) {
  905. logging.warn("Chat")("Muting user not found", userID)
  906. return
  907. }
  908. updateUser(userID, "BLOCK").then(() => {
  909. addMessage("system", user, "Has been blocked =)")
  910. removeRacer(userID)
  911. })
  912. }
  913. // Chat Contact Template
  914. const chatContacts = root.querySelector(".nt-safe-space-contacts"),
  915. chatContactTemplate = document.createElement("div")
  916. chatContactTemplate.classList.add("nt-safe-space-contact-item")
  917. chatContactTemplate.innerHTML = `
  918. <div class="nt-safe-space-contact-item-name">
  919. <img class="icon icon-nt-gold-s" src="/dist/site/images/themes/profiles/gold/nt-gold-icon-xl.png" alt="Nitro Gold">
  920. <span class="nt-safe-space-chatroom-message-team"></span>
  921. <span class="nt-safe-space-chatroom-message-name nt-gold-user"></span>
  922. <span class="nt-safe-space-chatroom-message-friend">${friendIconSVG}</span>
  923. </div>
  924. <div class="nt-safe-space-contact-item-body">
  925. <div class="nt-safe-space-contact-player">
  926. <div class="nt-safe-space-contact-avatar">
  927. <img />
  928. </div>
  929. <div class="nt-safe-space-contact-speech-bubble nt-safe-space-hidden">
  930. <div class="nt-safe-space-contact-speech-bubble-img"></div>
  931. </div>
  932. </div>
  933. <div class="nt-safe-space-contact-menu">
  934. <button class="nt-safe-space-contact-menu-item nt-safe-space-btn nt-safe-space-btn-mute">
  935. <span class="nt-safe-space-contact-menu-icon">${muteIconSVG}</span>
  936. <span class="nt-safe-space-contact-menu-label">Mute</span>
  937. </button>
  938. <button class="nt-safe-space-contact-menu-item nt-safe-space-btn nt-safe-space-btn-block">
  939. <span class="nt-safe-space-contact-menu-icon">${blockIconSVG}</span>
  940. <span class="nt-safe-space-contact-menu-label">Block</span>
  941. </button>
  942. </div>
  943. </div>`
  944. const handleContactOptionButtonClick = (e) => {
  945. e.preventDefault()
  946. const targetElement = e.target.closest(".nt-safe-space-btn"),
  947. userContact = e.target.closest(".nt-safe-space-contact-item"),
  948. targetUserID = parseInt(userContact?.dataset.user, 10)
  949. if (!targetUserID) {
  950. return
  951. }
  952. if (targetElement.classList.contains("nt-safe-space-btn-mute")) {
  953. muteUser(targetUserID)
  954. } else if (targetElement.classList.contains("nt-safe-space-btn-unmute")) {
  955. unmuteUser(targetUserID)
  956. } else if (targetElement.classList.contains("nt-safe-space-btn-block")) {
  957. blockUser(targetUserID)
  958. }
  959. }
  960. // Chat Message Template
  961. const chatMessageTemplate = document.createElement("div")
  962. chatMessageTemplate.classList.add("nt-safe-space-chatroom-message")
  963. chatMessageTemplate.innerHTML = `
  964. <div class="nt-safe-space-chatroom-message-heading">
  965. <img class="icon icon-nt-gold-s" src="/dist/site/images/themes/profiles/gold/nt-gold-icon-xl.png" alt="Nitro Gold">
  966. <span class="nt-safe-space-chatroom-message-team"></span>
  967. <span class="nt-safe-space-chatroom-message-name nt-gold-user"></span>
  968. <span class="nt-safe-space-chatroom-message-friend">${friendIconSVG}</span>
  969. <div class="nt-safe-space-chatroom-message-time"></div>
  970. </div>
  971. <div class="nt-safe-space-chatroom-message-body">
  972. <span class="nt-safe-space-chatroom-message-text"></span>
  973. <div class="nt-safe-space-chatroom-mesasge-img"></div>
  974. </div>`
  975. const chatNameTemplate = chatMessageTemplate.querySelector(".nt-safe-space-chatroom-message-heading").cloneNode(true)
  976. chatNameTemplate.querySelector(".nt-safe-space-chatroom-message-time").remove()
  977. // Setup Custom Sticker Shortcut Handler
  978. const handleKeyPress = (t, n) => {
  979. if (t !== "keydown") {
  980. return false
  981. }
  982. let selectedButton
  983. const { key } = n
  984. if (key.toLowerCase() === "s") {
  985. selectedButton = buttonSticker
  986. } else if (key.toLowerCase() === "c" && isFriendRace) {
  987. selectedButton = buttonChat
  988. }
  989. if (selectedButton) {
  990. if (selectedButton.classList.contains("selected")) {
  991. selectedButton.classList.remove("selected")
  992. toggleChatOptions(false)
  993. return false
  994. }
  995. toggleChatOptions(true)
  996. isStickers = selectedButton.classList.contains("option-sticker")
  997. refreshChatOptions()
  998. root.querySelectorAll(".nt-safe-space-chatroom-reply-toolbar-option").forEach((optionElement) => {
  999. optionElement.classList.remove("selected")
  1000. })
  1001. selectedButton.classList.add("selected")
  1002. return false
  1003. }
  1004. // Handle Chat Send (if the menu is opened)
  1005. const selected = root.querySelector(".nt-safe-space-chatroom-reply-toolbar-option.selected")
  1006. if (!selected) {
  1007. return false
  1008. }
  1009. if (/^[1-8]$/.test(key) && raceKeyboardObj) {
  1010. const index = parseInt(key - 1, 10)
  1011. if (isStickers) {
  1012. originalChatObj.sendMessage(userStickers[index].id, userStickers[index].src, "sticker")
  1013. } else {
  1014. originalChatObj.sendMessage(index, raceObj.props.chatTexts[index], "text")
  1015. }
  1016. flashChatButton(index)
  1017. return false
  1018. }
  1019. }
  1020. const addMessage = (type, user, message, imgSrc) => {
  1021. const { userID } = user,
  1022. { tag, tagColor, displayName, username } = user.profile,
  1023. isMe = userID == currentUserID,
  1024. isGold = user.profile.membership === "gold",
  1025. isFriend = friendIDs && friendIDs.includes(userID) ? true : user.isFriend,
  1026. newMessageElement = chatMessageTemplate.cloneNode(true),
  1027. stamp = new Date(),
  1028. newMessageTeamNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-team"),
  1029. newMessageNameNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-name"),
  1030. newMessageTextNode = newMessageElement.querySelector(".nt-safe-space-chatroom-message-text"),
  1031. newMessageImageNode = newMessageElement.querySelector(".nt-safe-space-chatroom-mesasge-img")
  1032. newMessageElement.querySelector(".nt-safe-space-chatroom-message-time").textContent = `- ${stamp.toLocaleTimeString("en-US")}`
  1033. newMessageElement.dataset.user = userID
  1034. newMessageNameNode.textContent = displayName || username
  1035. newMessageTextNode.textContent = message
  1036. if (isMe) {
  1037. newMessageElement.classList.add("is-me")
  1038. }
  1039. if (tag) {
  1040. newMessageTeamNode.textContent = `[${tag}]`
  1041. newMessageTeamNode.style.color = `#${tagColor}`
  1042. } else {
  1043. newMessageTeamNode.remove()
  1044. }
  1045. if (!isGold) {
  1046. newMessageNameNode.classList.remove("nt-gold-user")
  1047. newMessageElement.querySelector(".icon-nt-gold-s").remove()
  1048. }
  1049. if (!isFriend) {
  1050. newMessageElement.querySelector(".nt-safe-space-chatroom-message-friend").remove()
  1051. }
  1052. if (type === "system") {
  1053. newMessageTextNode.classList.add("system-message")
  1054. }
  1055. if (imgSrc) {
  1056. newMessageImageNode.style.backgroundImage = `url(${imgSrc})`
  1057. } else {
  1058. newMessageImageNode.remove()
  1059. }
  1060. chatMessages.appendChild(newMessageElement)
  1061. chatMessages.scrollTop = chatMessages.scrollHeight
  1062. }
  1063. // Return Chat component
  1064. return {
  1065. root: root,
  1066. systemUser,
  1067. addRacer,
  1068. removeRacer,
  1069. getRacer,
  1070. addMessage,
  1071. updateUser,
  1072. assignStickers: (stickers = []) => {
  1073. userStickers = stickers
  1074. refreshChatOptions()
  1075. },
  1076. enableKeyListener: (kbObj, chatObj) => {
  1077. if (!kbObj) {
  1078. throw new Error("Keyboard React Object is required")
  1079. }
  1080. if (!chatObj) {
  1081. throw new Error("Chat React Object is required")
  1082. }
  1083. originalChatObj = chatObj
  1084. raceKeyboardObj = kbObj
  1085. raceKeyboardObj.input.initialize({
  1086. boundElement: raceKeyboardObj.typingInputRef.current,
  1087. keyHandler: (t, n) => {
  1088. let continueEvent = true
  1089. if (!raceKeyboardObj.props.started) {
  1090. continueEvent = handleKeyPress(t, n)
  1091. }
  1092. if (continueEvent) {
  1093. raceKeyboardObj.handleKeyPress(t, n)
  1094. }
  1095. },
  1096. })
  1097. },
  1098. disableChat: () => {
  1099. toggleChat(false)
  1100. if (raceKeyboardObj) {
  1101. raceKeyboardObj.input.initialize({
  1102. boundElement: raceKeyboardObj.typingInputRef.current,
  1103. keyHandler: raceKeyboardObj.handleKeyPress,
  1104. })
  1105. }
  1106. },
  1107. displaySpeechBubble: (userID, imgSrc) => {
  1108. const speechBubble = document.querySelector(`#${racerContactIDPrefix}${userID} .nt-safe-space-contact-speech-bubble-img`)
  1109. if (speechBubble) {
  1110. if (userSpeechBubbleTimer[userID]) {
  1111. clearTimeout(userSpeechBubbleTimer[userID])
  1112. }
  1113. speechBubble.style.backgroundImage = `url(${imgSrc})`
  1114. speechBubble.parentNode.classList.remove("nt-safe-space-hidden")
  1115. userSpeechBubbleTimer[userID] = setTimeout(() => {
  1116. speechBubble.parentNode.classList.add("nt-safe-space-hidden")
  1117. userSpeechBubbleTimer[userID] = null
  1118. }, 4e3)
  1119. }
  1120. },
  1121. getChatUser: (userID) => {
  1122. return db.users.get(userID)
  1123. },
  1124. }
  1125. })(root, raceObj, db)
  1126. /** Displays Information about Race Status and Results. */
  1127. const InfoSection = (() => {
  1128. const root = document.createElement("div")
  1129. root.classList.add("nt-safe-space-info")
  1130. root.innerHTML = `
  1131. <div class="nt-safe-space-info-status">
  1132. <div class="nt-safe-space-info-status-title">Setting up Typing Test</div>
  1133. <div class="nt-safe-space-info-status-subtitle"></div>
  1134. <div class="nt-safe-space-info-status-wampus"><img src="/images/loot/sticker_1630508306.png" alt="Laughing Wampus" /></div>
  1135. </div>
  1136. <div class="nt-safe-space-info-footer">
  1137. </div>`
  1138. const status = root.querySelector(".nt-safe-space-info-status"),
  1139. statusTitle = root.querySelector(".nt-safe-space-info-status-title"),
  1140. statusSubTitle = root.querySelector(".nt-safe-space-info-status-subtitle"),
  1141. statusWampus = root.querySelector(".nt-safe-space-info-status-wampus"),
  1142. statusFooter = root.querySelector(".nt-safe-space-info-footer")
  1143. statusSubTitle.remove()
  1144. statusWampus.remove()
  1145. const updateStatusTitle = (text) => {
  1146. statusTitle.textContent = text
  1147. }
  1148. const updateStatusSubTitle = (text) => {
  1149. if (text) {
  1150. statusSubTitle.textContent = text
  1151. if (statusWampus.isConnected) {
  1152. statusWampus.before(statusSubTitle)
  1153. } else {
  1154. status.append(statusSubTitle)
  1155. }
  1156. } else {
  1157. statusSubTitle.remove()
  1158. }
  1159. }
  1160. const updatePlayer = (node) => {
  1161. statusFooter.append(node)
  1162. }
  1163. const toggleWampus = (show) => {
  1164. if (show) {
  1165. status.append(statusWampus)
  1166. } else {
  1167. statusWampus.remove()
  1168. }
  1169. }
  1170. const COUNTDOWN_STATES = [["Get Ready!", "It's Typing Test Time! Get ready..."], ["3..."], ["2..."], ["1..."], ["Let's Go!", "Go go go! GLHF!"]]
  1171. let countdownTimer,
  1172. lastCountdown = 0
  1173. const updateText = (state, chat) => {
  1174. let [status, systemChatMessage] = COUNTDOWN_STATES[state]
  1175. systemChatMessage = systemChatMessage || status
  1176. chat.addMessage("system", chat.systemUser, systemChatMessage)
  1177. updateStatusTitle(status)
  1178. }
  1179. return {
  1180. root,
  1181. updateStatusTitle,
  1182. updateStatusSubTitle,
  1183. updatePlayer,
  1184. toggleWampus,
  1185. startCountdown: (chat) => {
  1186. if (countdownTimer) {
  1187. logging.warn("Status")("You can only initiate countdown once")
  1188. return
  1189. }
  1190. lastCountdown = 0
  1191. updateText(lastCountdown, chat)
  1192. countdownTimer = setInterval(() => {
  1193. if (lastCountdown + 1 < COUNTDOWN_STATES.length - 1) {
  1194. updateText(++lastCountdown, chat)
  1195. }
  1196. }, 1e3)
  1197. },
  1198. stopCountdown: (chat) => {
  1199. clearTimeout(countdownTimer)
  1200. lastCountdown = COUNTDOWN_STATES.length - 1
  1201. updateText(lastCountdown, chat)
  1202. },
  1203. }
  1204. })()
  1205. ////////////////////////
  1206. // Backend Handling //
  1207. ////////////////////////
  1208. let disqualifiedUsers = [],
  1209. reloadRaceRequested = false,
  1210. canReloadRace = false,
  1211. isWampusRace = false
  1212. const server = raceObj.server,
  1213. currentUserID = raceObj.props.user.userID,
  1214. friendIDs = raceObj.props.friendIDs,
  1215. stickerList = raceObj.stickers,
  1216. chatTextList = raceObj.props.chatTexts
  1217. /** Reload next race earlier than usual. */
  1218. const requestNextRaceASAP = (e) => {
  1219. ChatRoom.addMessage("system", ChatRoom.systemUser, "No don't leave me :(")
  1220. if (canReloadRace) {
  1221. InfoSection.updateStatusSubTitle("Starting new race...")
  1222. raceObj.raceAgain(e)
  1223. return
  1224. }
  1225. reloadRaceRequested = true
  1226. InfoSection.updateStatusSubTitle("Loading new race, please wait...")
  1227. }
  1228. /** Key Event handler to allow early race reloading. */
  1229. const nextRaceASAPKeyHandler = (e) => {
  1230. if (e.key === "Enter") {
  1231. window.removeEventListener("keypress", nextRaceASAPKeyHandler)
  1232. requestNextRaceASAP(e)
  1233. }
  1234. }
  1235. // Setup User's stickers
  1236. let userStickers = raceObj.userStickers
  1237. .filter((s) => s.equipped)
  1238. .sort((a, b) => a.equipped - b.equipped)
  1239. .map((s) => s.lootID)
  1240. if (userStickers.length === 0) {
  1241. userStickers = raceObj.props.lootConfig.sticker.defaults
  1242. }
  1243. ChatRoom.assignStickers(
  1244. userStickers.map((id) => {
  1245. const s = raceObj.props.loot.find((s) => s.lootID === id)
  1246. return {
  1247. id,
  1248. name: s.name,
  1249. src: s.options.src,
  1250. }
  1251. })
  1252. )
  1253. // Track Speed Range and Race Mode
  1254. server.on("setup", (e) => {
  1255. if (typeof e.trackLeader === "string" && e.trackLeader !== "") {
  1256. if (e.trackLeader === raceObj.props.user.username) {
  1257. InfoSection.updateStatusTitle("Creating Friendly Typing Test")
  1258. } else {
  1259. InfoSection.updateStatusTitle("Joining Friendly Typing Test")
  1260. }
  1261. }
  1262. let subTitle = ""
  1263. if ((typeof e.trackLeader !== "string" || e.trackLeader !== raceObj.props.user.username) && e.scores && e.scores.length === 2) {
  1264. const [from, to] = e.scores
  1265. subTitle = `Speed Range: ${from} WPM - ${to} WPM`
  1266. } else {
  1267. subTitle = `${e.scoringMode.toUpperCase()} mode`
  1268. server.on("settings", (e) => {
  1269. InfoSection.updateStatusSubTitle(`${e.scoringMode.toUpperCase()} mode`)
  1270. })
  1271. }
  1272. if (subTitle) {
  1273. InfoSection.updateStatusSubTitle(subTitle)
  1274. }
  1275. })
  1276. // Track Race Status
  1277. server.on("status", (e) => {
  1278. const raceStatus = e.status
  1279. if (raceStatus === "countdown") {
  1280. logging.info("Racing")("Start countdown")
  1281. if (isWampusRace) {
  1282. InfoSection.toggleWampus(false)
  1283. }
  1284. InfoSection.startCountdown(ChatRoom)
  1285. } else if (raceStatus === "racing") {
  1286. logging.info("Racing")("Start racing")
  1287. InfoSection.stopCountdown(ChatRoom)
  1288. ChatRoom.disableChat()
  1289. const lastLetter = raceContainer.querySelector(".dash-copy .dash-word:last-of-type .dash-letter:nth-last-of-type(2)")
  1290. if (lastLetter) {
  1291. lastLetterObserver.observe(lastLetter, { attributes: true })
  1292. } else {
  1293. logging.warn("Init")("Unable to setup finish race tracker")
  1294. }
  1295. }
  1296. })
  1297. // Track New Racers
  1298. server.on("joined", (user) => {
  1299. if (user.robot) {
  1300. if (user.profile.specialRobot === "wampus") {
  1301. isWampusRace = true
  1302. InfoSection.toggleWampus(true)
  1303. }
  1304. return
  1305. }
  1306. ChatRoom.getChatUser(user.userID).then((data) => {
  1307. if (!data || data.status !== "BLOCK") {
  1308. const chatNode = ChatRoom.addRacer(user, data?.status)
  1309. if (user.userID === currentUserID) {
  1310. InfoSection.updatePlayer(chatNode)
  1311. }
  1312. ChatRoom.addMessage("system", user, "Has joined the chatroom")
  1313. }
  1314. if (data?.status === "MUTE") {
  1315. ChatRoom.addMessage("system", user, "Has been muted =)")
  1316. }
  1317. if (data?.status === "BLOCK") {
  1318. logging.info("Chat")("This user is blocked", JSON.stringify(user))
  1319. }
  1320. if (data) {
  1321. ChatRoom.updateUser(user.userID, data.status, user).then(() => {
  1322. logging.info("Chat")(`Sync user details (${data.status})`, JSON.stringify(user))
  1323. })
  1324. }
  1325. })
  1326. })
  1327. // Track Players Leaving (Friend Race)
  1328. server.on("left", (e) => {
  1329. if (!e) {
  1330. return
  1331. }
  1332. ChatRoom.getChatUser(e).then((data) => {
  1333. if (data?.status !== "BLOCK") {
  1334. const user = ChatRoom.getRacer(e)
  1335. if (user) {
  1336. ChatRoom.addMessage("system", user, "Has left the chatroom =(")
  1337. }
  1338. }
  1339. })
  1340. ChatRoom.removeRacer(e)
  1341. })
  1342. // Track New Chat Messages
  1343. server.on("chat", (e) => {
  1344. const user = raceObj.state.racers.find((r) => r.userID === e.from)
  1345. if (!user) {
  1346. logging.warn("Chat")("Received message from unknown user", JSON.stringify(e))
  1347. return
  1348. }
  1349. ChatRoom.getChatUser(user.userID).then((data) => {
  1350. let message, imgSrc
  1351. if (e.chatType === "sticker" && stickerList) {
  1352. const sticker = stickerList.find((s) => s.lootID === e.chatID)
  1353. if (sticker) {
  1354. message = sticker.name
  1355. imgSrc = sticker.options.src
  1356. }
  1357. } else if (e.chatType === "text" && chatTextList) {
  1358. message = chatTextList[e.chatID]
  1359. imgSrc = `/dist/site/images/chat/canned/chat_${e.chatID}.png`
  1360. } else {
  1361. message = "???"
  1362. }
  1363. if (!data || !["MUTE", "BLOCK"].includes(data.status)) {
  1364. ChatRoom.addMessage("msg", user, message, imgSrc)
  1365. ChatRoom.displaySpeechBubble(user.userID, imgSrc)
  1366. } else {
  1367. logging.info("Chat")(`${data.status} message received`, JSON.stringify({ ...e, message, imgSrc }))
  1368. }
  1369. })
  1370. })
  1371. // Track Racing Updates for disqualify and completion
  1372. server.on("update", (e) => {
  1373. e?.racers?.forEach((user) => {
  1374. if (!user.robot && user.disqualified && !disqualifiedUsers.includes(user.userID)) {
  1375. disqualifiedUsers = disqualifiedUsers.concat(user.userID)
  1376. ChatRoom.getChatUser(user.userID).then((data) => {
  1377. if (data?.status !== "BLOCK") {
  1378. ChatRoom.addMessage("system", user, "Has left the chatroom =(")
  1379. }
  1380. })
  1381. }
  1382. if (user.userID === currentUserID && user.progress.completeStamp > 0 && user.profile && !canReloadRace && !raceContainer.querySelector(".race-results")) {
  1383. if (reloadRaceRequested) {
  1384. InfoSection.updateStatusSubTitle("Starting new race...")
  1385. raceObj.raceAgain(e)
  1386. } else {
  1387. canReloadRace = true
  1388. }
  1389. }
  1390. })
  1391. })
  1392. /** Rank suffixes for Race Result. */
  1393. const RANK_SUFFIX = ["st", "nd", "rd"]
  1394. /** Mutation obverser to track whether results screen showed up. */
  1395. const resultObserver = new MutationObserver(([mutation], observer) => {
  1396. for (const newNode of mutation.addedNodes) {
  1397. if (newNode.classList?.contains("race-results")) {
  1398. observer.disconnect()
  1399. window.removeEventListener("keypress", nextRaceASAPKeyHandler)
  1400. const currentUserResult = raceObj.state.racers.find((r) => r.userID === currentUserID)
  1401. if (!currentUserResult || !currentUserResult.progress || typeof currentUserResult.place === "undefined") {
  1402. logging.warn("Finish")("Unable to find race results")
  1403. return
  1404. }
  1405. const resultMain = raceContainer.querySelector(".raceResults"),
  1406. resultContainer = resultMain.parentNode,
  1407. obj = resultContainer ? findReact(resultContainer) : null
  1408. if (!resultContainer || !obj) {
  1409. logging.warn("Finish")("Unable to hide result screen by default")
  1410. return
  1411. }
  1412. resultMain.style.marginLeft = "-10000px"
  1413. resultContainer.classList.add("is-minimized", "has-minimized")
  1414. obj.state.isHidden = true
  1415. obj.state.hasMinimized = true
  1416. setTimeout(() => {
  1417. resultMain.style.marginLeft = ""
  1418. }, 500)
  1419. const { typed, skipped, startStamp, completeStamp, errors } = currentUserResult.progress,
  1420. wpm = Math.round((typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4)),
  1421. time = ((completeStamp - startStamp) / 1e3).toFixed(2),
  1422. acc = ((1 - errors / (typed - skipped)) * 100).toFixed(2),
  1423. points = Math.round((100 + wpm / 2) * (1 - errors / (typed - skipped))),
  1424. place = currentUserResult.place,
  1425. rankSuffix = place >= 1 && place <= 3 ? RANK_SUFFIX[place - 1] : "th"
  1426. InfoSection.updateStatusTitle("Race Results")
  1427. InfoSection.updateStatusSubTitle(`${place}${rankSuffix} | ${acc}% Acc | ${wpm} WPM | ${points} points | ${time} secs`)
  1428. logging.info("Finish")("Display Alternative Result Screen")
  1429. break
  1430. }
  1431. }
  1432. })
  1433. /** Mutation observer to track whether last letter was typed (just finished race). */
  1434. const lastLetterObserver = new MutationObserver(([mutation], observer) => {
  1435. if (mutation.target.classList.contains("is-correct")) {
  1436. observer.disconnect()
  1437. window.addEventListener("keypress", nextRaceASAPKeyHandler)
  1438. InfoSection.updateStatusTitle("Finished")
  1439. if (isWampusRace) {
  1440. InfoSection.toggleWampus(true)
  1441. }
  1442. ChatRoom.addMessage("system", ChatRoom.systemUser, "Done! Time to review your result :)")
  1443. resultObserver.observe(raceContainer, { childList: true })
  1444. }
  1445. })
  1446. /////////////
  1447. // Final //
  1448. /////////////
  1449. // Hide chat (Nitro Type will break if the DOM element is removed)
  1450. const registerChatNode = (node, inputNode) => {
  1451. if (!node?.classList?.contains("raceChat")) {
  1452. if (node !== null) {
  1453. logging.warn("Init")("Invalid node element for registering chat.")
  1454. }
  1455. return
  1456. }
  1457. const raceKeyboardObj = findReact(inputNode),
  1458. originalChatObj = findReact(node)
  1459. node.style.display = "none"
  1460. if (raceKeyboardObj && originalChatObj) {
  1461. ChatRoom.enableKeyListener(raceKeyboardObj, originalChatObj)
  1462. } else {
  1463. logging.warn("Init")("Unable to overwrite chat system")
  1464. }
  1465. }
  1466. const typingInputObserver = new MutationObserver((mutations, observer) => {
  1467. for (const m of mutations) {
  1468. for (const node of m.addedNodes) {
  1469. if (node.classList?.contains("dash-copy-input")) {
  1470. observer.disconnect()
  1471. const raceChatNode = raceContainer.querySelector(".raceChat")
  1472. if (raceChatNode) {
  1473. registerChatNode(raceChatNode, node)
  1474. } else {
  1475. logging.warn("Init")("Unable to overwrite chat system")
  1476. }
  1477. break
  1478. }
  1479. }
  1480. }
  1481. })
  1482. const raceChatNode = raceContainer.querySelector(".raceChat"),
  1483. typingInputNode = raceContainer.querySelector(".dash-copy-input")
  1484. if (raceChatNode && typingInputNode) {
  1485. registerChatNode(raceChatNode, typingInputNode)
  1486. } else {
  1487. typingInputObserver.observe(raceContainer.querySelector(".dash-center"), { childList: true })
  1488. }
  1489. // Setup Race Track
  1490. root.append(InfoSection.root, ChatRoom.root)
  1491. // Replace Race Track
  1492. canvasTrack.replaceWith(root)
  1493. logging.info("Init")("Race Track has been updated")
  1494. }