UX Improvements

Add many UI improvements and additions

当前为 2024-01-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name UX Improvements
  3. // @author commander
  4. // @description Add many UI improvements and additions
  5. // @namespace https://github.com/asger-finding/tanktrouble-userscripts
  6. // @version 0.0.3
  7. // @license GPL-3.0
  8. // @match *://*.tanktrouble.com/*
  9. // @exclude *://classic.tanktrouble.com/
  10. // @run-at document-end
  11. // @grant GM_addStyle
  12. // @require https://update.greasyfork.org/scripts/482092/1309109/TankTrouble%20Development%20Library.js
  13. // @noframes
  14. // ==/UserScript==
  15.  
  16. // TODO: Search in the forum (searxng api?)
  17. // TODO: Button to render high-res tanks no outline in TankInfoBox
  18. // TODO: Minimum game quality setting
  19. // TODO: Lobby games carousel
  20. // TODO: control switcher
  21.  
  22. const ranges = {
  23. years: 3600 * 24 * 365,
  24. months: (365 * 3600 * 24) / 12,
  25. weeks: 3600 * 24 * 7,
  26. days: 3600 * 24,
  27. hours: 3600,
  28. minutes: 60,
  29. seconds: 1
  30. };
  31.  
  32. /**
  33. * Format a timestamp to relative time ago from now
  34. * @param date Date object
  35. * @returns Time ago
  36. */
  37. const timeAgo = date => {
  38. const formatter = new Intl.RelativeTimeFormat('en');
  39. const secondsElapsed = (date.getTime() - Date.now()) / 1000;
  40.  
  41. for (const key in ranges) {
  42. if (ranges[key] < Math.abs(secondsElapsed)) {
  43. const delta = secondsElapsed / ranges[key];
  44. return formatter.format(Math.ceil(delta), key);
  45. }
  46. }
  47.  
  48. return 'now';
  49. };
  50.  
  51. (() => {
  52. /**
  53. * Patch a sprite that doesn't have a .log bound to it
  54. * @param spriteName Name of the sprite in the DOM
  55. * @returns Function wrapper
  56. */
  57. const bindLogToSprite = spriteName => {
  58. const Sprite = Reflect.get(unsafeWindow, spriteName);
  59. if (!Sprite) throw new Error('No sprite in window with name', spriteName);
  60.  
  61. return function(...args) {
  62. const sprite = new Sprite(...args);
  63.  
  64. sprite.log = Log.create(spriteName);
  65.  
  66. return sprite;
  67. };
  68. };
  69.  
  70. Reflect.set(unsafeWindow, 'UIDiamondSprite', bindLogToSprite('UIDiamondSprite'));
  71. Reflect.set(unsafeWindow, 'UIGoldSprite', bindLogToSprite('UIGoldSprite'));
  72. })();
  73.  
  74. (() => {
  75. GM_addStyle(`
  76. @keyframes highlight-thread {
  77. 50% {
  78. border: #a0e900 2px solid;
  79. background-color: #dcffcc;
  80. }
  81. }
  82. .forum .thread.highlight .bubble,
  83. .forum .reply.highlight .bubble {
  84. animation: .5s ease-in 0.3s 2 alternate highlight-thread;
  85. }
  86. .forum .tanks {
  87. position: absolute;
  88. }
  89. .forum .reply.left .tanks {
  90. left: 0;
  91. }
  92. .forum .reply.right .tanks {
  93. right: 0;
  94. }
  95. .forum .tanks.tankCount2 {
  96. transform: scale(0.8);
  97. }
  98. .forum .tanks.tankCount3 {
  99. transform: scale(0.6);
  100. }
  101. .forum .tank.coCreator1 {
  102. position: absolute;
  103. transform: translate(-55px, 0px);
  104. }
  105. .forum .tank.coCreator2 {
  106. position: absolute;
  107. transform: translate(-110px, 0px);
  108. }
  109. .forum .reply.right .tank.coCreator1 {
  110. position: absolute;
  111. transform: translate(55px, 0px);
  112. }
  113. .forum .reply.right .tank.coCreator2 {
  114. position: absolute;
  115. transform: translate(110px, 0px);
  116. }
  117. .forum .share img {
  118. display: none;
  119. }
  120. .forum .thread .share:not(:active) .standard,
  121. .forum .thread .share:active .active,
  122. .forum .reply .share:not(:active) .standard,
  123. .forum .reply .share:active .active {
  124. display: inherit;
  125. }
  126. `);
  127.  
  128. // The jquery SVG plugin does not support the newer paint-order attribute
  129. $.svg._attrNames.paintOrder = 'paint-order';
  130.  
  131. /**
  132. * Add tank previews for all thread creators, not just the primary creator
  133. * @param threadOrReply Post data
  134. * @param threadOrReplyElement Parsed post element
  135. */
  136. const insertMultipleCreators = (threadOrReply, threadOrReplyElement) => {
  137. // Remove original tank preview
  138. threadOrReplyElement.find('.tank').remove();
  139.  
  140. const creators = {
  141. ...{ creator: threadOrReply.creator },
  142. ...threadOrReply.coCreator1 && { coCreator1: threadOrReply.coCreator1 },
  143. ...threadOrReply.coCreator2 && { coCreator2: threadOrReply.coCreator2 }
  144. };
  145. const creatorsContainer = $('<div/>')
  146. .addClass(`tanks tankCount${Object.keys(creators).length}`)
  147. .insertBefore(threadOrReplyElement.find('.container'));
  148.  
  149. // Render all creator tanks in canvas
  150. for (const [creatorType, playerId] of Object.entries(creators)) {
  151. const wrapper = document.createElement('div');
  152. wrapper.classList.add('tank', creatorType);
  153.  
  154. const canvas = document.createElement('canvas');
  155. canvas.width = UIConstants.TANK_ICON_WIDTH_SMALL;
  156. canvas.height = UIConstants.TANK_ICON_HEIGHT_SMALL;
  157. canvas.style.width = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] }px`;
  158. canvas.style.height = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] * 0.6 }px`;
  159. canvas.addEventListener('mouseup', () => {
  160. const rect = canvas.getBoundingClientRect();
  161. const win = canvas.ownerDocument.defaultView;
  162.  
  163. const top = rect.top + win.scrollY;
  164. const left = rect.left + win.scrollX;
  165.  
  166. TankTrouble.TankInfoBox.show(left + (canvas.clientWidth / 2), top + (canvas.clientHeight / 2), playerId, canvas.clientWidth / 2, canvas.clientHeight / 4);
  167. });
  168. UITankIcon.loadPlayerTankIcon(canvas, UIConstants.TANK_ICON_SIZES.SMALL, playerId);
  169.  
  170. wrapper.append(canvas);
  171. creatorsContainer.append(wrapper);
  172. }
  173.  
  174. // Render name of primary creator
  175. Backend.getInstance().getPlayerDetails(result => {
  176. const creatorName = $('<div/>');
  177. const username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';
  178.  
  179. // FIXME: Too-long names clip the svg container
  180. creatorName.svg({
  181. settings: {
  182. width: UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10,
  183. height: 25
  184. }
  185. });
  186. const nameSvg = creatorName.svg('get');
  187. const nameText = nameSvg.text('50%', 0, username, {
  188. textAnchor: 'middle',
  189. dominantBaseline: 'text-before-edge',
  190. fontFamily: 'TankTrouble',
  191. fontWeight: 'normal',
  192. fontSize: '80%',
  193. fill: 'white',
  194. stroke: 'black',
  195. strokeLineJoin: 'round',
  196. strokeWidth: 2,
  197. paintOrder: 'stroke'
  198. });
  199. nameSvg.configure(nameText);
  200. creatorsContainer.find('.tank.creator').append(creatorName);
  201. }, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
  202. };
  203.  
  204. /**
  205. * Scroll a post into view if it's not already
  206. * and highlight it once in view
  207. * @param threadOrReply Parsed post element
  208. */
  209. const highlightThreadOrReply = threadOrReply => {
  210. const observer = new IntersectionObserver(entries => {
  211. const [entry] = entries;
  212. const inView = entry.isIntersecting;
  213.  
  214. if (!inView) {
  215. threadOrReply[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
  216. } else {
  217. threadOrReply.addClass('highlight');
  218.  
  219. observer.disconnect();
  220. }
  221. });
  222.  
  223. observer.observe(threadOrReply[0]);
  224. };
  225.  
  226. /**
  227. * Insert a share button to the thread or reply that copies the link to the post to clipboard
  228. * @param threadOrReply Post data
  229. * @param threadOrReplyElement Parsed post element
  230. */
  231. const addShare = (threadOrReply, threadOrReplyElement) => {
  232. const isReply = Boolean(threadOrReply.threadId);
  233.  
  234. const url = new URL(window.location.href);
  235. const wasWindowOpenedFromPostShare = url.searchParams.get('ref') === 'share';
  236. if (wasWindowOpenedFromPostShare && isReply) {
  237. const urlReplyId = Number(url.searchParams.get('id'));
  238. if (urlReplyId === threadOrReply.id) highlightThreadOrReply(threadOrReplyElement);
  239. }
  240.  
  241. const likeAction = threadOrReplyElement.find('.action.like');
  242.  
  243. let shareAction = $('<div class="action share"></div>');
  244. const shareActionStandardImage = $('<img class="standard" src="https://i.imgur.com/emJXwew.png" srcset="https://i.imgur.com/UF4gXBk.png 2x"/>');
  245. const shareActionActiveImage = $('<img class="active" src="https://i.imgur.com/pNQ0Aja.png" srcset="https://i.imgur.com/Ti3IplV.png 2x"/>');
  246.  
  247. shareAction.append([shareActionStandardImage, shareActionActiveImage]);
  248. likeAction.after(shareAction);
  249.  
  250. // Replies have a duplicate actions container for
  251. // both right and left-facing replies.
  252. // So when the share button is appended, there may be multiple
  253. // and so we need to realize those instances as well
  254. shareAction = threadOrReplyElement.find('.action.share');
  255.  
  256. shareAction.tooltipster({
  257. position: 'top',
  258. offsetY: 5,
  259.  
  260. /** Reset tooltipster when mouse leaves */
  261. functionAfter: () => {
  262. shareAction.tooltipster('content', 'Copy link to clipboard');
  263. }
  264. });
  265. shareAction.tooltipster('content', 'Copy link to clipboard');
  266.  
  267. shareAction.on('mouseup', () => {
  268. const urlConstruct = new URL('/forum', window.location.origin);
  269.  
  270. if (isReply) {
  271. urlConstruct.searchParams.set('id', threadOrReply.id);
  272. urlConstruct.searchParams.set('threadId', threadOrReply.threadId);
  273. } else {
  274. urlConstruct.searchParams.set('threadId', threadOrReply.id);
  275. }
  276.  
  277. urlConstruct.searchParams.set('ref', 'share');
  278.  
  279. ClipboardManager.copy(urlConstruct.href);
  280.  
  281. shareAction.tooltipster('content', 'Copied!');
  282. });
  283. };
  284.  
  285. /**
  286. * Add text to details that shows when a post was last edited
  287. * @param threadOrReply Post data
  288. * @param threadOrReplyElement Parsed post element
  289. */
  290. const addLastEdited = (threadOrReply, threadOrReplyElement) => {
  291. const { created, latestEdit } = threadOrReply;
  292.  
  293. if (latestEdit) {
  294. const details = threadOrReplyElement.find('.bubble .details');
  295. const detailsText = details.text();
  296. const replyIndex = detailsText.indexOf('-');
  297. const lastReply = replyIndex !== -1
  298. ? ` - ${ detailsText.slice(replyIndex + 1).trim()}`
  299. : '';
  300.  
  301. // We remake creation time since the timeAgo
  302. // function estimates months slightly off
  303. // which may result in instances where the
  304. // edited happened longer ago than the thread
  305. // creation date
  306. const createdAgo = timeAgo(new Date(created * 1000));
  307. const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;
  308.  
  309. details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
  310. }
  311. };
  312.  
  313. /**
  314. * Add anchor tags to links in posts
  315. * @param _threadOrReply Post data
  316. * @param threadOrReplyElement Parsed post element
  317. */
  318. const addHyperlinks = (_threadOrReply, threadOrReplyElement) => {
  319. const threadOrReplyContent = threadOrReplyElement.find('.bubble .content');
  320.  
  321. if (threadOrReplyContent.length) {
  322. const urlRegex = /(?<_>https?:\/\/[\w\-_]+(?:\.[\w\-_]+)+(?:[\w\-.,@?^=%&amp;:/~+#]*[\w\-@?^=%&amp;/~+#])?)/gu;
  323. const messageWithLinks = threadOrReplyContent.html().replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
  324. threadOrReplyContent.html(messageWithLinks);
  325. }
  326. };
  327.  
  328. /**
  329. * Add extra features to a thread or reply
  330. * @param threadOrReply Post data
  331. */
  332. const addFeaturesToThreadOrReply = threadOrReply => {
  333. // FIXME: Threads and replies sometimes bug out. Investigate!
  334. const [key] = Object.keys(threadOrReply.html);
  335. const html = threadOrReply.html[key];
  336.  
  337. if (typeof html === 'string') {
  338. const threadOrReplyElement = $($.parseHTML(html));
  339.  
  340. insertMultipleCreators(threadOrReply, threadOrReplyElement);
  341. addLastEdited(threadOrReply, threadOrReplyElement);
  342. addShare(threadOrReply, threadOrReplyElement);
  343. addHyperlinks(threadOrReply, threadOrReplyElement);
  344.  
  345. threadOrReply.html[key] = threadOrReplyElement;
  346. }
  347. };
  348.  
  349. const threadListChanged = ForumView.getMethod('threadListChanged');
  350. ForumView.method('threadListChanged', function(...args) {
  351. const threadList = args.shift();
  352. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  353.  
  354. const result = threadListChanged.apply(this, [threadList, ...args]);
  355. return result;
  356. });
  357.  
  358. const replyListChanged = ForumView.getMethod('replyListChanged');
  359. ForumView.method('replyListChanged', function(...args) {
  360. const threadList = args.shift();
  361. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  362.  
  363. const result = replyListChanged.apply(this, [threadList, ...args]);
  364. return result;
  365. });
  366.  
  367. const getSelectedThread = ForumModel.getMethod('getSelectedThread');
  368. ForumModel.method('getSelectedThread', function(...args) {
  369. const result = getSelectedThread.apply(this, [...args]);
  370.  
  371. addFeaturesToThreadOrReply(result);
  372.  
  373. return result;
  374. });
  375. })();
  376.  
  377. (() => {
  378. Loader.interceptFunction(TankTrouble.AccountOverlay, '_initialize', (original, ...args) => {
  379. original(...args);
  380.  
  381. TankTrouble.AccountOverlay.accountCreatedText = $('<div></div>');
  382. TankTrouble.AccountOverlay.accountCreatedText.insertAfter(TankTrouble.AccountOverlay.accountHeadline);
  383. });
  384.  
  385. Loader.interceptFunction(TankTrouble.AccountOverlay, 'show', (original, ...args) => {
  386. original(...args);
  387.  
  388. Backend.getInstance().getPlayerDetails(result => {
  389. if (typeof result === 'object') {
  390. const created = new Date(result.getCreated() * 1000);
  391. const formatted = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full' }).format(created);
  392.  
  393. TankTrouble.AccountOverlay.accountCreatedText.text(`Created: ${formatted} (${timeAgo(created)})`);
  394. }
  395. }, () => {}, () => {}, TankTrouble.AccountOverlay.playerId, Caches.getPlayerDetailsCache());
  396. });
  397. })();
  398.  
  399. (() => {
  400. /**
  401. * Determine player's admin state
  402. * @param playerDetails Player details
  403. * @returns -1 for retired admin, 0 for non-admin, 1 for admin
  404. */
  405. const getAdminState = playerDetails => {
  406. const isAdmin = playerDetails.getGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  407.  
  408. if (isAdmin) return 1;
  409. else if (TankTrouble.WallOfFame.admins.includes(playerDetails.getUsername())) return -1;
  410. return 0;
  411. };
  412.  
  413. /**
  414. * Prepend admin details to username
  415. * @param usernameParts Transformable array for the username
  416. * @param playerDetails Player details
  417. * @returns Mutated username parts
  418. */
  419. const maskUsernameByAdminState = (usernameParts, playerDetails) => {
  420. const adminState = getAdminState(playerDetails);
  421.  
  422. if (adminState === 1) usernameParts.unshift(`(GM${ playerDetails.getGmLevel() }) `);
  423. else if (adminState === -1) usernameParts.unshift('(Retd.) ');
  424.  
  425. return usernameParts;
  426. };
  427.  
  428. /**
  429. * Mask username if not yet approved
  430. * If the user or an admin is logged in
  431. * locally, then still show the username
  432. * @param usernameParts Transformable array for the username
  433. * @param playerDetails Player details
  434. * @returns Mutated username parts
  435. */
  436. const maskUnapprovedUsername = (usernameParts, playerDetails) => {
  437. if (!playerDetails.getUsernameApproved()) {
  438. const playerLoggedIn = Users.isAnyUser(playerDetails.getPlayerId());
  439. const anyAdminLoggedIn = Users.getHighestGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  440.  
  441. if (playerLoggedIn || anyAdminLoggedIn) {
  442. usernameParts.unshift('× ');
  443. usernameParts.push(playerDetails.getUsername(), ' ×');
  444. } else {
  445. usernameParts.length = 0;
  446. usernameParts.push('× × ×');
  447. }
  448. } else {
  449. usernameParts.push(playerDetails.getUsername());
  450. }
  451.  
  452. return usernameParts;
  453. };
  454.  
  455. /**
  456. * Transforms the player's username
  457. * depending on parameters admin and username approved
  458. * @param playerDetails Player details
  459. * @returns New username
  460. */
  461. const transformUsername = playerDetails => {
  462. const usernameParts = [];
  463.  
  464. maskUnapprovedUsername(usernameParts, playerDetails);
  465. maskUsernameByAdminState(usernameParts, playerDetails);
  466.  
  467. return usernameParts.join('');
  468. };
  469.  
  470. Utils.classMethod('maskUnapprovedUsername', playerDetails => transformUsername(playerDetails));
  471. })();
  472.  
  473. (() => {
  474. GM_addStyle(`
  475. .walletIcon {
  476. object-fit: contain;
  477. margin-right: 6px;
  478. }
  479. `);
  480.  
  481. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, '_initialize', (original, ...args) => {
  482. original(...args);
  483.  
  484. // Initialize wallet elements
  485. TankTrouble.VirtualShopOverlay.walletGold = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  486. TankTrouble.VirtualShopOverlay.walletDiamonds = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  487. TankTrouble.VirtualShopOverlay.navigation.append([TankTrouble.VirtualShopOverlay.walletGold, TankTrouble.VirtualShopOverlay.walletDiamonds]);
  488. });
  489.  
  490. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, 'show', (original, ...args) => {
  491. original(...args);
  492.  
  493. const [params] = args;
  494. Backend.getInstance().getCurrency(result => {
  495. if (typeof result === 'object') {
  496. // Set wallet currency from result
  497. const goldButton = TankTrouble.VirtualShopOverlay.walletGold.find('button').empty();
  498. const diamondsButton = TankTrouble.VirtualShopOverlay.walletDiamonds.find('button').empty();
  499.  
  500. Utils.addImageWithClasses(goldButton, 'walletIcon', 'assets/images/virtualShop/gold.png');
  501. goldButton.append(result.getGold());
  502. Utils.addImageWithClasses(diamondsButton, 'walletIcon', 'assets/images/virtualShop/diamond.png');
  503. diamondsButton.append(result.getDiamonds());
  504. }
  505. }, () => {}, () => {}, params.playerId, Caches.getCurrencyCache());
  506. });
  507. })();
  508.  
  509. (() => {
  510. Loader.interceptFunction(TankTrouble.TankInfoBox, '_initialize', (original, ...args) => {
  511. original(...args);
  512.  
  513. // Initialize death info elements
  514. TankTrouble.TankInfoBox.infoDeathsDiv = $('<tr/>');
  515. TankTrouble.TankInfoBox.infoDeathsIcon = $('<img class="statsIcon" src="https://i.imgur.com/PMAUKdq.png" srcset="https://i.imgur.com/vEjIwA4.png 2x"/>');
  516. TankTrouble.TankInfoBox.infoDeaths = $('<div/>');
  517.  
  518. // Align to center
  519. TankTrouble.TankInfoBox.infoDeathsDiv.css({
  520. display: 'flex',
  521. 'align-items': 'center',
  522. margin: '0 auto',
  523. width: 'fit-content'
  524. });
  525.  
  526. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster({
  527. position: 'left',
  528. offsetX: 5
  529. });
  530.  
  531. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeathsIcon);
  532. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeaths);
  533. TankTrouble.TankInfoBox.infoDeathsDiv.insertAfter(TankTrouble.TankInfoBox.infoTable);
  534.  
  535. TankTrouble.TankInfoBox.infoDeaths.svg({
  536. settings: {
  537. width: UIConstants.TANK_INFO_MAX_NUMBER_WIDTH,
  538. height: 34
  539. }
  540. });
  541. TankTrouble.TankInfoBox.infoDeathsSvg = TankTrouble.TankInfoBox.infoDeaths.svg('get');
  542. });
  543.  
  544. Loader.interceptFunction(TankTrouble.TankInfoBox, 'show', (original, ...args) => {
  545. original(...args);
  546.  
  547. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster('content', 'Deaths');
  548. TankTrouble.TankInfoBox.infoDeathsSvg.clear();
  549.  
  550. const [,, playerId] = args;
  551.  
  552. Backend.getInstance().getPlayerDetails(result => {
  553. const deaths = typeof result === 'object' ? result.getDeaths() : 'N/A';
  554.  
  555. const deathsText = TankTrouble.TankInfoBox.infoDeathsSvg.text(1, 22, deaths.toString(), {
  556. textAnchor: 'start',
  557. fontFamily: 'Arial Black',
  558. fontSize: 14,
  559. fill: 'white',
  560. stroke: 'black',
  561. strokeLineJoin: 'round',
  562. strokeWidth: 3,
  563. letterSpacing: 1,
  564. paintOrder: 'stroke'
  565. });
  566. const deathsLength = Utils.measureSVGText(deaths.toString(), {
  567. fontFamily: 'Arial Black',
  568. fontSize: 14
  569. });
  570.  
  571. scaleAndTranslate = Utils.getSVGScaleAndTranslateToFit(UIConstants.TANK_INFO_MAX_NUMBER_WIDTH, deathsLength + 7, 34, 'left');
  572. TankTrouble.TankInfoBox.infoDeathsSvg.configure(deathsText, { transform: scaleAndTranslate });
  573. }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
  574. });
  575. })();