- // ==UserScript==
- // @name UX Improvements
- // @author commander
- // @description Many refactors and additions to the TankTrouble interface
- // @namespace https://github.com/asger-finding/tanktrouble-userscripts
- // @version 0.0.4
- // @license GPL-3.0
- // @match https://tanktrouble.com/*
- // @match https://beta.tanktrouble.com/*
- // @exclude *://classic.tanktrouble.com/
- // @run-at document-end
- // @grant GM_addStyle
- // @require https://update.greatest.deepsurf.us/scripts/482092/1309109/TankTrouble%20Development%20Library.js
- // @noframes
- // ==/UserScript==
-
- // TODO: Minimum game quality setting
- // TODO: Lobby games carousel instead
- // TODO: Controls switcher
-
- const ranges = {
- years: 3600 * 24 * 365,
- months: (365 * 3600 * 24) / 12,
- weeks: 3600 * 24 * 7,
- days: 3600 * 24,
- hours: 3600,
- minutes: 60,
- seconds: 1
- };
-
- /**
- * Format a timestamp to relative time ago from now
- * @param date Date object
- * @returns Time ago
- */
- const timeAgo = date => {
- const formatter = new Intl.RelativeTimeFormat('en');
- const secondsElapsed = (date.getTime() - Date.now()) / 1000;
-
- for (const key in ranges) {
- if (ranges[key] < Math.abs(secondsElapsed)) {
- const delta = secondsElapsed / ranges[key];
- return formatter.format(Math.ceil(delta), key);
- }
- }
-
- return 'now';
- };
-
- GM_addStyle(`
- player-name {
- width: 150px;
- height: 20px;
- left: -5px;
- top: -12px;
- position: relative;
- display: block;
- }`);
-
- // Wide "premium" screen
- GM_addStyle(`
- #content {
- max-width: 1884px !important;
- width: calc(100%) !important;
- }
-
- .horizontalAdSlot,
- .verticalAdSlot,
- #leftBanner,
- #rightBanner,
- #topBanner {
- display: none !important;
- }
- `);
-
- if (!customElements.get('player-name')) {
- customElements.define('player-name',
-
- /**
- * Custom HTML element that renders a TankTrouble-style player name
- * from the username, width and height attribute
- */
- class PlayerName extends HTMLElement {
-
- /**
- * Initialize the player name element
- */
- constructor() {
- super();
-
- const shadow = this.attachShadow({ mode: 'closed' });
-
- this.username = this.getAttribute('username') || 'Scrapped';
- this.width = this.getAttribute('width') || '150';
- this.height = this.getAttribute('height') || '25';
-
- // create the internal implementation
- this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- this.svg.setAttribute('width', this.width);
- this.svg.setAttribute('height', this.height);
-
- this.name = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- this.name.setAttribute('x', '50%');
- this.name.setAttribute('y', '0');
- this.name.setAttribute('text-anchor', 'middle');
- this.name.setAttribute('dominant-baseline', 'text-before-edge');
- this.name.setAttribute('font-family', 'TankTrouble');
- this.name.setAttribute('font-weight', 'normal');
- this.name.setAttribute('font-size', '16');
- this.name.setAttribute('fill', 'white');
- this.name.setAttribute('stroke', 'black');
- this.name.setAttribute('stroke-line-join', 'round');
- this.name.setAttribute('stroke-width', '2');
- this.name.setAttribute('paint-order', 'stroke');
- this.name.textContent = this.username;
-
- this.svg.appendChild(this.name);
-
- shadow.appendChild(this.svg);
- }
-
- /**
- * Scale the username SVG text when it's in the DOM.
- *
- * Bounding boxes will first be calculated right when
- * it can be rendered.
- */
- connectedCallback() {
- const nameWidth = this.name.getComputedTextLength();
- if (nameWidth > this.width) {
- // Scale text down to match svg size
- const newSize = Math.floor((this.width / nameWidth) * 100);
- this.name.setAttribute('font-size', `${ newSize }%`);
- }
- }
-
- });
- }
-
- (() => {
- /**
- * Patch a sprite that doesn't have a .log bound to it
- * @param spriteName Name of the sprite in the DOM
- * @returns Function wrapper
- */
- const bindLogToSprite = spriteName => {
- const Sprite = Reflect.get(unsafeWindow, spriteName);
- if (!Sprite) throw new Error('No sprite in window with name', spriteName);
-
- return function(...args) {
- const sprite = new Sprite(...args);
-
- sprite.log = Log.create(spriteName);
-
- return sprite;
- };
- };
-
- Reflect.set(unsafeWindow, 'UIDiamondSprite', bindLogToSprite('UIDiamondSprite'));
- Reflect.set(unsafeWindow, 'UIGoldSprite', bindLogToSprite('UIGoldSprite'));
- })();
-
- (() => {
- GM_addStyle(`
- @keyframes highlight-thread {
- 50% {
- border: #a0e900 2px solid;
- background-color: #dcffcc;
- }
- }
- .forum .thread.highlight .bubble,
- .forum .reply.highlight .bubble {
- animation: .5s ease-in 0.3s 2 alternate highlight-thread;
- }
- .forum .tanks {
- position: absolute;
- }
- .forum .reply.left .tanks {
- left: 0;
- }
- .forum .reply.right .tanks {
- right: 0;
- }
- .forum .tanks.tankCount2 {
- transform: scale(0.8);
- }
- .forum .tanks.tankCount3 {
- transform: scale(0.6);
- }
- .forum .tank.coCreator1 {
- position: absolute;
- transform: translate(-55px, 0px);
- }
- .forum .tank.coCreator2 {
- position: absolute;
- transform: translate(-110px, 0px);
- }
- .forum .reply.right .tank.coCreator1 {
- position: absolute;
- transform: translate(55px, 0px);
- }
- .forum .reply.right .tank.coCreator2 {
- position: absolute;
- transform: translate(110px, 0px);
- }
- .forum .share img {
- display: none;
- }
- .forum .thread .share:not(:active) .standard,
- .forum .thread .share:active .active,
- .forum .reply .share:not(:active) .standard,
- .forum .reply .share:active .active {
- display: inherit;
- }
- `);
-
- // The jquery SVG plugin does not support the newer paint-order attribute
- $.svg._attrNames.paintOrder = 'paint-order';
-
- /**
- * Add tank previews for all thread creators, not just the primary creator
- * @param threadOrReply Post data
- * @param threadOrReplyElement Parsed post element
- */
- const insertMultipleCreators = (threadOrReply, threadOrReplyElement) => {
- // Remove original tank preview
- threadOrReplyElement.find('.tank').remove();
-
- const creators = {
- ...{ creator: threadOrReply.creator },
- ...threadOrReply.coCreator1 && { coCreator1: threadOrReply.coCreator1 },
- ...threadOrReply.coCreator2 && { coCreator2: threadOrReply.coCreator2 }
- };
- const creatorsContainer = $('<div/>')
- .addClass(`tanks tankCount${Object.keys(creators).length}`)
- .insertBefore(threadOrReplyElement.find('.container'));
-
- // Render all creator tanks in canvas
- for (const [creatorType, playerId] of Object.entries(creators)) {
- const wrapper = document.createElement('div');
- wrapper.classList.add('tank', creatorType);
-
- const canvas = document.createElement('canvas');
- canvas.width = UIConstants.TANK_ICON_WIDTH_SMALL;
- canvas.height = UIConstants.TANK_ICON_HEIGHT_SMALL;
- canvas.style.width = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] }px`;
- canvas.style.height = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] * 0.6 }px`;
- canvas.addEventListener('mouseup', () => {
- const rect = canvas.getBoundingClientRect();
- const win = canvas.ownerDocument.defaultView;
-
- const top = rect.top + win.scrollY;
- const left = rect.left + win.scrollX;
-
- TankTrouble.TankInfoBox.show(left + (canvas.clientWidth / 2), top + (canvas.clientHeight / 2), playerId, canvas.clientWidth / 2, canvas.clientHeight / 4);
- });
- UITankIcon.loadPlayerTankIcon(canvas, UIConstants.TANK_ICON_SIZES.SMALL, playerId);
-
- wrapper.append(canvas);
- creatorsContainer.append(wrapper);
- }
-
- // Render name of primary creator
- Backend.getInstance().getPlayerDetails(result => {
- const username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';
- const width = UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10;
- const height = 25;
-
- const playerName = $(`<player-name username="${ username }" width="${ width }" height="${ height }"></player-name>`);
- creatorsContainer.find('.tank.creator').append(playerName);
- }, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
- };
-
- /**
- * Scroll a post into view if it's not already
- * and highlight it once in view
- * @param threadOrReply Parsed post element
- */
- const highlightThreadOrReply = threadOrReply => {
- const observer = new IntersectionObserver(entries => {
- const [entry] = entries;
- const inView = entry.isIntersecting;
-
- threadOrReply[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- if (inView) {
- threadOrReply.addClass('highlight');
-
- observer.disconnect();
- }
- });
-
- observer.observe(threadOrReply[0]);
- };
-
- /**
- * Insert a share button to the thread or reply that copies the link to the post to clipboard
- * @param threadOrReply Post data
- * @param threadOrReplyElement Parsed post element
- */
- const addShare = (threadOrReply, threadOrReplyElement) => {
- const isReply = Boolean(threadOrReply.threadId);
-
- const url = new URL(window.location.href);
- const wasWindowOpenedFromPostShare = url.searchParams.get('ref') === 'share';
- if (wasWindowOpenedFromPostShare && isReply) {
- const urlReplyId = Number(url.searchParams.get('id'));
- if (urlReplyId === threadOrReply.id) highlightThreadOrReply(threadOrReplyElement);
- }
-
- const likeAction = threadOrReplyElement.find('.action.like');
-
- let shareAction = $('<div class="action share"></div>');
- const shareActionStandardImage = $('<img class="standard" src="https://i.imgur.com/emJXwew.png" srcset="https://i.imgur.com/UF4gXBk.png 2x"/>');
- const shareActionActiveImage = $('<img class="active" src="https://i.imgur.com/pNQ0Aja.png" srcset="https://i.imgur.com/Ti3IplV.png 2x"/>');
-
- shareAction.append([shareActionStandardImage, shareActionActiveImage]);
- likeAction.after(shareAction);
-
- // Replies have a duplicate actions container for
- // both right and left-facing replies.
- // So when the share button is appended, there may be multiple
- // and so we need to realize those instances as well
- shareAction = threadOrReplyElement.find('.action.share');
-
- shareAction.tooltipster({
- position: 'top',
- offsetY: 5,
-
- /** Reset tooltipster when mouse leaves */
- functionAfter: () => {
- shareAction.tooltipster('content', 'Copy link to clipboard');
- }
- });
- shareAction.tooltipster('content', 'Copy link to clipboard');
-
- shareAction.on('mouseup', () => {
- const urlConstruct = new URL('/forum', window.location.origin);
-
- if (isReply) {
- urlConstruct.searchParams.set('id', threadOrReply.id);
- urlConstruct.searchParams.set('threadId', threadOrReply.threadId);
- } else {
- urlConstruct.searchParams.set('threadId', threadOrReply.id);
- }
-
- urlConstruct.searchParams.set('ref', 'share');
-
- ClipboardManager.copy(urlConstruct.href);
-
- shareAction.tooltipster('content', 'Copied!');
- });
- };
-
- /**
- * Add text to details that shows when a post was last edited
- * @param threadOrReply Post data
- * @param threadOrReplyElement Parsed post element
- */
- const addLastEdited = (threadOrReply, threadOrReplyElement) => {
- const { created, latestEdit } = threadOrReply;
-
- if (latestEdit) {
- const details = threadOrReplyElement.find('.bubble .details');
- const detailsText = details.text();
- const replyIndex = detailsText.indexOf('-');
- const lastReply = replyIndex !== -1
- ? ` - ${ detailsText.slice(replyIndex + 1).trim()}`
- : '';
-
- // We remake creation time since the timeAgo
- // function estimates months slightly off
- // which may result in instances where the
- // edited happened longer ago than the thread
- // creation date
- const createdAgo = timeAgo(new Date(created * 1000));
- const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;
-
- details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
- }
- };
-
- /**
- * Add anchor tags to links in posts
- * @param _threadOrReply Post data
- * @param threadOrReplyElement Parsed post element
- */
- const addHyperlinks = (_threadOrReply, threadOrReplyElement) => {
- const threadOrReplyContent = threadOrReplyElement.find('.bubble .content');
-
- if (threadOrReplyContent.length) {
- const urlRegex = /(?<_>https?:\/\/[\w\-_]+(?:\.[\w\-_]+)+(?:[\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?)/gu;
- const messageWithLinks = threadOrReplyContent.html().replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
- threadOrReplyContent.html(messageWithLinks);
- }
- };
-
- /**
- * Add extra features to a thread or reply
- * @param threadOrReply Post data
- * @param threadOrReplyElement
- */
- const addFeaturesToThreadOrReply = (threadOrReply, threadOrReplyElement) => {
- insertMultipleCreators(threadOrReply, threadOrReplyElement);
- addLastEdited(threadOrReply, threadOrReplyElement);
- addShare(threadOrReply, threadOrReplyElement);
- addHyperlinks(threadOrReply, threadOrReplyElement);
- };
-
- /**
- *
- * @param threadOrReply
- */
- const handleThreadOrReply = threadOrReply => {
- if (threadOrReply === null) return;
-
- const [key] = Object.keys(threadOrReply.html);
- const html = threadOrReply.html[key];
-
- if (typeof html === 'string') {
- const threadOrReplyElement = $($.parseHTML(html));
-
- addFeaturesToThreadOrReply(threadOrReply, threadOrReplyElement);
- threadOrReply.html[key] = threadOrReplyElement;
- threadOrReply.html.backup = html;
- } else if (html instanceof $) {
- // For some reason, the post breaks if it's already
- // been parsed through here. Therefore, we pull
- // from the backup html we set, and re-apply the changes
- const threadOrReplyElement = $($.parseHTML(threadOrReply.html.backup));
-
- addFeaturesToThreadOrReply(threadOrReply, threadOrReplyElement);
- threadOrReply.html[key] = threadOrReplyElement;
- }
- };
-
- const threadListChanged = ForumView.getMethod('threadListChanged');
- ForumView.method('threadListChanged', function(...args) {
- const threadList = args.shift();
- for (const thread of threadList) handleThreadOrReply(thread);
-
- const result = threadListChanged.apply(this, [threadList, ...args]);
- return result;
- });
-
- const replyListChanged = ForumView.getMethod('replyListChanged');
- ForumView.method('replyListChanged', function(...args) {
- const replyList = args.shift();
- for (const thread of replyList) handleThreadOrReply(thread);
-
- const result = replyListChanged.apply(this, [replyList, ...args]);
- return result;
- });
-
- const getSelectedThread = ForumModel.getMethod('getSelectedThread');
- ForumModel.method('getSelectedThread', function(...args) {
- const result = getSelectedThread.apply(this, [...args]);
-
- handleThreadOrReply(result);
-
- return result;
- });
- })();
-
- (() => {
- Loader.interceptFunction(TankTrouble.AccountOverlay, '_initialize', (original, ...args) => {
- original(...args);
-
- TankTrouble.AccountOverlay.accountCreatedText = $('<div></div>');
- TankTrouble.AccountOverlay.accountCreatedText.insertAfter(TankTrouble.AccountOverlay.accountHeadline);
- });
-
- Loader.interceptFunction(TankTrouble.AccountOverlay, 'show', (original, ...args) => {
- original(...args);
-
- Backend.getInstance().getPlayerDetails(result => {
- if (typeof result === 'object') {
- const created = new Date(result.getCreated() * 1000);
- const formatted = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full' }).format(created);
-
- TankTrouble.AccountOverlay.accountCreatedText.text(`Created: ${formatted} (${timeAgo(created)})`);
- }
- }, () => {}, () => {}, TankTrouble.AccountOverlay.playerId, Caches.getPlayerDetailsCache());
- });
- })();
-
- (() => {
- /**
- * Determine player's admin state
- * @param playerDetails Player details
- * @returns -1 for retired admin, 0 for non-admin, 1 for admin
- */
- const getAdminState = playerDetails => {
- const isAdmin = playerDetails.getGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
-
- if (isAdmin) return 1;
- else if (TankTrouble.WallOfFame.admins.includes(playerDetails.getUsername())) return -1;
- return 0;
- };
-
- /**
- * Prepend admin details to username
- * @param usernameParts Transformable array for the username
- * @param playerDetails Player details
- * @returns Mutated username parts
- */
- const maskUsernameByAdminState = (usernameParts, playerDetails) => {
- const adminState = getAdminState(playerDetails);
-
- if (adminState === 1) usernameParts.unshift(`(GM${ playerDetails.getGmLevel() }) `);
- else if (adminState === -1) usernameParts.unshift('(Retd.) ');
-
- return usernameParts;
- };
-
- /**
- * Mask username if not yet approved
- * If the user or an admin is logged in
- * locally, then still show the username
- * @param usernameParts Transformable array for the username
- * @param playerDetails Player details
- * @returns Mutated username parts
- */
- const maskUnapprovedUsername = (usernameParts, playerDetails) => {
- if (!playerDetails.getUsernameApproved()) {
- const playerLoggedIn = Users.isAnyUser(playerDetails.getPlayerId());
- const anyAdminLoggedIn = Users.getHighestGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
-
- if (playerLoggedIn || anyAdminLoggedIn) {
- usernameParts.unshift('× ');
- usernameParts.push(playerDetails.getUsername(), ' ×');
- } else {
- usernameParts.length = 0;
- usernameParts.push('× × ×');
- }
- } else {
- usernameParts.push(playerDetails.getUsername());
- }
-
- return usernameParts;
- };
-
- /**
- * Transforms the player's username
- * depending on parameters admin and username approved
- * @param playerDetails Player details
- * @returns New username
- */
- const transformUsername = playerDetails => {
- const usernameParts = [];
-
- maskUnapprovedUsername(usernameParts, playerDetails);
- maskUsernameByAdminState(usernameParts, playerDetails);
-
- return usernameParts.join('');
- };
-
- Utils.classMethod('maskUnapprovedUsername', playerDetails => transformUsername(playerDetails));
- })();
-
- (() => {
- GM_addStyle(`
- .walletIcon {
- object-fit: contain;
- margin-right: 6px;
- }
- `);
-
- Loader.interceptFunction(TankTrouble.VirtualShopOverlay, '_initialize', (original, ...args) => {
- original(...args);
-
- // Initialize wallet elements
- TankTrouble.VirtualShopOverlay.walletGold = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
- TankTrouble.VirtualShopOverlay.walletDiamonds = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
- TankTrouble.VirtualShopOverlay.navigation.append([TankTrouble.VirtualShopOverlay.walletGold, TankTrouble.VirtualShopOverlay.walletDiamonds]);
- });
-
- Loader.interceptFunction(TankTrouble.VirtualShopOverlay, 'show', (original, ...args) => {
- original(...args);
-
- const [params] = args;
- Backend.getInstance().getCurrency(result => {
- if (typeof result === 'object') {
- // Set wallet currency from result
- const goldButton = TankTrouble.VirtualShopOverlay.walletGold.find('button').empty();
- const diamondsButton = TankTrouble.VirtualShopOverlay.walletDiamonds.find('button').empty();
-
- Utils.addImageWithClasses(goldButton, 'walletIcon', 'assets/images/virtualShop/gold.png');
- goldButton.append(result.getGold());
- Utils.addImageWithClasses(diamondsButton, 'walletIcon', 'assets/images/virtualShop/diamond.png');
- diamondsButton.append(result.getDiamonds());
- }
- }, () => {}, () => {}, params.playerId, Caches.getCurrencyCache());
- });
- })();
-
- (() => {
- Loader.interceptFunction(TankTrouble.TankInfoBox, '_initialize', (original, ...args) => {
- original(...args);
-
- // Initialize death info elements
- TankTrouble.TankInfoBox.infoDeathsDiv = $('<tr/>');
- TankTrouble.TankInfoBox.infoDeathsIcon = $('<img class="statsIcon" src="https://i.imgur.com/PMAUKdq.png" srcset="https://i.imgur.com/vEjIwA4.png 2x"/>');
- TankTrouble.TankInfoBox.infoDeaths = $('<div/>');
-
- // Align to center
- TankTrouble.TankInfoBox.infoDeathsDiv.css({
- display: 'flex',
- 'align-items': 'center',
- margin: '0 auto',
- width: 'fit-content'
- });
-
- TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster({
- position: 'left',
- offsetX: 5
- });
-
- TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeathsIcon);
- TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeaths);
- TankTrouble.TankInfoBox.infoDeathsDiv.insertAfter(TankTrouble.TankInfoBox.infoTable);
-
- TankTrouble.TankInfoBox.infoDeaths.svg({
- settings: {
- width: UIConstants.TANK_INFO_MAX_NUMBER_WIDTH,
- height: 34
- }
- });
- TankTrouble.TankInfoBox.infoDeathsSvg = TankTrouble.TankInfoBox.infoDeaths.svg('get');
- });
-
- Loader.interceptFunction(TankTrouble.TankInfoBox, 'show', (original, ...args) => {
- original(...args);
-
- TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster('content', 'Deaths');
- TankTrouble.TankInfoBox.infoDeathsSvg.clear();
-
- const [,, playerId] = args;
-
- Backend.getInstance().getPlayerDetails(result => {
- const deaths = typeof result === 'object' ? result.getDeaths() : 'N/A';
-
- const deathsText = TankTrouble.TankInfoBox.infoDeathsSvg.text(1, 22, deaths.toString(), {
- textAnchor: 'start',
- fontFamily: 'Arial Black',
- fontSize: 14,
- fill: 'white',
- stroke: 'black',
- strokeLineJoin: 'round',
- strokeWidth: 3,
- letterSpacing: 1,
- paintOrder: 'stroke'
- });
- const deathsLength = Utils.measureSVGText(deaths.toString(), {
- fontFamily: 'Arial Black',
- fontSize: 14
- });
-
- scaleAndTranslate = Utils.getSVGScaleAndTranslateToFit(UIConstants.TANK_INFO_MAX_NUMBER_WIDTH, deathsLength + 7, 34, 'left');
- TankTrouble.TankInfoBox.infoDeathsSvg.configure(deathsText, { transform: scaleAndTranslate });
- }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
- });
- })();