├── bun.lockb ├── .gitattributes ├── src ├── assets │ ├── imgs │ │ ├── f00.png │ │ ├── f01.png │ │ ├── f02.png │ │ ├── f03.png │ │ ├── f04.png │ │ ├── f05.png │ │ ├── f06.png │ │ ├── f07.png │ │ ├── f08.png │ │ ├── f09.png │ │ └── f10.png │ └── icons │ │ ├── icon-16.png │ │ ├── icon-48.png │ │ └── icon-128.png ├── popup │ ├── components │ │ ├── switch.js │ │ ├── colorPicker.js │ │ ├── snackbar.js │ │ └── toggle.js │ ├── features │ │ ├── updateTimeFrameToggle.js │ │ ├── updateSwitches.js │ │ └── updateColorPicker.js │ ├── index.js │ ├── helpers │ │ ├── colorPickerHelpers.js │ │ ├── toggles.js │ │ ├── formListeners.js │ │ ├── submitters.js │ │ └── storageChanges.js │ ├── styles.css │ └── index.html ├── contentFaceIt │ ├── helpers │ │ ├── styling.js │ │ ├── colorHelper.js │ │ ├── teams.js │ │ ├── profile.js │ │ ├── user.js │ │ ├── utils.js │ │ ├── storageChanges.js │ │ ├── matchroom.js │ │ └── stats.js │ ├── components │ │ ├── color.js │ │ ├── badge.js │ │ ├── toggle.js │ │ ├── winrate.js │ │ ├── style.js │ │ └── popover.js │ ├── features │ │ ├── addStylingElement.js │ │ ├── addCreatorBadge.js │ │ ├── addTimeFrameToggle.js │ │ └── addMapStats.js │ └── index.js ├── contentSteam │ ├── index.js │ ├── features │ │ └── addFaceItStats.js │ ├── helpers │ │ ├── storageChanges.js │ │ ├── profile.js │ │ └── stats.js │ ├── contentSteam.css │ └── components │ │ └── stats.js ├── shared │ ├── helpers │ │ ├── colorConverter.js │ │ ├── index.js │ │ └── api.js │ ├── constants.js │ └── storage.js ├── background │ ├── index.js │ └── helpers │ │ └── api.js └── manifest.json ├── nodemon.json ├── scripts ├── lib │ ├── constants.js │ └── utils.js ├── build.js └── versioning.js ├── .changeset ├── config.json └── README.md ├── CHANGELOG.md ├── jsconfig.json ├── package.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md └── LICENSE /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/bun.lockb -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/assets/imgs/f00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f00.png -------------------------------------------------------------------------------- /src/assets/imgs/f01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f01.png -------------------------------------------------------------------------------- /src/assets/imgs/f02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f02.png -------------------------------------------------------------------------------- /src/assets/imgs/f03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f03.png -------------------------------------------------------------------------------- /src/assets/imgs/f04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f04.png -------------------------------------------------------------------------------- /src/assets/imgs/f05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f05.png -------------------------------------------------------------------------------- /src/assets/imgs/f06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f06.png -------------------------------------------------------------------------------- /src/assets/imgs/f07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f07.png -------------------------------------------------------------------------------- /src/assets/imgs/f08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f08.png -------------------------------------------------------------------------------- /src/assets/imgs/f09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f09.png -------------------------------------------------------------------------------- /src/assets/imgs/f10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/imgs/f10.png -------------------------------------------------------------------------------- /src/assets/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/icons/icon-16.png -------------------------------------------------------------------------------- /src/assets/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/icons/icon-48.png -------------------------------------------------------------------------------- /src/assets/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bytenote/VisusGG/HEAD/src/assets/icons/icon-128.png -------------------------------------------------------------------------------- /src/popup/components/switch.js: -------------------------------------------------------------------------------- 1 | export const updateSwitchValue = (parent, isActive) => { 2 | parent.checked = isActive; 3 | }; 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src/**"], 3 | "ext": ".js,.json", 4 | "ignore": [], 5 | "exec": "NODE_ENV=development bun run --hot ./scripts/build.js" 6 | } 7 | -------------------------------------------------------------------------------- /src/popup/components/colorPicker.js: -------------------------------------------------------------------------------- 1 | export const setColorPickerValue = (parent, color) => { 2 | parent.parentElement.style.background = color; 3 | parent.value = color; 4 | }; 5 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/styling.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | 3 | export const hasStylingElements = (parent) => 4 | !!parent.querySelector(`#${EXTENSION_NAME}-styling`); 5 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/color.js: -------------------------------------------------------------------------------- 1 | export const setColorOfElements = (color, elements) => 2 | elements.forEach(({ element, type, opacity }) => { 3 | element.style.cssText = `${type}: rgb(${color}${ 4 | opacity ? ', ' + opacity : '' 5 | })`; 6 | }); 7 | -------------------------------------------------------------------------------- /scripts/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const WEB_ACCESSIBLE_RESOURCES = [ 2 | 'imgs/f00.png', 3 | 'imgs/f01.png', 4 | 'imgs/f02.png', 5 | 'imgs/f03.png', 6 | 'imgs/f04.png', 7 | 'imgs/f05.png', 8 | 'imgs/f06.png', 9 | 'imgs/f07.png', 10 | 'imgs/f08.png', 11 | 'imgs/f09.png', 12 | 'imgs/f10.png', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/contentFaceIt/features/addStylingElement.js: -------------------------------------------------------------------------------- 1 | import { createStylingElement } from '../components/style'; 2 | import { hasStylingElements } from '../helpers/styling'; 3 | 4 | export const addStylingElement = async (parent) => { 5 | if (!hasStylingElements(parent)) { 6 | createStylingElement(parent); 7 | } 8 | 9 | return; 10 | }; 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/colorHelper.js: -------------------------------------------------------------------------------- 1 | import { getSyncStorage } from '../../shared/storage'; 2 | 3 | export const getColorToUse = (condition, ownTeamSide = false) => { 4 | const { cVal1, cVal2 } = getSyncStorage('colors'); 5 | 6 | if (condition) { 7 | return ownTeamSide ? cVal2 : cVal1; 8 | } else { 9 | return ownTeamSide ? cVal1 : cVal2; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | import { generateBuildOptions } from './lib/utils'; 2 | 3 | const sTime = performance.now(); 4 | 5 | const cPromise = Bun.build(generateBuildOptions('chrome')); 6 | const fPromise = Bun.build(generateBuildOptions('firefox')); 7 | 8 | await Promise.all([cPromise, fPromise]); 9 | 10 | const eTime = performance.now(); 11 | 12 | console.log(`[BUNDLER] Built in ${eTime - sTime}ms.`); 13 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/badge.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | 3 | export const insertCreatorBadge = (parent) => { 4 | const badgeDiv = document.createElement('div'); 5 | 6 | badgeDiv.setAttribute('id', `${EXTENSION_NAME}-badge`); 7 | badgeDiv.textContent = `${EXTENSION_NAME} CREATOR`; 8 | 9 | parent.insertAdjacentElement('afterbegin', badgeDiv); 10 | }; 11 | -------------------------------------------------------------------------------- /src/contentFaceIt/features/addCreatorBadge.js: -------------------------------------------------------------------------------- 1 | import { insertCreatorBadge } from '../components/badge'; 2 | import { getBannerPlayerCard, hasCreatorElement } from '../helpers/profile'; 3 | 4 | export const addCreatorBadge = (parent) => { 5 | const bannerElem = getBannerPlayerCard(parent); 6 | if (bannerElem && !hasCreatorElement(bannerElem)) { 7 | insertCreatorBadge(bannerElem); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/contentSteam/index.js: -------------------------------------------------------------------------------- 1 | const { initStorage, getSyncStorage } = require('../shared/storage'); 2 | const { addFaceItStats } = require('./features/addFaceItStats'); 3 | const { initStorageChangeListener } = require('./helpers/storageChanges'); 4 | 5 | (async () => { 6 | await initStorage(); 7 | initStorageChangeListener(); 8 | 9 | if (getSyncStorage('usesSteam')) { 10 | addFaceItStats(); 11 | } 12 | })(); 13 | -------------------------------------------------------------------------------- /scripts/versioning.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import packageJson from '../package.json'; 4 | import manifestJson from '../src/manifest.json'; 5 | 6 | manifestJson.version = packageJson.version; 7 | 8 | fs.writeFileSync( 9 | path.resolve(__dirname, '../src/manifest.json'), 10 | JSON.stringify(manifestJson, null, 2) 11 | ); 12 | 13 | console.log(`Updated manifest.json version to ${packageJson.version}`); 14 | -------------------------------------------------------------------------------- /src/popup/features/updateTimeFrameToggle.js: -------------------------------------------------------------------------------- 1 | import { getStorage, setSyncStorage } from '../../shared/storage'; 2 | import { initToggleButtons } from '../components/toggle'; 3 | 4 | export const displayTimeFrameToggle = async (activeToggleLabel = null) => { 5 | const buttonGroupElem = document.getElementById('button-edit-group'); 6 | 7 | const toggles = await getStorage('toggles'); 8 | setSyncStorage('toggles', toggles); 9 | 10 | initToggleButtons(toggles, buttonGroupElem, activeToggleLabel); 11 | }; 12 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/popup/components/snackbar.js: -------------------------------------------------------------------------------- 1 | export const displaySnackbar = (parent, message) => { 2 | removeSnackbar(); 3 | 4 | const feedbackElem = document.createElement('div'); 5 | feedbackElem.classList.add('feedback'); 6 | feedbackElem.textContent = message; 7 | 8 | parent.append(feedbackElem); 9 | 10 | setTimeout(() => { 11 | feedbackElem.remove(); 12 | }, 1500); 13 | }; 14 | 15 | const removeSnackbar = () => { 16 | const feedbackElem = document.querySelector('.feedback'); 17 | if (feedbackElem) { 18 | feedbackElem.remove(); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # visusgg 2 | 3 | ## 2.3.5 4 | 5 | ### Patch Changes 6 | 7 | - Fixed FACEIT user ID detection 8 | 9 | ## 2.3.4 10 | 11 | ### Patch Changes 12 | 13 | - 1809010: Updated to use latest FACEIT API 14 | 15 | ## 2.3.3 16 | 17 | ### Patch Changes 18 | 19 | - 18a1744: Fixed matchroom not being detected 20 | 21 | ## 2.3.2 22 | 23 | ### Patch Changes 24 | 25 | - 1093750: Fixed matchroom elements not being displayed after FACEIT 2.0 update 26 | 27 | ## 2.3.1 28 | 29 | ### Patch Changes 30 | 31 | - c583d23: Matchroom and profile performance and element detection improvements 32 | - 3a9deac: Now tracking ADR and HS% stats 33 | -------------------------------------------------------------------------------- /src/shared/helpers/colorConverter.js: -------------------------------------------------------------------------------- 1 | export const convertHexToRGBColor = (hexColor) => { 2 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor); 3 | 4 | if (result) { 5 | return `${parseInt(result[1], 16)}, ${parseInt( 6 | result[2], 7 | 16 8 | )}, ${parseInt(result[3], 16)}`; 9 | } 10 | 11 | return null; 12 | }; 13 | 14 | export const convertRGBToHexColor = (rgbColor) => { 15 | const rgbArr = rgbColor.split(', '); 16 | 17 | return rgbArr.reduce( 18 | (acc, curr) => 19 | (acc += (+curr).toString(16).padStart(2, '0').toUpperCase()), 20 | '#' 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/background/index.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { faceitAPI } from './helpers/api'; 3 | 4 | if (process.env.NODE_ENV === 'development') { 5 | browser.commands.onCommand.addListener((command) => { 6 | if (command === 'reload_extension') { 7 | browser.runtime.reload(); 8 | } 9 | }); 10 | } 11 | 12 | browser.runtime.onMessage.addListener((message, _, sendResponse) => { 13 | if (!message) return; 14 | const { path, token } = message; 15 | 16 | faceitAPI(path, token) 17 | .then((res) => sendResponse(res)) 18 | .catch((err) => { 19 | console.log(err); 20 | return false; 21 | }); 22 | 23 | return true; 24 | }); 25 | -------------------------------------------------------------------------------- /src/popup/features/updateSwitches.js: -------------------------------------------------------------------------------- 1 | import { getSyncStorage } from '../../shared/storage'; 2 | import { updateSwitchValue } from '../components/switch'; 3 | 4 | export const setSwitchesValue = () => { 5 | const SWITCHES = [ 6 | { id: 'form-switch-input', key: 'usesCompareMode' }, 7 | { id: 'toggle-faceit', key: 'usesFaceIt' }, 8 | { id: 'toggle-steam', key: 'usesSteam' }, 9 | ]; 10 | SWITCHES.forEach(({ id, key }) => { 11 | setSwitchValue(key, id); 12 | }); 13 | }; 14 | 15 | export const setSwitchValue = (storageKey, id) => { 16 | const value = getSyncStorage(storageKey); 17 | const switchElem = document.querySelector(`#${id}`); 18 | 19 | updateSwitchValue(switchElem, value); 20 | }; 21 | -------------------------------------------------------------------------------- /src/popup/index.js: -------------------------------------------------------------------------------- 1 | import { initStorage } from '../shared/storage'; 2 | import { setColorPickersColors } from './features/updateColorPicker'; 3 | import { setSwitchesValue } from './features/updateSwitches'; 4 | import { displayTimeFrameToggle } from './features/updateTimeFrameToggle'; 5 | import { initFormListeners } from './helpers/formListeners'; 6 | import { initStorageChangeListener } from './helpers/storageChanges'; 7 | import '@melloware/coloris/dist/coloris.css'; 8 | 9 | const initPopupElements = async () => { 10 | setSwitchesValue(); 11 | setColorPickersColors(); 12 | await displayTimeFrameToggle(); 13 | 14 | initFormListeners(); 15 | }; 16 | 17 | (async () => { 18 | await initStorage(); 19 | initStorageChangeListener(); 20 | 21 | await initPopupElements(); 22 | })(); 23 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | "checkJs": false, 11 | 12 | // Bundler mode 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "noEmit": true, 17 | 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | // Some stricter flags (enabled) 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noPropertyAccessFromIndexSignature": false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/contentSteam/features/addFaceItStats.js: -------------------------------------------------------------------------------- 1 | import { getSyncStorage } from '../../shared/storage'; 2 | import { createStatsContainer, hydrateStats } from '../components/stats'; 3 | import { 4 | getExtensionContainer, 5 | getSteamId, 6 | hasExtension, 7 | } from '../helpers/profile'; 8 | import { getStats } from '../helpers/stats'; 9 | 10 | export const addFaceItStats = async () => { 11 | if (!getSyncStorage('usesSteam')) { 12 | return; 13 | } 14 | 15 | const extensionContainer = getExtensionContainer(); 16 | if (extensionContainer) { 17 | if (!hasExtension()) { 18 | createStatsContainer(extensionContainer); 19 | } 20 | 21 | const steamId = getSteamId(); 22 | const { stats, selectedGame } = await getStats(steamId); 23 | hydrateStats(stats, selectedGame); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/popup/features/updateColorPicker.js: -------------------------------------------------------------------------------- 1 | import Coloris from '@melloware/coloris'; 2 | import { convertRGBToHexColor } from '../../shared/helpers/colorConverter'; 3 | import { getSyncStorage } from '../../shared/storage'; 4 | import { setColorPickerValue } from '../components/colorPicker'; 5 | import { 6 | getColorPickerElements, 7 | getColorType, 8 | } from '../helpers/colorPickerHelpers'; 9 | 10 | export const setColorPickersColors = () => { 11 | const colors = getSyncStorage('colors'); 12 | const colorPickerElems = getColorPickerElements(); 13 | 14 | Coloris.init(); 15 | Coloris({ 16 | themeMode: 'dark', 17 | alpha: false, 18 | }); 19 | 20 | for (const elem of colorPickerElems) { 21 | const color = colors[getColorType(elem)]; 22 | const hexColor = convertRGBToHexColor(color); 23 | 24 | setColorPickerValue(elem, hexColor); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/popup/helpers/colorPickerHelpers.js: -------------------------------------------------------------------------------- 1 | import { convertHexToRGBColor } from '../../shared/helpers/colorConverter'; 2 | import { getSyncStorage } from '../../shared/storage'; 3 | import { setColorPickerValue } from '../components/colorPicker'; 4 | 5 | export const getColorType = (elem) => 6 | elem.id.endsWith('1') ? 'cVal1' : 'cVal2'; 7 | 8 | export const getUpdatedColors = (elem, newColor) => { 9 | const colors = JSON.parse(JSON.stringify(getSyncStorage('colors'))); 10 | const colorType = getColorType(elem); 11 | const rgbColor = convertHexToRGBColor(newColor); 12 | 13 | colors[colorType] = rgbColor; 14 | 15 | return colors; 16 | }; 17 | 18 | export const getColorPickerElements = () => [ 19 | document.getElementById('form-picker1'), 20 | document.getElementById('form-picker2'), 21 | ]; 22 | 23 | export const colorPickerInputHandler = (e) => 24 | setColorPickerValue(e.target, e.target.value); 25 | -------------------------------------------------------------------------------- /src/contentSteam/helpers/storageChanges.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { isEqual } from '../../shared/helpers'; 3 | import { setSyncStorage } from '../../shared/storage'; 4 | import { removeStatsContainer } from '../components/stats'; 5 | import { addFaceItStats } from '../features/addFaceItStats'; 6 | 7 | export const initStorageChangeListener = () => { 8 | browser.storage.local.onChanged.removeListener(updateStorage); 9 | browser.storage.local.onChanged.addListener(updateStorage); 10 | }; 11 | 12 | const UPDATE_FUNC = { 13 | usesSteam: (key, newValue) => steamUpdater(key, newValue), 14 | }; 15 | 16 | const updateStorage = async (changes) => { 17 | const [[key, { oldValue, newValue }]] = Object.entries(changes); 18 | 19 | if (!isEqual(oldValue, newValue)) { 20 | UPDATE_FUNC[key]?.(key, newValue); 21 | } 22 | }; 23 | 24 | const steamUpdater = (key, newValue) => { 25 | setSyncStorage(key, newValue); 26 | 27 | if (!newValue) { 28 | removeStatsContainer(); 29 | } 30 | addFaceItStats(); 31 | }; 32 | -------------------------------------------------------------------------------- /src/background/helpers/api.js: -------------------------------------------------------------------------------- 1 | import pRetry, { AbortError } from 'p-retry'; 2 | import browser from 'webextension-polyfill'; 3 | 4 | const BASE_URL = 'https://www.faceit.com'; 5 | 6 | export const faceitAPI = async function (path, token = null) { 7 | try { 8 | token = 9 | (await browser?.cookies.get({ 10 | name: 't', 11 | url: 'https://faceit.com', 12 | })?.value) || token; 13 | const options = { 14 | headers: { 15 | ...(token && { Authorization: `Bearer ${token}` }), 16 | }, 17 | }; 18 | 19 | const response = await pRetry( 20 | () => 21 | fetch(`${BASE_URL}${path}`, options).then((res) => { 22 | if (res.status === 404) { 23 | throw new AbortError(res.statusText); 24 | } else if (!res.ok) { 25 | throw new Error(res.statusText); 26 | } 27 | return res; 28 | }), 29 | { 30 | retries: 3, 31 | } 32 | ); 33 | 34 | const json = await response.json(); 35 | 36 | return json; 37 | } catch (err) { 38 | console.log(err); 39 | return null; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/shared/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const isEqual = (oldVal, newVal) => { 2 | if (oldVal === newVal) { 3 | return true; 4 | } 5 | 6 | if (oldVal) { 7 | if (typeof oldVal === 'object') { 8 | if (Array.isArray(oldVal)) { 9 | if (oldVal.length === newVal.length) { 10 | let i = oldVal.length; 11 | 12 | for (i; i--; ) { 13 | if (!isEqual(oldVal[i], newVal[i])) { 14 | return false; 15 | } 16 | } 17 | 18 | return true; 19 | } 20 | 21 | return false; 22 | } else { 23 | const oldKeys = Object.keys(oldVal); 24 | const newKeys = Object.keys(newVal); 25 | 26 | if (oldKeys.length === newKeys.length) { 27 | for (const key in oldVal) { 28 | if (!isEqual(oldVal[key], newVal[key])) { 29 | return false; 30 | } 31 | } 32 | 33 | return true; 34 | } 35 | 36 | return false; 37 | } 38 | } 39 | 40 | return false; 41 | } 42 | 43 | return false; 44 | }; 45 | -------------------------------------------------------------------------------- /src/popup/helpers/toggles.js: -------------------------------------------------------------------------------- 1 | import { getSyncStorage } from '../../shared/storage'; 2 | import { timeFrameSubmitter } from './submitters'; 3 | 4 | export const getToggleInfo = (value) => { 5 | const toggles = getSyncStorage('toggles') ?? []; 6 | 7 | return ( 8 | toggles.find( 9 | (toggle) => toggle.label === value || toggle.type === value 10 | ) ?? {} 11 | ); 12 | }; 13 | 14 | export const onTimeFrameUnitClick = (e) => { 15 | const newUnitVal = e.target.value; 16 | const activeToggleBtn = document.querySelector( 17 | '#button-edit-group > .toggle-btn-active' 18 | ); 19 | 20 | const toggle = getToggleInfo(activeToggleBtn.value); 21 | if (toggle.amount) { 22 | timeFrameSubmitter( 23 | toggle.amount, 24 | newUnitVal, 25 | activeToggleBtn.textContent 26 | ); 27 | } 28 | }; 29 | 30 | export const onTimeFrameNumberClick = (e) => { 31 | const newAmountVal = +e.target.value; 32 | const activeToggleBtn = document.querySelector( 33 | '#button-edit-group > .toggle-btn-active' 34 | ); 35 | 36 | const toggle = getToggleInfo(activeToggleBtn.value); 37 | if (toggle.type) { 38 | timeFrameSubmitter( 39 | newAmountVal, 40 | toggle.type, 41 | activeToggleBtn.textContent 42 | ); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/teams.js: -------------------------------------------------------------------------------- 1 | import mem from 'mem'; 2 | import { getCurrentUserId } from './user'; 3 | 4 | export const isPlayerOfMatch = (roomId, teams) => 5 | getTeamsInfo(roomId, teams)?.isPlayerOfMatch; 6 | 7 | export const getOpponents = (roomId, teams) => 8 | getTeamsInfo(roomId, teams)?.opponents; 9 | 10 | export const getOwnTeam = (roomId, teams) => 11 | getTeamsInfo(roomId, teams)?.ownTeam; 12 | 13 | export const getOwnTeamSide = (roomId, teams) => 14 | getTeamsInfo(roomId, teams)?.ownTeamSide; 15 | 16 | const getTeamsInfo = mem((_, teams) => { 17 | const teamsDetails = { 18 | ownTeam: [], 19 | opponents: [], 20 | isPlayerOfMatch: false, 21 | ownTeamSide: 0, 22 | }; 23 | 24 | if (isPlayerOfRoster(teams?.faction1?.roster)) { 25 | teamsDetails.ownTeam = teams?.faction1?.roster ?? []; 26 | teamsDetails.opponents = teams?.faction2?.roster ?? []; 27 | teamsDetails.isPlayerOfMatch = true; 28 | teamsDetails.ownTeamSide = 0; 29 | } else if (isPlayerOfRoster(teams?.faction2?.roster)) { 30 | teamsDetails.ownTeam = teams?.faction2?.roster ?? []; 31 | teamsDetails.opponents = teams?.faction1?.roster ?? []; 32 | teamsDetails.isPlayerOfMatch = true; 33 | teamsDetails.ownTeamSide = 1; 34 | } 35 | 36 | return teamsDetails; 37 | }); 38 | 39 | const isPlayerOfRoster = (roster) => 40 | roster?.some((player) => player.id === getCurrentUserId()); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visusgg", 3 | "version": "2.3.5", 4 | "description": "Browser extension aiming to improve the user experience by providing additional stats around the FACEIT platform.", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "build": "NODE_ENV=production bun run ./scripts/build.js", 9 | "dev": "bun run nodemon", 10 | "format": "prettier --write .", 11 | "check-format": "prettier --check .", 12 | "add-changeset": "changeset add", 13 | "version": "changeset version && bun ./scripts/versioning.js && bun run format" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Bytenote/VisusGG.git" 18 | }, 19 | "author": "Bytenote", 20 | "license": "GNU GPLv3", 21 | "homepage": "https://chrome.google.com/webstore/detail/visusgg/kodlabmmaalpolkfolgpahbjehalecki", 22 | "prettier": "@bytenote/prettier-config", 23 | "devDependencies": { 24 | "@bytenote/prettier-config": "^1.0.6", 25 | "@changesets/changelog-git": "^0.2.0", 26 | "@changesets/cli": "^2.27.12", 27 | "bun-asset-loader": "^1.2.2", 28 | "bun-css-loader": "^1.4.2", 29 | "chrome-webstore-upload-cli": "^3.3.1", 30 | "nodemon": "^3.1.9", 31 | "web-ext": "^8.3.0", 32 | "webextension-polyfill": "^0.10.0" 33 | }, 34 | "dependencies": { 35 | "@melloware/coloris": "^0.17.1", 36 | "mem": "^9.0.2", 37 | "p-memoize": "^7.1.1", 38 | "p-retry": "^5.1.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/contentFaceIt/features/addTimeFrameToggle.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | import { getSyncStorage } from '../../shared/storage'; 3 | import { insertTimeFrameToggle } from '../components/toggle'; 4 | import { 5 | getMapObjects, 6 | getMatchRoomRoot, 7 | getToggleGroup, 8 | hasToggleElements, 9 | } from '../helpers/matchroom'; 10 | 11 | export const addTimeFrameToggle = (matchInfo, siblingRoot) => { 12 | if (!getSyncStorage('usesFaceIt')) { 13 | return; 14 | } 15 | 16 | const idSuffix = siblingRoot ? '-1' : '-0'; 17 | const matchRoomElem = getMatchRoomRoot(idSuffix, siblingRoot); 18 | const matchRoomMaps = matchInfo.matchCustom?.tree?.map?.values?.value; 19 | if (matchRoomElem && matchRoomMaps?.length > 0) { 20 | if (!hasToggleElements(idSuffix, matchRoomElem)) { 21 | const mapElems = getMapObjects( 22 | idSuffix, 23 | matchRoomElem, 24 | matchInfo.id, 25 | matchRoomMaps 26 | ); 27 | if (mapElems && mapElems.length > 0) { 28 | const firstMapElem = mapElems[0].mapElem; 29 | insertTimeFrameToggle(idSuffix, firstMapElem); 30 | } 31 | } 32 | } 33 | 34 | return; 35 | }; 36 | 37 | export const removeTimeFrameToggle = (idSuffix) => { 38 | const matchRoomElem = getMatchRoomRoot(idSuffix); 39 | if (matchRoomElem) { 40 | if (hasToggleElements(idSuffix, matchRoomElem)) { 41 | const toggleGroup = getToggleGroup(idSuffix, matchRoomElem); 42 | 43 | toggleGroup.remove(); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/profile.js: -------------------------------------------------------------------------------- 1 | import { CREATORS, EXTENSION_NAME } from '../../shared/constants'; 2 | import { 3 | findElementRecursively, 4 | getDirectChildTextContent, 5 | getOptimizedElement, 6 | getSameParentElement, 7 | } from './utils'; 8 | 9 | export const isCreatorProfile = () => { 10 | const urlName = getPlayerUrlName(); 11 | 12 | return CREATORS.includes(urlName) ? urlName : null; 13 | }; 14 | 15 | export const getBannerRoot = () => 16 | document.querySelector('.modal-content parasite-player-profile') || 17 | document.getElementById('parasite-container'); 18 | 19 | export const getBannerPlayerCard = (parent) => { 20 | const bannerOptimized = getOptimizedElement('profile-banner', () => { 21 | const avatarElem = getAvatar(parent); 22 | if (avatarElem) { 23 | const playerNameElem = findPlayerNameElement(parent); 24 | const parentElem = getSameParentElement(avatarElem, playerNameElem); 25 | 26 | return parentElem; 27 | } 28 | 29 | return null; 30 | }); 31 | 32 | return bannerOptimized; 33 | }; 34 | 35 | export const hasCreatorElement = (bannerElem) => 36 | !!bannerElem.querySelector(`#${EXTENSION_NAME}-badge`); 37 | 38 | const getAvatar = (parent) => 39 | parent?.querySelector('i[data-testid="avatar"]') || 40 | parent?.querySelector('img[aria-label="avatar"]'); 41 | 42 | const findPlayerNameElement = (parent) => { 43 | const urlName = getPlayerUrlName(); 44 | const playerName = findElementRecursively([parent], (elem) => { 45 | const textContent = getDirectChildTextContent(elem); 46 | 47 | return textContent === urlName; 48 | }); 49 | 50 | return playerName; 51 | }; 52 | 53 | const getPlayerUrlName = () => { 54 | const [_, profile] = 55 | /players(?:-modal)?\/([^/]+)/.exec(location.pathname) ?? []; 56 | 57 | return profile; 58 | }; 59 | -------------------------------------------------------------------------------- /src/shared/constants.js: -------------------------------------------------------------------------------- 1 | export const CACHE_TIME = 1000 * 60 * 7; 2 | export const CREATORS = ['MrMaxim', 'x3picF4ilx']; 3 | export const EXTENSION_NAME = 'VisusGG'; 4 | export const DEFAULT_AGE = 1000 * 60 * 60 * 24 * 7; 5 | export const DEFAULT_COLORS = { 6 | cVal1: '230, 0, 0', 7 | cVal2: '0, 153, 51', 8 | }; 9 | export const DEFAULT_COMPARE_MODE = false; 10 | export const DEFAULT_FACEIT = true; 11 | export const DEFAULT_STEAM = true; 12 | export const DEFAULT_TOGGLES = [ 13 | { 14 | label: '2d', 15 | name: '2 days', 16 | amount: 2, 17 | type: 'days', 18 | maxAge: 1000 * 60 * 60 * 24 * 2, 19 | }, 20 | { 21 | label: '1w', 22 | name: '1 week', 23 | amount: 1, 24 | type: 'weeks', 25 | maxAge: 1000 * 60 * 60 * 24 * 7, 26 | }, 27 | { 28 | label: '2w', 29 | name: '2 weeks', 30 | amount: 2, 31 | type: 'weeks', 32 | maxAge: 1000 * 60 * 60 * 24 * 14, 33 | }, 34 | ]; 35 | export const DEFAULT_STORAGE = [ 36 | { key: 'timeFrame', value: DEFAULT_AGE }, 37 | { key: 'toggles', value: DEFAULT_TOGGLES }, 38 | { key: 'usesCompareMode', value: DEFAULT_COMPARE_MODE }, 39 | { key: 'usesFaceIt', value: DEFAULT_FACEIT }, 40 | { key: 'usesSteam', value: DEFAULT_STEAM }, 41 | { key: 'colors', value: DEFAULT_COLORS }, 42 | ]; 43 | export const OBSERVER_OPTIONS = { 44 | childList: true, 45 | subtree: true, 46 | }; 47 | export const VIP_STEAM_IDS = { 48 | '76561197985066751': { 49 | name: 'MrMaxim', 50 | label: `${EXTENSION_NAME} Creator`, 51 | color: '#1fa704', 52 | }, 53 | '76561198119651364': { 54 | name: 'x3picF4ilx', 55 | label: `${EXTENSION_NAME} Creator`, 56 | color: '#1fa704', 57 | }, 58 | '76561198346163255': { 59 | name: 'Aquarius', 60 | label: 'Certified Cat Meme Expert', 61 | color: '#1fa704', 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/popup/helpers/formListeners.js: -------------------------------------------------------------------------------- 1 | import { 2 | colorPickerInputHandler, 3 | getColorPickerElements, 4 | } from './colorPickerHelpers'; 5 | import { 6 | colorPickerSubmitter, 7 | submitHandler, 8 | switchSubmitter, 9 | } from './submitters'; 10 | import { onTimeFrameNumberClick, onTimeFrameUnitClick } from './toggles'; 11 | 12 | export const initFormListeners = () => { 13 | const colorPickerElem = getColorPickerElements(); 14 | const ELEMS = [ 15 | { 16 | id: 'form-switch-input', 17 | event: 'change', 18 | handler: onCompareModeChange, 19 | }, 20 | { id: 'toggle-faceit', event: 'change', handler: onFaceItChange }, 21 | { id: 'toggle-steam', event: 'change', handler: onSteamChange }, 22 | { id: 'form', event: 'submit', handler: submitHandler }, 23 | ]; 24 | const timeFrameUnitBtns = [ 25 | ...document.getElementById('time-frame-units')?.children, 26 | ]; 27 | const timeFrameNumberBtns = [ 28 | ...document.getElementById('time-frame-numbers')?.children, 29 | ]; 30 | 31 | for (const elem of colorPickerElem) { 32 | elem.addEventListener('input', colorPickerInputHandler); 33 | elem.addEventListener('change', colorPickerSubmitter); 34 | } 35 | 36 | ELEMS.forEach(({ id, event, handler }) => { 37 | const elem = document.querySelector(`#${id}`); 38 | elem?.addEventListener(event, handler); 39 | }); 40 | 41 | timeFrameUnitBtns.forEach((btn) => { 42 | btn.addEventListener('click', onTimeFrameUnitClick); 43 | }); 44 | timeFrameNumberBtns.forEach((btn) => { 45 | btn.addEventListener('click', onTimeFrameNumberClick); 46 | }); 47 | }; 48 | 49 | const onCompareModeChange = (e) => { 50 | switchSubmitter(e, 'usesCompareMode'); 51 | }; 52 | 53 | const onFaceItChange = (e) => { 54 | switchSubmitter(e, 'usesFaceIt'); 55 | }; 56 | 57 | const onSteamChange = (e) => { 58 | switchSubmitter(e, 'usesSteam'); 59 | }; 60 | -------------------------------------------------------------------------------- /src/contentSteam/contentSteam.css: -------------------------------------------------------------------------------- 1 | #VisusGG-showcase-container { 2 | display: flex; 3 | flex-direction: row; 4 | position: static; 5 | height: 100%; 6 | padding-left: 0px; 7 | margin-bottom: 0px; 8 | } 9 | 10 | #VisusGG-player-level { 11 | height: 100px; 12 | width: 100px; 13 | padding-right: 20px; 14 | } 15 | 16 | #VisusGG-content-container.favoritegroup_content { 17 | height: 100%; 18 | } 19 | 20 | #VisusGG-level-container { 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | 26 | #VisusGG-stats-flag { 27 | height: 1.1rem; 28 | width: 1.65rem; 29 | padding-right: 8px; 30 | } 31 | 32 | .VisusGG-banner.favoritegroup_description { 33 | display: inline-block; 34 | border-radius: 3px; 35 | padding: 0px 7px; 36 | } 37 | 38 | #VisusGG-ban-banner { 39 | background: #d22b2b; 40 | color: #fff; 41 | } 42 | 43 | #VisusGG-temp-ban-banner { 44 | background: #fff239; 45 | color: #000; 46 | } 47 | 48 | #VisusGG-vip-banner { 49 | background: #1fa704; 50 | color: #fff; 51 | } 52 | 53 | #VisusGG-stats-player-body.favoritegroup_stats.showcase_stats_row { 54 | display: flex; 55 | position: static; 56 | padding-top: 10px; 57 | } 58 | 59 | #VisusGG-stats-player-body .VisusGG-stats-wrapper { 60 | display: inline-block; 61 | font-size: 1.8rem; 62 | cursor: default; 63 | margin-left: 0px; 64 | } 65 | 66 | #VisusGG-stats-player-body .VisusGG-stats-wrapper * { 67 | text-align: center; 68 | } 69 | 70 | #VisusGG-stats-player-body .VisusGG-stats-group-one { 71 | display: grid; 72 | grid-template-columns: repeat(2, 1fr); 73 | gap: 10px; 74 | text-align: left; 75 | } 76 | 77 | #VisusGG-stats-player-body .VisusGG-stats-group-two { 78 | display: grid; 79 | grid-template-columns: repeat(3, 1fr); 80 | gap: 15px; 81 | padding-left: 30px; 82 | } 83 | 84 | #VisusGG-container .responsive_tab_control select#VisusGG-game-selector option { 85 | background: rgba(24, 24, 24, 0.93); 86 | } 87 | -------------------------------------------------------------------------------- /src/contentFaceIt/index.js: -------------------------------------------------------------------------------- 1 | import { OBSERVER_OPTIONS } from '../shared/constants'; 2 | import { getMatchInfo } from '../shared/helpers/api'; 3 | import { getSyncStorage, initStorage } from '../shared/storage'; 4 | import { addCreatorBadge } from './features/addCreatorBadge'; 5 | import { addMapStats } from './features/addMapStats'; 6 | import { addStylingElement } from './features/addStylingElement'; 7 | import { addTimeFrameToggle } from './features/addTimeFrameToggle'; 8 | import { 9 | getContentRoot, 10 | getDialogSiblingRoot, 11 | getRoomId, 12 | } from './helpers/matchroom'; 13 | import { getBannerRoot, isCreatorProfile } from './helpers/profile'; 14 | import { initStorageChangeListener } from './helpers/storageChanges'; 15 | import { isPlayerOfMatch } from './helpers/teams'; 16 | import { isLoggedIn } from './helpers/user'; 17 | 18 | const domObserver = () => { 19 | const observer = new MutationObserver(async () => { 20 | const roomId = getRoomId(); 21 | 22 | if (roomId) { 23 | const rootElem = getContentRoot(); 24 | if (rootElem) { 25 | const siblingRoot = getDialogSiblingRoot(rootElem); 26 | const matchInfo = (await getMatchInfo(roomId)) ?? {}; 27 | if (matchInfo && isPlayerOfMatch(roomId, matchInfo.teams)) { 28 | addStylingElement(rootElem); 29 | addTimeFrameToggle(matchInfo, siblingRoot); 30 | await addMapStats(matchInfo, siblingRoot); 31 | } 32 | } 33 | } else if (isCreatorProfile()) { 34 | const rootElem = getBannerRoot(); 35 | if (rootElem) { 36 | addStylingElement(rootElem); 37 | addCreatorBadge(rootElem); 38 | } 39 | } 40 | }); 41 | observer.observe(document.body, OBSERVER_OPTIONS); 42 | }; 43 | 44 | (async () => { 45 | if (isLoggedIn()) { 46 | await initStorage(); 47 | initStorageChangeListener(); 48 | 49 | domObserver(); 50 | } 51 | })(); 52 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/user.js: -------------------------------------------------------------------------------- 1 | import { isJson } from './utils'; 2 | 3 | export const getCurrentUserId = () => { 4 | const legacyId = JSON.parse( 5 | localStorage.getItem('C_UCURRENT_USER.data.CURRENT_USER') 6 | )?.value?.currentUser?.id; 7 | if (legacyId) { 8 | return legacyId; 9 | } 10 | 11 | const auth = JSON.parse(localStorage.getItem('prefetched-auth')); 12 | if (auth) { 13 | return auth?.session?.entity?.id; 14 | } 15 | 16 | for (const key in localStorage) { 17 | const isId1 = key.includes('ab.storage.userId.'); 18 | const isId2 = key.includes('ab.storage.attributes.'); 19 | const isId3 = key.includes('ab.storage.events.'); 20 | let id = null; 21 | 22 | if (isId1) { 23 | id = JSON.parse(localStorage[key])?.v?.g; 24 | } else if (isId2) { 25 | id = Object.keys(JSON.parse(localStorage[key])?.v || {})?.[0]; 26 | } else if (isId3) { 27 | id = JSON.parse(localStorage[key])?.v?.[0]?.u; 28 | } 29 | 30 | if (id) { 31 | return id; 32 | } 33 | } 34 | 35 | const cookieUserId = _getUserIdFromCookies(); 36 | if (cookieUserId) { 37 | return cookieUserId; 38 | } 39 | 40 | return null; 41 | }; 42 | 43 | export const isLoggedIn = () => 44 | document.cookie.includes(' ab.storage.userId.') || !!getCurrentUserId(); 45 | 46 | const _getUserIdFromCookies = () => { 47 | const cookies = document.cookie.split(';'); 48 | const cookieContent = cookies 49 | .find((cookie) => cookie?.trim()?.startsWith('ab.storage.userId')) 50 | ?.split('=')?.[1]; 51 | if (cookieContent) { 52 | if (isJson(cookieContent)) { 53 | return JSON.parse(decodeURIComponent(cookieContent))?.g; 54 | } 55 | 56 | const cookieValue = decodeURIComponent(cookieContent); 57 | const cookieValueParts = cookieValue.split('|'); 58 | const userIdValue = cookieValueParts.find((part) => 59 | part.startsWith('g:') 60 | ); 61 | if (userIdValue) { 62 | return userIdValue.split(':')[1]; 63 | } 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/contentSteam/helpers/profile.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | 3 | export const getSteamId = () => 4 | getSteamIdFromAbuseForm() ?? 5 | getSteamIdFromPageTemplateScript() ?? 6 | getSteamIdFromReportButton(); 7 | 8 | export const getExtensionContainer = () => { 9 | const innerElem = document.querySelector('.profile_content_inner'); 10 | const leftColElem = innerElem?.querySelector('.profile_leftcol'); 11 | 12 | return leftColElem ?? null; 13 | }; 14 | 15 | export const hasExtension = () => 16 | !!document.getElementById(`${EXTENSION_NAME}-container`); 17 | 18 | export const getLevelImg = (level) => { 19 | if (typeof level === 'number') { 20 | if (level < 10) { 21 | return `0${level}`; 22 | } 23 | 24 | return level; 25 | } 26 | 27 | return '00'; 28 | }; 29 | 30 | const getSteamIdFromAbuseForm = () => { 31 | const abuseForm = document.getElementById('abuseForm'); 32 | const steamId = abuseForm?.querySelector('input[name="abuseID"]')?.value; 33 | 34 | return steamId ?? null; 35 | }; 36 | 37 | const getSteamIdFromPageTemplateScript = () => { 38 | const pageTemplate = document.getElementById( 39 | 'responsive_page_template_content' 40 | ); 41 | const pageTemplateScript = pageTemplate?.querySelector( 42 | 'script[type="text/javascript"]' 43 | ); 44 | const steamId = 45 | pageTemplateScript?.textContent?.match(/"steamid":"(\d+)"/)[1]; 46 | 47 | return steamId ?? null; 48 | }; 49 | 50 | const getSteamIdFromReportButton = () => { 51 | const popUpMenuParent = document.getElementById('profile_action_dropdown'); 52 | const popUpMenu = popUpMenuParent?.querySelector( 53 | '.popup_body.popup_menu.shadow_content' 54 | ); 55 | const menuItems = [ 56 | ...(popUpMenu?.querySelectorAll('.popup_menu_item') ?? []), 57 | ]; 58 | const reportButton = menuItems?.find( 59 | (elem) => elem.textContent.trim() === 'Report Player' 60 | ); 61 | let steamId = null; 62 | 63 | if (reportButton) { 64 | steamId = reportButton.getAttribute('onclick').match(/'([^']+)'/)[1]; 65 | } 66 | 67 | return steamId; 68 | }; 69 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "VisusGG", 4 | "description": "VisusGG, a browser extension by x3picF4ilx (Bytenote) & MrMaxim, aims to improve the user experience on FACEIT's platform.", 5 | "version": "2.3.5", 6 | "icons": { 7 | "16": "icon-16.png", 8 | "48": "icon-48.png", 9 | "128": "icon-128.png" 10 | }, 11 | "background": { 12 | "service_worker": "background.js" 13 | }, 14 | "content_scripts": [ 15 | { 16 | "run_at": "document_end", 17 | "matches": [ 18 | "https://www.faceit.com/*", 19 | "https://beta.faceit.com/*" 20 | ], 21 | "js": ["contentFaceIt.js"] 22 | }, 23 | { 24 | "run_at": "document_end", 25 | "matches": [ 26 | "https://*.steamcommunity.com/profiles/*", 27 | "https://*.steamcommunity.com/id/*" 28 | ], 29 | "exclude_matches": [ 30 | "https://*.steamcommunity.com/id/*/allcomments", 31 | "https://*.steamcommunity.com/profiles/*/allcomments" 32 | ], 33 | "js": ["contentSteam.js"], 34 | "css": ["contentSteam.css"] 35 | } 36 | ], 37 | "action": { 38 | "default_icon": "icon-48.png", 39 | "default_popup": "popup.html", 40 | "default_title": "VisusGG" 41 | }, 42 | "web_accessible_resources": [ 43 | { 44 | "resources": [ 45 | "imgs/f00.png", 46 | "imgs/f01.png", 47 | "imgs/f02.png", 48 | "imgs/f03.png", 49 | "imgs/f04.png", 50 | "imgs/f05.png", 51 | "imgs/f06.png", 52 | "imgs/f07.png", 53 | "imgs/f08.png", 54 | "imgs/f09.png", 55 | "imgs/f10.png" 56 | ], 57 | "matches": ["https://*.steamcommunity.com/*"] 58 | } 59 | ], 60 | "permissions": ["cookies", "storage"], 61 | "host_permissions": [ 62 | "https://*.faceit.com/*", 63 | "https://www.steamcommunity.com/profiles/*", 64 | "https://www.steamcommunity.com/id/*" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | env: 2 | DIRECTORY: build 3 | PROJECT_NAME: VisusGG 4 | 5 | name: Release 6 | on: 7 | workflow_dispatch: null 8 | jobs: 9 | Build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v2 14 | with: 15 | bun-version: 1.1.45 16 | 17 | - run: bun install 18 | - run: bun run build 19 | 20 | - name: Upload build artifacts 21 | uses: actions/upload-artifact@v4 22 | with: 23 | path: ${{ env.DIRECTORY }} 24 | 25 | Chrome: 26 | needs: Build 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/download-artifact@v4 31 | 32 | - name: Extract version from package.json 33 | id: extract_version 34 | run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_OUTPUT 35 | 36 | - name: Upload to Chrome Web Store 37 | run: npx chrome-webstore-upload-cli@3 38 | working-directory: artifact/chrome 39 | env: 40 | EXTENSION_ID: ${{ secrets.EXTENSION_ID }} 41 | CLIENT_ID: ${{ secrets.CLIENT_ID }} 42 | CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} 43 | REFRESH_TOKEN: ${{ secrets.REFRESH_TOKEN }} 44 | 45 | Firefox: 46 | needs: Build 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: actions/download-artifact@v4 51 | 52 | - name: Extract version from package.json 53 | id: extract_version 54 | run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_OUTPUT 55 | 56 | - name: Create source archive 57 | run: git archive --output source.zip HEAD ":!.changeset" ":!.github" && unzip -l source.zip 58 | 59 | - name: Upload to Firefox Add-ons 60 | run: npx web-ext@8 sign --channel listed --upload-source-code ../../source.zip 61 | working-directory: artifact/firefox 62 | env: 63 | WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }} 64 | WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }} 65 | -------------------------------------------------------------------------------- /src/shared/helpers/api.js: -------------------------------------------------------------------------------- 1 | import pMemoize from 'p-memoize'; 2 | import browser from 'webextension-polyfill'; 3 | import { CACHE_TIME } from '../constants'; 4 | 5 | export const getMatchInfo = (matchId) => 6 | fetchAPIMemoized(`/api/match/v2/match/${matchId}`); 7 | 8 | export const getLifeTimeStats = (game, playerId, roomId) => { 9 | if (Array.isArray(playerId)) { 10 | return fetchAPIMemoized( 11 | `/api/stats/v1/stats/users/lifetime?match_id=${roomId}&game=${game}&player_ids=${playerId.join( 12 | '&player_ids=' 13 | )}` 14 | ); 15 | } 16 | 17 | return fetchAPIMemoized( 18 | `/api/stats/v1/stats/users/${playerId}/games/${game}` 19 | ); 20 | }; 21 | 22 | export const getPlayerMatches = (game, playerId, size = 100) => 23 | fetchAPIMemoized( 24 | `/api/stats/v1/stats/time/users/${playerId}/games/${game}?size=${size}` 25 | ); 26 | 27 | export const getPlayerHistory = (game, playerId, from, to) => { 28 | return fetchAPIMemoized( 29 | `/api/data/v4/players/${playerId}/history?game=${game}&from=${from}&to=${to}&limit=100` 30 | ); 31 | }; 32 | 33 | export const getProfileBySteamId = (steamId) => 34 | fetchAPIMemoized( 35 | `/api/searcher/v1/players?limit=20&offset=0&game_id=${steamId}` 36 | ); 37 | 38 | export const getPlayerBans = (playerId) => 39 | fetchAPIMemoized(`/api/sheriff/v1/bans/${playerId}`); 40 | 41 | export const getPlayerInfo = (nickname) => 42 | fetchAPIMemoized(`/api/users/v1/nicknames/${nickname}`); 43 | 44 | const fetchAPI = async (path) => { 45 | if (typeof path !== 'string') return; 46 | 47 | try { 48 | const token = localStorage.getItem('token'); 49 | const response = 50 | (await browser.runtime?.sendMessage({ path, token })) ?? {}; 51 | const { result, code, payload } = response; 52 | 53 | if ( 54 | (result && result.toUpperCase() !== 'OK') || 55 | (code && code.toUpperCase() !== 'OPERATION-OK') 56 | ) { 57 | throw new Error(result, code, payload); 58 | } 59 | 60 | const returnVal = payload || response; 61 | 62 | return returnVal; 63 | } catch (err) { 64 | console.log(err); 65 | return null; 66 | } 67 | }; 68 | 69 | const fetchAPIMemoized = pMemoize(fetchAPI, { 70 | maxAge: CACHE_TIME, 71 | }); 72 | -------------------------------------------------------------------------------- /src/contentFaceIt/features/addMapStats.js: -------------------------------------------------------------------------------- 1 | import { getSyncStorage } from '../../shared/storage'; 2 | import { createPopover } from '../components/popover'; 3 | import { hydrateStats, insertStats } from '../components/winrate'; 4 | import { 5 | getMapObjects, 6 | getMatchRoomRoot, 7 | getStatsElements, 8 | hasStatsElements, 9 | } from '../helpers/matchroom'; 10 | import { 11 | getMapDictMemoized, 12 | getMapStats, 13 | loadMapStatsMemoized, 14 | } from '../helpers/stats'; 15 | 16 | export const addMapStats = async (matchInfo, siblingRoot) => { 17 | if (!getSyncStorage('usesFaceIt')) { 18 | return; 19 | } 20 | 21 | const idSuffix = siblingRoot ? '-1' : '-0'; 22 | const matchRoomElem = getMatchRoomRoot(idSuffix, siblingRoot); 23 | const matchRoomMaps = matchInfo.matchCustom?.tree?.map?.values?.value; 24 | if (matchRoomElem && matchRoomMaps?.length > 0) { 25 | const mapElems = getMapObjects( 26 | idSuffix, 27 | matchRoomElem, 28 | matchInfo.id, 29 | matchRoomMaps 30 | ); 31 | 32 | if (mapElems && mapElems.length > 0) { 33 | if (!hasStatsElements(matchRoomElem)) { 34 | mapElems.forEach(({ mapElem, mapName }) => 35 | insertStats(idSuffix, mapElem, mapName, matchInfo) 36 | ); 37 | createPopover(idSuffix); 38 | } 39 | 40 | const timeFrame = getSyncStorage('timeFrame'); 41 | const usesCompareMode = getSyncStorage('usesCompareMode'); 42 | const stats = await loadMapStatsMemoized( 43 | `${matchInfo.id}-${timeFrame}-${usesCompareMode}`, 44 | matchInfo 45 | ); 46 | const maps = getMapDictMemoized(matchInfo.id, matchRoomMaps); 47 | 48 | mapElems.forEach(({ mapElem, mapName }) => { 49 | const mapStats = getMapStats(mapName, maps, stats, matchInfo); 50 | hydrateStats(mapElem, mapStats); 51 | }); 52 | } 53 | } 54 | 55 | return; 56 | }; 57 | 58 | export const removeMapStats = (idSuffix) => { 59 | const matchRoomElem = getMatchRoomRoot(idSuffix); 60 | if (matchRoomElem) { 61 | const statsElems = getStatsElements(matchRoomElem); 62 | if (statsElems?.length > 0) { 63 | statsElems.forEach((elem) => { 64 | elem?.parentElement?.removeAttribute('style'); 65 | elem?.remove(); 66 | }); 67 | } 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/utils.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | 3 | export const getOptimizedElement = (attr, cb) => { 4 | const idAttr = `${EXTENSION_NAME}-${attr}`; 5 | const optimizedRoot = 6 | document.getElementById(idAttr) ?? 7 | getExtensionDataAttributeElement(attr); 8 | if (!optimizedRoot) { 9 | const rootElemOnce = cb(); 10 | if (rootElemOnce) { 11 | if (rootElemOnce.hasAttribute('id')) { 12 | if (!rootElemOnce.hasAttribute(`data-${EXTENSION_NAME}`)) { 13 | rootElemOnce.setAttribute(`data-${EXTENSION_NAME}`, attr); 14 | } 15 | } else { 16 | rootElemOnce.setAttribute('id', idAttr); 17 | } 18 | 19 | return rootElemOnce; 20 | } 21 | } 22 | 23 | return optimizedRoot; 24 | }; 25 | 26 | export const findElementRecursively = (args, cb) => { 27 | const [parent, ...r] = args; 28 | if (cb(...args)) { 29 | return parent; 30 | } 31 | 32 | for (const child of parent.children) { 33 | const foundElem = findElementRecursively([child, ...r], cb); 34 | if (foundElem) { 35 | return foundElem; 36 | } 37 | } 38 | }; 39 | 40 | export const getSameParentElement = (elem1, elem2) => { 41 | const parents1 = getParentElements(elem1); 42 | const parents2 = getParentElements(elem2); 43 | 44 | for (const parent1 of parents1) { 45 | for (const parent2 of parents2) { 46 | if (parent1 === parent2) { 47 | return parent1; 48 | } 49 | } 50 | } 51 | 52 | return null; 53 | }; 54 | 55 | export const getDirectChildTextContent = (elem) => 56 | [].reduce.call( 57 | elem.childNodes, 58 | (a, b) => a + (b.nodeType === 3 ? b.textContent.trim() : ''), 59 | '' 60 | ); 61 | 62 | export const isJson = (item) => { 63 | let value = typeof item !== 'string' ? JSON.stringify(item) : item; 64 | 65 | try { 66 | value = JSON.parse(value); 67 | } catch (e) { 68 | return false; 69 | } 70 | 71 | return typeof value === 'object' && value !== null; 72 | }; 73 | 74 | const getExtensionDataAttributeElement = (attr) => 75 | document.querySelector(`[data-${EXTENSION_NAME}="${attr}"]`); 76 | 77 | const getParentElements = (elem) => { 78 | const parents = []; 79 | let parent = elem; 80 | 81 | while (parent) { 82 | parents.push(parent); 83 | parent = parent.parentElement; 84 | } 85 | 86 | return parents; 87 | }; 88 | -------------------------------------------------------------------------------- /src/shared/storage.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { DEFAULT_STORAGE } from './constants'; 3 | 4 | const syncStorage = new Map(); 5 | 6 | export const initStorage = async () => { 7 | let storage = await getAllStorage(); 8 | 9 | if (!hasAllStorageProps(storage)) { 10 | const storagePromises = DEFAULT_STORAGE.map(({ key }) => 11 | getStorage(key) 12 | ); 13 | const storageProps = await Promise.all(storagePromises); 14 | 15 | const setterPromises = storageProps.map((value, index) => { 16 | if (!value) { 17 | const { key, value } = DEFAULT_STORAGE[index]; 18 | 19 | return setStorage(key, value); 20 | } 21 | 22 | return true; 23 | }); 24 | await Promise.all(setterPromises); 25 | 26 | storage = await getAllStorage(); 27 | } 28 | 29 | if (syncStorage.size < 1) { 30 | Object.entries(storage).forEach(([key, value]) => 31 | setSyncStorage(key, value) 32 | ); 33 | } 34 | }; 35 | 36 | export const getSyncStorage = (key) => { 37 | const sto = syncStorage.get(key); 38 | 39 | if (sto && key === 'toggles') { 40 | return sto.sort((a, b) => a?.maxAge - b?.maxAge); 41 | } 42 | 43 | return sto; 44 | }; 45 | 46 | export const setSyncStorage = (key, val) => { 47 | syncStorage.set(key, val); 48 | setStorage(key, val); 49 | }; 50 | 51 | export const setStorage = (key, val) => 52 | new Promise((resolve) => { 53 | browser.storage.local 54 | .set({ [key]: val }) 55 | .then(async () => resolve(true)) 56 | .catch((err) => { 57 | console.log(err); 58 | resolve(false); 59 | }); 60 | }); 61 | 62 | export const getStorage = (key) => 63 | new Promise((resolve) => { 64 | browser.storage.local 65 | .get(key) 66 | .then((res) => resolve(key === null ? res : res?.[key])) 67 | .catch((err) => { 68 | console.log(err); 69 | resolve(false); 70 | }); 71 | }); 72 | 73 | export const clearStorage = () => 74 | new Promise((resolve) => { 75 | browser.storage.local 76 | .clear() 77 | .then(() => resolve(true)) 78 | .catch((err) => { 79 | console.log(err); 80 | resolve(false); 81 | }); 82 | }); 83 | 84 | const getAllStorage = async () => await getStorage(null); 85 | 86 | const hasAllStorageProps = (storage) => 87 | storage && 88 | typeof storage === 'object' && 89 | Object.keys(storage)?.length === DEFAULT_STORAGE.length; 90 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/toggle.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | import { getMatchInfo } from '../../shared/helpers/api'; 3 | import { getSyncStorage, setSyncStorage } from '../../shared/storage'; 4 | import { addMapStats } from '../features/addMapStats'; 5 | import { 6 | getContentRoot, 7 | getDialogSiblingRoot, 8 | getMatchRoomRoot, 9 | getRoomId, 10 | } from '../helpers/matchroom'; 11 | 12 | export const insertTimeFrameToggle = (idSuffix, parent) => { 13 | const buttonGroup = document.createElement('div'); 14 | const toggles = getSyncStorage('toggles'); 15 | 16 | buttonGroup.setAttribute('id', `${EXTENSION_NAME + idSuffix}-button-group`); 17 | 18 | for (const toggle of toggles) { 19 | const button = createButton(toggle.label, toggle.maxAge, idSuffix); 20 | 21 | buttonGroup.append(button); 22 | } 23 | 24 | setActiveToggle(buttonGroup, toggles); 25 | 26 | parent.insertAdjacentElement('beforebegin', buttonGroup); 27 | }; 28 | 29 | const createButton = (label, maxAge, idSuffix) => { 30 | const button = document.createElement('button'); 31 | 32 | button.classList.add(`${EXTENSION_NAME}-toggle`); 33 | 34 | const onClick = (e) => clickHandler(e, maxAge, idSuffix); 35 | button.removeEventListener('click', onClick); 36 | button.addEventListener('click', onClick); 37 | 38 | button.textContent = label; 39 | 40 | return button; 41 | }; 42 | 43 | const clickHandler = (e, maxAge, idSuffix) => { 44 | const rootElem = getContentRoot(); 45 | const siblingRoot = getDialogSiblingRoot(rootElem); 46 | const activeButtons = getMatchRoomRoot( 47 | idSuffix, 48 | siblingRoot 49 | ).querySelectorAll(`.${EXTENSION_NAME}-toggle-active`); 50 | 51 | for (const button of activeButtons) { 52 | button.classList.remove(`${EXTENSION_NAME}-toggle-active`); 53 | } 54 | e.currentTarget?.classList.add(`${EXTENSION_NAME}-toggle-active`); 55 | 56 | setSyncStorage('timeFrame', maxAge); 57 | updateStats(); 58 | }; 59 | 60 | const setActiveToggle = (buttonGroup, toggles) => { 61 | const activeToggle = 62 | toggles.find( 63 | (toggle) => toggle.maxAge === getSyncStorage('timeFrame') 64 | ) || toggles?.[0]; 65 | 66 | if (activeToggle) { 67 | const activeButton = [...buttonGroup.children]?.find( 68 | (button) => button.textContent === activeToggle.label 69 | ); 70 | 71 | activeButton?.classList.add(`${EXTENSION_NAME}-toggle-active`); 72 | } 73 | }; 74 | 75 | const updateStats = async () => { 76 | const matchInfo = await getMatchInfo(getRoomId()); 77 | 78 | addMapStats(matchInfo); 79 | }; 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | 129 | # Misc 130 | /build -------------------------------------------------------------------------------- /src/popup/helpers/submitters.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_COLORS, 3 | DEFAULT_COMPARE_MODE, 4 | DEFAULT_FACEIT, 5 | DEFAULT_STEAM, 6 | DEFAULT_TOGGLES, 7 | } from '../../shared/constants'; 8 | import { 9 | getSyncStorage, 10 | setStorage, 11 | setSyncStorage, 12 | } from '../../shared/storage'; 13 | import { displaySnackbar } from '../components/snackbar'; 14 | import { getUpdatedColors } from './colorPickerHelpers'; 15 | 16 | export const submitHandler = async (e) => { 17 | e.preventDefault(); 18 | 19 | const submitter = e.submitter?.name; 20 | if (submitter === 'reset') { 21 | resetSubmitter(); 22 | 23 | displaySnackbar(e.target, 'Success'); 24 | } 25 | }; 26 | 27 | export const switchSubmitter = async (e, storageKey) => { 28 | const formElem = document.getElementById('form'); 29 | 30 | await setStorage(storageKey, e.target.checked); 31 | 32 | displaySnackbar(formElem, 'Success'); 33 | }; 34 | 35 | export const colorPickerSubmitter = async (e) => { 36 | const formElem = document.getElementById('form'); 37 | const updatedColors = getUpdatedColors(e.target, e.target.value); 38 | 39 | await setStorage('colors', updatedColors); 40 | setSyncStorage('colors', updatedColors); 41 | 42 | displaySnackbar(formElem, 'Success'); 43 | }; 44 | 45 | export const timeFrameSubmitter = async (amount, type, activeLabel) => { 46 | const formElem = document.getElementById('form'); 47 | const toggles = getSyncStorage('toggles'); 48 | 49 | const toggleIndex = toggles.findIndex( 50 | (toggle) => toggle.label === activeLabel 51 | ); 52 | if (toggleIndex >= 0) { 53 | const newToggle = { 54 | label: `${amount}${type[0]}`, 55 | name: `${amount} ${ 56 | amount > 1 ? type : type.slice(0, type.length - 1) 57 | }`, 58 | amount, 59 | type, 60 | maxAge: getAge(amount, type), 61 | }; 62 | 63 | toggles[toggleIndex] = newToggle; 64 | } 65 | 66 | await setStorage( 67 | 'toggles', 68 | toggles.sort((a, b) => a?.maxAge - b?.maxAge) 69 | ); 70 | 71 | displaySnackbar(formElem, 'Success'); 72 | }; 73 | 74 | const resetSubmitter = async () => { 75 | await setStorage( 76 | 'toggles', 77 | DEFAULT_TOGGLES.sort((a, b) => a?.maxAge - b?.maxAge) 78 | ); 79 | await setStorage('usesCompareMode', DEFAULT_COMPARE_MODE); 80 | await setStorage('usesFaceIt', DEFAULT_FACEIT); 81 | await setStorage('usesSteam', DEFAULT_STEAM); 82 | setSyncStorage('colors', DEFAULT_COLORS); 83 | }; 84 | 85 | const getAge = (amount, type) => { 86 | const defaultTime = 1000 * 60 * 60 * 24; 87 | const types = { 88 | days: defaultTime * amount, 89 | weeks: defaultTime * amount * 7, 90 | months: defaultTime * amount * 30, 91 | years: defaultTime * amount * 365, 92 | }; 93 | 94 | return types[type]; 95 | }; 96 | -------------------------------------------------------------------------------- /src/popup/helpers/storageChanges.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { isEqual } from '../../shared/helpers'; 3 | import { convertRGBToHexColor } from '../../shared/helpers/colorConverter'; 4 | import { setSyncStorage } from '../../shared/storage'; 5 | import { setColorPickerValue } from '../components/colorPicker'; 6 | import { removeOldToggles, updatePopupElements } from '../components/toggle'; 7 | import { setSwitchValue } from '../features/updateSwitches'; 8 | import { displayTimeFrameToggle } from '../features/updateTimeFrameToggle'; 9 | import { getColorPickerElements, getColorType } from './colorPickerHelpers'; 10 | 11 | export const initStorageChangeListener = () => { 12 | browser.storage.local.onChanged.removeListener(updateStorage); 13 | browser.storage.local.onChanged.addListener(updateStorage); 14 | }; 15 | 16 | const updateFunc = { 17 | toggles: (changes) => timeFrameUpdater(changes), 18 | usesCompareMode: (changes, key) => 19 | toggleUpdater(changes, key, 'form-switch-input'), 20 | usesFaceIt: (changes, key) => toggleUpdater(changes, key, 'toggle-faceit'), 21 | usesSteam: (changes, key) => toggleUpdater(changes, key, 'toggle-steam'), 22 | colors: (changes) => colorPickerUpdater(changes), 23 | }; 24 | 25 | const updateStorage = async (changes = {}) => { 26 | const [[key, { oldValue, newValue }]] = Object.entries(changes); 27 | 28 | if (!isEqual(oldValue, newValue)) { 29 | updateFunc[key]?.({ oldValue, newValue }, key); 30 | } 31 | }; 32 | 33 | const timeFrameUpdater = async ({ newValue, oldValue }) => { 34 | setSyncStorage('toggles', newValue); 35 | 36 | function countObjects(arr) { 37 | return arr.reduce((acc, curr) => { 38 | const key = JSON.stringify(curr); 39 | acc.set(key, (acc.get(key) || 0) + 1); 40 | 41 | return acc; 42 | }, new Map()); 43 | } 44 | 45 | const oldCount = countObjects(oldValue); 46 | const newCount = countObjects(newValue); 47 | const newToggle = newValue.find((toggle) => { 48 | const key = JSON.stringify(toggle); 49 | const newCountForKey = newCount.get(key) || 0; 50 | const oldCountForKey = oldCount.get(key) || 0; 51 | 52 | return newCountForKey > oldCountForKey; 53 | }); 54 | 55 | updatePopupElements(false, newToggle); 56 | removeOldToggles(); 57 | 58 | await displayTimeFrameToggle(newToggle.label); 59 | }; 60 | 61 | const toggleUpdater = async ({ newValue }, storageKey, id) => { 62 | setSyncStorage(storageKey, newValue); 63 | 64 | setSwitchValue(storageKey, id); 65 | }; 66 | 67 | const colorPickerUpdater = async ({ newValue }) => { 68 | const colorPickerElems = getColorPickerElements(); 69 | 70 | for (const elem of colorPickerElems) { 71 | const color = newValue[getColorType(elem)]; 72 | const hexColor = convertRGBToHexColor(color); 73 | 74 | setColorPickerValue(elem, hexColor); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/storageChanges.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { isEqual } from '../../shared/helpers'; 3 | import { getMatchInfo } from '../../shared/helpers/api'; 4 | import { 5 | getStorage, 6 | getSyncStorage, 7 | setSyncStorage, 8 | } from '../../shared/storage'; 9 | import { addMapStats, removeMapStats } from '../features/addMapStats'; 10 | import { 11 | addTimeFrameToggle, 12 | removeTimeFrameToggle, 13 | } from '../features/addTimeFrameToggle'; 14 | import { getContentRoot, getDialogSiblingRoot, getRoomId } from './matchroom'; 15 | import { isPlayerOfMatch } from './teams'; 16 | 17 | const UPDATE_FUNC = { 18 | toggles: (key, newValue) => DOMUpdater(key, newValue), 19 | usesCompareMode: (key, newValue) => genericUpdater(key, newValue), 20 | usesFaceIt: (key, newValue) => genericUpdater(key, newValue), 21 | colors: (key, newValue) => genericUpdater(key, newValue), 22 | }; 23 | 24 | export const initStorageChangeListener = () => { 25 | browser.storage.local.onChanged.removeListener(updateStorage); 26 | browser.storage.local.onChanged.addListener(updateStorage); 27 | }; 28 | 29 | const updateStorage = async (changes) => { 30 | const [[key, { oldValue, newValue }]] = Object.entries(changes); 31 | 32 | if (!isEqual(oldValue, newValue)) { 33 | UPDATE_FUNC[key]?.(key, newValue); 34 | } 35 | }; 36 | 37 | const DOMUpdater = async (key, toggles) => { 38 | const roomId = getRoomId(); 39 | 40 | if (roomId) { 41 | const rootElem = getContentRoot(); 42 | if (rootElem) { 43 | const matchInfo = (await getMatchInfo(roomId)) ?? {}; 44 | if (matchInfo && isPlayerOfMatch(roomId, matchInfo.teams)) { 45 | const siblingRoot = getDialogSiblingRoot(rootElem); 46 | const timeFrame = await getStorage('timeFrame'); 47 | const foundToggle = 48 | (await toggles.find( 49 | (toggle) => toggle.maxAge === timeFrame 50 | )) || toggles[0]; 51 | 52 | if (foundToggle) { 53 | setSyncStorage('timeFrame', foundToggle.maxAge); 54 | } 55 | setSyncStorage('toggles', toggles); 56 | 57 | const idSuffixes = ['-0', '-1']; 58 | for (const idSuffix of idSuffixes) { 59 | removeTimeFrameToggle(idSuffix); 60 | } 61 | if (key === 'usesFaceIt' && !getSyncStorage(key)) { 62 | for (const idSuffix of idSuffixes) { 63 | removeMapStats(idSuffix); 64 | } 65 | } 66 | 67 | addTimeFrameToggle(matchInfo, siblingRoot); 68 | await addMapStats(matchInfo, siblingRoot); 69 | } 70 | } 71 | } 72 | }; 73 | 74 | const genericUpdater = (key, newValue) => { 75 | const toggles = getSyncStorage('toggles'); 76 | 77 | setSyncStorage(key, newValue); 78 | DOMUpdater(key, toggles); 79 | }; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VisusGG (formerly FACE-M) 2 | 3 | VisusGG is a browser extension created by x3picF4ilx (Bytenote) & MrMaxim that is designed to improve the user experience around FACEIT's platform, by providing valuable insights into player and map statistics. 4 | 5 | ## Features 6 | 7 | ### FACEIT 8 | 9 | This extension shows the win rates of your opponents, as well as of your teammates, on a per map basis for different time periods. 10 | Hovering over the win percentages will display the individual player performance numbers of the accumulated team stats, thus offering a deeper understanding of each player's preferences and overall contributions. 11 | 12 | - See opponents win percentages during map selection 13 | - Toggle compare mode to see statistics for both teams 14 | - Switch between time frames or create custom ones 15 | - Compatible with all game modes, HUBs & Queues 16 | - Customize the win percentage colors 17 | - Check past games 18 | 19 | ### Steam 20 | 21 | With the release of v2.0.0, VisusGG now showcases FACEIT account details directly on Steam profiles, giving its users a convenient way to assess both their own and others' FACEIT stats without the need to manually search for them in a new tab. 22 | 23 | - View FACEIT stats on Steam profiles 24 | - Check account status and creation date 25 | - Compatible with private Steam profiles 26 | 27 | ## Download 28 | 29 | Get the extension from here: 30 | 31 | - [Chrome & Edge](https://chrome.google.com/webstore/detail/visusgg/kodlabmmaalpolkfolgpahbjehalecki) 32 | - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/visusgg) 33 | 34 | ## Build from source 35 | 36 | ###### Get Bun 37 | 38 | ```bash 39 | npm install -g bun 40 | ``` 41 | 42 | ###### Clone 43 | 44 | ```bash 45 | git clone git@github.com:Bytenote/VisusGG-dev.git 46 | ``` 47 | 48 | ###### Install 49 | 50 | ```bash 51 | bun install 52 | ``` 53 | 54 | ###### Development 55 | 56 | For now we need to separate both browser versions into different builds. 57 | However, they will be merged as soon as Firefox implements the necessary MV3 support. 58 | 59 | 60 | 61 | 63 | 66 | 69 | 70 | 71 | 72 | 75 | 82 | 89 | 90 | 91 | 92 | 95 | 100 | 105 | 106 | 107 | 108 | 111 | 118 | 125 | 126 | 127 | 128 | 131 | 139 | 148 | 149 | 150 |
62 | 64 | Chrome 65 | 67 | Firefox 68 |
73 | Start dev server 74 | 76 | 77 | ```bash 78 | bun run dev 79 | ``` 80 | 81 | 83 | 84 | ```bash 85 | bun run dev:firefox 86 | ``` 87 | 88 |
93 | Reload extension 94 | 96 | 97 | `Ctrl + Shift + E` 98 | 99 | 101 | 102 | `Ctrl + Shift + E` 103 | 104 |
109 | Build extension 110 | 112 | 113 | ```bash 114 | bun run build 115 | ``` 116 | 117 | 119 | 120 | ```bash 121 | bun run build:firefox 122 | ``` 123 | 124 |
129 | Test in Browser 130 | 132 |
    133 |
  1. Open chrome://extensions
  2. 134 |
  3. Enable Developer mode
  4. 135 |
  5. Click on Load unpacked
  6. 136 |
  7. Load the entire build folder
  8. 137 |
