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. .forum .tanks {
  77. position: absolute;
  78. }
  79. .forum .reply.left .tanks {
  80. left: 0;
  81. }
  82. .forum .reply.right .tanks {
  83. right: 0;
  84. }
  85. .forum .tanks.tankCount2 {
  86. transform: scale(0.8);
  87. }
  88. .forum .tanks.tankCount3 {
  89. transform: scale(0.6);
  90. }
  91. .forum .tank.coCreator1 {
  92. position: absolute;
  93. transform: translate(-55px, 0px);
  94. }
  95. .forum .tank.coCreator2 {
  96. position: absolute;
  97. transform: translate(-110px, 0px);
  98. }
  99. .forum .reply.right .tank.coCreator1 {
  100. position: absolute;
  101. transform: translate(55px, 0px);
  102. }
  103. .forum .reply.right .tank.coCreator2 {
  104. position: absolute;
  105. transform: translate(110px, 0px);
  106. }
  107. .forum .share img {
  108. display: none;
  109. }
  110. .forum .thread .share:not(:active) .standard,
  111. .forum .thread .share:active .active {
  112. display: inherit;
  113. }
  114. .forum .reply .share:not(:active) .standard,
  115. .forum .reply .share:active .active {
  116. display: inherit;
  117. }
  118. `);
  119.  
  120. // The jquery SVG plugin does not support the newer paint-order attribute
  121. $.svg._attrNames.paintOrder = 'paint-order';
  122.  
  123. /**
  124. * Add tank previews for all thread creators, not just the primary creator
  125. * @param threadOrReply Post data
  126. * @param threadOrReplyElement Parsed post element
  127. */
  128. const insertMultipleCreators = (threadOrReply, threadOrReplyElement) => {
  129. // Remove original tank preview
  130. threadOrReplyElement.find('.tank').remove();
  131.  
  132. const creators = {
  133. ...{ creator: threadOrReply.creator },
  134. ...threadOrReply.coCreator1 && { coCreator1: threadOrReply.coCreator1 },
  135. ...threadOrReply.coCreator2 && { coCreator2: threadOrReply.coCreator2 }
  136. };
  137. const creatorsContainer = $('<div/>')
  138. .addClass(`tanks tankCount${Object.keys(creators).length}`)
  139. .insertBefore(threadOrReplyElement.find('.container'));
  140.  
  141. // Render all creator tanks in canvas
  142. for (const [creatorType, playerId] of Object.entries(creators)) {
  143. const wrapper = document.createElement('div');
  144. wrapper.classList.add('tank', creatorType);
  145.  
  146. const canvas = document.createElement('canvas');
  147. canvas.width = UIConstants.TANK_ICON_WIDTH_SMALL;
  148. canvas.height = UIConstants.TANK_ICON_HEIGHT_SMALL;
  149. canvas.style.width = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] }px`;
  150. canvas.style.height = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] * 0.6 }px`;
  151. canvas.addEventListener('mouseup', () => {
  152. const rect = canvas.getBoundingClientRect();
  153. const win = canvas.ownerDocument.defaultView;
  154.  
  155. const top = rect.top + win.scrollY;
  156. const left = rect.left + win.scrollX;
  157.  
  158. TankTrouble.TankInfoBox.show(left + (canvas.clientWidth / 2), top + (canvas.clientHeight / 2), playerId, canvas.clientWidth / 2, canvas.clientHeight / 4);
  159. });
  160. UITankIcon.loadPlayerTankIcon(canvas, UIConstants.TANK_ICON_SIZES.SMALL, playerId);
  161.  
  162. wrapper.append(canvas);
  163. creatorsContainer.append(wrapper);
  164. }
  165.  
  166. // Render name of primary creator
  167. Backend.getInstance().getPlayerDetails(result => {
  168. const creatorName = $('<div/>');
  169. const username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';
  170.  
  171. // FIXME: Too-long names clip the svg container
  172. creatorName.svg({
  173. settings: {
  174. width: UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10,
  175. height: 25
  176. }
  177. });
  178. const nameSvg = creatorName.svg('get');
  179. const nameText = nameSvg.text('50%', 0, username, {
  180. textAnchor: 'middle',
  181. dominantBaseline: 'text-before-edge',
  182. fontFamily: 'TankTrouble',
  183. fontWeight: 'normal',
  184. fontSize: '80%',
  185. fill: 'white',
  186. stroke: 'black',
  187. strokeLineJoin: 'round',
  188. strokeWidth: 2,
  189. paintOrder: 'stroke'
  190. });
  191. nameSvg.configure(nameText);
  192. creatorsContainer.find('.tank.creator').append(creatorName);
  193. }, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
  194. };
  195.  
  196. /**
  197. * Insert a share button to the thread or reply that copies the link to the post to clipboard
  198. * @param threadOrReply Post data
  199. * @param threadOrReplyElement Parsed post element
  200. */
  201. const addShareButton = (threadOrReply, threadOrReplyElement) => {
  202. const likeAction = threadOrReplyElement.find('.action.like');
  203.  
  204. let shareAction = $('<div class="action share"></div>');
  205. const shareActionStandardImage = $('<img class="standard" src="https://i.imgur.com/emJXwew.png" srcset="https://i.imgur.com/UF4gXBk.png 2x"/>');
  206. const shareActionActiveImage = $('<img class="active" src="https://i.imgur.com/pNQ0Aja.png" srcset="https://i.imgur.com/Ti3IplV.png 2x"/>');
  207.  
  208. shareAction.append([shareActionStandardImage, shareActionActiveImage]);
  209. likeAction.after(shareAction);
  210.  
  211. // Replies have a duplicate actions container for
  212. // both right and left-facing replies.
  213. // So when the share button is appended, there may be multiple
  214. // and so we need to realize those instances as well
  215. shareAction = threadOrReplyElement.find('.action.share');
  216.  
  217. shareAction.tooltipster({
  218. position: 'top',
  219. offsetY: 5,
  220.  
  221. /** Reset tooltipster when mouse leaves */
  222. functionAfter: () => {
  223. shareAction.tooltipster('content', 'Copy link to clipboard');
  224. }
  225. });
  226. shareAction.tooltipster('content', 'Copy link to clipboard');
  227.  
  228. shareAction.on('mouseup', () => {
  229. const url = new URL('/forum', window.location.origin);
  230.  
  231. if (threadOrReply.threadId) {
  232. url.searchParams.set('id', threadOrReply.id);
  233. url.searchParams.set('threadId', threadOrReply.threadId);
  234. } else {
  235. url.searchParams.set('threadId', threadOrReply.id);
  236. }
  237.  
  238. ClipboardManager.copy(url.href);
  239.  
  240. shareAction.tooltipster('content', 'Copied!');
  241. });
  242. };
  243.  
  244. /**
  245. * Add text to details that shows when a post was last edited
  246. * @param threadOrReply Post data
  247. * @param threadOrReplyElement Parsed post element
  248. */
  249. const addLastEdited = (threadOrReply, threadOrReplyElement) => {
  250. const { created, latestEdit } = threadOrReply;
  251.  
  252. if (latestEdit) {
  253. const details = threadOrReplyElement.find('.bubble .details');
  254. const detailsText = details.text();
  255. const replyIndex = detailsText.indexOf('-');
  256. const lastReply = replyIndex !== -1
  257. ? ` - ${ detailsText.slice(replyIndex + 1).trim()}`
  258. : '';
  259.  
  260. // We remake creation time since the timeAgo
  261. // function estimates months slightly off
  262. // which may result in instances where the
  263. // edited happened longer ago than the thread
  264. // creation date
  265. const createdAgo = timeAgo(new Date(created * 1000));
  266. const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;
  267.  
  268. details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
  269. }
  270. };
  271.  
  272. /**
  273. * Add anchor tags to links in posts
  274. * @param _threadOrReply Post data
  275. * @param threadOrReplyElement Parsed post element
  276. */
  277. const addHyperlinks = (_threadOrReply, threadOrReplyElement) => {
  278. const threadOrReplyContent = threadOrReplyElement.find('.bubble .content');
  279.  
  280. if (threadOrReplyContent.length) {
  281. const urlRegex = /(?<_>https?:\/\/[\w\-_]+(?:\.[\w\-_]+)+(?:[\w\-.,@?^=%&amp;:/~+#]*[\w\-@?^=%&amp;/~+#])?)/gu;
  282. const messageWithLinks = threadOrReplyContent.html().replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
  283. threadOrReplyContent.html(messageWithLinks);
  284. }
  285. };
  286.  
  287. /**
  288. * Add extra features to a thread or reply
  289. * @param threadOrReply Post data
  290. */
  291. const addFeaturesToThreadOrReply = threadOrReply => {
  292. // FIXME: Threads and replies sometimes bug out. Investigate!
  293. const [key] = Object.keys(threadOrReply.html);
  294. const html = threadOrReply.html[key];
  295.  
  296. if (typeof html === 'string') {
  297. const threadOrReplyElement = $($.parseHTML(html));
  298.  
  299. insertMultipleCreators(threadOrReply, threadOrReplyElement);
  300. addLastEdited(threadOrReply, threadOrReplyElement);
  301. addShareButton(threadOrReply, threadOrReplyElement);
  302. addHyperlinks(threadOrReply, threadOrReplyElement);
  303.  
  304. threadOrReply.html[key] = threadOrReplyElement;
  305. }
  306. };
  307.  
  308. const threadListChanged = ForumView.getMethod('threadListChanged');
  309. ForumView.method('threadListChanged', function(...args) {
  310. const threadList = args.shift();
  311. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  312.  
  313. const result = threadListChanged.apply(this, [threadList, ...args]);
  314. return result;
  315. });
  316.  
  317. const replyListChanged = ForumView.getMethod('replyListChanged');
  318. ForumView.method('replyListChanged', function(...args) {
  319. const threadList = args.shift();
  320. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  321.  
  322. const result = replyListChanged.apply(this, [threadList, ...args]);
  323. return result;
  324. });
  325.  
  326. const getSelectedThread = ForumModel.getMethod('getSelectedThread');
  327. ForumModel.method('getSelectedThread', function(...args) {
  328. const result = getSelectedThread.apply(this, [...args]);
  329.  
  330. addFeaturesToThreadOrReply(result);
  331.  
  332. return result;
  333. });
  334. })();
  335.  
  336. (() => {
  337. Loader.interceptFunction(TankTrouble.AccountOverlay, '_initialize', (original, ...args) => {
  338. original(...args);
  339.  
  340. TankTrouble.AccountOverlay.accountCreatedText = $('<div></div>');
  341. TankTrouble.AccountOverlay.accountCreatedText.insertAfter(TankTrouble.AccountOverlay.accountHeadline);
  342. });
  343.  
  344. Loader.interceptFunction(TankTrouble.AccountOverlay, 'show', (original, ...args) => {
  345. original(...args);
  346.  
  347. Backend.getInstance().getPlayerDetails(result => {
  348. if (typeof result === 'object') {
  349. const created = new Date(result.getCreated() * 1000);
  350. const formatted = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full' }).format(created);
  351.  
  352. TankTrouble.AccountOverlay.accountCreatedText.text(`Created: ${formatted} (${timeAgo(created)})`);
  353. }
  354. }, () => {}, () => {}, TankTrouble.AccountOverlay.playerId, Caches.getPlayerDetailsCache());
  355. });
  356. })();
  357.  
  358. (() => {
  359. /**
  360. * Determine player's admin state
  361. * @param playerDetails Player details
  362. * @returns -1 for retired admin, 0 for non-admin, 1 for admin
  363. */
  364. const getAdminState = playerDetails => {
  365. const isAdmin = playerDetails.getGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  366.  
  367. if (isAdmin) return 1;
  368. else if (TankTrouble.WallOfFame.admins.includes(playerDetails.getUsername())) return -1;
  369. return 0;
  370. };
  371.  
  372. /**
  373. * Prepend admin details to username
  374. * @param usernameParts Transformable array for the username
  375. * @param playerDetails Player details
  376. * @returns Mutated username parts
  377. */
  378. const maskUsernameByAdminState = (usernameParts, playerDetails) => {
  379. const adminState = getAdminState(playerDetails);
  380.  
  381. if (adminState === 1) usernameParts.unshift(`(GM${ playerDetails.getGmLevel() }) `);
  382. else if (adminState === -1) usernameParts.unshift('(Retd.) ');
  383.  
  384. return usernameParts;
  385. };
  386.  
  387. /**
  388. * Mask username if not yet approved
  389. * If the user or an admin is logged in
  390. * locally, then still show the username
  391. * @param usernameParts Transformable array for the username
  392. * @param playerDetails Player details
  393. * @returns Mutated username parts
  394. */
  395. const maskUnapprovedUsername = (usernameParts, playerDetails) => {
  396. if (!playerDetails.getUsernameApproved()) {
  397. const playerLoggedIn = Users.isAnyUser(playerDetails.getPlayerId());
  398. const anyAdminLoggedIn = Users.getHighestGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  399.  
  400. if (playerLoggedIn || anyAdminLoggedIn) {
  401. usernameParts.unshift('× ');
  402. usernameParts.push(playerDetails.getUsername(), ' ×');
  403. } else {
  404. usernameParts.length = 0;
  405. usernameParts.push('× × ×');
  406. }
  407. } else {
  408. usernameParts.push(playerDetails.getUsername());
  409. }
  410.  
  411. return usernameParts;
  412. };
  413.  
  414. /**
  415. * Transforms the player's username
  416. * depending on parameters admin and username approved
  417. * @param playerDetails Player details
  418. * @returns New username
  419. */
  420. const transformUsername = playerDetails => {
  421. const usernameParts = [];
  422.  
  423. maskUnapprovedUsername(usernameParts, playerDetails);
  424. maskUsernameByAdminState(usernameParts, playerDetails);
  425.  
  426. return usernameParts.join('');
  427. };
  428.  
  429. Utils.classMethod('maskUnapprovedUsername', playerDetails => transformUsername(playerDetails));
  430. })();
  431.  
  432. (() => {
  433. GM_addStyle(`
  434. .walletIcon {
  435. object-fit: contain;
  436. margin-right: 6px;
  437. }
  438. `);
  439.  
  440. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, '_initialize', (original, ...args) => {
  441. original(...args);
  442.  
  443. // Initialize wallet elements
  444. TankTrouble.VirtualShopOverlay.walletGold = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  445. TankTrouble.VirtualShopOverlay.walletDiamonds = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  446. TankTrouble.VirtualShopOverlay.navigation.append([TankTrouble.VirtualShopOverlay.walletGold, TankTrouble.VirtualShopOverlay.walletDiamonds]);
  447. });
  448.  
  449. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, 'show', (original, ...args) => {
  450. original(...args);
  451.  
  452. const [params] = args;
  453. Backend.getInstance().getCurrency(result => {
  454. if (typeof result === 'object') {
  455. // Set wallet currency from result
  456. const goldButton = TankTrouble.VirtualShopOverlay.walletGold.find('button').empty();
  457. const diamondsButton = TankTrouble.VirtualShopOverlay.walletDiamonds.find('button').empty();
  458.  
  459. Utils.addImageWithClasses(goldButton, 'walletIcon', 'assets/images/virtualShop/gold.png');
  460. goldButton.append(result.getGold());
  461. Utils.addImageWithClasses(diamondsButton, 'walletIcon', 'assets/images/virtualShop/diamond.png');
  462. diamondsButton.append(result.getDiamonds());
  463. }
  464. }, () => {}, () => {}, params.playerId, Caches.getCurrencyCache());
  465. });
  466. })();
  467.  
  468. (() => {
  469. Loader.interceptFunction(TankTrouble.TankInfoBox, '_initialize', (original, ...args) => {
  470. original(...args);
  471.  
  472. // Initialize death info elements
  473. TankTrouble.TankInfoBox.infoDeathsDiv = $('<tr/>');
  474. TankTrouble.TankInfoBox.infoDeathsIcon = $('<img class="statsIcon" src="https://i.imgur.com/PMAUKdq.png" srcset="https://i.imgur.com/vEjIwA4.png 2x"/>');
  475. TankTrouble.TankInfoBox.infoDeaths = $('<div/>');
  476.  
  477. // Align to center
  478. TankTrouble.TankInfoBox.infoDeathsDiv.css({
  479. display: 'flex',
  480. 'align-items': 'center',
  481. margin: '0 auto',
  482. width: 'fit-content'
  483. });
  484.  
  485. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster({
  486. position: 'left',
  487. offsetX: 5
  488. });
  489.  
  490. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeathsIcon);
  491. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeaths);
  492. TankTrouble.TankInfoBox.infoDeathsDiv.insertAfter(TankTrouble.TankInfoBox.infoTable);
  493.  
  494. TankTrouble.TankInfoBox.infoDeaths.svg({
  495. settings: {
  496. width: UIConstants.TANK_INFO_MAX_NUMBER_WIDTH,
  497. height: 34
  498. }
  499. });
  500. TankTrouble.TankInfoBox.infoDeathsSvg = TankTrouble.TankInfoBox.infoDeaths.svg('get');
  501. });
  502.  
  503. Loader.interceptFunction(TankTrouble.TankInfoBox, 'show', (original, ...args) => {
  504. original(...args);
  505.  
  506. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster('content', 'Deaths');
  507. TankTrouble.TankInfoBox.infoDeathsSvg.clear();
  508.  
  509. const [,, playerId] = args;
  510.  
  511. Backend.getInstance().getPlayerDetails(result => {
  512. const deaths = typeof result === 'object' ? result.getDeaths() : 'N/A';
  513.  
  514. const deathsText = TankTrouble.TankInfoBox.infoDeathsSvg.text(1, 22, deaths.toString(), {
  515. textAnchor: 'start',
  516. fontFamily: 'Arial Black',
  517. fontSize: 14,
  518. fill: 'white',
  519. stroke: 'black',
  520. strokeLineJoin: 'round',
  521. strokeWidth: 3,
  522. letterSpacing: 1,
  523. paintOrder: 'stroke'
  524. });
  525. const deathsLength = Utils.measureSVGText(deaths.toString(), {
  526. fontFamily: 'Arial Black',
  527. fontSize: 14
  528. });
  529.  
  530. scaleAndTranslate = Utils.getSVGScaleAndTranslateToFit(UIConstants.TANK_INFO_MAX_NUMBER_WIDTH, deathsLength + 7, 34, 'left');
  531. TankTrouble.TankInfoBox.infoDeathsSvg.configure(deathsText, { transform: scaleAndTranslate });
  532. }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
  533. });
  534. })();