UX Improvements

Add many UI improvements and additions

目前為 2024-01-15 提交的版本,檢視 最新版本

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