├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── Makefile ├── README.md ├── __fixtures__ ├── getRemoveResponse.js ├── getUpdateResponse.js └── phxReply4bots.js ├── __test__ └── background.test.js ├── babel.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── assets │ ├── 128.png │ ├── images │ │ ├── playing.svg │ │ └── waitingOpponent.svg │ ├── levels │ │ ├── easy.svg │ │ ├── elementary.svg │ │ ├── hard.svg │ │ └── medium.svg │ └── logo.svg ├── background │ ├── browser-actions.js │ ├── index.js │ ├── models.js │ ├── notification.js │ ├── socket.js │ └── state.js ├── content │ └── content.js ├── options │ ├── actions │ │ └── index.js │ ├── components │ │ ├── App.jsx │ │ ├── Content.jsx │ │ ├── ContextApp.jsx │ │ ├── GroupToggles.jsx │ │ ├── SelectMenu.jsx │ │ └── Toggle.jsx │ ├── defaultStorage.js │ ├── index.html │ ├── options.jsx │ ├── options.scss │ └── reducer │ │ └── index.js └── popup │ ├── components │ ├── App.jsx │ ├── App.scss │ ├── LanguageIcon.jsx │ └── UserName.jsx │ ├── index.html │ └── popup.js └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - react 4 | - jsx-a11y 5 | - react-hooks 6 | - jest 7 | env: 8 | node: true 9 | browser: true 10 | jest: true 11 | 12 | parser: babel-eslint 13 | 14 | extends: 15 | - "airbnb" 16 | 17 | rules: 18 | no-console: 0 19 | react/prop-types: 0 20 | import/no-unresolved: 0 21 | no-param-reassign: 0 22 | arrow-parens: 23 | - 2 24 | - "as-needed" 25 | react/jsx-props-no-spreading: 0 26 | react/static-property-placement: 0 27 | react/state-in-constructor: 0 28 | react-hooks/rules-of-hooks: "error" 29 | react-hooks/exhaustive-deps: "warn" 30 | jest/no-disabled-tests: "warn" 31 | jest/no-focused-tests: "error" 32 | jest/no-identical-title: "error" 33 | jest/prefer-to-have-length: "warn" 34 | jest/valid-expect: "error" 35 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build and Lint 3 | 4 | on: 5 | - push 6 | - pull_request 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [13.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Install 25 | run: | 26 | make install 27 | make build 28 | 29 | - name: Run linter 30 | run: make lint 31 | 32 | - name: Run test 33 | run: make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | npm install 3 | 4 | develop: 5 | npm run watch:dev 6 | 7 | develop-prod: 8 | npm run watch 9 | 10 | build: 11 | npm run build && cp ./manifest.json ./dist 12 | 13 | lint: 14 | npx eslint . --ext js,jsx 15 | 16 | make lint-fix: 17 | npx eslint . --fix 18 | 19 | test: 20 | npx jest 21 | 22 | test-watch: 23 | npx jest --watch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codebattle Chrome extension 2 | 3 | Browser extension for the [codebattle.hexlet.io](https://codebattle.hexlet.io) project. 4 | CodeBattle is an open-source project that is developing by the Hexlet community. 5 | Up-to-date app version can be found on [web store](https://chrome.google.com/webstore/detail/codebattle-web-extension/embfhnfkfobkdohleknckodkmhgmpdli). 6 | 7 | ### Requirements 8 | 9 | - Mac / Linux 10 | - Browser Chrome 11 | 12 | ### Install 13 | 14 | ```bash 15 | $ git clone git@github.com:hexlet-codebattle/chrome_extension.git 16 | $ cd chrome_extension 17 | $ make install 18 | ``` 19 | - Open 20 | - Load unpacked add directory chrome_extension/dist 21 | 22 | 23 | ### Support 24 | 25 | - channel: codebattle 26 | 27 | 28 | ### Getting Started 29 | 30 | - Guide on working with Chrome extensions 31 | - Extension examples 32 | 33 | 34 | -------------------------------------------------------------------------------- /__fixtures__/getRemoveResponse.js: -------------------------------------------------------------------------------- 1 | const getRemoveResponse = gameId => (JSON.stringify([null, null, 'lobby', 'game:remove', { id: gameId }])); 2 | 3 | export default getRemoveResponse; 4 | -------------------------------------------------------------------------------- /__fixtures__/getUpdateResponse.js: -------------------------------------------------------------------------------- 1 | const getUpdateResponse = (gameId, state = 'waiting_opponent') => (JSON.stringify([null, 2 | null, 3 | 'lobby', 4 | 'game:upsert', 5 | { 6 | game: { 7 | id: gameId, 8 | inserted_at: '2020-10-04T22:38:22', 9 | is_bot: false, 10 | level: 'elementary', 11 | players: [{ 12 | id: 2782, 13 | name: 'H9ko', 14 | is_bot: false, 15 | github_id: 57991929, 16 | lang: 'js', 17 | editor_text: 'module.exports = () => {\n\n};', 18 | editor_lang: 'js', 19 | creator: true, 20 | game_result: 'undefined', 21 | check_result: { 22 | asserts: [], asserts_count: 0, output: '', result: '{"status": "info"}', status: 'initial', success_count: 0, 23 | }, 24 | achievements: ['played_ten_games', 'played_fifty_games'], 25 | rating: 1152, 26 | rating_diff: 0, 27 | }], 28 | state, 29 | timeout_seconds: 7200, 30 | type: 'public', 31 | }, 32 | }])); 33 | 34 | export default getUpdateResponse; 35 | -------------------------------------------------------------------------------- /__fixtures__/phxReply4bots.js: -------------------------------------------------------------------------------- 1 | export default JSON.stringify([ 2 | '7', 3 | '7', 4 | 'lobby', 5 | 'phx_reply', 6 | { 7 | response: { 8 | active_games: [ 9 | { 10 | id: 19887, 11 | inserted_at: '2020-09-27T13:30:54', 12 | is_bot: true, 13 | level: 'medium', 14 | players: [ 15 | { 16 | id: -27, 17 | name: 'HappyQleaner', 18 | is_bot: true, 19 | github_id: 35539033, 20 | lang: 'js', 21 | editor_text: 'module.exports = () => {\n\n};', 22 | editor_lang: 'js', 23 | creator: true, 24 | game_result: 'undefined', 25 | check_result: { 26 | asserts: [], 27 | asserts_count: 0, 28 | output: '', 29 | result: '{"status": "info"}', 30 | status: 'initial', 31 | success_count: 0, 32 | }, 33 | achievements: [ 34 | 'played_hundred_games', 35 | 'win_games_with?clojure_cpp_csharp_golang_haskell_js_kotlin_php_python_ruby', 36 | ], 37 | rating: 1365, 38 | rating_diff: 0, 39 | }, 40 | ], 41 | state: 'waiting_opponent', 42 | timeout_seconds: 3600, 43 | type: 'bot', 44 | }, 45 | { 46 | id: 19923, 47 | inserted_at: '2020-09-28T12:17:11', 48 | is_bot: true, 49 | level: 'hard', 50 | players: [ 51 | { 52 | id: -18, 53 | name: 'DenisPython', 54 | is_bot: true, 55 | github_id: 35539033, 56 | lang: 'js', 57 | editor_text: 'module.exports = () => {\n\n};', 58 | editor_lang: 'js', 59 | creator: true, 60 | game_result: 'undefined', 61 | check_result: { 62 | asserts: [], 63 | asserts_count: 0, 64 | output: '', 65 | result: '{"status": "info"}', 66 | status: 'initial', 67 | success_count: 0, 68 | }, 69 | achievements: [ 70 | 'played_hundred_games', 71 | 'win_games_with?cpp_csharp_elixir_golang_haskell_java_js_php_python_ruby_ts', 72 | ], 73 | rating: 1344, 74 | rating_diff: 0, 75 | }, 76 | ], 77 | state: 'waiting_opponent', 78 | timeout_seconds: 3600, 79 | type: 'bot', 80 | }, 81 | { 82 | id: 20011, 83 | inserted_at: '2020-09-30T16:15:49', 84 | is_bot: true, 85 | level: 'elementary', 86 | players: [ 87 | { 88 | id: -9, 89 | name: 'UlaBack', 90 | is_bot: true, 91 | github_id: 35539033, 92 | lang: 'ruby', 93 | editor_text: 'module.exports = () => {\n\n};', 94 | editor_lang: 'ruby', 95 | creator: true, 96 | game_result: 'undefined', 97 | check_result: { 98 | asserts: [], 99 | asserts_count: 0, 100 | output: '', 101 | result: '{"status": "info"}', 102 | status: 'initial', 103 | success_count: 0, 104 | }, 105 | achievements: [ 106 | 'played_hundred_games', 107 | 'win_games_with?cpp_csharp_elixir_haskell_java_js_php_python_ruby', 108 | ], 109 | rating: 1261, 110 | rating_diff: 0, 111 | }, 112 | ], 113 | state: 'waiting_opponent', 114 | timeout_seconds: 3600, 115 | type: 'bot', 116 | }, 117 | { 118 | id: 20013, 119 | inserted_at: '2020-09-30T17:14:20', 120 | is_bot: true, 121 | level: 'easy', 122 | players: [ 123 | { 124 | id: -5, 125 | name: 'ValyaFront', 126 | is_bot: true, 127 | github_id: 35539033, 128 | lang: 'js', 129 | editor_text: 'module.exports = () => {\n\n};', 130 | editor_lang: 'js', 131 | creator: true, 132 | game_result: 'undefined', 133 | check_result: { 134 | asserts: [], 135 | asserts_count: 0, 136 | output: '', 137 | result: '{"status": "info"}', 138 | status: 'initial', 139 | success_count: 0, 140 | }, 141 | achievements: [ 142 | 'played_hundred_games', 143 | 'win_games_with?clojure_cpp_elixir_golang_java_js_php_python_ruby', 144 | ], 145 | rating: 1287, 146 | rating_diff: 0, 147 | }, 148 | ], 149 | state: 'waiting_opponent', 150 | timeout_seconds: 3600, 151 | type: 'bot', 152 | }, 153 | ], 154 | }, 155 | status: 'ok', 156 | }, 157 | ]); 158 | -------------------------------------------------------------------------------- /__test__/background.test.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import WS from 'jest-websocket-mock'; 3 | import socketConnect from '../src/background/socket'; 4 | import { gameStatuses } from '../src/background/models'; 5 | import phxReply4bots from '../__fixtures__/phxReply4bots'; 6 | import getUpdateResponseWithID from '../__fixtures__/getUpdateResponse'; 7 | import getRemoveResponseWithID from '../__fixtures__/getRemoveResponse'; 8 | 9 | let serverWS = null; 10 | beforeEach(async () => { 11 | jest.clearAllMocks(); 12 | serverWS = new WS('ws://localhost:1234'); 13 | serverWS.on('connection', socket => { 14 | socket.on('message', data => { 15 | switch (data) { 16 | case ['7', '7', 'lobby', 'phx_join', {}]: 17 | serverWS.send(phxReply4bots); 18 | break; 19 | case [null, '8', 'phoenix', 'heartbeat', {}]: 20 | serverWS.send([null, '8', 'phoenix', 'heartbeat', {}]); 21 | break; 22 | default: 23 | break; 24 | } 25 | }); 26 | }); 27 | socketConnect('ws://localhost:1234'); 28 | await serverWS.connected; 29 | }); 30 | 31 | afterEach(() => { 32 | WS.clean(); 33 | }); 34 | 35 | describe('socket', () => { 36 | test('phxReply', () => { 37 | expect(browser.browserAction.setBadgeText).toHaveBeenCalledTimes(0); 38 | serverWS.send(phxReply4bots); 39 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 40 | text: null, 41 | }); 42 | }); 43 | test('add games->remove games', () => { 44 | expect(browser.browserAction.setBadgeText).toHaveBeenCalledTimes(0); 45 | serverWS.send(getUpdateResponseWithID(1000, gameStatuses.waiting)); 46 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 47 | text: '1', 48 | }); 49 | serverWS.send(getUpdateResponseWithID(1001, gameStatuses.waiting)); 50 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 51 | text: '2', 52 | }); 53 | serverWS.send(getRemoveResponseWithID(1001)); 54 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 55 | text: '1', 56 | }); 57 | serverWS.send(getRemoveResponseWithID(1000)); 58 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 59 | text: null, 60 | }); 61 | }); 62 | test('add game -> changeStatus', () => { 63 | expect(browser.browserAction.setBadgeText).toHaveBeenCalledTimes(0); 64 | serverWS.send(getUpdateResponseWithID(1002, gameStatuses.waiting)); 65 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 66 | text: '1', 67 | }); 68 | serverWS.send(getUpdateResponseWithID(1002, gameStatuses.joined)); 69 | expect(browser.browserAction.setBadgeText).toHaveBeenLastCalledWith({ 70 | text: null, 71 | }); 72 | expect(browser.browserAction.setBadgeText).toHaveBeenCalledTimes(2); 73 | }); 74 | 75 | test('add game -> notification called', () => { 76 | expect(browser.notifications.create).toHaveBeenCalledTimes(0); 77 | serverWS.send(getUpdateResponseWithID(1002, gameStatuses.waiting)); 78 | expect(browser.notifications.create).toHaveBeenCalledTimes(1); 79 | serverWS.send(getUpdateResponseWithID(1003, gameStatuses.waiting)); 80 | expect(browser.notifications.create).toHaveBeenCalledTimes(2); 81 | serverWS.send(getUpdateResponseWithID(1004, gameStatuses.waiting)); 82 | expect(browser.notifications.create).toHaveBeenCalledTimes(3); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | test: { 4 | plugins: ['@babel/plugin-transform-runtime'], 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Codebattle", 3 | "description": "Web-extension for CodeBattle", 4 | "homepage_url": "https://codebattle.hexlet.io/", 5 | "version": "0.1.6", 6 | "manifest_version": 2, 7 | "icons": { 8 | "128": "assets/128.png" 9 | }, 10 | "browser_action": { 11 | "default_icon": "assets/128.png", 12 | "default_title": "CodeBattle webExtension", 13 | "default_popup": "popup/index.html" 14 | }, 15 | "background": { 16 | "scripts": [ 17 | "background/background.js" 18 | ], 19 | "persistent": true 20 | }, 21 | "content_scripts": [ 22 | { 23 | "matches": [ 24 | "*://*/*" 25 | ], 26 | "js": [ 27 | "content/content.js" 28 | ] 29 | } 30 | ], 31 | "options_ui": { 32 | "page": "options/index.html", 33 | "open_in_tab": true 34 | }, 35 | "web_accessible_resources": [ 36 | "assets/*" 37 | ], 38 | "permissions": [ 39 | "*://codebattle.hexlet.io/", 40 | "*://localhost/*", 41 | "notifications", 42 | "cookies", 43 | "windows", 44 | "storage" 45 | ] 46 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-extension", 3 | "version": "0.1.6", 4 | "description": "Hand-made boilerplate for react-extension", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "cross-env NODE_ENV=production webpack --hide-modules", 9 | "build:dev": "cross-env NODE_ENV=development webpack --hide-modules", 10 | "watch": "NODE_ENV=production npm run build -- --watch", 11 | "watch:dev": "NODE_ENV=development npm run build:dev -- --watch" 12 | }, 13 | "jest": { 14 | "setupFiles": [ 15 | "jest-webextension-mock" 16 | ] 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@babel/polyfill": "^7.8.7", 23 | "axios": "^0.21.1", 24 | "bootstrap": "^4.5.0", 25 | "classnames": "^2.2.6", 26 | "debug": "^4.1.1", 27 | "eslint-plugin-jest": "^24.0.2", 28 | "font-mfizz": "^2.4.1", 29 | "react": "^17.0.0", 30 | "react-bootstrap": "^1.0.1", 31 | "react-dom": "^17.0.0", 32 | "rxjs": "^6.6.3", 33 | "socket.io-client": "^2.3.0", 34 | "webextension-polyfill": "^0.6.0" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.8.4", 38 | "@babel/core": "^7.9.6", 39 | "@babel/plugin-transform-runtime": "^7.11.5", 40 | "@babel/preset-env": "^7.9.6", 41 | "@babel/preset-react": "^7.9.4", 42 | "babel-eslint": "^10.1.0", 43 | "babel-loader": "^8.1.0", 44 | "clean-webpack-plugin": "^3.0.0", 45 | "copy-webpack-plugin": "^5.1.1", 46 | "cross-env": "^7.0.2", 47 | "css-loader": "^3.5.3", 48 | "ejs": "^3.1.3", 49 | "eslint": "^7.0.0", 50 | "eslint-config-airbnb": "^18.1.0", 51 | "eslint-plugin-babel": "^5.3.0", 52 | "eslint-plugin-import": "^2.20.2", 53 | "eslint-plugin-jsx": "^0.1.0", 54 | "eslint-plugin-jsx-a11y": "^6.2.3", 55 | "eslint-plugin-react": "^7.20.0", 56 | "eslint-plugin-react-hooks": "^4.0.2", 57 | "export-loader": "^1.0.52", 58 | "file-loader": "^6.0.0", 59 | "html-webpack-plugin": "^4.3.0", 60 | "jest": "^26.4.2", 61 | "jest-webextension-mock": "^3.6.1", 62 | "jest-websocket-mock": "^2.2.0", 63 | "mini-css-extract-plugin": "^0.9.0", 64 | "mock-socket": "^9.0.3", 65 | "node-sass": "^4.14.1", 66 | "sass-loader": "^8.0.2", 67 | "webpack": "^4.43.0", 68 | "webpack-bundle-analyzer": "^3.8.0", 69 | "webpack-cli": "^3.3.11", 70 | "webpack-extension-reloader": "^1.1.4" 71 | }, 72 | "babel": { 73 | "presets": [ 74 | "@babel/env", 75 | "@babel/react" 76 | ] 77 | } 78 | } -------------------------------------------------------------------------------- /src/assets/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexlet-codebattle/chrome_extension/cc17b23797a02b61a6059345b0d882e5bf32e180/src/assets/128.png -------------------------------------------------------------------------------- /src/assets/images/playing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/images/waitingOpponent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/levels/easy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/assets/levels/elementary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/levels/hard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/assets/levels/medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/background/browser-actions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import browser from 'webextension-polyfill'; 3 | import defaultStorage from '../options/defaultStorage'; 4 | import createNotification from './notification'; 5 | import { notifications } from './models'; 6 | 7 | const setBadge = number => (number > 0 8 | ? browser.browserAction.setBadgeText({ text: `${number}` }) 9 | : browser.browserAction.setBadgeText({ text: null })); 10 | 11 | const stopFlashingBadge = id => { 12 | clearInterval(id); 13 | browser.browserAction.setBadgeBackgroundColor({ color: [0, 0, 0, 0] }); 14 | }; 15 | 16 | const flashBadge = () => { 17 | let flashing = true; 18 | const flash = () => { 19 | if (flashing) { 20 | browser.browserAction.setBadgeBackgroundColor({ color: [0, 0, 0, 0] }); 21 | } else { 22 | browser.browserAction.setBadgeBackgroundColor({ color: '#FF0000' }); 23 | } 24 | flashing = !flashing; 25 | }; 26 | const badgeFlashTimerId = setInterval(flash, 500); 27 | return badgeFlashTimerId; 28 | }; 29 | 30 | const animateBadge = (timeout = 10000) => { 31 | window.chrome.storage.sync.get(defaultStorage, storage => { 32 | console.log('animateBadge', storage); 33 | if (storage.toggles.flashing) { 34 | const timerId = flashBadge(); 35 | setTimeout(() => { 36 | stopFlashingBadge(timerId); 37 | }, timeout); 38 | } 39 | }); 40 | }; 41 | 42 | const showNotification = (notification, message, gameID) => { 43 | window.chrome.storage.sync.get(defaultStorage, storage => { 44 | if (storage.toggles.showNotifications[notification]) { 45 | createNotification(gameID.toString(), notifications[notification], message); 46 | } else { 47 | throw new Error('Unexpected notification type = ', notification); 48 | } 49 | }); 50 | }; 51 | 52 | export { 53 | setBadge, flashBadge, animateBadge, showNotification, 54 | }; 55 | -------------------------------------------------------------------------------- /src/background/index.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { combineLatest, Subject } from 'rxjs'; 3 | import axios from 'axios'; 4 | import socketConnect from './socket'; 5 | import { activeGames$, userState$, actions$ } from './state'; 6 | import { handleOnButtonClicked } from './notification'; 7 | 8 | const message$ = new Subject(); 9 | const serverUrl = process.env.NODE_ENV === 'development' 10 | ? 'http://localhost:4000' 11 | : 'https://codebattle.hexlet.io/'; 12 | const socketUrl = process.env.NODE_ENV === 'development' 13 | ? 'ws://localhost:4000/extension/websocket?vsn=2.0.0' 14 | : 'wss://codebattle.hexlet.io/extension/websocket?vsn=2.0.0'; 15 | 16 | const setUser = () => browser.cookies.get({ name: '_codebattle_key', url: serverUrl }); 17 | setUser() 18 | .then(() => axios.get(`${serverUrl}/api/v1/user/current`, { withCredentials: true })) 19 | .then(({ data: { id } }) => axios.get(`${serverUrl}/api/v1/user/${id}/stats`, { withCredentials: true })) 20 | .then(({ data }) => actions$.next({ type: 'user:update', payload: data })) 21 | .catch(err => console.error(err)); 22 | 23 | browser.runtime.onConnect.addListener(popup => { 24 | let connected = true; 25 | 26 | popup.onMessage.addListener(msg => { 27 | message$.next(msg); 28 | }); 29 | 30 | const state = combineLatest([activeGames$, userState$, message$]) 31 | .subscribe(([activeGames, userState, message]) => { 32 | if (connected) { 33 | switch (message.action) { 34 | case 'getState': { 35 | popup.postMessage({ games: { active_games: activeGames }, info: userState }); 36 | break; 37 | } 38 | default: 39 | throw new Error(`Unexpected message action: ${message.action}`); 40 | } 41 | } 42 | }); 43 | 44 | popup.onDisconnect.addListener(() => { 45 | connected = false; 46 | state.unsubscribe(); 47 | }); 48 | }); 49 | browser.notifications.onButtonClicked.addListener(handleOnButtonClicked); 50 | socketConnect(socketUrl); 51 | -------------------------------------------------------------------------------- /src/background/models.js: -------------------------------------------------------------------------------- 1 | //! Module for all models 2 | 3 | export const gameStatuses = { 4 | waiting: 'waiting_opponent', 5 | joined: 'joined', 6 | finished: 'finished', 7 | removed: 'removed', 8 | }; 9 | const newGame = { 10 | type: 'basic', 11 | title: 'New game available', 12 | message: 'Primary message to display', 13 | iconUrl: 'assets/128.png', 14 | buttons: [ 15 | { 16 | title: 'Join', 17 | }, 18 | ], 19 | }; 20 | 21 | const opponentJoin = { 22 | type: 'basic', 23 | message: 'Opponent has join the game', 24 | iconUrl: '../assets/128.png', 25 | eventTime: 23, 26 | buttons: [ 27 | { 28 | title: 'Close button', 29 | }, 30 | ], 31 | }; 32 | 33 | const newTournament = { 34 | type: 'basic', 35 | message: 'New tournament has begun', 36 | iconUrl: '../assets/128.png', 37 | eventTime: 23, 38 | buttons: [ 39 | { 40 | title: 'Close button', 41 | }, 42 | ], 43 | }; 44 | 45 | export const notifications = { newGame, opponentJoin, newTournament }; 46 | -------------------------------------------------------------------------------- /src/background/notification.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | let currentOptions; 4 | 5 | export const handleOnButtonClicked = (clickedNotificationId, index) => { 6 | if (currentOptions.buttons[index].title === 'Join') { 7 | const link = `https://codebattle.hexlet.io/games/${clickedNotificationId}`; 8 | window.open(link, '_blank'); 9 | } 10 | }; 11 | 12 | export default async (id, options, message) => { 13 | currentOptions = { ...options, message }; 14 | const createdNotificationID = await browser.notifications.create(id, currentOptions); 15 | return createdNotificationID; 16 | }; 17 | -------------------------------------------------------------------------------- /src/background/socket.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // @ts-check 3 | import { actions$ } from './state'; 4 | 5 | const isGameWithPlayer = ({ type }) => type !== 'bot' && type !== 'private'; 6 | 7 | const socketConnect = (url, socket = new WebSocket(url)) => { 8 | const getLobby = () => socket.send(JSON.stringify(['7', '7', 'lobby', 'phx_join', {}])); 9 | const ping = () => socket.send(JSON.stringify([null, '8', 'phoenix', 'heartbeat', {}])); 10 | 11 | 12 | socket.onopen = () => { 13 | setInterval(ping, 6000); 14 | getLobby(); 15 | }; 16 | 17 | socket.onmessage = event => { 18 | const message = JSON.parse(event.data); 19 | const [, , channel, phx_reply, info] = message; 20 | if (channel === 'lobby') { 21 | try { 22 | switch (phx_reply) { 23 | case 'phx_reply': { 24 | const activeGames = info.response.active_games.filter( 25 | game => isGameWithPlayer(game), 26 | ); 27 | actions$.next({ type: 'games:add', payload: activeGames }); 28 | break; 29 | } 30 | // emits when added new game 31 | case 'game:upsert': { 32 | if (isGameWithPlayer(info.game)) { 33 | actions$.next({ type: 'games:update', payload: info.game }); 34 | } 35 | break; 36 | } 37 | case 'game:remove': 38 | case 'game:finish': { 39 | const id = info.game ? info.game.id : info.id; 40 | actions$.next({ type: 'games:remove', payload: { id } }); 41 | break; 42 | } 43 | default: 44 | throw new Error(`Unexpected response type: ${phx_reply}`); 45 | } 46 | } catch (err) { 47 | console.log(`Error in bg: ${err}`); 48 | } 49 | } 50 | }; 51 | socket.onerror = error => { console.log('WS got error = ', error); }; 52 | socket.onclose = dsc => { console.log('WS disconnected ', dsc); }; 53 | }; 54 | 55 | export default socketConnect; 56 | -------------------------------------------------------------------------------- /src/background/state.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { ReplaySubject, Subject, BehaviorSubject } from 'rxjs'; 4 | import { 5 | tap, 6 | scan, 7 | } from 'rxjs/operators'; 8 | import { animateBadge, showNotification, setBadge } from './browser-actions'; 9 | import { gameStatuses } from './models'; 10 | 11 | const activeGames$ = new BehaviorSubject([]); 12 | 13 | const onUpdate = action => { 14 | if (action.type === 'update') { 15 | const { state: gameStatus } = action.payload; 16 | if (gameStatus === gameStatuses.waiting) { 17 | animateBadge(); 18 | const message = `Player - ${action.payload.players[0].name} || game level - ${action.payload.level} || status - ${action.payload.state} `; 19 | showNotification('newGame', message, action.payload.id); 20 | } 21 | } 22 | }; 23 | const showWaitingGamesAmount = games => { 24 | const waitingGames = games.filter(game => game.state === 'waiting_opponent'); 25 | if (waitingGames.length > 0) { 26 | setBadge(waitingGames.length); 27 | } else { 28 | setBadge(''); 29 | } 30 | }; 31 | const initialState = { 32 | games: { active_games: [] }, 33 | user: { 34 | score: null, 35 | login: null, 36 | }, 37 | game: { 38 | id: null, 39 | state: null, 40 | }, 41 | }; 42 | const userStateReducer = (state, action = { type: '', payload: {} }) => { 43 | switch (action.type) { 44 | case 'add': 45 | case 'delete': 46 | case 'update': { 47 | return { ...state, ...action.payload }; 48 | } 49 | default: 50 | return state; 51 | } 52 | }; 53 | 54 | const gamesStateReducer = ( 55 | action = { type: '', payload: null }, 56 | ) => { 57 | const state = activeGames$; 58 | const { type, payload } = action; 59 | const value = activeGames$.getValue(); 60 | switch (type) { 61 | case 'add': { 62 | state.next([...value, ...payload]); 63 | break; 64 | } 65 | case 'remove': { 66 | const { id } = payload; 67 | if (!id) { 68 | throw new Error(`Unexpected payload type: ${payload}`); 69 | } 70 | state.next(value.filter(game => game.id !== id)); 71 | break; 72 | } 73 | case 'update': { 74 | const { id } = payload; 75 | if (!id) { 76 | throw new Error(`Unexpected payload type: ${payload}`); 77 | } 78 | const currentGame = value.find(game => game.id === payload.id); 79 | if (currentGame) { 80 | state.next(value.map(game => (game.id === id ? payload : game))); 81 | break; 82 | } 83 | state.next([...value, payload]); 84 | break; 85 | } 86 | default: 87 | throw new Error(`Unexpected type: ${type}`); 88 | } 89 | }; 90 | 91 | const userActions$ = new ReplaySubject(1); 92 | const userState$ = userActions$.pipe( 93 | scan(userStateReducer, initialState.user), 94 | tap(user => console.log('User state is = ', user)), 95 | ); 96 | 97 | // FIXME: move out state from actions pipe 98 | const activeGamesActions$ = new ReplaySubject(1); 99 | 100 | const activeGamesChanges$ = activeGamesActions$.pipe( 101 | tap(gamesStateReducer), 102 | ); 103 | 104 | const actions$ = new Subject(); 105 | 106 | actions$.subscribe(message => { 107 | const { type, payload } = message; 108 | const [reducer, action] = type.split(':'); 109 | switch (reducer) { 110 | case 'games': { 111 | activeGamesActions$.next({ type: action, payload }); 112 | break; 113 | } 114 | case 'game': { 115 | break; 116 | } 117 | case 'user': { 118 | userActions$.next({ type: action, payload }); 119 | return; 120 | } 121 | default: 122 | throw new Error(`Unexpected reducer type: ${reducer}`); 123 | } 124 | }); 125 | 126 | activeGamesChanges$ 127 | .pipe( 128 | tap(onUpdate), 129 | ).subscribe(); 130 | activeGames$ 131 | .pipe( 132 | tap(showWaitingGamesAmount), 133 | ) 134 | .subscribe(); 135 | 136 | export { 137 | userState$, 138 | activeGames$, 139 | actions$, 140 | }; 141 | -------------------------------------------------------------------------------- /src/content/content.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexlet-codebattle/chrome_extension/cc17b23797a02b61a6059345b0d882e5bf32e180/src/content/content.js -------------------------------------------------------------------------------- /src/options/actions/index.js: -------------------------------------------------------------------------------- 1 | export const setToggle = 'setToggle'; 2 | export const setShowNotification = 'setShowNotification'; 3 | export const setTheme = 'setTheme'; 4 | -------------------------------------------------------------------------------- /src/options/components/App.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useEffect, useReducer } from 'react'; 3 | import Content from './Content'; 4 | import ContextApp from './ContextApp'; 5 | import reducer from '../reducer'; 6 | 7 | const App = ({ storage }) => { 8 | const initialState = { ...storage }; 9 | const [state, dispatch] = useReducer(reducer, initialState); 10 | useEffect(() => { 11 | window.chrome.storage.sync.set(state); 12 | }, [state]); 13 | return ( 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /src/options/components/Content.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useContext } from 'react'; 3 | import ContextApp from './ContextApp'; 4 | import GroupToggles from './GroupToggles'; 5 | import SelectMenu from './SelectMenu'; 6 | import Toggle from './Toggle'; 7 | 8 | const themes = ['white', 'black']; 9 | const description = { 10 | flashing: 'Blink when a new game added', 11 | newGame: 'new game added', 12 | newTournament: 'new tournament created', 13 | opponentJoin: 'opponent join', 14 | }; 15 | const Title = () => ( 16 |
17 |

18 |
19 | Logo 20 |
21 |
CodeBattle Extension Options
22 |

23 |
24 | ); 25 | 26 | const Body = () => { 27 | const { state, dispatch } = useContext(ContextApp); 28 | const { flashing, showNotifications } = state.toggles; 29 | 30 | const handleChangeSetToggle = (name, checked) => () => { 31 | dispatch({ 32 | type: 'setToggle', 33 | payload: { [name]: !checked }, 34 | }); 35 | }; 36 | const handleChangeSetShowNotification = (name, checked) => () => { 37 | dispatch({ 38 | type: 'setShowNotification', 39 | payload: { [name]: !checked }, 40 | }); 41 | }; 42 | return ( 43 |
44 | 45 | 50 | 51 | 52 | 53 | {Object.entries(showNotifications).map(([name, checked]) => ( 54 | 60 | ))} 61 | 62 | 63 |
64 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | const Content = () => ( 75 |
76 |
77 |
78 | 79 | <Body /> 80 | </div> 81 | </div> 82 | </div> 83 | ); 84 | 85 | export default Content; 86 | -------------------------------------------------------------------------------- /src/options/components/ContextApp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ContextApp = React.createContext({}); 4 | 5 | export default ContextApp; 6 | -------------------------------------------------------------------------------- /src/options/components/GroupToggles.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default ({ description, children }) => ( 4 | <div className="border-bottom mb-2"> 5 | <div className="mb-2 ">{description}</div> 6 | <div className="ml-4"> 7 | {children} 8 | </div> 9 | </div> 10 | ); 11 | -------------------------------------------------------------------------------- /src/options/components/SelectMenu.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useContext } from 'react'; 3 | import ContextApp from './ContextApp'; 4 | 5 | const SelectMenu = ({ options, name, text }) => { 6 | const { state, dispatch } = useContext(ContextApp); 7 | const handleChange = event => { 8 | dispatch({ 9 | type: 'setTheme', 10 | payload: { [name]: event.target.value }, 11 | }); 12 | }; 13 | return ( 14 | <> 15 | <div className="pb-2">{text}</div> 16 | <select className="custom-select pb-2" value={state.popupTheme} onChange={handleChange}> 17 | {options.map((option, index) => ( 18 | <option key={`${option}${index.toString()}`} value={option}>{option}</option> 19 | ))} 20 | </select> 21 | </> 22 | ); 23 | }; 24 | 25 | export default SelectMenu; 26 | -------------------------------------------------------------------------------- /src/options/components/Toggle.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/label-has-associated-control */ 2 | import React from 'react'; 3 | 4 | export default ({ description, checked, handleChange }) => ( 5 | <div className="custom-control custom-switch pb-2"> 6 | <input 7 | type="checkbox" 8 | className="custom-control-input" 9 | defaultChecked={checked} 10 | onChange={handleChange} 11 | id={description} 12 | /> 13 | <label className="custom-control-label" htmlFor={description}>{description}</label> 14 | </div> 15 | ); 16 | -------------------------------------------------------------------------------- /src/options/defaultStorage.js: -------------------------------------------------------------------------------- 1 | const defaultStorage = { 2 | toggles: { 3 | flashing: true, 4 | showNotifications: { 5 | newGame: true, 6 | newTournament: true, 7 | opponentJoin: true, 8 | }, 9 | }, 10 | popupTheme: 'white', 11 | }; 12 | 13 | export default defaultStorage; 14 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | 4 | <head> 5 | <meta charset="UTF-8"> 6 | <meta name="viewport" 7 | content="width=device-width, initial-scale=1.0"> 8 | <title>CodeBattle options webExtension 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/options/options.jsx: -------------------------------------------------------------------------------- 1 | 2 | import ReactDOM from 'react-dom'; 3 | import React from 'react'; 4 | import './options.scss'; 5 | import App from './components/App'; 6 | import defaultStorage from './defaultStorage'; 7 | 8 | 9 | function restoreOptions() { 10 | window.chrome.storage.sync.get(defaultStorage, storage => { 11 | ReactDOM.render(, document.getElementById('root')); 12 | }); 13 | } 14 | document.addEventListener('DOMContentLoaded', restoreOptions); 15 | -------------------------------------------------------------------------------- /src/options/options.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/options/reducer/index.js: -------------------------------------------------------------------------------- 1 | import { setShowNotification, setTheme, setToggle } from '../actions'; 2 | 3 | export default (state, action) => { 4 | switch (action.type) { 5 | case setToggle: 6 | return { 7 | ...state, 8 | toggles: { 9 | ...state.toggles, 10 | ...action.payload, 11 | }, 12 | }; 13 | case setShowNotification: 14 | return { 15 | ...state, 16 | toggles: { 17 | ...state.toggles, 18 | showNotifications: { 19 | ...state.toggles.showNotifications, 20 | ...action.payload, 21 | }, 22 | }, 23 | }; 24 | case setTheme: 25 | return { 26 | ...state, 27 | ...action.payload, 28 | }; 29 | default: 30 | return state; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/popup/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.scss'; 3 | import UserName from './UserName'; 4 | 5 | const getAction = gameStatus => { 6 | switch (gameStatus) { 7 | case 'playing': { 8 | return 'show'; 9 | } 10 | case 'waiting_opponent': { 11 | return 'join'; 12 | } 13 | default: 14 | return null; 15 | } 16 | }; 17 | 18 | const GameLevelBadge = ({ level }) => ( 19 |
25 | {level} 26 |
27 | ); 28 | 29 | const Players = ({ players }) => { 30 | if (players.length === 1) { 31 | return ( 32 | 33 |
34 | 35 |
36 | 37 | ); 38 | } 39 | return ( 40 | <> 41 | 42 |
43 | 44 |
45 |
46 | VS 47 |
48 |
49 | 50 |
51 | 52 | 53 | ); 54 | }; 55 | 56 | const codebattleUrl = 'https://codebattle.hexlet.io/'; 57 | const getLink = id => `${codebattleUrl}games/${id}`; 58 | const userLink = id => `${codebattleUrl}users/${id}`; 59 | 60 | const ActiveGames = ({ games }) => ( 61 |
62 | 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | {games.map(({ 75 | id, level, state, players, 76 | }) => ( 77 | 78 | 81 | 93 | 94 | 101 | 102 | ))} 103 | 104 |
LevelState 68 | Players 69 | Actions
79 | 80 | 82 | {state} 92 | 95 | {getAction(state) && ( 96 | 97 | {getAction(state)} 98 | 99 | )} 100 |
105 |
106 | ); 107 | const UserButton = ({ user }) => { 108 | const { 109 | name, rating, id, github_id: githubId, 110 | } = user; 111 | return ( 112 |
  • 113 | 114 |
    115 | { name } 116 | { rating } 117 |
    118 | User profile avatar 119 |
    120 |
  • 121 | ); 122 | }; 123 | const SignInButton = () => ( 124 | 125 |
    Sign In
    126 |
    127 | ); 128 | const NavBar = ({ user }) => ( 129 | 145 | ); 146 | 147 | export default ({ state }) => { 148 | const { active_games: activeGames } = state.games; 149 | const { user } = state.info; 150 | return ( 151 | <> 152 |
    153 | 154 |
    155 |
    156 | {activeGames.length > 0 ? 157 | :
    No games available
    } 158 |
    159 |