138 |
140 |
    141 |
  1. Open about:debugging#addons
  2. 142 |
  3. Click on Load Temporary Add-on
  4. 143 |
  5. 144 | Load any file from build folder   145 |
  6. 146 |
147 |
151 | -------------------------------------------------------------------------------- /src/popup/components/toggle.js: -------------------------------------------------------------------------------- 1 | import { getToggleInfo } from '../helpers/toggles'; 2 | 3 | export const initToggleButtons = ( 4 | toggles, 5 | buttonGroupElem, 6 | activeToggleLabel = null 7 | ) => { 8 | for (const toggle of toggles) { 9 | const button = createButton( 10 | toggle.label, 11 | 'toggle-btn', 12 | activeToggleLabel 13 | ); 14 | 15 | buttonGroupElem.append(button); 16 | } 17 | }; 18 | 19 | export const removeOldToggles = () => { 20 | const toggleGroupChildren = [ 21 | ...document.getElementById('button-edit-group')?.children, 22 | ]; 23 | toggleGroupChildren.forEach((toggle) => { 24 | if (toggle.classList.contains('toggle-btn')) { 25 | toggle.removeEventListener('click', clickHandler); 26 | toggle.remove(); 27 | } 28 | }); 29 | }; 30 | 31 | export const updatePopupElements = (isDisabled, toggle = null) => { 32 | const timeFrameModalElem = document.getElementById('time-frame-modal'); 33 | timeFrameModalElem.style.visibility = isDisabled ? 'hidden' : 'visible'; 34 | 35 | if (toggle) { 36 | if (toggle?.type && toggle?.amount) { 37 | const timeFrameUnitBtns = [ 38 | ...document.getElementById('time-frame-units')?.children, 39 | ]; 40 | const timeFrameNumberBtns = [ 41 | ...document.getElementById('time-frame-numbers')?.children, 42 | ]; 43 | 44 | timeFrameUnitBtns.forEach((btn) => { 45 | if (btn.value === toggle.type) { 46 | btn.classList.add('time-frame-btn-active'); 47 | } else { 48 | btn.classList.remove('time-frame-btn-active'); 49 | } 50 | }); 51 | 52 | timeFrameNumberBtns.forEach((btn) => { 53 | if (+btn.value === toggle.amount) { 54 | btn.classList.add('time-frame-btn-active'); 55 | } else { 56 | btn.classList.remove('time-frame-btn-active'); 57 | } 58 | }); 59 | } 60 | } 61 | }; 62 | 63 | const createButton = (label, cssClass, activeToggleLabel = null) => { 64 | const btnGroup = document.getElementById('button-edit-group'); 65 | const button = document.createElement('button'); 66 | 67 | button.classList.add( 68 | cssClass, 69 | ...(activeToggleLabel === label && 70 | !hasClass(btnGroup, 'toggle-btn-active') 71 | ? ['toggle-btn-active'] 72 | : []) 73 | ); 74 | button.addEventListener('click', clickHandler); 75 | 76 | button.textContent = label; 77 | button.value = label; 78 | 79 | return button; 80 | }; 81 | 82 | const clickHandler = (e) => { 83 | const activeButtons = document.querySelectorAll('.toggle-btn-active'); 84 | 85 | if (e.currentTarget.classList.contains('toggle-btn-active')) { 86 | e.currentTarget.classList.remove('toggle-btn-active'); 87 | 88 | updatePopupElements(true); 89 | } else { 90 | const toggle = getToggleInfo(e.currentTarget.value); 91 | 92 | for (const button of activeButtons) { 93 | button.classList.remove('toggle-btn-active'); 94 | } 95 | e.currentTarget?.classList.add('toggle-btn-active'); 96 | 97 | updatePopupElements(false, toggle); 98 | } 99 | }; 100 | 101 | const hasClass = (parent, className) => !!parent.querySelector(`.${className}`); 102 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/winrate.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | import { getColorToUse } from '../helpers/colorHelper'; 3 | import { setColorOfElements } from './color'; 4 | import { hidePopover, showPopover } from './popover'; 5 | 6 | export const insertStats = (idSuffix, mapElement, mapName, matchInfo) => { 7 | const statsDiv = document.createElement('div'); 8 | const bar = document.createElement('span'); 9 | const winRateDiv = document.createElement('div'); 10 | const winRateText = document.createElement('div'); 11 | const winRateInfo = document.createElement('div'); 12 | 13 | statsDiv.classList.add(`${EXTENSION_NAME}-stats`); 14 | bar.classList.add(`${EXTENSION_NAME}-bar`); 15 | winRateDiv.classList.add(`${EXTENSION_NAME}-win-rate`); 16 | 17 | winRateText.textContent = '...%'; 18 | 19 | function onMouseOver(e) { 20 | showPopover(e, idSuffix, statsDiv, mapName, matchInfo); 21 | } 22 | function onMouseOut(e) { 23 | hidePopover(e, idSuffix, statsDiv); 24 | } 25 | 26 | winRateInfo.style.fontSize = '0.57rem'; 27 | 28 | statsDiv.removeEventListener('mouseover', onMouseOver); 29 | statsDiv.removeEventListener('mouseout', onMouseOut); 30 | 31 | statsDiv.addEventListener('mouseover', onMouseOver); 32 | statsDiv.addEventListener('mouseout', onMouseOut); 33 | 34 | winRateDiv.append(winRateText, winRateInfo); 35 | statsDiv.append(bar, winRateDiv); 36 | 37 | mapElement.insertAdjacentElement('afterbegin', statsDiv); 38 | }; 39 | 40 | export const hydrateStats = (mapElement, stats) => { 41 | const bar = mapElement.querySelector(`.${EXTENSION_NAME}-bar`); 42 | const [winRateElem, winRateInfoElem] = mapElement.querySelector( 43 | `.${EXTENSION_NAME}-win-rate` 44 | ).children; 45 | 46 | const { winRate, winRateInfo, condition, winRateSymbol } = 47 | getModeSpecificDataToDisplay(stats); 48 | 49 | if (winRateInfoElem && winRateInfoElem.textContent !== winRateInfo) { 50 | winRateInfoElem.textContent = winRateInfo; 51 | } 52 | 53 | if (!isNaN(winRate)) { 54 | const elements = [ 55 | { element: mapElement, type: 'background', opacity: 0.05 }, 56 | { element: bar, type: 'background' }, 57 | { element: winRateElem, type: 'color' }, 58 | ]; 59 | const colorToUse = getColorToUse(condition); 60 | setColorOfElements(colorToUse, elements); 61 | 62 | const displayValue = `${winRateSymbol + winRate}%`; 63 | if ( 64 | winRateElem?.textContent && 65 | winRateElem.textContent !== displayValue 66 | ) { 67 | winRateElem.textContent = displayValue; 68 | } 69 | } else { 70 | const displayValue = '---'; 71 | if ( 72 | winRateElem?.textContent && 73 | winRateElem.textContent !== displayValue 74 | ) { 75 | mapElement.style.removeProperty('background'); 76 | bar.style.removeProperty('background'); 77 | winRateElem.style.removeProperty('color'); 78 | winRateElem.textContent = displayValue; 79 | } 80 | } 81 | }; 82 | 83 | const getModeSpecificDataToDisplay = (stats) => { 84 | let data = { 85 | winRate: 0, 86 | winRateInfo: '', 87 | condition: false, 88 | winRateSymbol: '', 89 | }; 90 | 91 | if (stats?.length === 1) { 92 | data.winRate = stats[0]?.winRate; 93 | data.winRateInfo = 'Enemy Win %'; 94 | data.condition = data.winRate >= 50; 95 | } else if (stats?.length === 2) { 96 | const ownTeamSide = stats[0]?.ownTeamSide === 0 ? 0 : 1; 97 | const opponentTeamSide = ownTeamSide === 0 ? 1 : 0; 98 | 99 | data.winRate = 100 | stats[ownTeamSide]?.winRate - stats[opponentTeamSide]?.winRate; 101 | data.winRateInfo = 'Win %'; 102 | 103 | data.condition = data.winRate <= 0; 104 | data.winRateSymbol = data.winRate > 0 ? '+' : data.winRate === 0 && '±'; 105 | } 106 | 107 | return data; 108 | }; 109 | -------------------------------------------------------------------------------- /scripts/lib/utils.js: -------------------------------------------------------------------------------- 1 | import assetLoader from 'bun-asset-loader'; 2 | import cssLoader from 'bun-css-loader'; 3 | import { WEB_ACCESSIBLE_RESOURCES } from './constants'; 4 | 5 | export const generateBuildOptions = (browser) => { 6 | const basePath = `./build/${browser}`; 7 | 8 | return { 9 | entrypoints: [ 10 | './src/background/index.js', 11 | './src/contentFaceIt/index.js', 12 | './src/contentSteam/index.js', 13 | './src/popup/index.js', 14 | ], 15 | outdir: basePath, 16 | target: 'browser', 17 | format: 'esm', 18 | minify: true, 19 | sourcemap: isDev ? 'inline' : 'none', 20 | naming: '[dir].[ext]', 21 | plugins: [ 22 | assetLoader(generateAssetLoaderOptions(basePath)), 23 | cssLoader(), 24 | ], 25 | }; 26 | }; 27 | 28 | const generateAssetLoaderOptions = (basePath) => { 29 | const isChrome = basePath === './build/chrome'; 30 | const transformFunc = isChrome ? transformChrome : transformFirefox; 31 | 32 | return { 33 | assets: [ 34 | { 35 | from: './src/manifest.json', 36 | to: basePath, 37 | minify: true, 38 | transform: transformFunc, 39 | }, 40 | { 41 | from: './src/assets/icons', 42 | to: basePath, 43 | filter: /\.png$/, 44 | }, 45 | { 46 | from: './src/assets/imgs', 47 | to: `${basePath}/imgs`, 48 | filter: /\.png$/, 49 | }, 50 | { 51 | from: './src/contentSteam/contentSteam.css', 52 | to: basePath, 53 | minify: true, 54 | }, 55 | { 56 | from: './src/popup/styles.css', 57 | to: basePath, 58 | name: 'popup.css', 59 | minify: true, 60 | }, 61 | { 62 | from: './src/popup/index.html', 63 | to: basePath, 64 | name: 'popup.html', 65 | minify: true, 66 | }, 67 | ], 68 | }; 69 | }; 70 | 71 | const transformChrome = (content) => { 72 | let manifest = JSON.parse(content.toString()); 73 | 74 | if (isDev) { 75 | manifest = addReloadCommand(manifest); 76 | } 77 | 78 | return JSON.stringify(manifest); 79 | }; 80 | 81 | const transformFirefox = (content) => { 82 | let manifest = JSON.parse(content.toString()); 83 | 84 | if (isDev) { 85 | manifest = addReloadCommand(manifest); 86 | } 87 | manifest = convertManifestV3ToFirefoxV2(manifest); 88 | 89 | return JSON.stringify(manifest); 90 | }; 91 | 92 | const addReloadCommand = (manifest) => { 93 | manifest.commands = { 94 | reload_extension: { 95 | suggested_key: { 96 | default: 'Ctrl+Shift+E', 97 | mac: 'Command+Shift+E', 98 | }, 99 | description: 'Reload in dev mode', 100 | }, 101 | }; 102 | 103 | return manifest; 104 | }; 105 | 106 | const convertManifestV3ToFirefoxV2 = (manifest) => { 107 | manifest.manifest_version = 2; 108 | manifest.background = { 109 | scripts: ['background.js'], 110 | }; 111 | manifest.content_scripts[0] = { 112 | ...manifest.content_scripts[0], 113 | all_frames: false, 114 | }; 115 | manifest['browser_action'] = manifest.action; 116 | manifest.web_accessible_resources = WEB_ACCESSIBLE_RESOURCES; 117 | manifest.permissions = [ 118 | ...manifest.permissions, 119 | ...manifest.host_permissions, 120 | ]; 121 | 122 | manifest['browser_specific_settings'] = { 123 | gecko: { 124 | id: '{13c012e0-7b3e-48d8-8067-3f8504f91913}', 125 | strict_min_version: '58.0', 126 | }, 127 | }; 128 | 129 | delete manifest.action; 130 | delete manifest.host_permissions; 131 | 132 | return manifest; 133 | }; 134 | 135 | const isDev = process.env.NODE_ENV === 'development'; 136 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/style.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | 3 | export const createStylingElement = (parent) => { 4 | const rootStyling = document.createElement('style'); 5 | 6 | rootStyling.setAttribute('id', `${EXTENSION_NAME}-styling`); 7 | rootStyling.textContent = ` 8 | div#${EXTENSION_NAME}-0-button-group, 9 | div#${EXTENSION_NAME}-1-button-group { 10 | text-align: start; 11 | } 12 | div#${EXTENSION_NAME}-0-button-group > button.${EXTENSION_NAME}-toggle:first-child, 13 | div#${EXTENSION_NAME}-1-button-group > button.${EXTENSION_NAME}-toggle:first-child { 14 | border-radius: 4px 0 0 4px; 15 | } 16 | div#${EXTENSION_NAME}-0-button-group > button.${EXTENSION_NAME}-toggle:last-child, 17 | div#${EXTENSION_NAME}-1-button-group > button.${EXTENSION_NAME}-toggle:last-child { 18 | border-radius: 0 4px 4px 0; 19 | } 20 | button.${EXTENSION_NAME}-toggle { 21 | background: #1f1f1f; 22 | border: 1px solid #303030; 23 | color: rgba(255, 255, 255, 0.6); 24 | cursor: pointer; 25 | overflow: hidden; 26 | padding: 6px 12px; 27 | transition: background 100ms; 28 | } 29 | button.${EXTENSION_NAME}-toggle:hover:not(.${EXTENSION_NAME}-toggle-active) { 30 | background: #282828; 31 | } 32 | button.${EXTENSION_NAME}-toggle-active { 33 | background: #303030; 34 | color: #fff; 35 | } 36 | div.${EXTENSION_NAME}-stats { 37 | display: flex; 38 | align-items: center; 39 | } 40 | span.${EXTENSION_NAME}-bar { 41 | background: #303030; 42 | display: inline-block; 43 | height: 100%; 44 | width: 7px; 45 | } 46 | div.${EXTENSION_NAME}-win-rate { 47 | color: #303030; 48 | font-weight: bold; 49 | padding-left: 8px; 50 | text-align: center; 51 | width: 72px; 52 | } 53 | div.${EXTENSION_NAME}-popover { 54 | background: #161616; 55 | box-shadow: 0px 2px 8px 4px rgb(0, 0, 0, 0.38); 56 | color: rgba(255,255,255,0.6); 57 | display: block; 58 | font-family: Play, sans-serif; 59 | font-size: 14px; 60 | padding: 10px 18px; 61 | position: fixed; 62 | z-index: 3000; 63 | } 64 | div.${EXTENSION_NAME}-popover-heading { 65 | display: flex; 66 | font-weight: bold; 67 | justify-content: space-between; 68 | padding-bottom: 16px; 69 | padding-top: 12px; 70 | } 71 | div.${EXTENSION_NAME}-map { 72 | color: #fff; 73 | font-size: 17px; 74 | } 75 | div.${EXTENSION_NAME}-time-frame { 76 | color: #ff5500; 77 | font-size: 14px; 78 | } 79 | div.${EXTENSION_NAME}-player-div:not(:last-of-type), 80 | div.${EXTENSION_NAME}-player-div-compact:not(:last-of-type) { 81 | border-bottom: 1px solid #303030; 82 | } 83 | div.${EXTENSION_NAME}-player-div { 84 | display: grid; 85 | grid-template-columns: repeat(3, minmax(0, 1fr)); 86 | grid-auto-flow: column; 87 | grid-column-gap: 55px; 88 | padding: 16px 0px; 89 | width: 100%; 90 | } 91 | span.${EXTENSION_NAME}-player-name { 92 | font-weight: bold; 93 | overflow: hidden; 94 | text-overflow: ellipsis; 95 | white-space: nowrap; 96 | } 97 | span.${EXTENSION_NAME}-player-matches { 98 | text-align: center; 99 | } 100 | span.${EXTENSION_NAME}-player-win-rate { 101 | display: inline-block; 102 | font-size: 16px; 103 | font-weight: bold; 104 | text-align: end; 105 | width: auto; 106 | } 107 | div.${EXTENSION_NAME}-stats-div-compact { 108 | display: grid; 109 | grid-auto-flow: column; 110 | grid-column-gap: 20px; 111 | grid-template-columns: repeat(2, minmax(0, 1fr)); 112 | padding: 14px 0px; 113 | padding-top: 0px; 114 | width: 100%; 115 | } 116 | div.${EXTENSION_NAME}-team-div-compact { 117 | border-bottom: 3px solid #303030; 118 | display: grid; 119 | font-size: 15px; 120 | font-weight: bold; 121 | grid-auto-flow: column; 122 | grid-template-columns: repeat(3, minmax(0, 1fr)); 123 | padding: 14px 0px; 124 | width: 100%; 125 | } 126 | div.${EXTENSION_NAME}-team-div-compact > span.${EXTENSION_NAME}-player-win-rate-compact { 127 | font-size: 15px; 128 | font-weight: bold; 129 | } 130 | div.${EXTENSION_NAME}-player-div-compact { 131 | display: grid; 132 | font-size: 12px; 133 | font-weight: bold; 134 | grid-template-columns: repeat(3, minmax(0, 1fr)); 135 | grid-auto-flow: column; 136 | padding: 14px 0px; 137 | width: 100%; 138 | } 139 | span.${EXTENSION_NAME}-player-name-compact{ 140 | font-weight: bold; 141 | overflow: hidden; 142 | text-overflow: ellipsis; 143 | white-space: nowrap; 144 | } 145 | span.${EXTENSION_NAME}-player-matches-compact { 146 | text-align: center; 147 | } 148 | span.${EXTENSION_NAME}-player-win-rate-compact { 149 | display: inline-block; 150 | font-size: 12px; 151 | font-weight: bold; 152 | text-align: end; 153 | width: auto; 154 | } 155 | div#${EXTENSION_NAME}-badge { 156 | display: inline-block; 157 | color: #ff5500; 158 | font-size: 14px; 159 | font-weight: bold; 160 | padding-bottom: 3px; 161 | } 162 | @keyframes ripple { 163 | to { 164 | transform: scale(4); 165 | opacity: 0; 166 | } 167 | }`; 168 | 169 | parent.appendChild(rootStyling); 170 | }; 171 | -------------------------------------------------------------------------------- /src/popup/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-theme-primary: #ff5500; 3 | --color-text-primary: #fff; 4 | --color-text-secondary: rgba(255, 255, 255, 0.6); 5 | --color-bg-primary: #161616; 6 | --color-bg-secondary: #1f1f1f; 7 | --color-bg-elevated-4: rgb(39, 39, 39); 8 | --color-bg-elevated-12: rgb(51, 51, 51); 9 | --color-button-disabled: #fea3756c; 10 | --font-family: 'Segoe UI', Tahoma, sans-serif; 11 | } 12 | 13 | body { 14 | font-family: var(--font-family); 15 | background: var(--color-bg-primary); 16 | color: var(--color-text-secondary); 17 | font-weight: 400; 18 | padding: 0px; 19 | margin: 0px; 20 | width: 525px; 21 | overflow: hidden; 22 | } 23 | 24 | .heading { 25 | background: var(--color-bg-elevated-4); 26 | color: var(--color-theme-primary); 27 | font-size: 20px; 28 | font-weight: 700; 29 | letter-spacing: 0.8px; 30 | padding: 22px 25px; 31 | text-align: start; 32 | } 33 | 34 | .info { 35 | display: block; 36 | color: var(--color-text-secondary); 37 | font-size: 11px; 38 | font-weight: 400; 39 | } 40 | 41 | .creators { 42 | color: var(--color-text-primary); 43 | font-size: 11px; 44 | } 45 | 46 | .sub-heading { 47 | display: flex; 48 | justify-content: space-between; 49 | align-items: center; 50 | background: var(--color-bg-elevated-12); 51 | color: var(--color-theme-primary); 52 | font-size: 16px; 53 | font-weight: 500; 54 | text-transform: uppercase; 55 | padding: 16px 25px; 56 | } 57 | 58 | .content-container { 59 | padding: 15px 40px; 60 | } 61 | 62 | .feature-helper { 63 | fill: var(--color-theme-primary); 64 | width: 10px; 65 | height: 10px; 66 | padding: 1px; 67 | margin-left: 5px; 68 | border: 1px solid var(--color-theme-primary); 69 | border-radius: 50%; 70 | } 71 | 72 | .option { 73 | padding-bottom: 25px; 74 | } 75 | 76 | .option-heading { 77 | display: flex; 78 | align-items: center; 79 | color: var(--color-text-primary); 80 | font-size: 13px; 81 | font-weight: 600; 82 | text-align: start; 83 | } 84 | 85 | .option-helper { 86 | fill: #fff; 87 | width: 10px; 88 | height: 10px; 89 | padding: 1px; 90 | margin-left: 5px; 91 | border: 1px solid #fff; 92 | border-radius: 50%; 93 | } 94 | 95 | .option-content { 96 | display: flex; 97 | justify-content: space-between; 98 | align-items: center; 99 | } 100 | 101 | .option-text { 102 | display: inline-block; 103 | } 104 | 105 | .option-action { 106 | display: flex; 107 | justify-content: flex-end; 108 | min-width: 140px; 109 | max-width: 140px; 110 | padding-left: 10px; 111 | } 112 | 113 | #button-edit-group > .toggle-btn:first-child { 114 | border-radius: 4px 0 0 4px; 115 | } 116 | 117 | #button-edit-group > .toggle-btn:last-child { 118 | border-radius: 0 4px 4px 0; 119 | } 120 | 121 | .toggle-btn { 122 | background: var(--color-bg-secondary); 123 | border: 1px solid #303030; 124 | color: var(--color-text-secondary); 125 | cursor: pointer; 126 | overflow: hidden; 127 | padding: 6px 12px; 128 | transition: background 100ms; 129 | } 130 | 131 | .toggle-btn:hover:not(.toggle-btn-active), 132 | .time-frame-btn:hover:not(.time-frame-btn-active) { 133 | background: #282828; 134 | } 135 | 136 | .toggle-btn-active, 137 | .time-frame-btn-group > .time-frame-btn.time-frame-btn-active { 138 | background: #303030; 139 | color: var(--color-text-primary); 140 | } 141 | 142 | #time-frame-modal { 143 | position: absolute; 144 | background: var(--color-bg-primary); 145 | border-radius: 4px; 146 | width: 428px; 147 | padding: 10px; 148 | z-index: 99; 149 | box-shadow: 0px 2px 8px 4px rgb(0, 0, 0, 0.38); 150 | top: 197px; 151 | left: 38px; 152 | visibility: hidden; 153 | } 154 | 155 | .time-frame-btn-group { 156 | display: flex; 157 | justify-content: space-between; 158 | gap: 5px; 159 | } 160 | 161 | .time-frame-btn-group:first-child { 162 | margin-bottom: 5px; 163 | } 164 | 165 | .time-frame-btn-group > .time-frame-btn { 166 | background: var(--color-bg-secondary); 167 | border-radius: 4px; 168 | border: 1px solid #303030; 169 | color: var(--color-text-secondary); 170 | cursor: pointer; 171 | overflow: hidden; 172 | padding: 6px 0px; 173 | transition: background 100ms; 174 | width: 100%; 175 | } 176 | 177 | .option-desc { 178 | color: var(--color-text-secondary); 179 | font-size: 11px; 180 | font-weight: 400; 181 | } 182 | 183 | .form-submit-btn { 184 | background: var(--color-theme-primary); 185 | border: 0px; 186 | border-radius: 2px; 187 | color: var(--color-text-primary); 188 | cursor: pointer; 189 | font-size: 12px; 190 | font-weight: 600; 191 | height: 28px; 192 | min-width: 75px; 193 | text-transform: uppercase; 194 | transition: background 100ms; 195 | } 196 | 197 | .form-submit-btn:hover:not(:disabled) { 198 | background: #ff5500b3; 199 | } 200 | 201 | .form-submit-btn:active:not(:disabled) { 202 | background: #f87b3d8b; 203 | } 204 | 205 | .form-submit-btn:disabled { 206 | background: var(--color-button-disabled); 207 | color: var(--color-text-secondary); 208 | cursor: auto; 209 | } 210 | 211 | .toggle { 212 | display: inline-block; 213 | height: 10px; 214 | min-width: 35px; 215 | position: relative; 216 | width: 25px; 217 | } 218 | 219 | .toggle-switch-input { 220 | display: none; 221 | } 222 | 223 | .toggle-switch-input:checked + .toggle-span { 224 | background: #fea3756c; 225 | } 226 | 227 | .toggle-switch-input:checked + .toggle-span:before { 228 | background: var(--color-theme-primary); 229 | transform: translate(18px, 0); 230 | } 231 | 232 | .toggle-span { 233 | background: var(--color-text-secondary); 234 | border-radius: 28px; 235 | bottom: 0; 236 | cursor: pointer; 237 | display: block; 238 | left: 0; 239 | position: absolute; 240 | right: 0; 241 | top: 0; 242 | transition: all 0.25s; 243 | width: 100%; 244 | } 245 | 246 | .toggle-span:before { 247 | background: var(--color-text-primary); 248 | border-radius: 100%; 249 | bottom: -5px; 250 | content: ''; 251 | display: block; 252 | height: 20px; 253 | left: -2px; 254 | position: absolute; 255 | transition: all 0.25s; 256 | width: 20px; 257 | } 258 | 259 | .form-color-picker { 260 | cursor: pointer; 261 | border: none; 262 | display: block; 263 | height: 100%; 264 | opacity: 0; 265 | width: 30px; 266 | } 267 | 268 | #form-color-picker-wrapper2 { 269 | margin-left: 7px; 270 | } 271 | 272 | .feedback { 273 | align-items: center; 274 | animation: fadeIn 0.25s; 275 | background: rgb(0, 153, 51); 276 | border-radius: 3px; 277 | color: var(--color-text-primary); 278 | display: flex; 279 | justify-content: center; 280 | font-size: 14px; 281 | font-weight: 600; 282 | max-height: 40px; 283 | padding: 10px 25px; 284 | position: absolute; 285 | right: 10px; 286 | top: 10px; 287 | } 288 | 289 | .clr-field button, 290 | #clr-color-preview, 291 | input#clr-color-value { 292 | display: none; 293 | } 294 | 295 | .clr-field { 296 | height: 27px; 297 | margin-left: auto; 298 | margin-right: 0; 299 | width: 34px; 300 | } 301 | 302 | .clr-picker { 303 | padding-bottom: 10px; 304 | } 305 | 306 | @keyframes fadeIn { 307 | 0% { 308 | opacity: 0; 309 | } 310 | 100% { 311 | opacity: 1; 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/matchroom.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | import { getMapDictMemoized } from './stats'; 3 | import { 4 | findElementRecursively, 5 | getDirectChildTextContent, 6 | getOptimizedElement, 7 | } from './utils'; 8 | 9 | export const getRoomId = (path = location.pathname) => 10 | /room\/([0-9a-z]+-[0-9a-z]+-[0-9a-z]+-[0-9a-z]+-[0-9a-z]+(?:-[0-9a-z]+)?)/.exec( 11 | path 12 | )?.[1] || null; 13 | 14 | export const getContentRoot = () => document.getElementById('canvas-body'); 15 | 16 | export const getDialogSiblingRoot = (elem) => { 17 | return getOptimizedElement('canvas-dialog-root', () => { 18 | const parent = elem.parentElement; 19 | const siblings = [...(parent?.children ?? [])]; 20 | 21 | return siblings.find( 22 | (x) => 23 | x.getAttribute('role') === 'dialog' && 24 | x.getAttribute('data-state') === 'open' 25 | ); 26 | }); 27 | }; 28 | 29 | export const getMatchRoomRoot = (idSuffix, parent) => 30 | getOptimizedElement(`matchroom-overview${idSuffix}`, () => { 31 | const infoCol = (parent ?? document).querySelector('div[name="info"]'); 32 | if (infoCol) { 33 | const infoSiblings = [...(infoCol.parentElement.children ?? [])]; 34 | const hasSiblings = infoSiblings.filter( 35 | (x) => 36 | x.getAttribute('name') === 'roster1' || 37 | x.getAttribute('name') === 'roster2' 38 | ); 39 | if (hasSiblings.length > 0) { 40 | return infoCol.parentElement; 41 | } 42 | } 43 | }); 44 | 45 | export const getMapObjects = ( 46 | idSuffix, 47 | matchRoomElem, 48 | matchRoomId, 49 | matchRoomMaps = [] 50 | ) => { 51 | const mapDict = getMapDictMemoized(matchRoomId, matchRoomMaps); 52 | const matchRoomInfoColumn = getOptimizedElement( 53 | `matchroom-info-column${idSuffix}`, 54 | () => matchRoomElem.querySelector("div > div[name='info']") 55 | ); 56 | 57 | const knownMapObjects = getKnownMapObjects( 58 | matchRoomInfoColumn ?? matchRoomElem, 59 | idSuffix 60 | ); 61 | if (knownMapObjects.length > 0) { 62 | return knownMapObjects; 63 | } 64 | 65 | if (matchRoomElem) { 66 | const potentialMapElems = findPotentialMapElements( 67 | matchRoomInfoColumn, 68 | matchRoomElem, 69 | matchRoomMaps 70 | ); 71 | 72 | const mapObjects = (potentialMapElems ?? []) 73 | .reduce((acc, mapElem) => { 74 | let mapName = ''; 75 | 76 | if ( 77 | mapElem?.querySelector('div.startSlot') && 78 | mapElem.querySelector('div.middleSlot') 79 | ) { 80 | mapName = getMapNameFromElement( 81 | mapElem.querySelector('div.middleSlot'), 82 | mapDict 83 | ); 84 | } else { 85 | const useDirectChild = true; 86 | mapName = findElementRecursively( 87 | [mapElem, mapDict, useDirectChild], 88 | getMapNameFromElement 89 | )?.textContent; 90 | 91 | console.info(`[${EXTENSION_NAME}]: Using backup map name`); 92 | } 93 | 94 | if (mapName) { 95 | acc.push({ 96 | mapElem, 97 | mapName, 98 | }); 99 | } 100 | 101 | return acc; 102 | }, []) 103 | .map(({ mapElem, mapName }, i) => { 104 | const attr = `${EXTENSION_NAME + idSuffix}-map-${i}`; 105 | const attrType = mapElem.hasAttribute('id') 106 | ? `data-${EXTENSION_NAME}` 107 | : 'id'; 108 | 109 | const isKnownMapElem = mapElem.getAttribute(attrType) === attr; 110 | if (!isKnownMapElem) { 111 | mapElem.setAttribute(attrType, attr); 112 | mapElem.setAttribute(`data-${EXTENSION_NAME}-map`, mapName); 113 | } 114 | 115 | return { 116 | mapElem, 117 | mapName, 118 | }; 119 | }); 120 | 121 | return mapObjects; 122 | } 123 | }; 124 | 125 | export const hasStatsElements = (parent) => 126 | [...parent.querySelectorAll(`div.${EXTENSION_NAME}-stats`)].length > 0; 127 | 128 | export const getStatsElements = (parent) => [ 129 | ...parent.querySelectorAll(`div.${EXTENSION_NAME}-stats`), 130 | ]; 131 | 132 | export const hasToggleElements = (idSuffix, parent) => 133 | !!parent.querySelector(`#${EXTENSION_NAME + idSuffix}-button-group`); 134 | 135 | export const getToggleGroup = (idSuffix, parent) => 136 | parent.querySelector(`#${EXTENSION_NAME + idSuffix}-button-group`); 137 | 138 | const getKnownMapObjects = (container, idSuffix) => { 139 | const mapObjects = []; 140 | 141 | let i = 0; 142 | while (true) { 143 | const attr = `${EXTENSION_NAME + idSuffix}-map-${i}`; 144 | const mapElem = 145 | document.getElementById(attr) ?? 146 | container.querySelector(`[data-${EXTENSION_NAME}="${attr}"]`); 147 | if (mapElem) { 148 | mapObjects.push({ 149 | mapElem, 150 | mapName: mapElem.getAttribute(`data-${EXTENSION_NAME}-map`), 151 | }); 152 | } else break; 153 | 154 | i++; 155 | } 156 | 157 | return mapObjects; 158 | }; 159 | 160 | const findPotentialMapElements = ( 161 | container, 162 | containerBackup, 163 | matchRoomMaps 164 | ) => { 165 | const parent = container ?? containerBackup; 166 | 167 | const mapElems1 = [ 168 | ...(parent.querySelectorAll('[data-testid=matchPreference]') ?? []), 169 | ]; 170 | if (mapElems1?.length > 0) { 171 | return mapElems1; 172 | } 173 | 174 | const mapElems2 = [...(parent?.querySelectorAll('div.endSlot') ?? [])].map( 175 | (x) => x?.parentElement?.parentElement 176 | ); 177 | if (mapElems2?.length > 0) { 178 | return mapElems2; 179 | } 180 | 181 | const mapElems3 = findMapElementsByImageSrc(containerBackup, matchRoomMaps); 182 | if (mapElems3?.length > 0) { 183 | console.info(`[${EXTENSION_NAME}]: Using backup map elements`); 184 | 185 | return mapElems3; 186 | } 187 | }; 188 | 189 | const findMapElementsByImageSrc = (container, matchRoomMaps) => 190 | matchRoomMaps.reduce((maps, map) => { 191 | const mapElem = 192 | container.querySelector(`div[src="${map.image_sm}"]`)?.parentElement 193 | ?.parentElement?.parentElement ?? 194 | container.querySelector(`div[src="${map.image_lg}"]`)?.parentElement 195 | ?.parentElement?.parentElement; 196 | if (mapElem) { 197 | maps.push(mapElem); 198 | } 199 | 200 | return maps; 201 | }, []); 202 | 203 | const getMapNameFromElement = (elem, mapDict, useDirectChild = false) => { 204 | let textContent = elem.textContent.trim(); 205 | if (useDirectChild) { 206 | textContent = getDirectChildTextContent(elem); 207 | } 208 | 209 | const mapNames = mapDict[textContent]; 210 | if (mapNames) { 211 | return ( 212 | mapNames.class_name || 213 | mapNames.name || 214 | mapNames.guid || 215 | mapNames.game_map_id 216 | ); 217 | } 218 | }; 219 | -------------------------------------------------------------------------------- /src/contentSteam/helpers/stats.js: -------------------------------------------------------------------------------- 1 | import { 2 | getLifeTimeStats, 3 | getPlayerBans, 4 | getPlayerInfo, 5 | getPlayerMatches, 6 | getProfileBySteamId, 7 | } from '../../shared/helpers/api'; 8 | 9 | export const getStats = async (steamId, selectedGame) => { 10 | try { 11 | const accountRes = await getProfileBySteamId(steamId, 'csgo'); 12 | let playerInfo = {}; 13 | 14 | if (accountRes) { 15 | const csAccounts = accountRes?.filter((player) => 16 | player?.games?.some((game) => 17 | ['cs2', 'csgo'].includes(game?.name?.toLowerCase()) 18 | ) 19 | ); 20 | if (csAccounts.length === 0) { 21 | return playerInfo; 22 | } 23 | 24 | const orderedAccounts = getOrderedAccountsByPriority(csAccounts); 25 | for (const { country, id, nickname } of orderedAccounts) { 26 | if (id) { 27 | const csgoStatsPromise = getLifeTimeStats('csgo', id); 28 | const cs2StatsPromise = getLifeTimeStats('cs2', id); 29 | const banPromise = getPlayerBans(id); 30 | const profilePromise = getPlayerInfo(nickname); 31 | const csgoMatchesPromise = getPlayerMatches('csgo', id, 20); 32 | const cs2MatchesPromise = getPlayerMatches('cs2', id, 20); 33 | 34 | const [ 35 | csgoStats, 36 | cs2Stats, 37 | bans, 38 | profile, 39 | csgoMatches, 40 | cs2Matches, 41 | ] = await Promise.all([ 42 | csgoStatsPromise, 43 | cs2StatsPromise, 44 | banPromise, 45 | profilePromise, 46 | csgoMatchesPromise, 47 | cs2MatchesPromise, 48 | ]); 49 | 50 | const hasMatchingSteamId = 51 | profile?.games?.cs2?.game_id === steamId || 52 | profile?.games?.csgo?.game_id === steamId || 53 | false; 54 | if (hasMatchingSteamId) { 55 | playerInfo.cs2 = getAvgStats( 56 | cs2Matches, 57 | cs2Stats, 58 | profile, 59 | 'cs2' 60 | ); 61 | playerInfo.csgo = getAvgStats( 62 | csgoMatches, 63 | csgoStats, 64 | profile, 65 | 'csgo' 66 | ); 67 | 68 | selectedGame = !!selectedGame 69 | ? selectedGame 70 | : getSelectedGame(cs2Matches); 71 | 72 | playerInfo.membership = getMembershipStatus( 73 | profile?.memberships 74 | ); 75 | playerInfo.createdAt = getAccountCreationDate( 76 | profile?.created_at ?? 77 | cs2Stats?.lifetime?.created_at ?? 78 | csgoStats?.lifetime?.created_at 79 | ); 80 | playerInfo.description = getAccountDescription( 81 | playerInfo.membership, 82 | playerInfo.createdAt 83 | ); 84 | 85 | const reason = bans?.[0]?.reason?.toLowerCase(); 86 | if (reason) { 87 | const banType = 88 | bans[0]?.ends_at === null 89 | ? 'permanent' 90 | : 'temporary'; 91 | const banValue = 92 | banType === 'permanent' 93 | ? 'Banned' 94 | : 'Temp banned'; 95 | 96 | playerInfo.ban = { 97 | type: banType, 98 | reason, 99 | value: `${banValue} for ${reason}`, 100 | }; 101 | } 102 | 103 | playerInfo.country = country; 104 | playerInfo.id = id; 105 | playerInfo.nickname = nickname; 106 | playerInfo.steamId = steamId; 107 | 108 | break; 109 | } 110 | } 111 | } 112 | } 113 | 114 | return { stats: playerInfo, selectedGame }; 115 | } catch (err) { 116 | console.log(err); 117 | 118 | return {}; 119 | } 120 | }; 121 | 122 | const getOrderedAccountsByPriority = (accounts) => { 123 | return accounts.sort((a, b) => { 124 | const getOrderPriority = (account) => { 125 | const games = account?.games ?? []; 126 | 127 | const priority = games.reduce((acc, curr) => { 128 | if (curr.name === 'cs2') { 129 | return Math.min(acc, 3); 130 | } 131 | if (curr.name === 'csgo') { 132 | return Math.min(acc, 4); 133 | } 134 | 135 | return acc; 136 | }, 5); 137 | 138 | return priority; 139 | }; 140 | 141 | const aPriority = getOrderPriority(a); 142 | const bPriority = getOrderPriority(b); 143 | 144 | return aPriority - bPriority; 145 | }); 146 | }; 147 | 148 | const getAvgStats = (matches, stats, profile, game) => { 149 | const avgStats = {}; 150 | let matchAmount = 0; 151 | let hasPlayed = true; 152 | 153 | const totalStats = matches.reduce( 154 | (acc, curr) => { 155 | if (curr?.gameMode?.includes('5v5')) { 156 | acc.kills += +curr?.i6 ?? 0; 157 | acc.wins += +curr?.i10 ?? 0; 158 | acc.killsPerDeath += +curr?.c2 ?? 0; 159 | acc.killsPerRound += +curr?.c3 ?? 0; 160 | acc.headshots += +curr?.c4 ?? 0; 161 | acc.damagePerRound += +curr?.c10 ?? 0; 162 | 163 | matchAmount += 1; 164 | } 165 | 166 | return acc; 167 | }, 168 | { 169 | kills: 0, 170 | wins: 0, 171 | killsPerDeath: 0, 172 | killsPerRound: 0, 173 | headshots: 0, 174 | damagePerRound: 0, 175 | } 176 | ); 177 | 178 | for (const key in totalStats) { 179 | const value = totalStats[key] / matchAmount || 0; 180 | 181 | if ( 182 | key !== 'wins' && 183 | key !== 'damagePerRound' && 184 | key !== 'headshots' && 185 | value === 0 186 | ) { 187 | avgStats[`avg${capitalize(key)}`] = '-'; 188 | hasPlayed = false; 189 | } else { 190 | avgStats[`avg${capitalize(key)}`] = 191 | key === 'kills' || 192 | key === 'damagePerRound' || 193 | key === 'headshots' 194 | ? Math.round(value) 195 | : value.toFixed(2); 196 | } 197 | } 198 | 199 | avgStats.level = hasPlayed 200 | ? (profile.games?.[game]?.skill_level ?? '-') 201 | : '-'; 202 | avgStats.elo = hasPlayed 203 | ? (profile?.games?.[game]?.faceit_elo ?? '-') 204 | : '-'; 205 | avgStats.matches = hasPlayed ? (stats?.lifetime?.m1 ?? '-') : '-'; 206 | avgStats.wins = hasPlayed ? (stats?.lifetime?.m2 ?? '-') : '-'; 207 | 208 | return avgStats; 209 | }; 210 | 211 | const getMembershipStatus = (membership) => { 212 | const premiumMemberShips = ['cs2', 'csgo', 'plus', 'premium']; 213 | return membership.some((membership) => 214 | premiumMemberShips.includes(membership) 215 | ) 216 | ? 'Premium' 217 | : 'Free'; 218 | }; 219 | 220 | const getAccountCreationDate = (date) => { 221 | if (!date) { 222 | return null; 223 | } 224 | 225 | return new Date(date)?.toLocaleString('ja-JP', { 226 | year: 'numeric', 227 | month: '2-digit', 228 | day: '2-digit', 229 | }); 230 | }; 231 | 232 | const getAccountDescription = (membership, createdAt) => { 233 | if (!createdAt || membership === 'Banned') { 234 | return membership; 235 | } 236 | 237 | return `${membership} (${createdAt})`; 238 | }; 239 | 240 | const getSelectedGame = (cs2Matches = []) => 241 | cs2Matches.length > 0 && 242 | cs2Matches.find((match) => match?.gameMode.includes('5v5')) 243 | ? 'cs2' 244 | : 'csgo'; 245 | 246 | const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); 247 | -------------------------------------------------------------------------------- /src/contentFaceIt/components/popover.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME } from '../../shared/constants'; 2 | import { getSyncStorage } from '../../shared/storage'; 3 | import { getColorToUse } from '../helpers/colorHelper'; 4 | import { getMatchRoomRoot } from '../helpers/matchroom'; 5 | import { 6 | getMapDictMemoized, 7 | getMapStats, 8 | loadMapStatsMemoized, 9 | } from '../helpers/stats'; 10 | import { setColorOfElements } from './color'; 11 | 12 | export const createPopover = (idSuffix) => { 13 | const popoverDiv = document.createElement('div'); 14 | const headingDiv = document.createElement('div'); 15 | const mapDiv = document.createElement('div'); 16 | const mapSpan = document.createElement('span'); 17 | const timeFrameDiv = document.createElement('div'); 18 | const timeFrameSpan = document.createElement('span'); 19 | 20 | const rootElem = getMatchRoomRoot(idSuffix); 21 | 22 | popoverDiv.id = `${EXTENSION_NAME + idSuffix}-popover`; 23 | headingDiv.id = `${EXTENSION_NAME + idSuffix}-popover-heading`; 24 | 25 | popoverDiv.classList.add(`${EXTENSION_NAME}-popover`); 26 | headingDiv.classList.add(`${EXTENSION_NAME}-popover-heading`); 27 | mapDiv.classList.add(`${EXTENSION_NAME}-map`); 28 | timeFrameDiv.classList.add(`${EXTENSION_NAME}-time-frame`); 29 | 30 | popoverDiv.style.display = 'none'; 31 | 32 | timeFrameDiv.append(timeFrameSpan); 33 | mapDiv.append(mapSpan); 34 | headingDiv.append(mapDiv, timeFrameDiv); 35 | popoverDiv.prepend(headingDiv); 36 | 37 | rootElem?.append(popoverDiv); 38 | }; 39 | 40 | export const showPopover = async (_, idSuffix, parent, mapName, matchInfo) => { 41 | const rootElem = getMatchRoomRoot(idSuffix); 42 | const timeFrameName = getTimeFrameName(); 43 | 44 | const contentContainer = rootElem.querySelector( 45 | `#${EXTENSION_NAME + idSuffix}-popover-content` 46 | ); 47 | if (!contentContainer) { 48 | const matchRoomMaps = matchInfo.matchCustom?.tree?.map?.values?.value; 49 | const maps = getMapDictMemoized(matchInfo?.id, matchRoomMaps); 50 | const timeFrame = getSyncStorage('timeFrame'); 51 | const usesCompareMode = getSyncStorage('usesCompareMode'); 52 | const allStats = await loadMapStatsMemoized( 53 | `${matchInfo.id}-${timeFrame}-${usesCompareMode}`, 54 | matchInfo 55 | ); 56 | const stats = getMapStats(mapName, maps, allStats, matchInfo); 57 | 58 | const popoverDiv = rootElem.querySelector( 59 | `#${EXTENSION_NAME + idSuffix}-popover` 60 | ); 61 | const mapSpan = rootElem.querySelector(`.${EXTENSION_NAME}-map span`); 62 | const timeFrameSpan = rootElem.querySelector( 63 | `.${EXTENSION_NAME}-time-frame span` 64 | ); 65 | 66 | const coordinates = parent.getBoundingClientRect() || {}; 67 | const { x, y } = getPopoverAnchor(coordinates, stats); 68 | 69 | setPopoverActive(popoverDiv, x, y); 70 | 71 | mapSpan.textContent = stats[0]?.map || stats[1]?.map; 72 | timeFrameSpan.textContent = timeFrameName; 73 | 74 | const hasStats = 75 | Object.keys(stats?.[0] || {}).length > 1 || 76 | (usesCompareMode && Object.keys(stats?.[1] || {}).length > 1); 77 | const contentDiv = document.createElement('div'); 78 | 79 | contentDiv.id = `${EXTENSION_NAME + idSuffix}-popover-content`; 80 | 81 | if (hasStats) { 82 | if (!usesCompareMode) { 83 | const players = stats[0]?.players; 84 | if (players) { 85 | addPlayers(contentDiv, players, false); 86 | } 87 | } else { 88 | addCompareMode(contentDiv, stats); 89 | } 90 | } else { 91 | showNoData(contentDiv); 92 | } 93 | 94 | popoverDiv.append(contentDiv); 95 | } 96 | }; 97 | 98 | export const hidePopover = (_, idSuffix) => { 99 | const rootElem = getMatchRoomRoot(idSuffix); 100 | const popover = rootElem?.querySelector( 101 | `#${EXTENSION_NAME + idSuffix}-popover` 102 | ); 103 | 104 | popover.style.display = 'none'; 105 | 106 | popover 107 | .querySelector(`#${EXTENSION_NAME + idSuffix}-popover-content`) 108 | ?.remove(); 109 | }; 110 | 111 | const getTimeFrameName = () => { 112 | const timeFrame = getSyncStorage('timeFrame'); 113 | const toggles = getSyncStorage('toggles'); 114 | 115 | const toggle = toggles.find((x) => x.maxAge === timeFrame); 116 | 117 | return toggle?.name; 118 | }; 119 | 120 | const getPopoverAnchor = ({ x, y }, stats) => { 121 | const team1 = stats?.[0]?.players; 122 | const team2 = stats?.[1]?.players; 123 | const playerCount = team1?.size > team2?.size ? team1?.size : team2?.size; 124 | const height = (playerCount * 53 + 20) / 2 + 24 - 25 ?? 20; 125 | 126 | return { x: x + 20, y: height ? y - height : y - 32 }; 127 | }; 128 | 129 | const setPopoverActive = (element, x, y) => { 130 | element.style.left = `${x}px`; 131 | element.style.top = `${y}px`; 132 | element.style.display = 'block'; 133 | }; 134 | 135 | const addPlayers = (parent, players, isCompact = false) => { 136 | for (const [player, playerStats] of players) { 137 | const playerDiv = document.createElement('div'); 138 | const nameSpan = document.createElement('span'); 139 | const matchesSpan = document.createElement('span'); 140 | const winRateSpan = document.createElement('span'); 141 | 142 | playerDiv.classList.add( 143 | `${EXTENSION_NAME}-player-div${isCompact ? '-compact' : ''}` 144 | ); 145 | nameSpan.classList.add( 146 | `${EXTENSION_NAME}-player-name${isCompact ? '-compact' : ''}` 147 | ); 148 | matchesSpan.classList.add( 149 | `${EXTENSION_NAME}-player-matches${isCompact ? '-compact' : ''}` 150 | ); 151 | winRateSpan.classList.add( 152 | `${EXTENSION_NAME}-player-win-rate${isCompact ? '-compact' : ''}` 153 | ); 154 | 155 | if (playerStats?.isCurrentUser) { 156 | nameSpan.style.cssText = 'color: #ff5500;'; 157 | } 158 | 159 | nameSpan.textContent = `${player}`; 160 | matchesSpan.textContent = `${playerStats?.wins || 0}/${ 161 | playerStats?.matches || 0 162 | }`; 163 | winRateSpan.textContent = `(${playerStats?.winRate || 0}%)`; 164 | 165 | if (!isCompact) { 166 | if (playerStats?.winRate || playerStats?.winRate === 0) { 167 | const elements = [{ element: winRateSpan, type: 'color' }]; 168 | const colorToUse = getColorToUse(playerStats.winRate >= 50); 169 | 170 | setColorOfElements(colorToUse, elements); 171 | } 172 | } 173 | 174 | playerDiv.append(nameSpan, matchesSpan, winRateSpan); 175 | parent.append(playerDiv); 176 | } 177 | }; 178 | 179 | const addCompareMode = (parent, stats) => { 180 | const isCompact = true; 181 | 182 | const statsDiv = document.createElement('div'); 183 | statsDiv.classList.add(`${EXTENSION_NAME}-stats-div-compact`); 184 | 185 | for (const teamStats of stats) { 186 | const { matches, players, wins, winRate, ownTeamSide } = 187 | teamStats ?? {}; 188 | const teamDiv = document.createElement('div'); 189 | const teamStatsDiv = document.createElement('div'); 190 | const nameSpan = document.createElement('span'); 191 | const matchesSpan = document.createElement('span'); 192 | const winRateSpan = document.createElement('span'); 193 | 194 | teamStatsDiv.classList.add(`${EXTENSION_NAME}-team-div-compact`); 195 | nameSpan.classList.add(`${EXTENSION_NAME}-player-name-compact`); 196 | matchesSpan.classList.add(`${EXTENSION_NAME}-player-matches-compact`); 197 | winRateSpan.classList.add(`${EXTENSION_NAME}-player-win-rate-compact`); 198 | 199 | matchesSpan.textContent = `${wins || 0}/${matches || 0}`; 200 | winRateSpan.textContent = `(${winRate || 0}%)`; 201 | 202 | if (!isNaN(winRate)) { 203 | const elements = [{ element: winRateSpan, type: 'color' }]; 204 | const isOwnTeamSide = !isNaN(ownTeamSide); 205 | const colorToUse = getColorToUse(winRate >= 50, isOwnTeamSide); 206 | 207 | setColorOfElements(colorToUse, elements); 208 | } 209 | 210 | if (players) { 211 | addPlayers(teamDiv, players, isCompact); 212 | } 213 | 214 | teamStatsDiv.append(nameSpan, matchesSpan, winRateSpan); 215 | teamDiv.prepend(teamStatsDiv); 216 | statsDiv.append(teamDiv); 217 | } 218 | 219 | parent.insertAdjacentElement('afterbegin', statsDiv); 220 | }; 221 | 222 | const showNoData = (parent) => { 223 | const dataDiv = document.createElement('div'); 224 | 225 | dataDiv.classList.add(`${EXTENSION_NAME}-popover-heading`); 226 | dataDiv.textContent = 'NO DATA'; 227 | 228 | parent.append(dataDiv); 229 | }; 230 | -------------------------------------------------------------------------------- /src/contentFaceIt/helpers/stats.js: -------------------------------------------------------------------------------- 1 | import mem from 'mem'; 2 | import pMemoize from 'p-memoize'; 3 | import { getPlayerMatches } from '../../shared/helpers/api'; 4 | import { getSyncStorage } from '../../shared/storage'; 5 | import { getOpponents, getOwnTeam, getOwnTeamSide } from './teams'; 6 | import { getCurrentUserId } from './user'; 7 | 8 | export const loadMapStatsMemoized = pMemoize((_, matchInfo) => 9 | loadMapStats(matchInfo) 10 | ); 11 | 12 | export const getMapDictMemoized = mem((_, maps) => getMapDict(maps)); 13 | 14 | export const getMapStats = (map, maps, stats, matchInfo) => { 15 | const usesCompareMode = getSyncStorage('usesCompareMode'); 16 | const opponentStats = stats?.opponents; 17 | const ownTeamStats = stats?.ownTeam; 18 | 19 | if (opponentStats && ownTeamStats) { 20 | const mapObj = maps[map]; 21 | 22 | if (mapObj) { 23 | const opponentMapStats = 24 | getMapStatsObject(opponentStats, mapObj) ?? {}; 25 | const mapStats = [opponentMapStats]; 26 | 27 | if (usesCompareMode) { 28 | const ownTeamMapStats = 29 | getMapStatsObject(ownTeamStats, mapObj) ?? {}; 30 | 31 | if (getOwnTeamSide(matchInfo.id, matchInfo.teams) > 0) { 32 | ownTeamMapStats['ownTeamSide'] = 1; 33 | mapStats.push(ownTeamMapStats); 34 | } else { 35 | ownTeamMapStats['ownTeamSide'] = 0; 36 | mapStats.unshift(ownTeamMapStats); 37 | } 38 | } 39 | 40 | return mapStats; 41 | } 42 | } 43 | 44 | return []; 45 | }; 46 | 47 | const loadMapStats = async (matchInfo) => { 48 | try { 49 | const usesCompareMode = getSyncStorage('usesCompareMode'); 50 | 51 | let teamsStats = { 52 | opponents: {}, 53 | ownTeam: {}, 54 | }; 55 | 56 | if (matchInfo?.teams) { 57 | const opponents = getOpponents(matchInfo.id, matchInfo.teams); 58 | const teammates = getOwnTeam(matchInfo.id, matchInfo.teams); 59 | 60 | if (opponents?.length > 0 && teammates?.length > 0) { 61 | let opponentResponse = []; 62 | let ownTeamResponse = []; 63 | 64 | const playerStatsPromises = 65 | getPlayerStatsPromisesDependingOnMode( 66 | opponents, 67 | teammates, 68 | usesCompareMode 69 | ); 70 | const response = await Promise.all(playerStatsPromises); 71 | 72 | if (usesCompareMode) { 73 | opponentResponse = response.slice(0, opponents.length); 74 | ownTeamResponse = response.slice(opponents.length); 75 | 76 | teamsStats.ownTeam = getTeamStats( 77 | ownTeamResponse, 78 | teammates, 79 | matchInfo 80 | ); 81 | } else { 82 | opponentResponse = response; 83 | } 84 | 85 | teamsStats.opponents = getTeamStats( 86 | opponentResponse, 87 | opponents, 88 | matchInfo 89 | ); 90 | } 91 | } 92 | 93 | return teamsStats; 94 | } catch (err) { 95 | console.log(err); 96 | return null; 97 | } 98 | }; 99 | 100 | const getMapDict = (maps = []) => { 101 | const mapsObj = {}; 102 | 103 | maps.forEach(({ image_lg, image_sm, ...mapProps }) => { 104 | let workshopName = ''; 105 | 106 | if (!isNaN(mapProps.game_map_id)) { 107 | workshopName = `workshop/${mapProps.game_map_id}/${mapProps.class_name}`; 108 | mapsObj[workshopName] = { 109 | ...mapProps, 110 | game_map_id: workshopName, 111 | }; 112 | } 113 | mapsObj[mapProps.name] = { 114 | ...mapProps, 115 | ...(workshopName && { game_map_id: workshopName }), 116 | }; 117 | mapsObj[mapProps.class_name] = { 118 | ...mapProps, 119 | ...(workshopName && { game_map_id: workshopName }), 120 | }; 121 | }); 122 | 123 | return mapsObj; 124 | }; 125 | 126 | const getMapStatsObject = (stats, mapObj) => 127 | stats[mapObj.class_name] || stats[mapObj.game_map_id] || stats[mapObj.name]; 128 | 129 | const getPlayerStatsPromisesDependingOnMode = ( 130 | opponents, 131 | teammates, 132 | usesCompareMode 133 | ) => 134 | usesCompareMode 135 | ? [...opponents, ...teammates].map(({ id }) => 136 | getPlayerMatches('cs2', id) 137 | ) 138 | : opponents.map(({ id }) => getPlayerMatches('cs2', id)); 139 | 140 | const getTeamStats = (playersStats, opponents, matchInfo) => { 141 | const teamStats = {}; 142 | 143 | if (playersStats?.length === opponents.length) { 144 | for (const player of playersStats) { 145 | let playerStats = {}; 146 | 147 | for (let [ 148 | i, 149 | { 150 | gameMode, 151 | date, 152 | i1: map, 153 | i2: winningTeamId, 154 | teamId, 155 | playerId, 156 | nickname, 157 | }, 158 | ] of player.entries()) { 159 | const inTimeFrame = isInTimeFrame(matchInfo?.createdAt, date); 160 | if (inTimeFrame) { 161 | if ( 162 | gameMode === matchInfo.matchCustom?.overview?.name || 163 | gameMode === 164 | matchInfo.matchCustom?.overview?.elo_type || 165 | matchInfo.matchCustom?.overview?.name.includes( 166 | gameMode 167 | ) || 168 | matchInfo.entity?.name.includes(gameMode) 169 | ) { 170 | if (map === 'workshop/125995702/aim_redline') { 171 | map = 'workshop/125995702/aim_redline_original'; 172 | } 173 | 174 | if (!teamStats[map]) { 175 | teamStats[map] = getTeamStatObj(); 176 | teamStats[map].map = 177 | getMapDictMemoized( 178 | matchInfo.id, 179 | matchInfo?.matchCustom?.tree?.map?.values 180 | ?.value 181 | )?.[map]?.name || null; 182 | } 183 | if (!playerStats[map]) { 184 | playerStats[map] = getPlayerStatObj(playerId); 185 | } 186 | if (winningTeamId === teamId) { 187 | teamStats[map].wins += 1; 188 | playerStats[map].wins += 1; 189 | } 190 | 191 | teamStats[map].matches += 1; 192 | playerStats[map].matches += 1; 193 | } 194 | } else if (inTimeFrame === false) { 195 | addPlayerMapStats(teamStats, playerStats, nickname); 196 | 197 | break; 198 | } 199 | 200 | if (i === player.length - 1) { 201 | addPlayerMapStats(teamStats, playerStats, nickname); 202 | } 203 | } 204 | } 205 | 206 | addTeamWinRate(teamStats); 207 | } 208 | 209 | return teamStats; 210 | }; 211 | 212 | const isInTimeFrame = (startDate, endDate) => { 213 | const maxAge = getSyncStorage('timeFrame'); 214 | startDate = new Date(startDate).getTime(); 215 | 216 | if (startDate > endDate) { 217 | if (startDate - maxAge < endDate) { 218 | return true; 219 | } 220 | 221 | return false; 222 | } 223 | 224 | return null; 225 | }; 226 | 227 | const getTeamStatObj = () => { 228 | const obj = { 229 | map: '', 230 | matches: 0, 231 | players: new Map(), 232 | winRate: 0, 233 | wins: 0, 234 | }; 235 | 236 | return obj; 237 | }; 238 | 239 | const getPlayerStatObj = (playerId) => { 240 | const obj = { 241 | matches: 0, 242 | playerId, 243 | winRate: 0, 244 | wins: 0, 245 | }; 246 | 247 | return obj; 248 | }; 249 | 250 | const addPlayerMapStats = (teamStats, playerStats, nickname) => { 251 | for (const recentMap in playerStats) { 252 | playerStats[recentMap].winRate = Math.round( 253 | (playerStats[recentMap].wins / playerStats[recentMap].matches) * 100 254 | ); 255 | 256 | teamStats[recentMap].players.set(nickname, playerStats[recentMap]); 257 | 258 | if (playerStats[recentMap]?.playerId === getCurrentUserId()) { 259 | playerStats[recentMap]['isCurrentUser'] = true; 260 | } 261 | } 262 | }; 263 | 264 | const addTeamWinRate = (teamStats) => { 265 | for (const mapProp in teamStats) { 266 | const mapStats = teamStats[mapProp]; 267 | const players = [...mapStats.players.keys()]; 268 | 269 | teamStats[mapProp].winRate = Math.round( 270 | players.reduce( 271 | (acc, curr) => acc + mapStats.players.get(curr)?.winRate, 272 | 0 273 | ) / mapStats.players.size 274 | ); 275 | } 276 | }; 277 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VisusGG 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | VisusGG 14 | 15 | by 16 | x3picF4ilx 17 | & 18 | MrMaxim 19 | 20 |
21 | 22 |
23 |
24 |
25 | FaceIt 26 | 29 | 30 | 33 | 34 | 35 |
36 |
37 | 45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 |
53 | Edit time frame 54 |
55 | 56 | Adjust the time frame for each button by 57 | selecting it (limited to the last 100 58 | matches). 59 | 60 |
61 |
62 |
63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 |
Compare Mode
71 | 72 | Instead of only showing the enemy team's win 73 | rate percentage on maps, this feature will 74 | compare your team's win percentage with the 75 | enemy team's. Useful when playing with 76 | random players. 77 | 78 |
79 |
80 | 88 |
89 |
90 |
91 | 92 |
93 |
94 |
95 |
Set colors
96 | 97 | Customize win rate percentage colors. 98 | 99 |
100 |
101 |
102 | 108 |
109 |
110 | 116 |
117 |
118 |
119 |
120 | 121 |
122 |
123 |
124 |
Reset settings
125 | 126 | Load the default values. 127 | 128 |
129 |
130 | 138 |
139 |
140 |
141 | 142 |
143 |
144 | 147 | 150 | 153 | 156 |
157 |
161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 |
171 |
172 |
173 | 174 |
175 |
176 | Steam 177 | 180 | 181 | 184 | 185 | 186 |
187 |
188 | 196 |
197 |
198 |
199 |
200 | 201 | 202 | -------------------------------------------------------------------------------- /src/contentSteam/components/stats.js: -------------------------------------------------------------------------------- 1 | import { EXTENSION_NAME, VIP_STEAM_IDS } from '../../shared/constants'; 2 | import { getLevelImg } from '../helpers/profile'; 3 | 4 | export const createStatsContainer = (parent) => { 5 | const unrankedImg = chrome.runtime.getURL('imgs/f00.png'); 6 | const statsContainer = document.createElement('div'); 7 | const customizationContainer = document.createElement('div'); 8 | const customizationBlock = document.createElement('div'); 9 | const showcaseContainer = document.createElement('div'); 10 | const showCaseBackground = document.createElement('div'); 11 | const levelContainer = document.createElement('div'); 12 | const contentContainer = document.createElement('div'); 13 | const playerHeader = document.createElement('div'); 14 | const playerBody = document.createElement('div'); 15 | const playerNameElem = document.createElement('a'); 16 | const levelElem = document.createElement('img'); 17 | 18 | statsContainer.id = `${EXTENSION_NAME}-container`; 19 | showcaseContainer.id = `${EXTENSION_NAME}-showcase-container`; 20 | levelContainer.id = `${EXTENSION_NAME}-level-container`; 21 | levelElem.id = `${EXTENSION_NAME}-player-level`; 22 | playerNameElem.id = `${EXTENSION_NAME}-player-name`; 23 | contentContainer.id = `${EXTENSION_NAME}-content-container`; 24 | playerHeader.id = `${EXTENSION_NAME}-player-header`; 25 | playerBody.id = `${EXTENSION_NAME}-stats-player-body`; 26 | 27 | customizationContainer.classList.add('profile_customization'); 28 | customizationBlock.classList.add('profile_customization_block'); 29 | showcaseContainer.classList.add('favoritegroup_showcase_group'); 30 | showCaseBackground.classList.add('showcase_content_bg'); 31 | contentContainer.classList.add('favoritegroup_content'); 32 | playerHeader.classList.add('favoritegroup_namerow', 'ellipsis'); 33 | playerBody.classList.add('favoritegroup_stats', 'showcase_stats_row'); 34 | playerNameElem.classList.add('favoritegroup_name', 'whitelink'); 35 | 36 | customizationContainer.setAttribute('data-panel', '{"type":"PanelGroup"}'); 37 | levelElem.src = unrankedImg; 38 | playerNameElem.href = '#'; 39 | 40 | playerNameElem.textContent = '-'; 41 | 42 | addStats(playerBody); 43 | 44 | const customizationHeader = createHeader(); 45 | 46 | playerHeader.append(playerNameElem); 47 | contentContainer.append(playerHeader, playerBody); 48 | levelContainer.appendChild(levelElem); 49 | showcaseContainer.append(levelContainer, contentContainer); 50 | showCaseBackground.append(showcaseContainer); 51 | customizationBlock.appendChild(showCaseBackground); 52 | customizationContainer.append(customizationHeader, customizationBlock); 53 | statsContainer.appendChild(customizationContainer); 54 | 55 | parent.insertAdjacentElement('afterbegin', statsContainer); 56 | }; 57 | 58 | export const hydrateStats = (stats, selectedGame) => { 59 | const nicknameElem = document.getElementById( 60 | `${EXTENSION_NAME}-player-name` 61 | ); 62 | 63 | if (stats?.nickname) { 64 | addGameSelector(stats, selectedGame); 65 | 66 | const descriptionNode = document.createTextNode( 67 | ` - ${stats.description}` 68 | ); 69 | const playerHeader = document.getElementById( 70 | `${EXTENSION_NAME}-player-header` 71 | ); 72 | let flagElem = document.getElementById(`${EXTENSION_NAME}-stats-flag`); 73 | if (!flagElem) { 74 | flagElem = document.createElement('img'); 75 | flagElem.id = `${EXTENSION_NAME}-stats-flag`; 76 | } 77 | 78 | flagElem.id = `${EXTENSION_NAME}-stats-flag`; 79 | 80 | flagElem.src = `https://cdn-frontend.faceit.com/web/112-1536332382/src/app/assets/images-compress/flags/${stats.country}.png`; 81 | flagElem.title = stats.country?.toLowerCase(); 82 | flagElem.alt = stats.country?.toLowerCase(); 83 | 84 | nicknameElem.href = `https://www.faceit.com/en/players/${stats.nickname}`; 85 | nicknameElem.target = '_blank'; 86 | 87 | if (stats.ban?.reason) { 88 | addBanBanner(stats.ban); 89 | } 90 | 91 | if (stats.steamId in VIP_STEAM_IDS) { 92 | const vip = VIP_STEAM_IDS[stats.steamId]; 93 | addVIPBanner(vip); 94 | } 95 | 96 | playerHeader.prepend(flagElem); 97 | playerHeader.appendChild(descriptionNode); 98 | 99 | nicknameElem.textContent = stats.nickname; 100 | 101 | updateStats(stats, selectedGame); 102 | } else { 103 | nicknameElem.textContent = 'No account found'; 104 | nicknameElem.style.cursor = 'default'; 105 | } 106 | }; 107 | 108 | export const removeStatsContainer = () => { 109 | const statsContainer = document.querySelector( 110 | `#${EXTENSION_NAME}-container` 111 | ); 112 | if (statsContainer) { 113 | statsContainer.remove(); 114 | } 115 | }; 116 | 117 | const updateStats = (stats, selectedGame) => { 118 | if (stats?.nickname) { 119 | const levelElem = document.getElementById( 120 | `${EXTENSION_NAME}-player-level` 121 | ); 122 | const eloElem = document.getElementById(`${EXTENSION_NAME}-stats-elo`); 123 | const matchesElem = document.getElementById( 124 | `${EXTENSION_NAME}-stats-matches` 125 | ); 126 | const avgKillsElem = document.getElementById( 127 | `${EXTENSION_NAME}-stats-avg-kills` 128 | ); 129 | const avgKdElem = document.getElementById( 130 | `${EXTENSION_NAME}-stats-avg-kd` 131 | ); 132 | const avgKrElem = document.getElementById( 133 | `${EXTENSION_NAME}-stats-avg-kr` 134 | ); 135 | 136 | levelElem.src = chrome.runtime.getURL( 137 | `imgs/f${getLevelImg(stats?.[selectedGame].level)}.png` 138 | ); 139 | eloElem.textContent = stats[selectedGame]?.elo ?? '-'; 140 | matchesElem.textContent = stats[selectedGame]?.matches ?? '-'; 141 | avgKillsElem.textContent = stats[selectedGame]?.avgKills ?? '-'; 142 | avgKdElem.textContent = stats[selectedGame]?.avgKillsPerDeath ?? '-'; 143 | avgKrElem.textContent = stats[selectedGame]?.avgKillsPerRound ?? '-'; 144 | } 145 | }; 146 | 147 | const createHeader = () => { 148 | const customizationHeader = document.createElement('div'); 149 | const customizationHeaderTitle = document.createElement('span'); 150 | 151 | customizationHeader.id = `${EXTENSION_NAME}-customization-header`; 152 | customizationHeader.classList.add('profile_customization_header'); 153 | 154 | customizationHeader.style.display = 'flex'; 155 | customizationHeader.style.justifyContent = 'space-between'; 156 | customizationHeaderTitle.textContent = 'FACEIT'; 157 | 158 | customizationHeader.append(customizationHeaderTitle); 159 | 160 | return customizationHeader; 161 | }; 162 | 163 | const createGameSelector = () => { 164 | const showcaseBgElem = document.querySelector( 165 | `#${EXTENSION_NAME}-container .showcase_content_bg` 166 | ); 167 | const gameSelectorContainer = document.createElement('div'); 168 | const gameSelector = document.createElement('select'); 169 | 170 | gameSelector.id = `${EXTENSION_NAME}-game-selector`; 171 | 172 | gameSelector.style.minWidth = '120px'; 173 | gameSelector.style.maxWidth = '180px'; 174 | gameSelector.style.background = 175 | getComputedStyle(showcaseBgElem)?.backgroundColor; 176 | gameSelectorContainer.classList.add('responsive_tab_control'); 177 | 178 | const options = ['CS2', 'CSGO']; 179 | for (const option of options) { 180 | const optionElem = document.createElement('option'); 181 | optionElem.value = option.toLowerCase(); 182 | optionElem.textContent = option; 183 | 184 | gameSelector.appendChild(optionElem); 185 | } 186 | 187 | gameSelector.value = ''; 188 | 189 | gameSelectorContainer.appendChild(gameSelector); 190 | 191 | return gameSelectorContainer; 192 | }; 193 | 194 | const addGameSelector = (stats, selectedGame) => { 195 | const gameSelectorContainer = createGameSelector(); 196 | const showcaseHeader = document.getElementById( 197 | `${EXTENSION_NAME}-customization-header` 198 | ); 199 | 200 | showcaseHeader.appendChild(gameSelectorContainer); 201 | 202 | const gameSelector = document.getElementById( 203 | `${EXTENSION_NAME}-game-selector` 204 | ); 205 | gameSelector.value = selectedGame; 206 | 207 | const onGameSelectorChange = (e) => { 208 | const newVal = e.target.value; 209 | 210 | updateStats(stats, newVal); 211 | }; 212 | gameSelector.removeEventListener('change', onGameSelectorChange); 213 | gameSelector.addEventListener('change', onGameSelectorChange); 214 | }; 215 | 216 | const addStats = (statsContainer) => { 217 | const statsGroupOne = document.createElement('div'); 218 | const statsGroupTwo = document.createElement('div'); 219 | 220 | statsGroupOne.classList.add(`${EXTENSION_NAME}-stats-group-one`); 221 | statsGroupTwo.classList.add(`${EXTENSION_NAME}-stats-group-two`); 222 | 223 | const statsClasses = [ 224 | { 225 | classes: [ 226 | 'showcase_stat', 227 | 'favoritegroup_online', 228 | `${EXTENSION_NAME}-stats-wrapper`, 229 | ], 230 | label: 'Elo', 231 | id: `${EXTENSION_NAME}-stats-elo`, 232 | }, 233 | { 234 | classes: [ 235 | 'showcase_stat', 236 | 'favoritegroup_online', 237 | `${EXTENSION_NAME}-stats-wrapper`, 238 | ], 239 | label: 'Matches', 240 | id: `${EXTENSION_NAME}-stats-matches`, 241 | }, 242 | { 243 | classes: [ 244 | 'showcase_stat', 245 | 'favoritegroup_inchat', 246 | `${EXTENSION_NAME}-stats-wrapper`, 247 | ], 248 | label: 'AVG Kills', 249 | id: `${EXTENSION_NAME}-stats-avg-kills`, 250 | }, 251 | { 252 | classes: [ 253 | 'showcase_stat', 254 | 'favoritegroup_inchat', 255 | `${EXTENSION_NAME}-stats-wrapper`, 256 | ], 257 | label: 'AVG K/D', 258 | id: `${EXTENSION_NAME}-stats-avg-kd`, 259 | }, 260 | { 261 | classes: [ 262 | 'showcase_stat', 263 | 'favoritegroup_inchat', 264 | `${EXTENSION_NAME}-stats-wrapper`, 265 | ], 266 | label: 'AVG K/R', 267 | id: `${EXTENSION_NAME}-stats-avg-kr`, 268 | }, 269 | ]; 270 | 271 | for (const { classes, label, id } of statsClasses) { 272 | const statContainer = document.createElement('div'); 273 | const statContainer2 = document.createElement('div'); 274 | const valueContainer = document.createElement('div'); 275 | const labelContainer = document.createElement('div'); 276 | 277 | statContainer.classList.add(...classes); 278 | valueContainer.classList.add('value'); 279 | labelContainer.classList.add('label'); 280 | 281 | valueContainer.id = id; 282 | 283 | statContainer2.style.display = 'inline-block'; 284 | 285 | valueContainer.textContent = '-'; 286 | labelContainer.textContent = label; 287 | 288 | if (label === 'Elo' || label === 'Matches') { 289 | statContainer2.append(valueContainer, labelContainer); 290 | 291 | statContainer.append(statContainer2); 292 | statsGroupOne.appendChild(statContainer); 293 | } else { 294 | statContainer.append(valueContainer, labelContainer); 295 | statsGroupTwo.appendChild(statContainer); 296 | } 297 | 298 | statsContainer.append(statsGroupOne, statsGroupTwo); 299 | } 300 | }; 301 | 302 | const addBanBanner = (ban) => { 303 | let label = ban.value; 304 | let bannerId = 305 | ban.type === 'permanent' 306 | ? `${EXTENSION_NAME}-ban-banner` 307 | : `${EXTENSION_NAME}-temp-ban-banner`; 308 | 309 | addBanner(label, bannerId); 310 | }; 311 | 312 | const addVIPBanner = ({ label, color }) => { 313 | const bannerId = `${EXTENSION_NAME}-vip-banner`; 314 | 315 | addBanner(label, bannerId, color); 316 | }; 317 | 318 | const addBanner = (label, id, color) => { 319 | const playerHeader = document.getElementById( 320 | `${EXTENSION_NAME}-player-header` 321 | ); 322 | const bannerContainer = document.createElement('div'); 323 | 324 | bannerContainer.classList.add( 325 | 'favoritegroup_description', 326 | `${EXTENSION_NAME}-banner` 327 | ); 328 | 329 | bannerContainer.id = id; 330 | bannerContainer.textContent = label; 331 | 332 | if (color) { 333 | bannerContainer.style.background = color; 334 | } 335 | 336 | playerHeader.insertAdjacentElement('afterend', bannerContainer); 337 | }; 338 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------