├── __mocks__ ├── styleMock.js └── fileMock.js ├── .babelrc ├── test └── jestsetup.js ├── src ├── Mobile.js ├── Panel.js ├── components │ ├── App │ │ ├── App.css │ │ ├── CommandListContainer │ │ │ ├── CommandListContainer.css │ │ │ ├── CommandList │ │ │ │ ├── CommandList.css │ │ │ │ └── CommandList.js │ │ │ └── CommandListContainer.js │ │ ├── App.test.js │ │ └── App.js │ ├── ConfigPage │ │ ├── Config.css │ │ ├── ConfigPage.test.js │ │ ├── ConfigContainer │ │ │ ├── ConfigContainer.css │ │ │ ├── ConfigCommand │ │ │ │ ├── ConfigCommand.css │ │ │ │ └── ConfigCommand.js │ │ │ └── ConfigContainer.js │ │ └── ConfigPage.js │ └── Authentication │ │ ├── Authentication.test.js │ │ └── Authentication.js ├── VideoComponent.js ├── VideoOverlay.js ├── Config.js └── LiveConfig.js ├── template.html ├── public ├── panel.html ├── config.html ├── mobile.html ├── video_overlay.html ├── live_config.html └── video_component.html ├── bin └── generate_cert.sh ├── .gitignore ├── index.js ├── package.json ├── README.md └── webpack.config.js /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react" 5 | ] 6 | } -------------------------------------------------------------------------------- /test/jestsetup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | Enzyme.configure({ adapter: new Adapter() }) -------------------------------------------------------------------------------- /src/Mobile.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./components/App/App" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/Panel.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./components/App/App" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/components/App/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: Arial, Helvetica, sans-serif; 3 | } 4 | 5 | .App-light{ 6 | color:#232127; 7 | } 8 | 9 | .App-dark{ 10 | color:#e5e3e8; 11 | } -------------------------------------------------------------------------------- /src/VideoComponent.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./components/App/App" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/VideoOverlay.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./components/App/App" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/components/ConfigPage/Config.css: -------------------------------------------------------------------------------- 1 | .Config{ 2 | font-family: Arial, Helvetica, sans-serif; 3 | } 4 | 5 | .Config-light{ 6 | color:#232127; 7 | } 8 | 9 | .Config-dark{ 10 | color:#e5e3e8; 11 | } -------------------------------------------------------------------------------- /src/Config.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import ConfigPage from "./components/ConfigPage/ConfigPage" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/LiveConfig.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import ConfigPage from "./components/ConfigPage/ConfigPage" 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ) -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigPage.test.js: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme' 2 | import React from 'react' 3 | import ConfigPage from './ConfigPage' 4 | 5 | test('renders without failing', ()=>{ 6 | let wrapper = shallow() 7 | 8 | expect(wrapper).toBeDefined() 9 | }) -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigContainer/ConfigContainer.css: -------------------------------------------------------------------------------- 1 | .config-container-container{ 2 | display:flex; 3 | flex-direction: column; 4 | } 5 | 6 | .config-container-form{ 7 | margin-bottom:.5em; 8 | } 9 | 10 | .config-container-footer{ 11 | margin-top:.5em; 12 | } -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigContainer/ConfigCommand/ConfigCommand.css: -------------------------------------------------------------------------------- 1 | .config-command-container{ 2 | display:flex; 3 | } 4 | 5 | .config-command-actions{ 6 | height: inherit; 7 | padding-right:1em; 8 | cursor: pointer; 9 | border-right: 1px solid; 10 | } 11 | 12 | .config-command-command{ 13 | padding-left:1em; 14 | } -------------------------------------------------------------------------------- /src/components/App/CommandListContainer/CommandListContainer.css: -------------------------------------------------------------------------------- 1 | .header{ 2 | width:100%; 3 | display:flex; 4 | flex-direction:row; 5 | color: #e5e3e8; 6 | background-color:#6441A4; 7 | font-weight: 400; 8 | } 9 | 10 | .header > .header-text, .header > .header-expand-toggle{ 11 | margin:.25em; 12 | } 13 | 14 | .header > .header-expand-toggle{ 15 | margin-left:auto; 16 | } -------------------------------------------------------------------------------- /src/components/App/CommandListContainer/CommandList/CommandList.css: -------------------------------------------------------------------------------- 1 | .command-container{ 2 | display: block; 3 | padding-bottom:5px; 4 | padding-top:5px; 5 | margin: .5em; 6 | border-bottom: 1px solid; 7 | } 8 | 9 | .command-container:hover{ 10 | cursor:pointer; 11 | } 12 | 13 | .command-header{ 14 | width:100%; 15 | display:flex; 16 | flex-direction:row; 17 | } 18 | 19 | .command-header > .command-expand-toggle{ 20 | margin-left:auto; 21 | } -------------------------------------------------------------------------------- /template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 |
11 | 14 | 15 | -------------------------------------------------------------------------------- /public/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Starter 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/config.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sample Config Page 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/mobile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sample Config Page 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/video_overlay.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Starter 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/live_config.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sample Config Page 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/video_component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Starter 8 | 9 | 10 |
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/App/App.test.js: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme' 2 | import React from 'react' 3 | import App from './App' 4 | 5 | test('renders without failing', ()=>{ 6 | let wrapper = shallow() 7 | 8 | expect(wrapper).toBeDefined() 9 | }) 10 | 11 | test('able to change theme based on context',()=>{ 12 | let wrapper = shallow() 13 | let instance = wrapper.instance() 14 | 15 | expect(wrapper.state('theme')).toEqual('light') 16 | instance.contextUpdate({theme:'dark'},['theme']) 17 | expect(wrapper.state('theme')).toEqual('dark') 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigContainer/ConfigCommand/ConfigCommand.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './ConfigCommand.css' 3 | 4 | export default class ConfigCommand extends React.Component{ 5 | constructor(props){ 6 | super(props) 7 | } 8 | 9 | render(){ 10 | return( 11 |
12 |
this.props.delete(this.props.commandKey)}> 13 | Delete 14 |
15 |
16 | Command: {this.props.command.command}
17 | Description: {this.props.command.description} 18 |
19 |
20 | ) 21 | } 22 | } -------------------------------------------------------------------------------- /bin/generate_cert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | NAME=${1:-server} 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | openssl req \ 5 | -newkey rsa:4096 \ 6 | -days 1001 \ 7 | -nodes \ 8 | -x509 \ 9 | -subj "/C=US/ST=California/L=San Francisco/O=LULZCorp/OU=web/CN=localhost" \ 10 | -extensions SAN \ 11 | -config <( cat $( [[ "Darwin" = "$(uname -s)" ]] && echo /System/Library/OpenSSL/openssl.cnf || echo /etc/ssl/openssl.cnf ) \ 12 | <(printf "[SAN]\nsubjectAltName='DNS:localhost'")) \ 13 | -keyout "${DIR}/${NAME}.key" \ 14 | -out "${DIR}/${NAME}.crt" 15 | 16 | echo "" 17 | echo "* Generated $NAME.key and $NAME.crt files in local directory" 18 | echo "" 19 | 20 | if [[ "$OSTYPE" == "darwin"* ]]; then 21 | echo "* Installing cert into local Keychain." 22 | echo "* To see or modify, run 'Keychain Access' app and look in the 'System' Folder" 23 | sudo security add-trusted-cert -d -p ssl -r trustRoot -k "/Library/Keychains/System.keychain" "${DIR}/${NAME}.crt" 24 | else 25 | echo "* Please install and trust cert at conf/$NAME.crt" 26 | fi 27 | cd "$DIR" 28 | if [[ ! -d "${DIR}/../conf/" ]]; then 29 | mkdir "${DIR}/../conf/" 30 | fi 31 | mv ${NAME}.{key,crt} "${DIR}/../conf/" 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Configuration 2 | conf/* 3 | 4 | # Junk 5 | .DS_Store 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://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 (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # Finalized files 67 | dist/ 68 | -------------------------------------------------------------------------------- /src/components/App/CommandListContainer/CommandList/CommandList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './CommandList.css' 4 | 5 | export default class CommandList extends React.Component{ 6 | constructor(props){ 7 | super(props) 8 | 9 | this.state={ 10 | expanded: false 11 | } 12 | } 13 | 14 | toggleDescription(){ 15 | this.setState(prevState=>{ 16 | return { 17 | expanded:!prevState.expanded 18 | } 19 | }) 20 | } 21 | 22 | render(){ 23 | return( 24 |
this.toggleDescription()}> 25 |
26 |
27 | {this.props.command.command} 28 |
29 |
30 | {this.state.expanded ? "Collapse" : "Expand"} 31 |
32 |
33 |
34 | {this.state.expanded ? this.props.command.description : ""} 35 |
36 |
37 | ) 38 | } 39 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin') 2 | 3 | let entryPoints = { 4 | VideoComponent:{ 5 | path:"./src/VideoComponent.js", 6 | outputHtml:"video_component.html", 7 | build:true 8 | }, 9 | VideoOverlay:{ 10 | path:"./src/VideoOverlay.js", 11 | outputHtml:"video_overlay.html", 12 | build:true 13 | }, 14 | Panel:{ 15 | path:"./src/Panel.js", 16 | outputHtml:"panel.html", 17 | build:true 18 | }, 19 | Config:{ 20 | path:"./src/Config.js", 21 | outputHtml:"config.html", 22 | build:true 23 | }, 24 | LiveConfig:{ 25 | path:"./src/LiveConfig.js", 26 | outputHtml:"live_config.html", 27 | build:true 28 | }, 29 | Mobile:{ 30 | path:"./src/Mobile.js", 31 | outputHtml:"mobile.html", 32 | build:true 33 | } 34 | } 35 | 36 | let entry = {} 37 | let output = [] 38 | for(name in entryPoints){ 39 | if(entryPoints[name].build){ 40 | entry[name]=entryPoints[name].path 41 | output.push(new HtmlWebpackPlugin({ 42 | inject:true, 43 | chunks:name, 44 | template:'./template.html', 45 | filename:entryPoints[name].outputHtml 46 | })) 47 | } 48 | } 49 | console.log(entry) 50 | console.log(output) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "webpack --mode production", 9 | "start": "webpack-dev-server --mode development", 10 | "cert": "./bin/generate_cert.sh server", 11 | "host": "webpack-dev-server --mode development --devrig" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "babel-cli": "^6.26.0", 18 | "babel-core": "^6.26.0", 19 | "babel-jest": "^23.4.2", 20 | "babel-loader": "^7.1.4", 21 | "babel-preset-env": "^1.7.0", 22 | "babel-preset-react": "^6.24.1", 23 | "css-loader": "^0.28.11", 24 | "enzyme": "^3.4.1", 25 | "enzyme-adapter-react-16": "^1.2.0", 26 | "file-loader": "^1.1.11", 27 | "html-webpack-plugin": "^3.2.0", 28 | "jest": "^23.5.0", 29 | "style-loader": "^0.21.0", 30 | "webpack": "^4.6.0", 31 | "webpack-cli": "^3.1.0", 32 | "webpack-dev-server": "^3.1.3" 33 | }, 34 | "dependencies": { 35 | "jsonwebtoken": "^8.3.0", 36 | "react": "^16.3.2", 37 | "react-dom": "^16.3.2" 38 | }, 39 | "jest": { 40 | "setupFiles": [ 41 | "/test/jestsetup.js" 42 | ], 43 | "moduleNameMapper": { 44 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 45 | "\\.(css|less)$": "/__mocks__/styleMock.js" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/App/CommandListContainer/CommandListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CommandList from './CommandList/CommandList' 3 | 4 | import './CommandListContainer.css' 5 | 6 | export default class CommandListContainer extends React.Component{ 7 | constructor(props){ 8 | super(props) 9 | 10 | this.state={ 11 | expanded:this.props.panel, 12 | panel:this.props.panel 13 | } 14 | } 15 | 16 | expandCommands(){ 17 | this.setState((prevstate)=>{ 18 | return {expanded:!prevstate.expanded} 19 | }) 20 | } 21 | 22 | render(){ 23 | return ( 24 |
25 |
26 |
27 | Bot Commander 28 |
29 | {!this.state.panel ? 30 |
this.expandCommands()}> 31 | {this.state.expanded ? 'expanded': 'hidden'} 32 |
33 | : 34 |
35 |
36 | } 37 | 38 |
39 | {this.state.expanded ? 40 | this.props.commands ? 41 | this.props.commands.map((v,i)=>{ 42 | return () 43 | }) 44 | : '' 45 | : '' 46 | } 47 |
48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /src/components/Authentication/Authentication.test.js: -------------------------------------------------------------------------------- 1 | import Authentication from './Authentication' 2 | import "isomorphic-fetch" 3 | 4 | let normalToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjU5NzMxOTAsIm9wYXF1ZV91c2VyX2lkIjoiVVJpZ0FCQzEyIiwiY2hhbm5lbF9pZCI6ImRlbW9fY2hhbm5lbCIsInJvbGUiOiJ2aWV3ZXIiLCJwdWJzdWJfcGVybXMiOnsibGlzdGVuIjpbImJyb2FkY2FzdCIsImdsb2JhbCJdLCJzZW5kIjpbImJyb2FkY2FzdCJdfSwidXNlcl9pZCI6Inh4eHh4eHh4eCIsImlhdCI6MTUzNDQzNzE5MH0.O2gmsiVIUegWVMwS_mbRU1SF6cdBmHJ5E7JLmsV9AcY' 5 | let modToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjU5NzMxOTAsIm9wYXF1ZV91c2VyX2lkIjoiVVJpZ0FCQzEyIiwiY2hhbm5lbF9pZCI6ImRlbW9fY2hhbm5lbCIsInJvbGUiOiJicm9hZGNhc3RlciIsInB1YnN1Yl9wZXJtcyI6eyJsaXN0ZW4iOlsiYnJvYWRjYXN0IiwiZ2xvYmFsIl0sInNlbmQiOlsiYnJvYWRjYXN0Il19LCJ1c2VyX2lkIjoieHh4eHh4eHh4IiwiaWF0IjoxNTM0NDM3MTkwfQ.KhwwsrhcsPanRyaPL6dO2knTD0JMXcP38oVqDgjt5Mk' 6 | 7 | let auth = new Authentication() 8 | 9 | test('able to create new Authenciation instance', ()=>{ 10 | expect(auth).toBeDefined() 11 | }) 12 | 13 | test('able to set a token', ()=>{ 14 | auth.setToken(normalToken,'U12345678') 15 | expect(auth.isAuthenticated()).toEqual(true) 16 | }) 17 | 18 | 19 | describe('makeCall tests', ()=>{ 20 | test('able to call a test URL', async ()=>{ 21 | let response = await auth.makeCall('https://twitch.tv/') 22 | expect(response.status).toEqual(200) 23 | }) 24 | 25 | test('rejects when no credentials', ()=>{ 26 | auth.setToken('','') 27 | return expect(auth.makeCall('https://twitch.tv/')).rejects.toEqual('Unauthorized') 28 | }) 29 | 30 | test('rejects on invalid response',()=>{ 31 | auth.setToken('abc123','U12345678') 32 | return expect(auth.makeCall('htts://api')).rejects.toBeDefined() 33 | }) 34 | 35 | test('rejecsts on bad credentials',async ()=>{ 36 | return expect(auth.makeCall('https://google.com')).rejects.toBeDefined() 37 | }) 38 | }) 39 | 40 | describe('moderator tests', ()=>{ 41 | test('returns valid mod status',()=>{ 42 | auth.setToken(modToken,'ABC123') 43 | expect(auth.isModerator()).toEqual(true) 44 | 45 | auth.setToken(normalToken,'ABC123') 46 | expect(auth.isModerator()).toEqual(false) 47 | }) 48 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bot Commander Config Service Example 2 | 3 | ## Background 4 | 5 | This is a quick example of the new config service, and how to build an extension with the service that does not rely on a backend server (EBS). 6 | 7 | ## Requirements 8 | 9 | There is only one requirement to use this example. 10 | 11 | * Node.JS LTS or greater. 12 | 13 | You may also find that using `yarn` is easier than `npm`, so we do recommend installing that as well by running: 14 | ``` 15 | npm i -g yarn 16 | ``` 17 | in an elevated command line interface. 18 | 19 | ## First time Usage 20 | 21 | ### [Developer Rig](https://dev.twitch.tv/docs/extensions/rig/) Usage 22 | 23 | If you are using the developer rig and have used this as your basis for your extension, please ignore the below steps- the developer rig has taken care of it for you! 24 | 25 | ### Local Development 26 | 27 | If you're wanting to develop this locally, use the below instructions. 28 | To use this, simply clone the repository into the folder of your choice. 29 | 30 | For example, to clone this into a `` folder, simply run the following in a commandline interface: 31 | ``` 32 | git clone 33 | ``` 34 | 35 | Next, do the following: 36 | 37 | 1. Change directories into the cloned folder. 38 | 2. Run `yarn install` to install all prerequisite packages needed to run the template. 39 | 3. Run `yarn cert` to generate the needed certificates. This allows the server to be run over HTTPS vs. HTTP. 40 | 4. Run `yarn start` to run the sample. If everything works, you should be be able to go to the developer rig, create a panel view, and see `Hello world!` 41 | 42 | ## Usage 43 | 44 | To build your finalized React JS files, simply run `yarn build` to build the various webpacked files. 45 | 46 | ## File Structure 47 | 48 | The file structure in the template is laid out with the following: 49 | 50 | ### bin 51 | 52 | The `/bin` folder holds the cert generation script. 53 | 54 | ### conf 55 | 56 | The `/conf` folder holds the generated certs after the cert generation script runs. 57 | 58 | ### dist 59 | 60 | `/dist` holds the final JS files after building. 61 | 62 | ### public 63 | 64 | `/public` houses the static HTML files used for your code's entrypoint. 65 | 66 | ### src 67 | 68 | This folder houses all source code and relevant files (such as images). Each React class/component is given a folder to house all associated files (such as associated CSS). 69 | 70 | Below this folder, the structure is much simpler. 71 | 72 | This would be: 73 | 74 | ` 75 | components 76 | -\App 77 | --\App.js 78 | --\App.test.js 79 | --\App.css 80 | -\Authentication 81 | --\Authentication.js 82 | ... 83 | ` 84 | 85 | Each component is under the `components` folder. 86 | -------------------------------------------------------------------------------- /src/components/Authentication/Authentication.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | 3 | /** 4 | * Helper class for authentication against an EBS service. Allows the storage of a token to be accessed across componenents. 5 | */ 6 | export default class Authentication{ 7 | 8 | constructor(token, opaque_id){ 9 | this.state={ 10 | token, 11 | opaque_id, 12 | user_id:false, 13 | isMod:false, 14 | role:"" 15 | } 16 | } 17 | 18 | // this does guarantee the user is a moderator- this is fairly simple to bypass - so pass the JWT and verify 19 | // server-side that this is true. This, however, allows you to render client-side UI for users without holding on a backend to verify the JWT. 20 | isModerator(){ 21 | return this.state.isMod 22 | } 23 | 24 | // similar to mod status, this isn't always verifyable, so have your backend verify before proceeding. 25 | hasSharedId(){ 26 | return !!this.state.user_id 27 | } 28 | 29 | getUserId(){ 30 | return this.state.user_id 31 | } 32 | 33 | // set the token in the Authentication componenent state 34 | setToken(token,opaque_id){ 35 | let mod = false 36 | let role = "" 37 | let user_id = "" 38 | 39 | try { 40 | let decoded = jwt.decode(token) 41 | 42 | if(decoded.role === 'broadcaster' || decoded.role === 'moderator'){ 43 | mod = true 44 | } 45 | 46 | user_id = decoded.user_id 47 | role = decoded.role 48 | } catch (e) { 49 | console.log('Invalid token.') 50 | token='' 51 | opaque_id='' 52 | } 53 | 54 | this.state={ 55 | token, 56 | opaque_id, 57 | isMod:mod, 58 | user_id, 59 | role 60 | } 61 | } 62 | 63 | // checks to ensure there is a valid token in the state 64 | isAuthenticated(){ 65 | if(this.state.token && this.state.opaque_id){ 66 | return true 67 | }else{ 68 | return false 69 | } 70 | } 71 | 72 | /** 73 | * Makes a call against a given endpoint using a specific method. 74 | * 75 | * Returns a Promise with the Request() object per fetch documentation. 76 | * 77 | */ 78 | 79 | makeCall(url, method="GET"){ 80 | return new Promise((resolve, reject)=>{ 81 | if(this.isAuthenticated()){ 82 | let headers={ 83 | 'Content-Type':'application/json', 84 | 'Authorization': `Bearer ${this.state.token}` 85 | } 86 | 87 | fetch(url, 88 | { 89 | method, 90 | headers, 91 | }) 92 | .then(response=>resolve(response)) 93 | .catch(e=>reject(e)) 94 | }else{ 95 | reject('Unauthorized') 96 | } 97 | }) 98 | } 99 | } -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Authentication from '../Authentication/Authentication' 3 | import CommandListContainer from './CommandListContainer/CommandListContainer' 4 | 5 | import './App.css' 6 | 7 | export default class App extends React.Component{ 8 | constructor(props){ 9 | super(props) 10 | this.Authentication = new Authentication() 11 | 12 | this.anchor = this.queryParamParse().anchor 13 | //if the extension is running on twitch or dev rig, set the shorthand here. otherwise, set to null. 14 | this.twitch = window.Twitch ? window.Twitch.ext : null 15 | this.state={ 16 | finishedLoading:false, 17 | theme:'light', 18 | commands:[] 19 | } 20 | } 21 | 22 | queryParamParse(){ 23 | let query = window.location.search 24 | let obj = {} 25 | query.substring(1).split('&').map(v=>{ 26 | let s = v.split('=') 27 | obj[s[0]] = decodeURIComponent(s[1]) 28 | }) 29 | 30 | return obj 31 | } 32 | 33 | contextUpdate(context, delta){ 34 | if(delta.includes('theme')){ 35 | this.setState(()=>{ 36 | return {theme:context.theme} 37 | }) 38 | } 39 | } 40 | 41 | componentDidMount(){ 42 | if(this.twitch){ 43 | this.twitch.onAuthorized((auth)=>{ 44 | this.Authentication.setToken(auth.token, auth.userId) 45 | if(!this.state.finishedLoading){ 46 | this.setState(()=>{ 47 | return {finishedLoading:true} 48 | }) 49 | } 50 | }) 51 | 52 | this.twitch.onContext((context,delta)=>{ 53 | this.contextUpdate(context,delta) 54 | }) 55 | 56 | this.twitch.configuration.onChanged(()=>{ 57 | let config = this.twitch.configuration.broadcaster ? this.twitch.configuration.broadcaster.content : '' 58 | try{ 59 | config = JSON.parse(config) 60 | }catch(e){ 61 | config = '' 62 | } 63 | 64 | this.setState(()=>{ 65 | return{ 66 | commands:config 67 | } 68 | }) 69 | }) 70 | } 71 | } 72 | 73 | componentWillUnmount(){ 74 | if(this.twitch){ 75 | this.twitch.unlisten('broadcast', ()=>console.log('successfully unlistened')) 76 | } 77 | } 78 | 79 | render(){ 80 | if(this.state.finishedLoading && this.state.commands.length > 0){ 81 | return ( 82 |
83 | 84 |
85 | ) 86 | }else{ 87 | return ( 88 |
89 | No commands configured. 90 |
91 | ) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require("path") 3 | const webpack = require("webpack") 4 | const HtmlWebpackPlugin = require('html-webpack-plugin') 5 | 6 | // defines where the bundle file will live 7 | const bundlePath = path.resolve(__dirname, "dist/") 8 | 9 | module.exports = (_env,argv)=> { 10 | let entryPoints = { 11 | VideoComponent:{ 12 | path:"./src/VideoComponent.js", 13 | outputHtml:"video_component.html", 14 | build:false 15 | }, 16 | VideoOverlay:{ 17 | path:"./src/VideoOverlay.js", 18 | outputHtml:"video_overlay.html", 19 | build:false 20 | }, 21 | Panel:{ 22 | path:"./src/Panel.js", 23 | outputHtml:"panel.html", 24 | build:true 25 | }, 26 | Config:{ 27 | path:"./src/Config.js", 28 | outputHtml:"config.html", 29 | build:true 30 | }, 31 | LiveConfig:{ 32 | path:"./src/LiveConfig.js", 33 | outputHtml:"live_config.html", 34 | build:false 35 | }, 36 | Mobile:{ 37 | path:"./src/Mobile.js", 38 | outputHtml:"mobile.html", 39 | build:true 40 | } 41 | } 42 | 43 | let entry = {} 44 | 45 | // edit webpack plugins here! 46 | let plugins = [ 47 | new webpack.HotModuleReplacementPlugin() 48 | ] 49 | 50 | for(name in entryPoints){ 51 | if(entryPoints[name].build){ 52 | entry[name]=entryPoints[name].path 53 | if(argv.mode==='production'){ 54 | plugins.push(new HtmlWebpackPlugin({ 55 | inject:true, 56 | chunks:[name], 57 | template:'./template.html', 58 | filename:entryPoints[name].outputHtml 59 | })) 60 | } 61 | } 62 | } 63 | 64 | let config={ 65 | //entry points for webpack- remove if not used/needed 66 | entry, 67 | optimization: { 68 | minimize: false // neccessary to pass Twitch's review process 69 | }, 70 | module: { 71 | rules: [ 72 | { 73 | test: /\.(js|jsx)$/, 74 | exclude: /(node_modules|bower_components)/, 75 | loader: 'babel-loader', 76 | options: { presets: ['env'] } 77 | }, 78 | { 79 | test: /\.css$/, 80 | use: [ 'style-loader', 'css-loader' ] 81 | }, 82 | { 83 | test: /\.(jpe?g|png|gif|svg)$/i, 84 | loader: "file-loader", 85 | options:{ 86 | name:"img/[name].[ext]" 87 | } 88 | } 89 | ] 90 | }, 91 | resolve: { extensions: ['*', '.js', '.jsx'] }, 92 | output: { 93 | filename: "[name].bundle.js", 94 | path:bundlePath 95 | }, 96 | plugins 97 | } 98 | if(argv.mode==='development'){ 99 | config.devServer = { 100 | contentBase: path.join(__dirname,'public'), 101 | host:argv.devrig ? 'localhost.rig.twitch.tv' : 'localhost', 102 | headers: { 103 | 'Access-Control-Allow-Origin': '*' 104 | }, 105 | port: 8080 106 | } 107 | if(fs.existsSync(path.resolve(__dirname,'conf/server.key'))){ 108 | config.devServer.https = { 109 | key:fs.readFileSync(path.resolve(__dirname,'conf/server.key')), 110 | cert:fs.readFileSync(path.resolve(__dirname,'conf/server.crt')) 111 | } 112 | } 113 | } 114 | 115 | return config; 116 | } 117 | -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Authentication from '../Authentication/Authentication' 3 | import ConfigContainer from './ConfigContainer/ConfigContainer' 4 | 5 | import './Config.css' 6 | 7 | export default class ConfigPage extends React.Component{ 8 | constructor(props){ 9 | super(props) 10 | this.Authentication = new Authentication() 11 | 12 | //if the extension is running on twitch or dev rig, set the shorthand here. otherwise, set to null. 13 | this.twitch = window.Twitch ? window.Twitch.ext : null 14 | this.state={ 15 | finishedLoading:false, 16 | theme:'light', 17 | commands:[] 18 | } 19 | } 20 | 21 | contextUpdate(context, delta){ 22 | if(delta.includes('theme')){ 23 | this.setState(()=>{ 24 | return {theme:context.theme} 25 | }) 26 | } 27 | } 28 | 29 | componentDidMount(){ 30 | // do config page setup as needed here 31 | if(this.twitch){ 32 | this.twitch.onAuthorized((auth)=>{ 33 | this.Authentication.setToken(auth.token, auth.userId) 34 | if(!this.state.finishedLoading){ 35 | // if the component hasn't finished loading (as in we've not set up after getting a token), let's set it up now. 36 | 37 | // now we've done the setup for the component, let's set the state to true to force a rerender with the correct data. 38 | this.setState(()=>{ 39 | return {finishedLoading:true} 40 | }) 41 | } 42 | }) 43 | 44 | this.twitch.onContext((context,delta)=>{ 45 | this.contextUpdate(context,delta) 46 | }) 47 | 48 | this.twitch.configuration.onChanged(()=>{ 49 | let config = this.twitch.configuration.broadcaster ? this.twitch.configuration.broadcaster.content : [] 50 | try{ 51 | config = JSON.parse(config) 52 | }catch(e){ 53 | config = [] 54 | } 55 | 56 | this.setState(()=>{ 57 | return{ 58 | commands:config 59 | } 60 | }) 61 | }) 62 | } 63 | } 64 | 65 | saveConfig(commands){ 66 | this.twitch.configuration.set('broadcaster', '1.0', JSON.stringify(commands)) 67 | 68 | this.setState(prevState=>{ 69 | return{ 70 | commands 71 | } 72 | }) 73 | } 74 | 75 | render(){ 76 | if(this.state.finishedLoading && this.Authentication.isModerator()){ 77 | return( 78 |
79 |
80 | this.saveConfig(commands)}/> 81 |
82 |
83 | ) 84 | } 85 | else{ 86 | return( 87 |
88 |
89 | Loading... 90 |
91 |
92 | ) 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /src/components/ConfigPage/ConfigContainer/ConfigContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ConfigCommand from './ConfigCommand/ConfigCommand' 3 | 4 | import './ConfigContainer.css' 5 | 6 | export default class ConfigContainer extends React.Component{ 7 | constructor(props){ 8 | super(props) 9 | 10 | this.state = { 11 | commands:this.props.commands 12 | } 13 | } 14 | 15 | onSubmit(event) { 16 | event.preventDefault() 17 | 18 | if(this.state.commands.length <= 5){ 19 | this.setState(prevState=>{ 20 | let commands = prevState.commands 21 | commands.push({ 22 | command:this.state.command, 23 | description:this.state.description, 24 | date:new Date() 25 | }) 26 | return { 27 | commands 28 | } 29 | }) 30 | this.commandInput.value = "" 31 | this.descriptionInput.value = "" 32 | } 33 | } 34 | 35 | handleInputChange(event) { 36 | const target = event.target; 37 | const value = target.value; 38 | const name = target.name; 39 | 40 | this.setState({ 41 | [name]: value 42 | }); 43 | } 44 | 45 | deleteCommand(key){ 46 | this.setState(prevState=>{ 47 | return{ 48 | commands:prevState.commands.filter(old=>old.date != key)} 49 | } 50 | ) 51 | } 52 | 53 | render(){ 54 | return( 55 |
56 |
57 |
this.onSubmit(e)}> 58 | 68 |
69 | 79 |
80 | 81 |
82 |
83 |
84 |
85 | {this.state.commands.map((v,i)=>{ 86 | return this.deleteCommand(key)}/> 87 | })} 88 |
89 |
90 | this.props.saveConfig(this.state.commands)} 93 | disabled={this.state.commands.length===0} 94 | value="Save commands!" 95 | /> 96 |
97 |
98 | ) 99 | } 100 | } --------------------------------------------------------------------------------