├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── package.json ├── src ├── assets │ ├── brian.jpg │ ├── defaultChannelBanner-1920x1080.png │ ├── defaultChannelBanner-240x135.png │ ├── defaultChannelBanner-480x270.png │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── html_code.html │ │ ├── mstile-150x150.png │ │ └── site.webmanifest │ ├── og-image.jpg │ └── pin.svg ├── css │ ├── chat.scss │ ├── dark.scss │ ├── home.scss │ ├── index.scss │ ├── light.scss │ ├── modcards.scss │ ├── variables.scss │ └── whispers.scss ├── html │ └── index.html ├── js │ ├── colorcorrection.js │ ├── config.js │ ├── controllers │ │ ├── autocompletecontroller.js │ │ ├── autocompletepanelcontroller.js │ │ ├── buttonsettingscontroller.js │ │ ├── chatcontroller.js │ │ ├── dialogcontroller.js │ │ ├── homecontroller.js │ │ ├── iconpickercontroller.js │ │ ├── maincontroller.js │ │ ├── settingsdialogcontroller.js │ │ ├── streamcontroller.js │ │ ├── streamlistcontroller.js │ │ ├── whispercontroller.js │ │ └── whispertoastcontroller.js │ ├── defaultConfig.js │ ├── defaultLayouts.js │ ├── directives │ │ ├── autocompletedirective.js │ │ ├── buttonsettingsdirective.js │ │ ├── chatlinedirective.js │ │ ├── draggabledirective.js │ │ ├── dynamicstylesheetdirective.js │ │ ├── filters.js │ │ ├── goldenlayoutdragsourcedirective.js │ │ ├── iconpickerdirective.js │ │ ├── onscrolldirective.js │ │ ├── simplescrolldirective.js │ │ ├── streamlistdirective.js │ │ └── throttledevents.js │ ├── errorreporting.js │ ├── helpers.js │ ├── iconCodes.json │ ├── index.js │ ├── languages.json │ ├── migrations.js │ ├── services │ │ ├── apiservice.js │ │ ├── chatservice.js │ │ ├── ffzsocketservice.js │ │ ├── keypressservice.js │ │ ├── throttleddigestservice.js │ │ └── toastservice.js │ ├── themes │ │ ├── dark.js │ │ └── light.js │ └── urlRegex.js └── templates │ ├── autocompletetemplate.html │ ├── buttonsettings.html │ ├── chatline.html │ ├── chatwindow.html │ ├── homewindow.html │ ├── iconpicker.html │ ├── iconpickerpanel.html │ ├── icontemplate.html │ ├── objecttoast.html │ ├── settingsdialog.html │ ├── streamlisttemplate.html │ ├── streamwindow.html │ ├── whispertoasttemplate.html │ └── whisperwindow.html ├── webpack.config.js ├── webpack.dev.js ├── webpack.prod.js ├── webpack.staging.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [Makefile] 24 | indent_style = tab -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "browser": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 2017 10 | }, 11 | "root": true, 12 | "rules": { 13 | "object-curly-spacing": [ 14 | 2, 15 | "always" 16 | ], 17 | "comma-dangle": [ 18 | 2, 19 | "never" 20 | ], 21 | "no-param-reassign": "off", 22 | "no-plusplus": "off", 23 | "arrow-parens": [ 24 | 2, 25 | "as-needed" 26 | ], 27 | "require-jsdoc": "off", 28 | "no-underscore-dangle": "off", 29 | "no-invalid-this": "off", 30 | "newline-per-chained-call": "off", 31 | "class-methods-use-this": "off", 32 | "prefer-destructuring": "off", 33 | "no-mixed-operators": "off", 34 | "max-len": [ 35 | 1, 36 | 150 37 | ], 38 | "linebreak-style": [ 39 | 1, 40 | "unix" 41 | ], 42 | "indent": [ 43 | 2, 44 | 2, 45 | { 46 | "SwitchCase": 1, 47 | "MemberExpression": 0 48 | } 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | src/js/config.*.json 61 | dist/ 62 | img/ 63 | tmp/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/.vscode/settings.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModCh.at 2 | View and moderate multiple twitch.tv channels at the same time 3 | 4 | ModCh.at is the newest in moderation tech - a complete twitch viewing, chatting and moderation experience in one compact package, no download required. 5 | 6 | It was built to replace "legacy twitch chat", which, in combination with FFZ and multitwitch-style websites used to be the premier way for moderators to manage their chats. Since twitch decided to disable legacy chat over the next couple of days (according to twitch, on 2018/4/20), I am releasing the beta for everyone to enjoy! 7 | 8 | It features a completely custom chat, tons of moderation features and the ability to watch any number of channels as well as never before seen configurability. 9 | 10 | ## Usage 11 | If you want to use [ModCh.at](https://modch.at), simply go to that URL and... thats it! 12 | 13 | ## Contributing 14 | If you find errors, bugs or other problems or have an idea or suggestion, please open an issue on the repository! 15 | 16 | If you want to help out with features and the like, clone the repository, make sure you have yarn installed and run 17 | 18 | yarn install 19 | 20 | Then create a config.prod.json, config.staging.json and a config.dev.json in /src/js with the following: 21 | 22 | { 23 | "auth": { 24 | "client_id": "juyvdc7nz2wxxavwkjb1fj8g0eg2nj", 25 | "redirect_uri": "http://localhost:8080" 26 | } 27 | } 28 | 29 | (you can change the client id and redirect uri to your own app, but this isnt necessary in most cases) 30 | 31 | To build and run the development environment, run 32 | 33 | yarn dev 34 | 35 | I would like to ask you not to host your own version of this, but to give back to this tool by submitting a pull request. 36 | 37 | ### Development environment 38 | I strongly suggest an editor like Atom, VS Code or similar with support for .editorconfig and ESLint. 39 | Please make sure to adhere to the style set by the .eslint config (at least, to the point where theres no errors) 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mt2", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/CBenni/mt2", 6 | "author": "CBenni ", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "webpack --optimize-minimize --define process.env.NODE_ENV='\"prod\"' --config webpack.prod.js", 10 | "stage": "webpack --optimize-minimize --define process.env.NODE_ENV='\"staging\"' --config webpack.staging.js", 11 | "dev": "webpack-dev-server --history-api-fallback --config webpack.dev.js", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "devDependencies": { 15 | "babel-core": "^6.26.0", 16 | "babel-loader": "^7.1.2", 17 | "babel-plugin-angularjs-annotate": "^0.8.2", 18 | "babel-polyfill": "^6.26.0", 19 | "babel-preset-env": "^1.6.0", 20 | "clean-webpack-plugin": "^0.1.19", 21 | "copy-webpack-plugin": "^4.1.1", 22 | "css-loader": "^0.28.7", 23 | "eslint": "^4.9.0", 24 | "eslint-config-airbnb-base": "^12.1.0", 25 | "eslint-plugin-import": "^2.8.0", 26 | "extract-text-webpack-plugin": "^3.0.1", 27 | "file-loader": "^1.1.5", 28 | "html-loader": "^0.5.1", 29 | "html-webpack-plugin": "^2.30.1", 30 | "imports-loader": "^0.8.0", 31 | "node-sass": "^4.8.1", 32 | "raw-loader": "^0.5.1", 33 | "rimraf": "^2.6.2", 34 | "sass-loader": "^6.0.6", 35 | "style-loader": "^0.19.0", 36 | "webpack": "^3.7.1", 37 | "webpack-dev-server": "^2.9.1" 38 | }, 39 | "dependencies": { 40 | "@iamadamjowett/angular-click-outside": "^2.10.1", 41 | "angular": "^1.6.9", 42 | "angular-animate": "^1.6.9", 43 | "angular-aria": "^1.6.9", 44 | "angular-cookies": "^1.6.9", 45 | "angular-material": "^1.1.7", 46 | "angular-messages": "^1.6.9", 47 | "angular-ui-router": "^1.0.3", 48 | "angular-ui-sortable": "^0.18.0", 49 | "file-saver": "^1.3.8", 50 | "golden-layout": "^1.5.9", 51 | "jquery": "^3.3.1", 52 | "jquery-ui": "^1.12.1", 53 | "lodash": "^4.17.5", 54 | "mime": "^2.2.0", 55 | "raven-js": "^3.24.2", 56 | "simple-scrollbar": "^0.4.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/assets/brian.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/brian.jpg -------------------------------------------------------------------------------- /src/assets/defaultChannelBanner-1920x1080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/defaultChannelBanner-1920x1080.png -------------------------------------------------------------------------------- /src/assets/defaultChannelBanner-240x135.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/defaultChannelBanner-240x135.png -------------------------------------------------------------------------------- /src/assets/defaultChannelBanner-480x270.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/defaultChannelBanner-480x270.png -------------------------------------------------------------------------------- /src/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /src/assets/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /src/assets/favicon/html_code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /src/assets/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ModCh.at by CBenni", 3 | "short_name": "ModCh.at by CBenni", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png?v=xQz9azANQA", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png?v=xQz9azANQA", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/assets/og-image.jpg -------------------------------------------------------------------------------- /src/assets/pin.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | -------------------------------------------------------------------------------- /src/css/chat.scss: -------------------------------------------------------------------------------- 1 | .chat-line-wrapper { 2 | padding: 4px 2px; 3 | 4 | &.chat-line-deleted { 5 | opacity: .5; 6 | 7 | .chat-line-text { 8 | text-decoration: line-through; 9 | } 10 | } 11 | 12 | &.action-msg { 13 | .chat-line-username, .chat-line-text { 14 | font-style: italic; 15 | } 16 | 17 | .chat-line-colon { 18 | display: none; 19 | } 20 | } 21 | 22 | &.system-msg { 23 | .chat-line-mod-buttons, .chat-line-username, .chat-line-colon { 24 | display: none; 25 | } 26 | } 27 | 28 | &.usernotice-msg { 29 | background-color: hsla(0,0%,50%,.1); 30 | border-left: 4px solid #6441a4; 31 | } 32 | 33 | &.timeout-msg, &.timeout-msg .chat-line-time { 34 | color: #838383; 35 | } 36 | 37 | &.automod-msg { 38 | background-color: #d0d0d0; 39 | border-left: 4px solid #ec1313; 40 | } 41 | 42 | &.mention { 43 | background-color: rgba(255,0,0,0.3); 44 | } 45 | 46 | .chat-mention { 47 | background-color: rgba(0,0,0,0.3); 48 | padding: 2px 4px; 49 | cursor: pointer; 50 | } 51 | 52 | .chat-line-time { 53 | color: rgba(0, 0, 0, 0.7); 54 | font-size: small; 55 | } 56 | 57 | 58 | img.emote { 59 | vertical-align: middle; 60 | margin: -1em 0 -1em 0; 61 | } 62 | 63 | img.chat-line-badge { 64 | vertical-align: text-bottom; 65 | margin: 0 1px 0 0; 66 | } 67 | 68 | .chat-line-username { 69 | cursor: pointer; 70 | } 71 | 72 | .chat-line-text { 73 | word-break: break-word; 74 | } 75 | 76 | .system-user { 77 | font-weight: bold; 78 | cursor: pointer; 79 | outline: none; 80 | } 81 | } 82 | 83 | .emote { 84 | max-height: 1.5em; 85 | } 86 | 87 | .chat-container { 88 | overflow-y: scroll; 89 | background-color: $page-body; 90 | } 91 | 92 | .chat-settings-menu { 93 | position: absolute; 94 | background-color: $widget-body; 95 | z-index: 70; 96 | padding: 8px; 97 | top: 40px; 98 | width: 350px 99 | } 100 | 101 | .chat-paused-indicator { 102 | position: absolute; 103 | bottom: 0; 104 | background-color: rgba(0,0,0,0.5); 105 | width: 100%; 106 | padding: 2px; 107 | cursor: pointer; 108 | } 109 | 110 | .chat-input-box { 111 | min-height: 20px; 112 | 113 | 114 | textarea { 115 | width: 100%; 116 | height: 4em; 117 | border: 0px none; 118 | resize: none; 119 | box-sizing: border-box; 120 | background-color: $widget-body; 121 | padding-right: 24px; 122 | } 123 | } 124 | 125 | .chat-header-button { 126 | img { 127 | height: 32px; 128 | } 129 | } 130 | 131 | .chat-line-mod-button { 132 | line-height: initial; 133 | cursor: pointer; 134 | 135 | span.chat-line-mod-button-label { 136 | font-weight: bold; 137 | text-transform: none; 138 | vertical-align: middle; 139 | color: rgba(0,0,0,0.54); 140 | } 141 | 142 | img.chat-line-mod-button-label { 143 | margin: -0.75px 0; 144 | max-height: 1.5em; 145 | vertical-align: middle; 146 | } 147 | 148 | md-icon.material-icons { 149 | height: 19px; 150 | font-size: 19px; 151 | min-height: 19px; 152 | min-width: 19px; 153 | width: 19px; 154 | } 155 | 156 | &.automod-approve md-icon { 157 | color: #0dbf2a; 158 | } 159 | 160 | &.automod-deny md-icon { 161 | color: #ec1313; 162 | } 163 | } 164 | 165 | .chat-status-indicators .chat-status-indicator { 166 | padding: 0 8px; 167 | } 168 | 169 | .large-tooltip { 170 | max-height: none; 171 | overflow: visible; 172 | height: auto; 173 | transform: translateY(-100%); 174 | } 175 | 176 | .emote-menu-button { 177 | position: absolute; 178 | bottom: 30px; 179 | right: 0; 180 | } 181 | 182 | .emote-menu { 183 | position: absolute; 184 | bottom: 4em; 185 | right: 0; 186 | height: 50%; 187 | max-width: 500px; 188 | max-height: 500px; 189 | width: 90%; 190 | z-index: 65; 191 | background-color: rgba(200,200,200,0.8); 192 | 193 | .emote { 194 | cursor: pointer; 195 | } 196 | 197 | .emote-menu-list { 198 | overflow-y: auto; 199 | } 200 | } 201 | 202 | .md-panel.autocomplete-panel { 203 | width: 300px; 204 | height: 20px; 205 | position: relative; 206 | overflow: visible; 207 | 208 | .autocomplete-panel-content { 209 | position: absolute; 210 | bottom: 0; 211 | width: 100%; 212 | background-color: $widget-body; 213 | 214 | .autocomplete-selected { 215 | background-color: #7d5bbe; 216 | color: white; 217 | } 218 | 219 | img { 220 | max-height: 32px; 221 | margin-right: 6px; 222 | } 223 | } 224 | } 225 | 226 | .chat-line { 227 | outline: none; 228 | } 229 | 230 | .chat-lines.has-custom-cursor { 231 | .chat-line:hover { 232 | background-color: rgba(128,128,128, 0.5); 233 | } 234 | .chat-line-wrapper { 235 | .chat-line-username, .chat-mention { 236 | cursor: inherit; 237 | } 238 | } 239 | } 240 | 241 | .hide-chat-input { 242 | height: 25px; 243 | textarea { 244 | display: none; 245 | } 246 | } 247 | 248 | .chat-line-wrapper .chat-line-text { 249 | a, a:hover, a:visited, a:active { 250 | color: black; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/css/dark.scss: -------------------------------------------------------------------------------- 1 | .whisper-container { 2 | .whisper-conversation { 3 | .whisper-header { 4 | background-color: $widget-header-dark; 5 | } 6 | 7 | .whisper-body-wrapper { 8 | background-color: $page-body-dark; 9 | } 10 | 11 | .whisper-input-field { 12 | background-color: $widget-body-dark; 13 | color: $text-color-dark; 14 | } 15 | } 16 | } 17 | 18 | .lm_content { 19 | background-color: $page-body-dark; 20 | } 21 | 22 | .lm_goldenlayout, .lm_splitter { 23 | background-color: $splitter-body-dark; 24 | } 25 | 26 | .chat-container { 27 | overflow-y: scroll; 28 | background-color: $page-body-dark; 29 | 30 | 31 | .chat-line-mod-button-label { 32 | color: $text-color-dark; 33 | } 34 | } 35 | 36 | .chat-settings-menu { 37 | background-color: $widget-body-dark; 38 | } 39 | 40 | .chat-input-box { 41 | textarea { 42 | background-color: $widget-body-dark; 43 | color: $text-color-dark; 44 | } 45 | } 46 | 47 | .stream-list .stream-list-item .stream-add-buttons .stream-add-button { 48 | background-color: $widget-body-dark; 49 | md-icon { 50 | color: $text-color-dark; 51 | } 52 | } 53 | 54 | &, .chat-line-wrapper .chat-line-time, .whisper-lines-wrapper .chat-line-time { 55 | color: $text-color-dark; 56 | } 57 | .chat-line-wrapper { 58 | &.timeout-msg { 59 | .chat-line-time, md-icon.md-dark-theme { 60 | color: #838383; 61 | } 62 | } 63 | 64 | &.automod-msg { 65 | background-color: #262626; 66 | } 67 | } 68 | 69 | 70 | .md-button.md-default-theme.md-primary:not([disabled]) md-icon, 71 | .md-button.md-primary:not([disabled]) md-icon, 72 | md-select.md-default-theme .md-select-icon, 73 | md-select .md-select-icon, 74 | .md-button.md-dark-theme.md-primary.md-fab, .md-button.md-dark-theme.md-primary.md-raised, 75 | .md-button.md-dark-theme.md-primary { 76 | color: $text-color-dark; 77 | } 78 | 79 | md-tabs.md-dark-theme .md-tab.md-active, md-tabs.md-dark-theme .md-tab.md-active md-icon, md-tabs.md-dark-theme .md-tab.md-focused, md-tabs.md-dark-theme .md-tab.md-focused md-icon, 80 | md-input-container.md-input-focused label, md-input-container.md-default-theme .md-placeholder, md-input-container .md-placeholder, md-input-container.md-default-theme label, md-input-container label, 81 | md-input-container.md-default-theme:not(.md-input-invalid).md-input-has-value label, md-input-container:not(.md-input-invalid).md-input-has-value label 82 | { 83 | color: $text-color-dark; 84 | } 85 | 86 | md-input-container.md-default-theme .md-input, md-input-container .md-input { 87 | border-color: $text-color-dark; 88 | color: $text-color-dark; 89 | } 90 | 91 | .mod-card { 92 | .mod-card-header { 93 | background-color: $widget-header-dark; 94 | } 95 | .mod-card-body { 96 | background-color: $widget-body-dark; 97 | } 98 | } 99 | 100 | md-switch.md-dark-theme.md-checked .md-bar { 101 | background-color: rgb(158,158,158); 102 | } 103 | 104 | md-switch.md-dark-theme.md-checked .md-thumb { 105 | background-color: rgb(255,64,129); 106 | } 107 | 108 | .emote-menu { 109 | background-color: rgba(30,30,30,0.8); 110 | } 111 | 112 | .md-panel.autocomplete-panel { 113 | .autocomplete-panel-content { 114 | background-color: $widget-body-dark; 115 | } 116 | } 117 | 118 | md-sidenav { 119 | background-color: $widget-body-dark; 120 | } 121 | 122 | md-menu-content.md-dark-theme { 123 | background-color: $widget-body-dark; 124 | 125 | md-menu-item { 126 | color: $text-color-dark; 127 | } 128 | } 129 | 130 | .chat-line-wrapper .chat-line-text { 131 | a, a:hover, a:visited, a:active { 132 | color: $text-color-dark; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/css/home.scss: -------------------------------------------------------------------------------- 1 | .stream-list { 2 | overflow-y: auto; 3 | 4 | .stream-list-item { 5 | width: 300px; 6 | height: 280px; 7 | margin: 8px; 8 | position: relative; 9 | 10 | img.stream-preview { 11 | width: 300px; 12 | } 13 | 14 | .stream-title { 15 | white-space: nowrap; 16 | text-overflow: ellipsis; 17 | max-width: 100%; 18 | overflow: hidden; 19 | } 20 | 21 | .stream-preview-overlay { 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | padding: 2px; 26 | width: 100%; 27 | 28 | .stream-add-buttons { 29 | 30 | .stream-add-button { 31 | background-color: #e7e7e7; 32 | border-radius: 5px; 33 | padding: 2px; 34 | margin: 2px; 35 | cursor: move; 36 | } 37 | } 38 | 39 | .stream-live-indicator { 40 | .circle { 41 | background-color: red; 42 | border-radius: 12px; 43 | height: 16px; 44 | width: 16px; 45 | margin-right: 4px; 46 | } 47 | .live-text { 48 | padding-bottom: 1px; 49 | } 50 | background-color: rgba(0,0,0,0.7); 51 | padding: 4px 6px; 52 | margin: 2px; 53 | } 54 | 55 | .chat-adder { 56 | span.chat-adder-label { 57 | font-size: 20px; 58 | min-width: 24px; 59 | text-align: center; 60 | display: inline-block; 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | 68 | .settings-dialog { 69 | width: 70%; 70 | min-height: 50%; 71 | 72 | .drag-handle { 73 | cursor: move; 74 | } 75 | 76 | .settings-row { 77 | min-width: 400px; 78 | padding-right: 50px; 79 | 80 | label { 81 | width: 200px; 82 | } 83 | } 84 | 85 | .settings-extra-mentions { 86 | padding: 8px; 87 | margin-bottom: 8px; 88 | md-input-container { 89 | margin-bottom: 8px; 90 | } 91 | 92 | md-checkbox { 93 | margin-bottom: 0; 94 | } 95 | } 96 | 97 | .settings-mod-button { 98 | margin-bottom: 8px; 99 | 100 | md-input-container.dropdown { 101 | min-width: 150px; 102 | max-width: 150px; 103 | width: 150px; 104 | } 105 | 106 | .settings-mod-button-setting-complex { 107 | min-width: 400px; 108 | margin-right: 20px; 109 | } 110 | 111 | .settings-mod-button-setting-simple { 112 | min-width: 300px; 113 | margin-right: 20px; 114 | } 115 | 116 | .settings-mod-button-setting-very-simple { 117 | min-width: 100px; 118 | margin-right: 20px; 119 | } 120 | 121 | md-input-container.text-input { 122 | width: 90%; 123 | } 124 | } 125 | 126 | md-content { 127 | overflow-x: hidden; 128 | } 129 | } 130 | 131 | 132 | .icon-code-select { 133 | 134 | md-content { 135 | display: flex; 136 | flex-direction: row; 137 | flex-wrap: wrap; 138 | max-width: 300px; 139 | } 140 | 141 | md-icon { 142 | color: inherit; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/css/index.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/angular-material/angular-material.scss'; 2 | @import '../../node_modules/golden-layout/src/css/goldenlayout-base.css'; 3 | @import './variables.scss'; 4 | @import './chat.scss'; 5 | @import './whispers.scss'; 6 | @import './home.scss'; 7 | @import './modcards.scss'; 8 | 9 | body, html { 10 | width: 100%; 11 | height: 100%; 12 | margin: 0; 13 | padding: 0; 14 | overflow: hidden; 15 | } 16 | 17 | .wrapper { 18 | width: 100%; 19 | height: 100%; 20 | max-width: 100%; 21 | max-height: 100%; 22 | } 23 | 24 | body.theme-dark { 25 | @import '../../node_modules/golden-layout/src/css/goldenlayout-dark-theme'; 26 | @import './dark'; 27 | } 28 | 29 | body.theme-light { 30 | @import '../../node_modules/golden-layout/src/css/goldenlayout-light-theme'; 31 | @import './light'; 32 | } 33 | 34 | iframe { 35 | width: 100%; 36 | height: 100%; 37 | } 38 | 39 | .compact { 40 | margin: 0; 41 | &.md-button { 42 | padding: 0; 43 | min-width: 0; 44 | min-height: 19px; 45 | line-height: 0; 46 | } 47 | } 48 | 49 | md-input-container.compact { 50 | margin: 0; 51 | padding: 0; 52 | } 53 | 54 | md-input-container.no-error-spacer { 55 | margin-bottom: 0; 56 | &::after, .md-errors-spacer { 57 | display: none; 58 | } 59 | } 60 | 61 | md-input-container { 62 | max-width: 100%; 63 | } 64 | 65 | .footer { 66 | padding: 0 8px; 67 | } 68 | 69 | .md-button.md-raised:not([disabled]).loginWithTwitch { 70 | &,&:hover { 71 | background-color: hsl(258, 39%, 35%); 72 | } 73 | } 74 | 75 | .md-button.tiny-button { 76 | padding: 0; 77 | margin: 0; 78 | min-width: 0; 79 | min-height: 0; 80 | line-height: 0; 81 | } 82 | 83 | .md-button.dynamic-icon-button { 84 | width: auto; 85 | min-width: 40px; 86 | } 87 | 88 | .column-reverse { 89 | flex-direction: column-reverse; 90 | } 91 | 92 | .md-toast-text { 93 | white-space: pre-line; 94 | } 95 | 96 | .lm_controls { 97 | .lm_popout, .lm_maximise { 98 | display: none; 99 | } 100 | } 101 | 102 | .lm_tab .tab-icon { 103 | font-size: 12px; 104 | max-height: 12px; 105 | margin-right: 4px; 106 | vertical-align: top; 107 | 108 | img { 109 | height: 12px; 110 | } 111 | 112 | md-icon { 113 | font-size: 12px; 114 | height: auto; 115 | width: auto; 116 | min-height: unset; 117 | min-width: unset; 118 | } 119 | } 120 | 121 | 122 | .lm_header .lm_tabs .lm_tab.unread-tab { 123 | background-color: darkred; 124 | color: white; 125 | 126 | .tab-notification-count { 127 | font-size: 12px; 128 | background-color: black; 129 | color: white; 130 | padding: 2px 5.7px; 131 | margin: -2px 4px; 132 | border-radius: 10px; 133 | } 134 | } 135 | 136 | md-toast { 137 | outline: none; 138 | } 139 | 140 | 141 | ::-webkit-scrollbar-track 142 | { 143 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 144 | box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 145 | border-radius: 10px; 146 | } 147 | 148 | ::-webkit-scrollbar 149 | { 150 | width: 12px; 151 | } 152 | 153 | ::-webkit-scrollbar-thumb 154 | { 155 | border-radius: 10px; 156 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3); 157 | box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 158 | background-color: #555; 159 | } 160 | 161 | .md-panel-outer-wrapper { 162 | width: 0 !important; 163 | height: 0 !important; 164 | } 165 | -------------------------------------------------------------------------------- /src/css/light.scss: -------------------------------------------------------------------------------- 1 | .lm_content { 2 | background-color: $page-body; 3 | } 4 | 5 | .chat-container { 6 | background-color: $page-body; 7 | } 8 | -------------------------------------------------------------------------------- /src/css/modcards.scss: -------------------------------------------------------------------------------- 1 | .mod-card { 2 | width: 400px; 3 | min-height: 250px; 4 | position: absolute; 5 | z-index: 60; 6 | 7 | .mod-card-header { 8 | background-color: $widget-header; 9 | cursor: move; 10 | 11 | img.mod-card-profile-picture { 12 | height: 80px; 13 | padding: 0; 14 | } 15 | 16 | span.tiny-icon { 17 | font-size: 24px; 18 | line-height: 0px; 19 | vertical-align: middle; 20 | } 21 | 22 | .mod-card-username { 23 | text-overflow: ellipsis; 24 | overflow: hidden; 25 | font-weight: bold; 26 | } 27 | 28 | .mod-card-channel-info { 29 | font-size: small; 30 | } 31 | 32 | md-icon.mod-card-pin { 33 | transform: rotate(45deg); 34 | } 35 | md-icon.mod-card-pinned { 36 | transform: rotate(0deg); 37 | } 38 | 39 | .mod-card-controls button { 40 | margin: 0 -8px; 41 | } 42 | } 43 | .mod-card-body { 44 | background-color: $widget-body; 45 | } 46 | 47 | button.md-button.mod-card-mod-button { 48 | font-size: 20px; 49 | text-transform: none; 50 | margin: 0 6px; 51 | 52 | img { 53 | vertical-align: middle; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/css/variables.scss: -------------------------------------------------------------------------------- 1 | $widget-header: rgb(209,209,209); 2 | $widget-body: rgb(240,240,240); 3 | $page-body: rgb(245,245,245); 4 | 5 | $page-body-dark: hsl(0, 0%, 13%); 6 | $widget-header-dark: rgb(100,100,100); 7 | $widget-body-dark: hsl(0, 0%, 20%); 8 | $text-color-dark: rgb(218, 216, 222); 9 | $splitter-body-dark: hsl(0, 0%, 23%); 10 | -------------------------------------------------------------------------------- /src/css/whispers.scss: -------------------------------------------------------------------------------- 1 | .whisper-window { 2 | height: 100%; 3 | } 4 | 5 | .whisper-user-list, .whisper-content { 6 | max-height: 100%; 7 | min-width: 250px; 8 | overflow-y: auto; 9 | .whisper-user-name { 10 | font-weight: bold; 11 | } 12 | .whisper-unread-count { 13 | padding: 2px 10px; 14 | border-radius: 20px; 15 | background-color: black; 16 | } 17 | .chat-line-time { 18 | color: rgba(0, 0, 0, 0.7); 19 | font-size: small; 20 | } 21 | } 22 | 23 | .whisper-user-list { 24 | max-width: 50%; 25 | width: 500px; 26 | 27 | .md-list-item-text { 28 | max-width: calc(100% - 56px); 29 | } 30 | } 31 | 32 | .whisper-lines-wrapper { 33 | overflow-y: auto; 34 | line-height: 1.5; 35 | } 36 | 37 | .whisper-last-message, .whisper-user-name { 38 | overflow: hidden; 39 | text-overflow: ellipsis; 40 | white-space: nowrap; 41 | max-width: 100%; 42 | .emote { 43 | max-height: 1em; 44 | vertical-align: middle; 45 | } 46 | } 47 | 48 | .whisper-text { 49 | line-height: 1.5; 50 | img.emote { 51 | vertical-align: middle; 52 | margin: -1em 0 -1em 0; 53 | } 54 | } 55 | 56 | md-toast.whisper-toast .md-toast-content { 57 | max-width: 400px; 58 | } 59 | 60 | .whisper-date, .whisper-header-name { 61 | text-align: center; 62 | } 63 | 64 | .whisper-toast-message-user-name { 65 | font-weight: bold; 66 | } 67 | 68 | .whisper-toast-message-header { 69 | font-size: large; 70 | img.emote { 71 | vertical-align: middle; 72 | margin: -1em 0 -1em 0; 73 | } 74 | } 75 | 76 | .whisper-toast-text { 77 | padding: 8px; 78 | } 79 | 80 | .whisper-toast-profile-pic { 81 | height: 50px; 82 | } 83 | -------------------------------------------------------------------------------- /src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ModTwitch by CBenni 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 90 | 91 | 92 | 99 | 100 | 101 | 102 |
103 |
104 |

ModCh.at

105 |

by 106 | @cbenni_o 107 |

108 |
109 |
110 | Dedicated to Brian McNulty (1983 - 2018) 111 |
112 |
113 | 114 |
115 |
116 |
"Be who you are and say what you feel, because those who mind dont matter and those who matter dont mind" 117 | — Dr. Seuss 118 |
119 |
120 |
121 | Logo by 122 | @StolkieBiz 123 |
124 |
125 |
126 |
127 |
129 |
130 | {{conversation.user.fullName}} 131 | 132 | close 133 | 134 |
135 |
136 |
137 |
138 |
139 |
140 | 142 |
143 | 148 |
149 |
151 |
152 |
153 | 154 |
155 |
156 |
157 |
158 |
{{modCard.user.fullName}}
159 |
160 | {{modCard.chatCtrl.channelObj.name}} 161 |
162 |
163 |
164 | 165 | 166 | settings 167 | 168 | 169 | 170 | 171 | Open mod card in channel: 172 | 173 | 174 | 175 | 176 | {{chatCtrl.channelObj.name}} 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | close 186 | 187 |
188 |
189 | 203 |
204 |
205 |
206 |
207 | 209 | {{modButton.tooltip}} 210 | {{modButton.icon.code}} 211 | {{modButton.icon.text}} 212 | 213 | 214 |
215 |
216 |
217 | 218 | 219 | 220 | 221 | -------------------------------------------------------------------------------- /src/js/colorcorrection.js: -------------------------------------------------------------------------------- 1 | export function rgbToHsl(r, g, b) { 2 | r /= 255; 3 | g /= 255; 4 | b /= 255; 5 | const max = Math.max(r, g, b); 6 | const min = Math.min(r, g, b); 7 | let h; 8 | let s; 9 | const l = (max + min) / 2; 10 | 11 | if (max === min) { 12 | h = 0; 13 | s = 0; // achromatic 14 | } else { 15 | const d = max - min; 16 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 17 | if (max === r) { 18 | h = (g - b) / d + (g < b ? 6 : 0); 19 | } else if (max === g) { 20 | h = (b - r) / d + 2; 21 | } else if (max === b) { 22 | h = (r - g) / d + 4; 23 | } 24 | h /= 6; 25 | } 26 | 27 | return [h, s, l]; 28 | } 29 | 30 | 31 | function hue2rgb(p, q, t) { 32 | if (t < 0) t += 1; 33 | if (t > 1) t -= 1; 34 | if (t < 1 / 6) return p + (q - p) * 6 * t; 35 | if (t < 1 / 2) return q; 36 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 37 | return p; 38 | } 39 | 40 | export function hslToRgb(h, s, l) { 41 | let r; 42 | let g; 43 | let b; 44 | 45 | if (s === 0) { 46 | r = l; 47 | g = l; 48 | b = l; 49 | } else { 50 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 51 | const p = 2 * l - q; 52 | 53 | r = hue2rgb(p, q, h + 1 / 3); 54 | g = hue2rgb(p, q, h); 55 | b = hue2rgb(p, q, h - 1 / 3); 56 | } 57 | 58 | return [r * 255, g * 255, b * 255]; 59 | } 60 | 61 | const hexRegex = /#([a-fA-F0-9]{2,})([a-fA-F0-9]{2,})([a-fA-F0-9]{2,})/; 62 | const rgbRegex = /rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)(?:,\s*(\d+(?:\.\d+)?))?\)/; 63 | 64 | export function colorToRGB(color) { 65 | let match = hexRegex.exec(color); 66 | if (match) { 67 | return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]; 68 | } 69 | match = rgbRegex.exec(color); 70 | if (match) { 71 | return [parseFloat(match[1], 10), parseFloat(match[2], 10), parseFloat(match[3], 10)]; 72 | } 73 | throw new Error(`Couldnt parse color: ${color}`); 74 | } 75 | 76 | // formulae taken from https://www.w3.org/TR/AERT/#color-contrast 77 | export function getBrightness(rgb) { 78 | return rgb[0] * 0.299 + rgb[1] * 0.578 + rgb[2] * 0.114; 79 | } 80 | 81 | const colorCorrectionCache = {}; 82 | 83 | export function fixContrastHSL(bg, fg) { 84 | if (colorCorrectionCache[`${bg}-${fg}`]) return colorCorrectionCache[`${bg}-${fg}`]; 85 | 86 | const fgHsl = rgbToHsl(...colorToRGB(fg)); 87 | const bgHsl = rgbToHsl(...colorToRGB(bg)); 88 | const bgBrightness = getBrightness(colorToRGB(bg)); 89 | let fgBrightness = getBrightness(colorToRGB(fg)); 90 | 91 | if (bgBrightness < 50 && fgBrightness < bgBrightness) { 92 | // dark backgrounds never have colors darker than them 93 | fgBrightness = bgBrightness; 94 | } 95 | if (bgBrightness > 150 && fgBrightness > bgBrightness) { 96 | // bright backgrounds never have colors bright than them 97 | fgBrightness = bgBrightness; 98 | } 99 | 100 | const extremeL = bgHsl[2] > 0.5 ? 0 : 1; 101 | const eps = 5; 102 | let count = 0; 103 | while ((Math.sqrt(bgBrightness) - Math.sqrt(fgBrightness)) < 4 && count++ < 5) { 104 | fgHsl[2] = (fgHsl[2] * eps + extremeL) / (1 + eps); 105 | // chromatic abberation is a thing. Fix it. 106 | fgHsl[1] = Math.min(1, fgHsl[1] * 3); 107 | const newFg = hslToRgb(...fgHsl); 108 | fgBrightness = getBrightness(newFg); 109 | } 110 | 111 | const res = `hsl(${fgHsl[0] * 360}, ${fgHsl[1] * 100}%, ${fgHsl[2] * 100}%)`; 112 | 113 | colorCorrectionCache[`${bg}-${fg}`] = res; 114 | return res; 115 | } 116 | -------------------------------------------------------------------------------- /src/js/config.js: -------------------------------------------------------------------------------- 1 | import devConfig from './config.dev.json'; 2 | import prodConfig from './config.prod.json'; 3 | import stagingConfig from './config.staging.json'; 4 | 5 | console.log('Using config ', process.env.NODE_ENV); 6 | 7 | let config; // eslint-disable-line import/no-mutable-exports 8 | if (process.env.NODE_ENV === 'prod') config = prodConfig; 9 | else if (process.env.NODE_ENV === 'staging') config = stagingConfig; 10 | else config = devConfig; 11 | export default config; 12 | -------------------------------------------------------------------------------- /src/js/controllers/autocompletecontroller.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/mt2/4959166b3258afba6377c9fe7eebfd28e6ed7371/src/js/controllers/autocompletecontroller.js -------------------------------------------------------------------------------- /src/js/controllers/autocompletepanelcontroller.js: -------------------------------------------------------------------------------- 1 | export default class AutocompletePanelController { 2 | constructor($scope, mdPanelRef, ThrottledDigestService) { 3 | 'ngInject'; 4 | 5 | this.$scope = $scope; 6 | this.mdPanelRef = mdPanelRef; 7 | this.ThrottledDigestService = ThrottledDigestService; 8 | } 9 | 10 | selectItem(item) { 11 | this.info.selectedItem = item; 12 | this.mdPanelRef.close('click'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/js/controllers/buttonsettingscontroller.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import _ from 'lodash'; 3 | 4 | import iconCodes from '../iconCodes.json'; 5 | 6 | export default class ButtonSettingsController { 7 | constructor($scope) { 8 | 'ngInject'; 9 | 10 | this.$scope = $scope; 11 | this.iconCodes = iconCodes; 12 | } 13 | 14 | deleteButton(button) { 15 | _.pull(this.buttons, button); 16 | } 17 | 18 | addButton() { 19 | this.buttons.push(angular.copy(this.defaultButton)); 20 | } 21 | 22 | setHotkey(button, $event) { 23 | let code = ''; 24 | if ($event) { 25 | code = $event.originalEvent.code; 26 | if (code === 'Escape') code = ''; 27 | $event.preventDefault(); 28 | $event.stopImmediatePropagation(); 29 | } 30 | button.hotkey = code; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/js/controllers/dialogcontroller.js: -------------------------------------------------------------------------------- 1 | export default class DialogController { 2 | constructor($scope, $mdDialog) { 3 | 'ngInject'; 4 | 5 | this.$scope = $scope; 6 | this.$mdDialog = $mdDialog; 7 | 8 | $scope.hide = () => { 9 | $mdDialog.hide(); 10 | }; 11 | $scope.cancel = () => { 12 | $mdDialog.cancel(); 13 | }; 14 | $scope.answer = answer => { 15 | $mdDialog.hide(answer); 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/js/controllers/homecontroller.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import SettingsDialog from '../../templates/settingsdialog.html'; 3 | 4 | function uniquifySearchResults(results) { 5 | const streams = {}; 6 | _.each(results, stream => { 7 | const oldStream = streams[stream.channel._id] || {}; 8 | if (oldStream.priority > stream.priority) { 9 | streams[stream.channel._id] = _.merge(stream, oldStream); 10 | } else { 11 | streams[stream.channel._id] = _.merge(oldStream, stream); 12 | } 13 | }); 14 | return _.values(streams); 15 | } 16 | 17 | function mockStreamFromChannel(channel) { 18 | const videoBanner = channel.video_banner || '/assets/defaultChannelBanner-1920x1080.png'; 19 | return { 20 | preview: { 21 | small: videoBanner.replace('1920x1080', '240x135'), 22 | medium: videoBanner.replace('1920x1080', '480x270'), 23 | large: videoBanner 24 | }, 25 | channel 26 | }; 27 | } 28 | 29 | function setPriority(array, basePriority = 0) { 30 | let cnt = 0; 31 | _.each(array, item => { 32 | item.priority = basePriority + (cnt++); 33 | }); 34 | return array; 35 | } 36 | 37 | export default class HomeController { 38 | constructor($scope, $timeout, ApiService, $mdDialog) { 39 | 'ngInject'; 40 | 41 | this.$timeout = $timeout; 42 | this.layout = $scope.layout; 43 | this.container = $scope.container; 44 | this.state = $scope.state; 45 | this.ApiService = ApiService; 46 | this.mainCtrl = $scope.$parent.mainCtrl; 47 | this.$mdDialog = $mdDialog; 48 | 49 | this.streamSearchText = ''; 50 | this.globalStreams = []; 51 | this.followedStreams = []; 52 | this.searchedStreams = null; 53 | this.selectedStreamsTab = 0; 54 | 55 | this.searchForStreamsDebounced = _.debounce(searchText => this.searchForStreams(searchText), 500); 56 | this.currentStreamSearch = null; 57 | 58 | this.getGlobalStreams(); 59 | $timeout(() => { 60 | if (this.mainCtrl.auth) this.selectedStreamsTab = 1; 61 | this.getFollowedStreams(); 62 | }); 63 | 64 | this.currentChannel = 'cbenni'; 65 | } 66 | 67 | async getGlobalStreams() { 68 | try { 69 | const streamsResponse = await this.ApiService.twitchGet('https://api.twitch.tv/kraken/streams/'); 70 | if (streamsResponse.data.streams) { 71 | this.globalStreams = streamsResponse.data.streams; 72 | } 73 | } catch (err) { 74 | console.error(err); 75 | } 76 | 77 | this.$timeout(() => { 78 | this.getGlobalStreams(); 79 | }, 60 * 1000); 80 | } 81 | 82 | async getFollowedStreams() { 83 | if (this.mainCtrl.auth) { 84 | try { 85 | const streamsResponse = await this.ApiService.twitchGet('https://api.twitch.tv/kraken/streams/followed/', null, this.mainCtrl.auth.token); 86 | if (streamsResponse.data.streams) { 87 | this.followedStreams = streamsResponse.data.streams; 88 | } 89 | } catch (err) { 90 | console.error(err); 91 | } 92 | 93 | this.$timeout(() => { 94 | this.getFollowedStreams(); 95 | }, 60 * 1000); 96 | } 97 | } 98 | 99 | async getSearchedStreams() { 100 | const searchText = this.streamSearchText; 101 | this.searchForStreamsDebounced(searchText); 102 | } 103 | 104 | getStreams() { 105 | if (this.searchedStreams !== null) return this.searchedStreams; 106 | if (this.followedStreams.length > 0) return this.followedStreams; 107 | return this.globalStreams; 108 | } 109 | 110 | searchForStreams(searchText) { 111 | try { 112 | if (searchText.length > 0) { 113 | const streamsSearch = this.ApiService.twitchGet(`https://api.twitch.tv/kraken/search/streams?query=${window.encodeURIComponent(searchText)}&limit=25`) 114 | .then(response => setPriority(response.data.streams, 1000)); 115 | const channelLookup = this.ApiService.twitchGetUserByName(searchText).then(user => { 116 | if (user) return this.ApiService.twitchGet(`https://api.twitch.tv/kraken/channels/${user._id}`).then(response => setPriority([mockStreamFromChannel(response.data)], 10000)); 117 | return []; 118 | }); 119 | const channelSearch = this.ApiService.twitchGet(`https://api.twitch.tv/kraken/search/channels?query=${window.encodeURIComponent(searchText)}&limit=25`) 120 | .then(response => { 121 | const channels = response.data.channels; 122 | return setPriority(_.map(channels, mockStreamFromChannel), 0); 123 | }); 124 | return Promise.all([streamsSearch, channelLookup, channelSearch]).then(results => { 125 | this.searchedStreams = _.orderBy(uniquifySearchResults(_.flatten(results)), ['priority'], ['desc']); 126 | this.selectedStreamsTab = 2; 127 | }).catch(err => { 128 | console.log('Search failed', err); 129 | }); 130 | } 131 | this.searchedStreams = null; 132 | } catch (err) { 133 | console.error(err); 134 | } 135 | return null; 136 | } 137 | 138 | debugTest(...args) { 139 | console.log('Debug test: ', args); 140 | } 141 | 142 | openSettings($event) { 143 | this.$mdDialog.show({ 144 | template: SettingsDialog, 145 | targetEvent: $event, 146 | clickOutsideToClose: true, 147 | escapeToClose: true, 148 | controller: 'SettingsDialogController', 149 | controllerAs: 'dialogCtrl', 150 | locals: { 151 | mainCtrl: this.mainCtrl, 152 | homeCtrl: this 153 | }, 154 | bindToController: true 155 | }).finally(() => { 156 | this.mainCtrl.updateConfig(); 157 | }); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/js/controllers/iconpickercontroller.js: -------------------------------------------------------------------------------- 1 | export class IconPickerController { 2 | constructor($scope) { 3 | 'ngInject'; 4 | 5 | this.$scope = $scope; 6 | } 7 | } 8 | 9 | export class IconPickerPanelController { 10 | constructor($scope) { 11 | 'ngInject'; 12 | 13 | this.$scope = $scope; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/js/controllers/maincontroller.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import GoldenLayout from 'golden-layout'; 3 | import $ from 'jquery'; 4 | import _ from 'lodash'; 5 | 6 | import defaultConfig from '../defaultConfig'; 7 | import defaultLayouts from '../defaultLayouts'; 8 | import { migrateConfig, migrateLayouts } from '../migrations'; 9 | import config from '../config'; 10 | 11 | import chatTemplate from '../../templates/chatwindow.html'; 12 | import streamTemplate from '../../templates/streamwindow.html'; 13 | import homeTemplate from '../../templates/homewindow.html'; 14 | import iconTemplate from '../../templates/icontemplate.html'; 15 | import whisperTemplate from '../../templates/whisperwindow.html'; 16 | 17 | const windowTemplates = { 18 | chatTemplate, 19 | streamTemplate, 20 | homeTemplate, 21 | whisperTemplate 22 | 23 | }; 24 | 25 | 26 | export default class MainController { 27 | constructor($compile, $rootScope, $scope, $timeout, $sce, $window, ApiService, ChatService, KeyPressService) { 28 | 'ngInject'; 29 | 30 | this.ApiService = ApiService; 31 | this.ChatService = ChatService; 32 | this.KeyPressService = KeyPressService; 33 | this.$scope = $scope; 34 | this.$sce = $sce; 35 | 36 | this.defaultConfig = defaultConfig; 37 | this.defaultLayouts = defaultLayouts; 38 | 39 | this.config = this.defaultConfig; 40 | this.layouts = this.defaultLayouts; 41 | this.modCards = []; 42 | this.chatControllers = []; 43 | this.whisperController = null; 44 | 45 | // initialize layout 46 | const storedConfig = localStorage.getItem('mt2-config'); 47 | if (storedConfig) { 48 | this.config = JSON.parse(storedConfig); 49 | // run migrations 50 | migrateConfig(this.config); 51 | $timeout(() => { $scope.loadingScreenClass = 'hide-fast'; }, 1000); 52 | } else { 53 | $timeout(() => { $scope.loadingScreenClass = 'hide-first-time'; }, 4000); 54 | } 55 | const storedLayouts = localStorage.getItem('mt2-layouts'); 56 | if (storedLayouts) { 57 | this.layouts = JSON.parse(storedLayouts); 58 | migrateLayouts(this.layouts); 59 | } 60 | 61 | // temp "fix" for https://github.com/WolframHempel/golden-layout/issues/418 62 | _.each(this.layouts, layout => this.fixActiveIndexes(layout)); 63 | const currentProfile = localStorage.getItem('mt2-currentProfile'); 64 | if (currentProfile) this.selectedProfile = parseInt(currentProfile, 10); 65 | else this.selectedProfile = 0; 66 | 67 | const layout = new GoldenLayout(this.getCurrentLayout(), $('#layout-container')); 68 | this.layout = layout; 69 | 70 | const AngularModuleComponent = (container, state) => { 71 | const html = windowTemplates[state.templateId]; 72 | const element = container.getElement(); 73 | 74 | element.html(html); 75 | 76 | const linkFun = $compile(element); 77 | const newScope = $scope.$new(true, $scope); 78 | newScope.container = container; 79 | if (state.templateId === 'chatTemplate') { 80 | if (!_.find(this.config.settings.chatPresets, preset => preset.id === state.preset)) { 81 | if (this.config.settings.chatPresets.length > 0) { 82 | state.preset = this.config.settings.chatPresets[0].id; 83 | } else { 84 | newScope.$destroy(); 85 | return null; 86 | } 87 | } 88 | } 89 | newScope.state = state; 90 | newScope.mainCtrl = this; 91 | newScope.notifications = 0; 92 | linkFun(newScope); 93 | 94 | // add icon 95 | container.on('tab', tab => { 96 | newScope.tab = tab; 97 | 98 | const notificationElement = $(''); 99 | tab.element.prepend(notificationElement); 100 | function updateNotifications() { 101 | if (newScope.notifications > 0) { 102 | notificationElement.text(newScope.notifications); 103 | tab.element.addClass('unread-tab'); 104 | } else { 105 | notificationElement.text(''); 106 | tab.element.removeClass('unread-tab'); 107 | } 108 | } 109 | newScope.$watch('notifications', updateNotifications); 110 | const oldSetActive = Object.getPrototypeOf(tab).setActive; 111 | tab.setActive = isActive => { 112 | if (newScope.onTabActive) newScope.onTabActive(isActive); 113 | oldSetActive.call(tab, isActive); 114 | updateNotifications(); 115 | }; 116 | 117 | 118 | let icon = state.icon; 119 | if (!icon) { 120 | const preset = this.getChatPreset(state.preset); 121 | if (preset) icon = preset.icon; 122 | } 123 | if (icon) { 124 | const iconElement = $(''); 125 | iconElement.html(iconTemplate); 126 | tab.element.prepend(iconElement); 127 | const iconLinkFun = $compile(iconElement); 128 | const iconScope = $scope.$new(true, $scope); 129 | iconScope.icon = icon; 130 | iconLinkFun(iconScope); 131 | } 132 | }); 133 | }; 134 | 135 | layout.registerComponent('angularModule', AngularModuleComponent); 136 | layout.init(); 137 | 138 | angular.element($window).bind('resize', () => { 139 | layout.updateSize(); 140 | }); 141 | 142 | layout.on('stateChanged', () => { 143 | this.updateConfig(); 144 | }); 145 | $rootScope.updateConfig = () => { 146 | this.updateConfig(); 147 | }; 148 | 149 | // initialize authentication 150 | const auth = localStorage.getItem('mt2-auth'); 151 | if (auth) { 152 | this.auth = JSON.parse(auth); 153 | ChatService.init(this.auth); 154 | this.initWhisperWindow(); 155 | } 156 | 157 | if (window.location.hash) { 158 | let match; 159 | const hasRegex = /(\w+)=(\w*)/g; 160 | while (match = hasRegex.exec(window.location.hash)) { // eslint-disable-line no-cond-assign 161 | const [property, value] = match.slice(1); 162 | if (property === 'access_token') { 163 | ApiService.twitchGet('https://api.twitch.tv/kraken/', null, value).then(result => { 164 | if (result.data.token.valid) { 165 | this.auth = { 166 | name: result.data.token.user_name, 167 | id: result.data.token.user_id, 168 | token: value 169 | }; 170 | ChatService.init(this.auth); 171 | localStorage.setItem('mt2-auth', JSON.stringify(this.auth)); 172 | this.removeHash(); 173 | window.location.reload(true); 174 | } else { 175 | // TODO: dont use alert here. 176 | alert('Invalid token'); 177 | } 178 | }); 179 | } 180 | } 181 | } 182 | 183 | // initialize mod card key hooks 184 | 185 | const keyWatchers = [ 186 | this.KeyPressService.on('keydown', event => this.keydown(event), 10) 187 | ]; 188 | 189 | this.$scope.$on('$destroy', () => { 190 | _.each(keyWatchers, keyWatcher => keyWatcher()); 191 | }); 192 | } 193 | 194 | fixActiveIndexes(layout) { 195 | if (layout.content) { 196 | layout.activeItemIndex = Math.min(layout.activeItemIndex || 0, layout.content.length - 1); 197 | _.each(layout.content, contentItem => this.fixActiveIndexes(contentItem)); 198 | } 199 | } 200 | 201 | getTitle() { 202 | return 'ModCh.at by CBenni'; 203 | } 204 | 205 | getCurrentLayout() { 206 | return this.layouts[this.selectedProfile]; 207 | } 208 | 209 | getChatPreset(id) { 210 | return _.find(this.getSetting('chatPresets'), { id }); 211 | } 212 | 213 | getSetting(key) { 214 | // we dont use default because we want "" to be interpreted as "use default". 215 | const value = _.get(this.config.settings, key); 216 | if (value !== undefined) return value; 217 | return _.get(this.defaultConfig.settings, key); 218 | } 219 | 220 | updateConfig() { 221 | this.layouts[this.selectedProfile] = this.layout.toConfig(); 222 | localStorage.setItem('mt2-config', angular.toJson(this.config)); 223 | localStorage.setItem('mt2-layouts', angular.toJson(this.layouts)); 224 | } 225 | 226 | loginWithTwitch() { 227 | window.location.href = `https://id.twitch.tv/oauth2/authorize?client_id=${config.auth.client_id}` 228 | + `&redirect_uri=${config.auth.redirect_uri}&response_type=token&scope=chat_login%20user_subscriptions`; 229 | } 230 | 231 | logoutFromTwitch() { 232 | localStorage.removeItem('mt2-auth'); 233 | window.location.reload(true); 234 | } 235 | 236 | removeHash() { 237 | window.history.pushState('', document.title, window.location.pathname + window.location.search); 238 | } 239 | 240 | 241 | togglePinned(modCard) { 242 | if (modCard.pinned) { 243 | _.remove(this.modCards, card => !card.pinned); 244 | modCard.pinned = false; 245 | } else { 246 | modCard.pinned = true; 247 | } 248 | } 249 | 250 | openModCard($event, user, chatCtrl) { 251 | const cardWidth = 400; 252 | const cardHeight = 250; 253 | const distanceX = 100; 254 | const distanceY = 0; 255 | 256 | let xPos = $event.pageX - cardWidth - distanceX; 257 | let yPos = $event.pageY - cardHeight - distanceY; 258 | if (xPos < 10) xPos = $event.pageX + distanceX; 259 | if (yPos < 10) yPos = $event.pageY + distanceY; 260 | 261 | const modCard = { 262 | user, 263 | chatCtrl, 264 | pinned: false, 265 | xPos: `${xPos}px`, 266 | yPos: `${yPos}px` 267 | }; 268 | 269 | _.remove(this.modCards, card => !card.pinned); 270 | this.modCards.push(modCard); 271 | 272 | this.ApiService.twitchGet(`https://api.twitch.tv/helix/users/follows?from_id=${user.id}&to_id=${chatCtrl.channelObj.id}`).then(response => { 273 | modCard.followedAt = new Date(response.data.data.followed_at); 274 | }); 275 | 276 | this.ApiService.twitchGet(`https://api.twitch.tv/kraken/channels/${user.id}`).then(response => { 277 | modCard.stats = response.data; 278 | }); 279 | 280 | return modCard; 281 | } 282 | 283 | closeModCard(card) { 284 | _.pull(this.modCards, card); 285 | } 286 | 287 | keydown(event) { 288 | const modCard = _.find(this.modCards, { pinned: false }); 289 | if (!modCard) return false; 290 | const modCardButtons = this.getSetting('modCardButtons'); 291 | if (modCardButtons) { 292 | for (let i = 0; i < modCardButtons.length; ++i) { 293 | const button = modCardButtons[i]; 294 | if (button.hotkey === event.code) { 295 | modCard.chatCtrl.modAction(event, button, modCard); 296 | this.closeModCard(modCard); 297 | return true; 298 | } 299 | } 300 | } 301 | return false; 302 | } 303 | 304 | findTab(configItem, templateId) { 305 | return this.layout.root.getItemsByFilter(item => item.config.componentState && item.config.componentState.templateId === templateId); 306 | } 307 | 308 | initWhisperWindow() { 309 | // find whisper window 310 | if (this.findTab(this.layout.config, 'whisperTemplate').length === 0) { 311 | const homeTab = this.findTab(this.layout.config, 'homeTemplate')[0]; 312 | if (homeTab) { 313 | const parent = homeTab.parent; 314 | parent.addChild({ 315 | title: 'Whispers', 316 | type: 'component', 317 | componentName: 'angularModule', 318 | isClosable: false, 319 | componentState: { 320 | module: 'mtApp', 321 | templateId: 'whisperTemplate', 322 | icon: { 323 | type: 'icon', 324 | code: 'chat_bubble_outline' 325 | } 326 | } 327 | }); 328 | } else console.error('No home tab found!'); 329 | } 330 | } 331 | 332 | selectWhisperTab() { 333 | const whisperTabs = this.findTab(this.layout.config, 'whisperTemplate'); 334 | if (whisperTabs.length === 1) { 335 | const whisperTab = whisperTabs[0]; 336 | whisperTab.parent.setActiveContentItem(whisperTab); 337 | } 338 | } 339 | 340 | registerChatController(chatCtrl) { 341 | this.chatControllers.push(chatCtrl); 342 | return () => { 343 | _.pull(this.chatControllers, chatCtrl); 344 | }; 345 | } 346 | 347 | openMenu($mdMenu, ev) { 348 | $mdMenu.open(ev); 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/js/controllers/settingsdialogcontroller.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import _ from 'lodash'; 3 | import fileSaver from 'file-saver'; 4 | 5 | import DialogController from './dialogcontroller'; 6 | import { genNonce, loadJSONFromFile } from '../helpers'; 7 | import defaultConfig from '../defaultConfig'; 8 | import iconCodes from '../iconCodes.json'; 9 | 10 | export default class SettingsDialogController extends DialogController { 11 | constructor($scope, $mdDialog, $mdToast) { 12 | 'ngInject'; 13 | 14 | super($scope, $mdDialog); 15 | this.$mdDialog = $mdDialog; 16 | this.$mdToast = $mdToast; 17 | 18 | this.importFile = ''; 19 | $scope.settings = this.mainCtrl.config.settings; 20 | if (!$scope.settings) { 21 | $scope.settings = _.extend({}, defaultConfig.settings); 22 | this.mainCtrl.config.settings = $scope.settings; 23 | } 24 | 25 | $scope.defaultButton = { 26 | action: { 27 | type: 'command', 28 | command: '/timeout {{user.name}} 60' 29 | }, 30 | tooltip: '1 min timeout', 31 | icon: { 32 | type: 'text', 33 | text: '60s' 34 | }, 35 | hotkey: '', 36 | show: 'mod' 37 | }; 38 | 39 | $scope.defaultChatHeaderButton = { 40 | action: { 41 | type: 'command', 42 | command: '/help' 43 | }, 44 | tooltip: 'New Button', 45 | icon: { 46 | type: 'text', 47 | text: 'Button' 48 | }, 49 | hotkey: '', 50 | show: 'always' 51 | }; 52 | 53 | this.defaultChatPreset = { 54 | name: 'Chat', 55 | icon: { 56 | type: 'icon', 57 | code: 'chat' 58 | }, 59 | settings: { 60 | incognito: false, 61 | messageFilters: [ 62 | 'modlogs', 63 | 'subs', 64 | 'chat', 65 | 'bots', 66 | 'mentions', 67 | 'bits', 68 | 'automod' 69 | ] 70 | } 71 | }; 72 | 73 | this.defaultExtraMention = { 74 | type: 'word', 75 | data: '', 76 | ignoreCase: true 77 | }; 78 | 79 | this.initDefault('style'); 80 | this.initDefault('styleSheet'); 81 | this.initDefault('modButtons'); 82 | this.initDefault('modCardButtons'); 83 | this.initDefault('chatHeaderButtons'); 84 | this.initDefault('colorAdjustment'); 85 | this.initDefault('monoColor'); 86 | this.initDefault('chatSettings'); 87 | this.initDefault('chatPresets'); 88 | this.initDefault('timeFormat'); 89 | this.initDefault('scrollbackLength'); 90 | this.initDefault('chatSettings.extraMentions'); 91 | 92 | this.iconCodes = iconCodes; 93 | } 94 | 95 | initDefault(setting) { 96 | if (_.get(this.$scope.settings, setting) === undefined) { 97 | _.set(this.$scope.settings, setting, this.mainCtrl.getSetting(setting)); 98 | } 99 | } 100 | 101 | addChatPreset() { 102 | const newPreset = angular.copy(this.defaultChatPreset); 103 | newPreset.id = genNonce(); 104 | this.$scope.settings.chatPresets.push(newPreset); 105 | } 106 | 107 | deleteChatPreset(preset) { 108 | _.pull(this.$scope.settings.chatPresets, preset); 109 | } 110 | 111 | exportSettings() { 112 | fileSaver.saveAs(new File([angular.toJson(this.mainCtrl.config)], 'modchat-settings.json', { type: 'application/json;charset=utf-8' })); 113 | } 114 | 115 | importSettings($event) { 116 | const confirm = this.$mdDialog.confirm() 117 | .title('Import settings') 118 | .textContent('Are you sure you want to import stored settings? This will replace your current settings.') 119 | .targetEvent($event) 120 | .ok('OK') 121 | .cancel('Cancel') 122 | .multiple(true); 123 | 124 | loadJSONFromFile().then(config => { 125 | this.$mdDialog.show(confirm) 126 | .then(() => { 127 | if (config.settings) { 128 | localStorage.setItem('mt2-config', angular.toJson(config)); 129 | window.location.reload(); 130 | } else { 131 | throw new Error('Config invalid!'); 132 | } 133 | }).catch(err => { 134 | this.$mdToast.simple() 135 | .textContent(`Import error: ${err.toString()}`); 136 | }); 137 | }); 138 | } 139 | 140 | resetSettings($event) { 141 | const confirm = this.$mdDialog.confirm() 142 | .title('Reset settings') 143 | .textContent('Are you sure you want to clear all settings? This cannot be undone.') 144 | .targetEvent($event) 145 | .ok('OK') 146 | .cancel('Cancel') 147 | .multiple(true); 148 | 149 | this.$mdDialog.show(confirm) 150 | .then(() => { 151 | localStorage.removeItem('mt2-config'); 152 | window.location.reload(); 153 | }) 154 | .catch(() => {}); 155 | } 156 | 157 | addExtraMention() { 158 | this.$scope.settings.chatSettings.extraMentions.push(angular.copy(this.defaultExtraMention)); 159 | } 160 | 161 | deleteExtraMention(mention) { 162 | _.pull(this.$scope.settings.chatSettings.extraMentions, mention); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/js/controllers/streamcontroller.js: -------------------------------------------------------------------------------- 1 | export default class StreamController { 2 | constructor($sce, $scope) { 3 | 'ngInject'; 4 | 5 | this.layout = $scope.layout; 6 | this.container = $scope.container; 7 | this.state = $scope.state; 8 | this.$sce = $sce; 9 | 10 | this.container.setTitle(this.state.channel); 11 | } 12 | 13 | getEmbedUrl() { 14 | return this.$sce.trustAsResourceUrl(`https://player.twitch.tv/?channel=${this.state.channel}`); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/js/controllers/streamlistcontroller.js: -------------------------------------------------------------------------------- 1 | 2 | export default class StreamListController { 3 | constructor($scope) { 4 | 'ngInject'; 5 | 6 | this.$scope = $scope; 7 | $scope.homeCtrl = $scope.$parent.mainCtrl; 8 | $scope.mainCtrl = $scope.$parent.$parent.mainCtrl; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/js/controllers/whispercontroller.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import raven from 'raven-js'; 3 | 4 | import whisperToastTemplate from '../../templates/whispertoasttemplate.html'; 5 | import { getFullName } from '../helpers'; 6 | 7 | function idLarger(id1, id2) { 8 | if (id1.length > id2.length) return true; 9 | if (id2.length > id1.length) return false; 10 | return id1 > id2; 11 | } 12 | 13 | function createThreadID(userID, userID2) { 14 | if (idLarger(userID, userID2)) { 15 | return `${userID2}_${userID}`; 16 | } 17 | return `${userID}_${userID2}`; 18 | } 19 | 20 | export default class WhisperController { 21 | constructor($scope, $timeout, $sce, $filter, $mdToast, ApiService, ChatService, ThrottledDigestService) { 22 | 'ngInject'; 23 | 24 | this.ApiService = ApiService; 25 | this.ChatService = ChatService; 26 | this.ThrottledDigestService = ThrottledDigestService; 27 | this.mainCtrl = $scope.$parent.mainCtrl; 28 | this.$scope = $scope; 29 | this.$sce = $sce; 30 | this.$filter = $filter; 31 | this.$mdToast = $mdToast; 32 | 33 | this.mainCtrl.whisperController = this; 34 | 35 | this.isSidenavOpen = true; 36 | this.whisperInputContent = ''; 37 | 38 | $timeout(() => { 39 | if (this.mainCtrl.auth) { 40 | this.conversations = []; 41 | this.loadConversations(); 42 | ChatService.on('whisper_received', pubsubMessage => { 43 | this.addWhisper(pubsubMessage.data.message.data).then(() => { 44 | this.ThrottledDigestService.$apply($scope); 45 | }); 46 | }); 47 | ChatService.on('whisper_sent', pubsubMessage => { 48 | this.addWhisper(pubsubMessage.data.message.data).then(() => { 49 | this.ThrottledDigestService.$apply($scope); 50 | }); 51 | }); 52 | ChatService.on('NOTICE', parsed => { 53 | if (parsed.tags['msg-id'] === 'whisper_restricted_recipient') { 54 | // @msg-id=whisper_restricted_recipient;target-user-id=19264788 :tmi.twitch.tv NOTICE #jtv :That user's settings prevent them from receiving this whisper. 55 | this.addSystemMsg(parsed); 56 | } 57 | }); 58 | } 59 | }); 60 | 61 | $scope.$parent.onTabActive = isActive => { 62 | if (isActive) { 63 | if (this.selectedConversation) { 64 | this.markAsRead(this.selectedConversation); 65 | // this.selectedConversation.lastRead = this.selectedConversation.lastMessage.id; 66 | } 67 | this.updateUnreadStatus(); 68 | } 69 | }; 70 | } 71 | 72 | loadConversations() { 73 | this.ApiService.twitchGet('https://im-proxy.modch.at/v1/threads?limit=50', null, this.mainCtrl.auth.token).then(response => { 74 | this.conversations = _.map(response.data.data, conversation => { 75 | const otherUser = _.find(conversation.participants, participant => `${participant.id}` !== this.mainCtrl.auth.id); 76 | const user = { 77 | id: otherUser.id, 78 | name: otherUser.username, 79 | displayName: otherUser.display_name, 80 | fullName: getFullName(otherUser.username, otherUser.display_name), 81 | color: otherUser.color, 82 | badges: otherUser.badges, 83 | profileImage: otherUser.profile_image && otherUser.profile_image['50x50'].url 84 | }; 85 | const lastMessage = { 86 | id: conversation.last_message.id, 87 | trailing: conversation.last_message.body, 88 | tags: conversation.last_message.tags, 89 | time: new Date(conversation.last_message.sent_ts * 1000), 90 | user 91 | }; 92 | lastMessage.html = this.$sce.trustAsHtml(this.ChatService.renderMessage(lastMessage, lastMessage.tags.emotes)); 93 | return { 94 | id: conversation.id, 95 | user, 96 | lines: null, 97 | lastMessage, 98 | whisperText: '', 99 | lastRead: conversation.last_read 100 | }; 101 | }); 102 | this.updateUnreadStatus(); 103 | }); 104 | } 105 | 106 | async selectConversation(conversation) { 107 | if (!conversation) return; 108 | await this.initConversation(conversation); 109 | this.selectedConversation = conversation; 110 | this.isSidenavOpen = false; 111 | this.mainCtrl.selectWhisperTab(); 112 | this.markAsRead(conversation); 113 | } 114 | 115 | initConversation(conversation) { 116 | if (conversation.lines) { 117 | return conversation; 118 | } 119 | conversation.lastDate = ''; 120 | return this.ApiService.twitchGet(`https://im-proxy.modch.at/v1/threads/${conversation.id}/messages?limit=20`, null, this.mainCtrl.auth.token).then(response => { 121 | this.updateUnreadStatus(); 122 | conversation.lines = _.map(_.reverse(response.data.data), message => { 123 | const date = new Date(message.sent_ts * 1000); 124 | let dateString = this.$filter('date')(date); 125 | if (dateString === conversation.lastDate) dateString = ''; 126 | else conversation.lastDate = dateString; 127 | const line = { 128 | id: message.id, 129 | trailing: message.body, 130 | tags: message.tags, 131 | time: date, 132 | date: dateString, 133 | user: { 134 | id: message.from_id, 135 | name: message.tags.login, 136 | displayName: message.tags.display_name, 137 | fullName: getFullName(message.tags.login, message.tags.display_name), 138 | color: message.tags.color 139 | } 140 | }; 141 | line.html = this.$sce.trustAsHtml(this.ChatService.renderMessage(line, line.tags.emotes)); 142 | return line; 143 | }); 144 | return conversation; 145 | }); 146 | } 147 | 148 | markAsRead(conversation) { 149 | if (!conversation || !conversation.lastMessage) return null; 150 | return this.ApiService.twitchPost(`https://im-proxy.modch.at/v1/threads/${conversation.id}`, { mark_read: conversation.lastMessage.id }, null, this.mainCtrl.auth.token).then(response => { 151 | conversation.lastRead = response.data.last_read; 152 | this.updateUnreadStatus(); 153 | }); 154 | } 155 | 156 | async addSystemMsg(msg) { 157 | const transformedMessage = { 158 | time: msg.sent_ts ? new Date(msg.sent_ts * 1000) : new Date(), 159 | tags: msg.tags, 160 | trailing: msg.trailing, 161 | user: { 162 | id: msg.tags['target-user-id'], 163 | name: 'jtv', 164 | displayName: '', 165 | fullName: '', 166 | color: '', 167 | badges: '' 168 | }, 169 | isAction: false, 170 | isSystem: true, 171 | recipient: { 172 | id: msg.tags['target-user-id'], 173 | username: '', 174 | display_name: '', 175 | fullName: '', 176 | color: '', 177 | badges: '' 178 | }, 179 | threadID: createThreadID(this.mainCtrl.auth.id, msg.tags['target-user-id']), 180 | id: `whisper_not_delivered_${Date.now()}` 181 | }; 182 | transformedMessage.html = this.$sce.trustAsHtml(transformedMessage.trailing); 183 | 184 | const conversation = this.findConversation(transformedMessage); 185 | if (!conversation) return; 186 | 187 | await this.initConversation(conversation); 188 | if (conversation) { 189 | conversation.lines.push(transformedMessage); 190 | conversation.lastMessage = transformedMessage; 191 | conversation.lastRead = transformedMessage.id; 192 | } 193 | this.updateUnreadStatus(); 194 | } 195 | 196 | async addWhisper(msg) { 197 | if (msg.tags.badges && msg.tags.badges.length > 0) await this.upgradeBadges(msg.tags.badges); 198 | 199 | let isAction = false; 200 | const actionmatch = /^\u0001ACTION (.*)\u0001$/.exec(msg.body); 201 | if (actionmatch != null) { 202 | isAction = true; 203 | msg.body = actionmatch[1]; 204 | } 205 | 206 | 207 | const transformedMessage = { 208 | time: msg.sent_ts ? new Date(msg.sent_ts * 1000) : new Date(), 209 | tags: msg.tags, 210 | trailing: msg.body, 211 | user: { 212 | id: msg.from_id, 213 | name: msg.tags.login, 214 | displayName: msg.tags.display_name, 215 | fullName: getFullName(msg.tags.login, msg.tags.display_name), 216 | color: msg.tags.color, 217 | badges: msg.tags.badges 218 | }, 219 | isAction, 220 | recipient: msg.recipient, 221 | threadID: msg.thread_id, 222 | id: msg.id 223 | }; 224 | transformedMessage.html = this.$sce.trustAsHtml(this.ChatService.renderMessage(transformedMessage, transformedMessage.tags.emotes)); 225 | 226 | const conversation = this.findConversation(transformedMessage); 227 | if (!conversation) return; 228 | 229 | await this.initConversation(conversation); 230 | let dateString = this.$filter('date')(transformedMessage.time); 231 | if (dateString === conversation.lastDate) dateString = ''; 232 | else conversation.lastDate = dateString; 233 | transformedMessage.date = dateString; 234 | if (!conversation.lastMessage || conversation.lastMessage.id !== transformedMessage.id) { 235 | conversation.lines.push(transformedMessage); 236 | conversation.lastMessage = transformedMessage; 237 | } 238 | 239 | if (`${msg.from_id}` !== this.mainCtrl.auth.id && (this.selectedConversation !== conversation || !this.$scope.$parent.tab.isActive)) { 240 | this.$mdToast.show({ 241 | hideDelay: 5000, 242 | position: 'top right', 243 | template: whisperToastTemplate, 244 | controller: 'WhisperToastController', 245 | controllerAs: 'whisperToastCtrl', 246 | locals: { 247 | conversation, 248 | whisperCtrl: this, 249 | message: transformedMessage 250 | }, 251 | bindToController: true 252 | }); 253 | } else { 254 | conversation.lastRead = transformedMessage.id; 255 | } 256 | this.updateUnreadStatus(); 257 | } 258 | 259 | openConversation(user) { 260 | const threadID = createThreadID(this.mainCtrl.auth.id, user.id); 261 | const convo = this.findConversation({ user, threadID }); 262 | if (convo) this.selectConversation(convo); 263 | } 264 | 265 | findConversation(msg) { 266 | let convo = _.find(this.conversations, conversation => conversation.id === msg.threadID); 267 | if (!convo) { 268 | let otherUser = msg.user; 269 | if (`${otherUser.id}` === this.mainCtrl.auth.id && msg.recipient) { 270 | // we sent the message ourselves, find the other user 271 | otherUser = { 272 | id: msg.recipient.id, 273 | name: msg.recipient.username, 274 | displayName: msg.recipient.display_name, 275 | fullName: getFullName(msg.recipient.username, msg.recipient.display_name), 276 | color: msg.recipient.color, 277 | badges: msg.recipient.badges 278 | }; 279 | this.upgradeBadges(otherUser.badges); 280 | } 281 | 282 | this.ApiService.twitchGet(`https://api.twitch.tv/kraken/channels/${otherUser.id}`).then(response => { 283 | otherUser.profileImage = response.data.logo; 284 | otherUser.displayName = response.data.display_name; 285 | otherUser.name = response.data.name; 286 | otherUser.fullName = getFullName(otherUser.name, otherUser.displayName); 287 | }); 288 | convo = { 289 | user: otherUser, 290 | lines: null, 291 | whisperText: '', 292 | collapse: false, 293 | id: msg.threadID 294 | }; 295 | this.conversations.unshift(convo); 296 | } 297 | return convo; 298 | } 299 | 300 | sendWhisper($event, conversation) { 301 | if ($event.keyCode === 13 && conversation.whisperText.length > 0) { 302 | this.ChatService.chatSend(`PRIVMSG #jtv :/w ${conversation.user.name} ${conversation.whisperText}`); 303 | conversation.whisperText = ''; 304 | } 305 | } 306 | 307 | async upgradeBadges(badgeList) { 308 | const badges = await this.ChatService.getBadges(); 309 | _.each(badgeList, badge => { 310 | const badgeSet = badges[badge.id]; 311 | if (badgeSet) { 312 | const versionInfo = badgeSet.versions[badge.version]; 313 | if (versionInfo) { 314 | badge.url = versionInfo.image_url_1x; 315 | badge.title = versionInfo.title; 316 | badge.name = badge.id; 317 | } 318 | } 319 | }); 320 | } 321 | 322 | openMenu($mdMenu, ev) { 323 | $mdMenu.open(ev); 324 | } 325 | 326 | updateUnreadStatus() { 327 | const unread = _.countBy(this.conversations, conversation => (((conversation.lastMessage && conversation.lastMessage.id) || 0) - (conversation.lastRead || 0)) > 0).true; 328 | console.log('Unread whispers: ', unread); 329 | this.$scope.$parent.notifications = unread; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/js/controllers/whispertoastcontroller.js: -------------------------------------------------------------------------------- 1 | export default class WhisperToastController { 2 | constructor($mdToast) { 3 | 'ngInject'; 4 | 5 | this.$mdToast = $mdToast; 6 | } 7 | 8 | goToWhisper() { 9 | this.whisperCtrl.selectConversation(this.conversation); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/js/defaultConfig.js: -------------------------------------------------------------------------------- 1 | import { configMigrations } from './migrations'; 2 | 3 | export default { 4 | settings: { 5 | style: 'dark', 6 | styleSheet: '', 7 | colorAdjustment: 'hsl', 8 | monoColor: 'rgb(245,245,245)', 9 | modButtons: [ 10 | { 11 | action: { 12 | type: 'command', 13 | command: '/delete {{tags.id}}' 14 | }, 15 | tooltip: 'Delete message', 16 | icon: { 17 | type: 'icon', 18 | code: 'close' 19 | }, 20 | hotkey: '', 21 | show: 'bannable' 22 | }, 23 | { 24 | action: { 25 | type: 'command', 26 | command: '/timeout {{user.name}} 600' 27 | }, 28 | tooltip: 'Timeout', 29 | icon: { 30 | type: 'icon', 31 | code: 'hourglass_empty' 32 | }, 33 | hotkey: '', 34 | show: 'bannable' 35 | }, 36 | { 37 | action: { 38 | type: 'command', 39 | command: '/ban {{user.name}}' 40 | }, 41 | tooltip: 'Ban', 42 | icon: { 43 | type: 'icon', 44 | code: 'block' 45 | }, 46 | hotkey: '', 47 | show: 'bannable' 48 | } 49 | ], 50 | modCardButtons: [ 51 | { 52 | action: { 53 | type: 'whisper' 54 | }, 55 | tooltip: 'Whisper', 56 | icon: { 57 | type: 'text', 58 | text: 'Whisper' 59 | }, 60 | hotkey: 'KeyW', 61 | show: 'always' 62 | }, 63 | { 64 | action: { 65 | type: 'command', 66 | command: '/timeout {{user.name}} 1' 67 | }, 68 | tooltip: 'Purge', 69 | icon: { 70 | type: 'icon', 71 | code: 'remove_circle_outline' 72 | }, 73 | hotkey: 'KeyP', 74 | show: 'bannable' 75 | }, 76 | { 77 | action: { 78 | type: 'command', 79 | command: '/timeout {{user.name}} 600' 80 | }, 81 | tooltip: 'Timeout', 82 | icon: { 83 | type: 'icon', 84 | code: 'hourglass_empty' 85 | }, 86 | hotkey: 'KeyT', 87 | show: 'bannable' 88 | }, 89 | { 90 | action: { 91 | type: 'command', 92 | command: '/ban {{user.name}}' 93 | }, 94 | tooltip: 'Ban', 95 | icon: { 96 | type: 'icon', 97 | code: 'block' 98 | }, 99 | hotkey: 'KeyB', 100 | show: 'bannable' 101 | }, 102 | { 103 | action: { 104 | type: 'url', 105 | url: 'https://cbenni.com/{{channel.name}}/?user={{user.name}}' 106 | }, 107 | tooltip: 'Logviewer', 108 | icon: { 109 | type: 'image', 110 | image: 'https://cbenni.com/html/img/favicon-32x32.png' 111 | }, 112 | hotkey: '', 113 | show: 'always' 114 | } 115 | ], 116 | chatHeaderButtons: [ 117 | { 118 | action: { 119 | type: 'url', 120 | url: 'https://cbenni.com/{{channel.name}}/' 121 | }, 122 | tooltip: 'Logviewer', 123 | icon: { 124 | type: 'image', 125 | image: 'https://cbenni.com/html/img/favicon-32x32.png' 126 | }, 127 | hotkey: '', 128 | show: 'always' 129 | }, 130 | { 131 | action: { 132 | type: 'url', 133 | url: 'https://twitch.moobot.tv/{{channel.name}}' 134 | }, 135 | tooltip: 'Moobot', 136 | icon: { 137 | type: 'image', 138 | image: 'https://cbenni.com/static/moobot.png' 139 | }, 140 | hotkey: '', 141 | show: 'mod' 142 | } 143 | ], 144 | chatSettings: { 145 | extraMentions: [], 146 | knownBots: [ 147 | 'drangrybot', 148 | 'gather_bot', 149 | 'hnlbot', 150 | 'mikuia', 151 | 'monstercat', 152 | 'moobot', 153 | 'nightbot', 154 | 'ohbot', 155 | 'poketrivia', 156 | 'snusbot', 157 | 'streamelements', 158 | 'vivbot', 159 | 'wizebot', 160 | 'xanbot' 161 | ], 162 | pauseOn: [ 163 | 'hover', 164 | 'hotkey' 165 | ] 166 | }, 167 | chatPresets: [ 168 | { 169 | id: 'default-chat', 170 | name: 'Chat', 171 | icon: { 172 | type: 'icon', 173 | code: 'chat' 174 | }, 175 | settings: { 176 | incognito: false, 177 | hideChatInput: false, 178 | messageFilters: [ 179 | 'modlogs', 180 | 'subs', 181 | 'chat', 182 | 'bots', 183 | 'mentions', 184 | 'bits', 185 | 'automod' 186 | ] 187 | } 188 | }, 189 | { 190 | id: 'default-modlogs', 191 | name: 'Modlogs', 192 | icon: { 193 | type: 'icon', 194 | code: 'gavel' 195 | }, 196 | settings: { 197 | incognito: false, 198 | hideChatInput: true, 199 | messageFilters: [ 200 | 'modlogs', 201 | 'automod' 202 | ] 203 | } 204 | } 205 | ], 206 | timeFormat: 'HH:mm', 207 | scrollbackLength: 5000 208 | }, 209 | version: configMigrations.length 210 | }; 211 | -------------------------------------------------------------------------------- /src/js/defaultLayouts.js: -------------------------------------------------------------------------------- 1 | import { layoutMigrations } from './migrations'; 2 | 3 | export default [{ 4 | settings: { 5 | showPopoutIcon: false 6 | }, 7 | content: [ 8 | { 9 | title: 'Home', 10 | type: 'component', 11 | componentName: 'angularModule', 12 | isClosable: false, 13 | componentState: { 14 | module: 'mtApp', 15 | templateId: 'homeTemplate', 16 | icon: { 17 | type: 'icon', 18 | code: 'home' 19 | } 20 | } 21 | } 22 | ], 23 | version: layoutMigrations.length 24 | }]; 25 | -------------------------------------------------------------------------------- /src/js/directives/autocompletedirective.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import angular from 'angular'; 3 | 4 | import autocompletePanelTemplate from '../../templates/autocompletetemplate.html'; 5 | 6 | function getCurrentWord(element) { 7 | const selectionPos = element.prop('selectionStart'); 8 | // find word (including special characters) 9 | const str = element.val(); 10 | const wordStart = str.lastIndexOf(' ', selectionPos - 1) + 1; 11 | let wordEnd = str.indexOf(' ', selectionPos); 12 | if (wordEnd === -1) wordEnd = str.length; 13 | const selection = str.slice(wordStart, wordEnd); 14 | const trimmed = /\w+/.exec(selection); 15 | return { 16 | start: wordStart, 17 | end: wordEnd, 18 | text: selection.toLowerCase(), 19 | trimmed: trimmed && trimmed[0].toLowerCase() 20 | }; 21 | } 22 | 23 | function pushByHeuristic(array, heuristic, value, maxlength, uniqueBy) { 24 | if (uniqueBy) { 25 | const duplicateIndex = _.findIndex(array, x => _.get(x.value, uniqueBy) === _.get(value, uniqueBy)); 26 | if (duplicateIndex >= 0) { 27 | if (heuristic < array[duplicateIndex].heuristic) array[duplicateIndex] = value; 28 | return; 29 | } 30 | } 31 | 32 | const index = _.sortedIndexBy(array, { heuristic, value }, x => x.heuristic); 33 | if (index >= maxlength) return; 34 | array.splice(index, 0, { heuristic, value }); 35 | if (array.length > maxlength) array.splice(maxlength); 36 | } 37 | 38 | function findEmotes(local, global, word) { 39 | const emotes = []; 40 | const originPenalties = { 41 | 'twitch global': 0, 42 | 'bttv global': 50, 43 | 'ffz global': 50 44 | }; 45 | 46 | let index = 0; 47 | _.each(global, emote => { 48 | if (emote.code.toLowerCase() === word.trimmed) pushByHeuristic(emotes, originPenalties[emote.origin] + 0, emote, 5, 'code'); 49 | else if (emote.prefixless && emote.prefixless === word.trimmed) pushByHeuristic(emotes, originPenalties[emote.origin] + 0, emote, 5, 'code'); 50 | else if (emote.code.toLowerCase().startsWith(word.text)) pushByHeuristic(emotes, 0, emote, 5, 'code'); 51 | else if (emote.code.toLowerCase().startsWith(word.trimmed)) pushByHeuristic(emotes, originPenalties[emote.origin] + index++, emote, 5, 'code'); 52 | else if (emote.prefixless && emote.prefixless.startsWith(word.trimmed)) pushByHeuristic(emotes, originPenalties[emote.origin] + index++, emote, 5, 'code'); 53 | else if (emote.code.toLowerCase().includes(word.trimmed)) pushByHeuristic(emotes, originPenalties[emote.origin] + 500 + index++, emote, 5, 'code'); 54 | }); 55 | _.each(local, emote => { 56 | if (emote.code.toLowerCase() === word.trimmed) pushByHeuristic(emotes, 0, emote, 5, 'code'); 57 | else if (emote.code.toLowerCase().startsWith(word.trimmed)) pushByHeuristic(emotes, index++, emote, 5, 'code'); 58 | else if (emote.code.toLowerCase().includes(word.trimmed)) pushByHeuristic(emotes, 500 + index++, emote, 5, 'code'); 59 | }); 60 | return emotes.map(x => x.value); 61 | } 62 | 63 | export default function autocompleteDirective($rootScope, $mdPanel, KeyPressService, ThrottledDigestService) { 64 | 'ngInject'; 65 | 66 | return { 67 | restrict: 'A', 68 | require: 'ngModel', 69 | scope: { 70 | autocomplete: '=' 71 | }, 72 | link($scope, element, something, ngModel) { 73 | let panel = null; 74 | const panelInfo = { 75 | items: [], 76 | selectedIndex: 0, 77 | selectedItem: null 78 | }; 79 | $scope.autocomplete.status = panelInfo; 80 | 81 | function selectItem(index) { 82 | if (index < 0) index = 0; 83 | if (index >= panelInfo.items.length) index = panelInfo.items.length - 1; 84 | panelInfo.selectedIndex = index; 85 | panelInfo.selectedItem = panelInfo.items[index]; 86 | } 87 | 88 | function applySelection(word, item) { 89 | const oldStr = ngModel.$viewValue; 90 | let newStr = oldStr.slice(0, word.start) + item.value + oldStr.slice(word.end); 91 | if (word.end === oldStr.length) newStr += ' '; 92 | ngModel.$setViewValue(newStr); 93 | ngModel.$render(); 94 | if (panel) panel.hide(); 95 | panelInfo.items = []; 96 | selectItem(-1); 97 | setTimeout(() => { 98 | element.prop('selectionStart', word.start + item.value.length + 1); 99 | element.prop('selectionEnd', word.start + item.value.length + 1); 100 | }, 1); 101 | } 102 | 103 | function showAutoComplete() { 104 | if (panel) { 105 | panel.open(); 106 | } else { 107 | const position = $mdPanel.newPanelPosition().relativeTo(element).addPanelPosition($mdPanel.xPosition.ALIGN_START, $mdPanel.yPosition.ABOVE); 108 | panel = $mdPanel.create({ 109 | template: autocompletePanelTemplate, 110 | controller: 'AutocompletePanelController', 111 | controllerAs: 'autocompleteCtrl', 112 | bindToController: true, 113 | locals: { 114 | info: panelInfo 115 | }, 116 | panelClass: 'autocomplete-panel', 117 | escapeToClose: true, 118 | clickOutsideToClose: true, 119 | focusOnOpen: false, 120 | trapFocus: false, 121 | propagateContainerEvents: true, 122 | origin: element, 123 | attachTo: angular.element(document.body), 124 | position, 125 | groupName: 'autocomplete', 126 | onCloseSuccess: (ref, reason) => { 127 | if (reason === 'click') applySelection(getCurrentWord(element), panelInfo.selectedItem); 128 | } 129 | }); 130 | panel.open(); 131 | } 132 | ThrottledDigestService.$apply($scope); 133 | } 134 | 135 | let recentMessageSelection = null; 136 | function onKey(event) { 137 | ThrottledDigestService.$apply($rootScope); 138 | if (event.target !== element[0]) return false; 139 | const word = getCurrentWord(element); 140 | 141 | const oldMessageSelection = recentMessageSelection; 142 | if (event.type === 'keydown') recentMessageSelection = null; 143 | 144 | if (event.key === 'Tab' || (event.key === 'Enter' && panelInfo.selectedItem)) { 145 | if (event.type === 'keydown' && panelInfo.selectedItem) { 146 | applySelection(word, panelInfo.selectedItem); 147 | } 148 | event.preventDefault(true); 149 | event.stopImmediatePropagation(); 150 | return true; 151 | } else if (event.key === 'ArrowUp') { 152 | if (event.type === 'keydown') { 153 | if (panelInfo.items.length === 0 && $scope.autocomplete.messages.length > 0) { 154 | if (oldMessageSelection === null) recentMessageSelection = $scope.autocomplete.messages.length - 1; 155 | else recentMessageSelection = Math.max(oldMessageSelection - 1, 0); 156 | if (recentMessageSelection !== null) { 157 | const msg = $scope.autocomplete.messages[recentMessageSelection]; 158 | if (msg) { 159 | ngModel.$setViewValue(msg); 160 | ngModel.$render(); 161 | } 162 | } 163 | } else selectItem(panelInfo.selectedIndex + 1); 164 | } 165 | event.preventDefault(true); 166 | return true; 167 | } else if (event.key === 'ArrowDown') { 168 | if (event.type === 'keydown') { 169 | if (panelInfo.items.length === 0 && oldMessageSelection !== null) { 170 | if (oldMessageSelection < $scope.autocomplete.messages.length - 1) recentMessageSelection = oldMessageSelection + 1; 171 | if (recentMessageSelection !== null) { 172 | const msg = $scope.autocomplete.messages[recentMessageSelection]; 173 | if (msg) { 174 | ngModel.$setViewValue(msg); 175 | ngModel.$render(); 176 | } 177 | } 178 | } else selectItem(panelInfo.selectedIndex - 1); 179 | } 180 | event.preventDefault(true); 181 | return true; 182 | } else if (event.key === 'Escape') { 183 | panelInfo.items = []; 184 | selectItem(-1); 185 | event.preventDefault(true); 186 | event.stopImmediatePropagation(); 187 | if (panel) panel.hide(); 188 | return true; 189 | } else if (event.type === 'keyup') { 190 | if (word.text[0] === '@' && word.trimmed) { 191 | panelInfo.items = _.map( 192 | _.takeRight( 193 | _.sortBy( 194 | _.filter($scope.autocomplete.users, userInfo => userInfo.user.name.startsWith(word.trimmed)) 195 | , 'relevance' 196 | ) 197 | , 5 198 | ) 199 | , item => ({ text: `@${item.user.fullName}`, value: `@${item.user.name}`, color: item.user.color }) 200 | ); 201 | if (panelInfo.items.length > 0) { 202 | selectItem(0); 203 | showAutoComplete(); 204 | return true; 205 | } 206 | } else if (word.text[0] === '!' && word.trimmed) { 207 | // showAutoComplete($scope.autocomplete.commands); 208 | } else if (word.text[0] === ':' && word.text.length > 1) { 209 | const emotes = findEmotes($scope.autocomplete.emotes.local, $scope.autocomplete.emotes.global, word); 210 | panelInfo.items = _.map(emotes, emote => ({ 211 | text: emote.code, 212 | value: emote.code, 213 | img: emote.url 214 | })); 215 | selectItem(0); 216 | if (panelInfo.items.length > 0) { 217 | showAutoComplete(); 218 | return true; 219 | } 220 | } 221 | if (panel) { 222 | panel.hide(); 223 | panelInfo.selectedIndex = -1; 224 | panelInfo.selectedItem = null; 225 | } 226 | } 227 | return true; 228 | } 229 | 230 | const keyEvents = [ 231 | KeyPressService.on('keydown', onKey, 200), 232 | KeyPressService.on('keyup', onKey, 200) 233 | ]; 234 | 235 | $scope.$on('$destroy', () => { 236 | _.each(keyEvents, keyEvent => keyEvent()); 237 | }); 238 | } 239 | }; 240 | } 241 | -------------------------------------------------------------------------------- /src/js/directives/buttonsettingsdirective.js: -------------------------------------------------------------------------------- 1 | import buttonSettingsTemplate from '../../templates/buttonsettings.html'; 2 | 3 | 4 | export default function buttonSettingsDirective() { 5 | return { 6 | restrict: 'EAC', 7 | template: buttonSettingsTemplate, 8 | scope: { 9 | buttons: '=', 10 | defaultButton: '=' 11 | }, 12 | controller: 'ButtonSettingsController', 13 | controllerAs: 'btnsCtrl', 14 | bindToController: true 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/js/directives/chatlinedirective.js: -------------------------------------------------------------------------------- 1 | import chatLineTemplate from '../../templates/chatline.html'; 2 | 3 | export function onScrollDirective() { 4 | return { 5 | restrict: 'A', 6 | link($scope, $element, attrs) { 7 | $element.bind('scroll', event => { 8 | $scope.$eval(attrs.onScroll, { $event: event, $element, scrollPos: $element[0].scrollTop }); 9 | }); 10 | } 11 | }; 12 | } 13 | 14 | export function compileDirective($compile) { 15 | 'ngInject'; 16 | 17 | return { 18 | restrict: 'A', 19 | link: ($scope, $element, attrs) => { 20 | $scope.$watch(scope => scope.$eval(attrs.compile), value => { 21 | $element.html(value); 22 | $compile($element.children('.compile'))($scope); 23 | }); 24 | } 25 | }; 26 | } 27 | 28 | export function chatLineDirective() { 29 | return { 30 | restrict: 'AC', 31 | template: chatLineTemplate 32 | }; 33 | } 34 | 35 | export function isntEmptyFilter() { 36 | return obj => obj && Object.keys(obj).length > 0; 37 | } 38 | -------------------------------------------------------------------------------- /src/js/directives/draggabledirective.js: -------------------------------------------------------------------------------- 1 | import 'jquery-ui/ui/widgets/sortable'; 2 | 3 | export default function () { 4 | return { 5 | restrict: 'A', 6 | link($scope, element, attrs) { 7 | const options = $scope.$eval(attrs.draggable); 8 | element.draggable(options); 9 | } 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/js/directives/dynamicstylesheetdirective.js: -------------------------------------------------------------------------------- 1 | export default function DynamicStylesheetDirective() { 2 | 'ngInject'; 3 | 4 | return { 5 | restrict: 'A', 6 | scope: { 7 | dynamicStylesheet: '=' 8 | }, 9 | link: ($scope, element) => { 10 | element.attr('href', $scope.dynamicStylesheet); 11 | 12 | $scope.$watch('dynamicStylesheet', () => { 13 | element.prop('disabled', 'disabled'); 14 | setTimeout(() => { 15 | element.attr('href', $scope.dynamicStylesheet); 16 | element.prop('disabled', ''); 17 | }, 10); 18 | }); 19 | } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/js/directives/filters.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { formatDuration } from '../helpers'; 3 | 4 | export function timeAgoFilter() { 5 | return date => { 6 | if (!date) return '0'; 7 | const d = Date.now(); 8 | const age = (d - new Date(date).getTime()) / 1000; 9 | let res = ''; 10 | if (age < 60) { 11 | res = '< 1 min'; 12 | } else if (age < 3600) { 13 | const mins = Math.round(age / 60); 14 | res = `${mins} min`; 15 | } else if (age < 3600 * 24) { 16 | const hrs = Math.round(age / 3600); 17 | if (hrs === 1) res = '1h'; 18 | else res = `${hrs} hrs`; 19 | } else if (age < 3600 * 24 * 365) { 20 | const days = Math.round(age / (3600 * 24)); 21 | if (days === 1) res = '1 day'; 22 | else res = `${days} days`; 23 | } else { 24 | const years = Math.round(age / (3600 * 24 * 365 / 10)) / 10; 25 | if (years === 1) res = '1 year'; 26 | else res = `${years} years`; 27 | } 28 | return res; 29 | }; 30 | } 31 | 32 | export function durationFilter() { 33 | return formatDuration; 34 | } 35 | 36 | export function largeNumberFilter() { 37 | return number => { 38 | if (!number) return '0'; 39 | if (number < 1000) return number; 40 | else if (number < 1e4) { 41 | return `${Math.floor(number / 1e2) / 10}k`; 42 | } else if (number < 1e6) { 43 | return `${Math.floor(number / 1e3)}k`; 44 | } 45 | return `${Math.floor(number / 1e6)}M`; 46 | }; 47 | } 48 | 49 | export function uniqueFilter() { 50 | return (list, by) => _.uniqBy(list, by); 51 | } 52 | 53 | /* 54 | export default function () { 55 | return date => { 56 | const d = Date.now(); 57 | const age = (d - new Date(date).getTime()) / 1000; 58 | let res = ''; 59 | if (age < 60) { 60 | res = 'less than a minute ago'; 61 | } else if (age < 3600) { 62 | const mins = Math.round(age / 60); 63 | if (mins === 1) res = 'a minute ago'; 64 | else res = `${mins} minutes ago`; 65 | } else if (age < 3600 * 24) { 66 | const hrs = Math.round(age / 3600); 67 | if (hrs === 1) res = 'an hour ago'; 68 | else res = `${hrs} hours ago`; 69 | } else if (age < 3600 * 24 * 365) { 70 | const days = Math.round(age / (3600 * 24)); 71 | if (days === 1) res = 'yesterday'; 72 | else res = `${days} days ago`; 73 | } else { 74 | const years = Math.round(age / (3600 * 24 * 365)); 75 | if (years === 1) res = 'last year'; 76 | else res = `${years} years ago`; 77 | } 78 | return res; 79 | }; 80 | } 81 | */ 82 | -------------------------------------------------------------------------------- /src/js/directives/goldenlayoutdragsourcedirective.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export default function goldenLayoutDragSource() { 4 | return { 5 | restrict: 'A', 6 | scope: { 7 | goldenLayoutDragSource: '=', 8 | glDsLayout: '=', 9 | glDsChannel: '=', 10 | glDsTemplate: '=', 11 | glDsPreset: '=', 12 | glDsIcon: '=' 13 | }, 14 | link($scope, $element) { 15 | const config = { 16 | title: $scope.glDsChannel, 17 | type: 'component', 18 | componentName: 'angularModule', 19 | componentState: { 20 | module: 'mtApp', 21 | templateId: $scope.glDsTemplate, 22 | channel: $scope.glDsChannel, 23 | preset: $scope.glDsPreset, 24 | icon: $scope.glDsIcon 25 | } 26 | }; 27 | 28 | $scope.$watch('glDsChannel', () => { 29 | config.title = $scope.glDsChannel; 30 | config.componentState.channel = $scope.glDsChannel; 31 | }); 32 | $scope.$watch('glDsTemplate', () => { 33 | config.componentState.templateId = $scope.glDsTemplate; 34 | }); 35 | const dragSource = $scope.glDsLayout.createDragSource($element[0], config); 36 | 37 | $scope.$on('$destroy', () => { 38 | if (dragSource._dragListener._bDragging) { 39 | dragSource._dragListener.on('dragStop', () => { 40 | dragSource._dragListener.destroy(); 41 | }); 42 | } else { 43 | dragSource._dragListener.destroy(); 44 | } 45 | _.pull($scope.glDsLayout._dragSources, dragSource); 46 | }); 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/js/directives/iconpickerdirective.js: -------------------------------------------------------------------------------- 1 | import iconPickerPanelTemplate from '../../templates/iconpickerpanel.html'; 2 | import iconPickerTemplate from '../../templates/iconpicker.html'; 3 | 4 | 5 | export function iconPickerDirective() { 6 | return { 7 | restrict: 'EAC', 8 | template: iconPickerTemplate, 9 | scope: '', 10 | controller: 'IconPickerController', 11 | controllerAs: 'iconPickerCtrl', 12 | bindToController: true 13 | }; 14 | } 15 | 16 | export const iconPickerPanelPreset = { 17 | controller: 'IconPickerPanelController', 18 | controllerAs: 'iconPickerPanelCtrl', 19 | template: iconPickerPanelTemplate, 20 | panelClass: 'icon-picker-dropdown', 21 | 'z-index': 85, 22 | clickOutsideToClose: true, 23 | escapeToClose: true 24 | }; 25 | -------------------------------------------------------------------------------- /src/js/directives/onscrolldirective.js: -------------------------------------------------------------------------------- 1 | export default function onScrollDirective() { 2 | return { 3 | restrict: 'A', 4 | scope: { 5 | onScroll: '&' 6 | }, 7 | link($scope, $element) { 8 | const lastScroll = $element[0].scrollTop; 9 | const onScroll = event => { 10 | const direction = $element[0].scrollTop - lastScroll; 11 | $scope.onScroll({ 12 | $event: event, $element, scrollPos: $element[0].scrollTop, direction 13 | }); 14 | }; 15 | 16 | onScroll(null); 17 | 18 | $element.on('scroll', onScroll); 19 | 20 | $scope.$on('$destroy', () => { 21 | $element.off('scroll', onScroll); 22 | }); 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/js/directives/simplescrolldirective.js: -------------------------------------------------------------------------------- 1 | import simpleScrollbar from 'simple-scrollbar'; 2 | 3 | export default function SimpleScrollbarDirective() { 4 | return { 5 | restrict: 'A', 6 | link($scope, element) { 7 | simpleScrollbar.initEl(element[0]); 8 | } 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/js/directives/streamlistdirective.js: -------------------------------------------------------------------------------- 1 | import streamListTemplate from '../../templates/streamlisttemplate.html'; 2 | 3 | 4 | export default function streamListDirective() { 5 | return { 6 | restrict: 'A', 7 | template: streamListTemplate, 8 | scope: { 9 | streamList: '=' 10 | }, 11 | controller: 'StreamListController', 12 | controllerAs: 'streamsCtrl', 13 | bindToController: true 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/js/directives/throttledevents.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | function throttledEventDirective(eventName) { 4 | const directiveName = _.camelCase(['throttled', eventName]); 5 | return ($parse, ThrottledDigestService) => { 6 | 'ngInject'; 7 | 8 | return { 9 | restrict: 'A', 10 | compile($element, attr) { 11 | const fn = $parse(attr[directiveName]); 12 | return (scope, element) => { 13 | element.on(eventName, event => { 14 | const callback = () => { 15 | fn(scope, { $event: event }); 16 | }; 17 | ThrottledDigestService.$apply(scope, callback); 18 | }); 19 | }; 20 | } 21 | }; 22 | }; 23 | } 24 | 25 | export function throttledUserScrollDirective($parse, ThrottledDigestService) { 26 | 'ngInject'; 27 | 28 | return { 29 | restrict: 'A', 30 | compile($element, attr) { 31 | const fn = $parse(attr.throttledUserScroll); 32 | return (scope, element) => { 33 | fn(scope, { $event: null, $element: element }); 34 | element.on('wheel DOMMouseScroll mousewheel keyup', event => { 35 | const callback = () => { 36 | fn(scope, { $event: event, $element: element }); 37 | }; 38 | ThrottledDigestService.$apply(scope, callback); 39 | }); 40 | }; 41 | } 42 | }; 43 | } 44 | 45 | export const throttledMousemoveDirective = throttledEventDirective('mousemove'); 46 | export const throttledKeydownDirective = throttledEventDirective('keydown'); 47 | export const throttledClickDirective = throttledEventDirective('click'); 48 | -------------------------------------------------------------------------------- /src/js/errorreporting.js: -------------------------------------------------------------------------------- 1 | import raven from 'raven-js'; 2 | import angular from 'angular'; 3 | 4 | raven.config('https://9c0989719ae64953abeebc8f6c76e2ab@sentry.io/1193838') 5 | .addPlugin(require('raven-js/plugins/angular'), angular) 6 | .install(); 7 | -------------------------------------------------------------------------------- /src/js/helpers.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import $ from 'jquery'; 3 | 4 | export function genNonce() { 5 | const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz'; 6 | const result = []; 7 | window.crypto.getRandomValues(new Uint8Array(32)).forEach(c => 8 | result.push(charset[c % charset.length])); 9 | return result.join(''); 10 | } 11 | 12 | const rx = /^(?:@([^ ]+) )?(?:[:](\S+) )?(\S+)(?: (?!:)(.+?))?(?: [:](.+))?$/; 13 | const rx2 = /([^=;]+)=([^;]*)/g; 14 | const STATE_V3 = 1; 15 | const STATE_PREFIX = 2; 16 | const STATE_COMMAND = 3; 17 | const STATE_PARAM = 4; 18 | const STATE_TRAILING = 5; 19 | 20 | export function parseIRCMessage(message) { 21 | const data = rx.exec(message); 22 | if (data === null) { 23 | console.error(`Couldnt parse message '${message}'`); 24 | return null; 25 | } 26 | const tagdata = data[STATE_V3]; 27 | const tags = {}; 28 | if (tagdata) { 29 | let m; 30 | do { 31 | m = rx2.exec(tagdata); 32 | if (m) { 33 | const [, key, val] = m; 34 | tags[key] = val.replace(/\\s/g, ' ').trim(); 35 | } 36 | } while (m); 37 | } 38 | return { 39 | tags, 40 | command: data[STATE_COMMAND], 41 | prefix: data[STATE_PREFIX], 42 | param: data[STATE_PARAM], 43 | trailing: data[STATE_TRAILING] 44 | }; 45 | } 46 | 47 | export function sdbmCode(str) { 48 | let hash = 0; 49 | for (let i = 0; i < str.length; i++) { 50 | // eslint-disable-next-line no-bitwise 51 | hash = str.charCodeAt(i) + (hash << 6) + (hash << 16) - hash; 52 | } 53 | return Math.abs(hash); 54 | } 55 | export const entityMap = { 56 | '&': '&', 57 | '<': '<', 58 | '>': '>', 59 | '"': '"', 60 | "'": ''', 61 | '/': '/', 62 | '\\': '\', 63 | '{': '{', 64 | '}': '}' 65 | }; 66 | export const htmlEntities = _.invert(entityMap); 67 | 68 | export function escapeHtml(str) { 69 | return str.replace(/[&<>"'/{}\\]/g, m => entityMap[m]); 70 | } 71 | 72 | export function formatTimespan(timespan) { 73 | let age = Math.round(parseInt(timespan, 10)); 74 | const periods = [ 75 | { abbr: 'y', len: 3600 * 24 * 365 }, 76 | { abbr: 'm', len: 3600 * 24 * 30 }, 77 | { abbr: 'd', len: 3600 * 24 }, 78 | { abbr: ' hrs', len: 3600 }, 79 | { abbr: ' min', len: 60 }, 80 | { abbr: ' sec', len: 1 } 81 | ]; 82 | let res = ''; 83 | let count = 0; 84 | for (let i = 0; i < periods.length; ++i) { 85 | if (age >= periods[i].len) { 86 | const pval = Math.floor(age / periods[i].len); 87 | age %= periods[i].len; 88 | res += (res ? ' ' : '') + pval + periods[i].abbr; 89 | count++; 90 | if (count >= 2) break; 91 | } 92 | } 93 | return res; 94 | } 95 | 96 | export function formatCount(i) { 97 | return i <= 1 ? '' : ` (${i} times)`; 98 | } 99 | 100 | export function formatTimeout(timeout) { 101 | const tags = timeout.tags; 102 | if (timeout.type === 'timeout') { 103 | // timeout 104 | if (!tags.reasons || tags.reasons.length === 0) { 105 | return `<${tags['display-name']} has been timed out for ${formatTimespan(tags.duration)}${formatCount(tags.count)}>`; 106 | } else if (tags.reasons.length === 1) { 107 | return `<${tags['display-name']} has been timed out for ${formatTimespan(tags.duration)}. Reason: ${tags.reasons.join(', ')}${formatCount(tags.count)}>`; 108 | } 109 | return `<${tags['display-name']} has been timed out for ${formatTimespan(tags.duration)}. Reasons: ${tags.reasons.join(', ')}${formatCount(tags.count)}>`; 110 | } 111 | // banned 112 | if (timeout.type === 'ban') { 113 | if (!tags.reasons || tags.reasons.length === 0) { 114 | return `<${tags['display-name']} has been banned>`; 115 | } else if (tags.reasons.length === 1) { 116 | return `<${tags['display-name']} has been banned. Reason: ${tags.reasons.join(', ')}>`; 117 | } 118 | return `<${tags['display-name']} has been banned. Reasons: ${tags.reasons.join(', ')}>`; 119 | } 120 | return ''; 121 | } 122 | 123 | export function capitalizeFirst(str) { 124 | return str.slice(0, 1).toUpperCase() + str.slice(1); 125 | } 126 | 127 | export function jsonParseRecursive(thing) { 128 | if (typeof (thing) === 'object') { 129 | _.each(thing, (val, prop) => { 130 | thing[prop] = jsonParseRecursive(val); 131 | }); 132 | return thing; 133 | } else if (typeof (thing) === 'string' && (thing[0] === '[' || thing[0] === '{')) { 134 | try { 135 | return jsonParseRecursive(JSON.parse(thing)); 136 | } catch (err) { 137 | return thing; 138 | } 139 | } else return thing; 140 | } 141 | 142 | export function formatDuration(duration, baseUnit) { 143 | if (!duration) return '0'; 144 | let res = ''; 145 | duration = parseInt(duration, 10); 146 | if (baseUnit) { 147 | duration *= { 148 | minutes: 60, hours: 3600, days: 86400, years: 86400 * 365 149 | }[baseUnit]; 150 | } 151 | if (duration < 60) { 152 | res = `${duration}s`; 153 | } else if (duration < 3600) { 154 | const mins = Math.round(duration / 60); 155 | res = `${mins}m`; 156 | } else if (duration < 3600 * 24) { 157 | const hrs = Math.round(duration / 3600); 158 | res = `${hrs}h`; 159 | } else if (duration < 3600 * 24 * 365) { 160 | const days = Math.round(duration / (3600 * 24)); 161 | res = `${days}d`; 162 | } else { 163 | const years = Math.round(duration / (3600 * 24 * 365 / 10)) / 10; 164 | res = `${years}y`; 165 | } 166 | return res; 167 | } 168 | 169 | export function stringifyTimeout(timeoutNotice) { 170 | let res = null; 171 | if (timeoutNotice.duration <= 1) res = 'has been purged'; 172 | else if (Number.isFinite(timeoutNotice.duration)) res = `has been timed out for ${formatDuration(timeoutNotice.duration)}`; 173 | else res = 'has been banned'; 174 | if (timeoutNotice.count > 1) res += ` (${timeoutNotice.count} times)`; 175 | if (timeoutNotice.reasons.length === 1) { 176 | res += ` with reason: ${timeoutNotice.reasons[0]}`; 177 | } else if (timeoutNotice.reasons.length > 1) { 178 | res += ` with reasons: ${timeoutNotice.reasons.join(', ')}`; 179 | } 180 | return res; 181 | } 182 | 183 | // used to turn regex emote codes into proper names 184 | export function instantiateRegex(regex) { 185 | const res = regex.replace(/\[([^\]])[^\]]*\]/g, '$1') 186 | .replace(/\(([^|)]*)(?:\|[^)]*)*\)/g, '$1') 187 | .replace(/\\?(.)\??/g, '$1') 188 | .replace(/&(\w+);/g, m => htmlEntities[m] || m); 189 | if (res !== regex) return res.toUpperCase(); 190 | return regex; 191 | } 192 | 193 | export function alwaysResolve(promise) { 194 | return new Promise(resolve => { 195 | promise.then(resolve).catch(resolve); 196 | }); 197 | } 198 | 199 | const textToCursorCache = new Map(); 200 | export function textToCursor(text, size, font) { 201 | const cacheKey = JSON.stringify([text, size, font]); 202 | const cached = textToCursorCache.get(cacheKey); 203 | if (cached) return cached; 204 | const canvas = document.createElement('canvas'); 205 | canvas.height = size; 206 | let ctx = canvas.getContext('2d'); 207 | ctx.font = `${size || 24}px '${font || 'Arial'}'`; 208 | canvas.width = ctx.measureText(text).width; 209 | ctx = canvas.getContext('2d'); 210 | ctx.font = `${size || 24}px '${font || 'Arial'}'`; 211 | ctx.fillStyle = 'white'; 212 | ctx.strokeStyle = 'black'; 213 | ctx.strokeWidth = 5; 214 | ctx.fillText(text, 0, size); 215 | ctx.moveTo(0, 0); 216 | ctx.lineTo(5, 0); 217 | ctx.lineTo(0, 5); 218 | ctx.fill(); 219 | const result = canvas.toDataURL(); 220 | textToCursorCache.set(cacheKey, result); 221 | return result; 222 | } 223 | 224 | export function getFullName(name, displayName) { 225 | if (name === displayName.toLowerCase()) return displayName; 226 | return `${displayName} (${name})`; 227 | } 228 | 229 | export function listenEvent(eventEmitter, event, callback) { 230 | eventEmitter.on(event, callback); 231 | return () => { 232 | eventEmitter.removeListener(event, callback); 233 | }; 234 | } 235 | 236 | export function loadJSONFromFile() { 237 | return new Promise((resolve, reject) => { 238 | const element = $(''); 239 | element.click().on('change', () => { 240 | const file = element[0].files[0]; 241 | const reader = new FileReader(); 242 | reader.onload = e => { 243 | try { 244 | resolve(JSON.parse(e.target.result)); 245 | } catch (err) { 246 | reject(err); 247 | } 248 | }; 249 | reader.onerror = e => reject(e); 250 | reader.readAsText(file); 251 | }); 252 | }); 253 | } 254 | 255 | export function safeLink(url) { 256 | return $('').attr('href', url).text(url)[0].outerHTML; 257 | } 258 | 259 | export const globalModTypes = ['staff', 'admin', 'global_mod']; 260 | -------------------------------------------------------------------------------- /src/js/iconCodes.json: -------------------------------------------------------------------------------- 1 | [ 2 | "chat", 3 | "gavel", 4 | "close", 5 | "done", 6 | "done_all", 7 | "announcement", 8 | "check_circle", 9 | "delete", 10 | "favorite", 11 | "favorite_border", 12 | "build", 13 | "hourglass_empty", 14 | "hourglass_full", 15 | "info", 16 | "info_outline", 17 | "lock", 18 | "lock_open", 19 | "lock_outline", 20 | "query_builder", 21 | "thumb_down", 22 | "thumb_up", 23 | "theaters", 24 | "visibility", 25 | "visibility_off", 26 | "watch_later", 27 | "error", 28 | "error_outline", 29 | "warning", 30 | "forward_10", 31 | "forward_30", 32 | "forward_5", 33 | "replay", 34 | "replay_10", 35 | "replay_30", 36 | "replay_5", 37 | "volume_mute", 38 | "add", 39 | "add_box", 40 | "add_circle", 41 | "add_circle_outline", 42 | "block", 43 | "clear", 44 | "flag", 45 | "create", 46 | "remove", 47 | "remove_circle", 48 | "remove_circle_outline", 49 | "report", 50 | "reply", 51 | "send", 52 | "access_alarm", 53 | "access_time", 54 | "brightness_low", 55 | "strikethrough_s", 56 | "insert_emoticon", 57 | "cancel", 58 | "sentiment_satisfied", 59 | "sentiment_neutral", 60 | "sentiment_dissatisfied", 61 | "sentiment_very_dissatisfied", 62 | "sentiment_very_satisfied", 63 | "check_box", 64 | "check_box_outline_blank", 65 | "indeterminate_check_box", 66 | "star", 67 | "star_border", 68 | "star_half" 69 | ] 70 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import 'jquery-ui'; 2 | import angular from 'angular'; 3 | import angularMaterial from 'angular-material'; 4 | import angularAnimate from 'angular-animate'; 5 | import angularAria from 'angular-aria'; 6 | import angularCookies from 'angular-cookies'; 7 | import 'angular-ui-sortable'; 8 | import '@iamadamjowett/angular-click-outside'; 9 | 10 | import './errorreporting'; 11 | 12 | import '../css/index.scss'; 13 | 14 | import MainController from './controllers/maincontroller'; 15 | import HomeController from './controllers/homecontroller'; 16 | import WhisperController from './controllers/whispercontroller'; 17 | import ChatController from './controllers/chatcontroller'; 18 | import StreamController from './controllers/streamcontroller'; 19 | import DialogController from './controllers/dialogcontroller'; 20 | import SettingsDialogController from './controllers/settingsdialogcontroller'; 21 | import ButtonSettingsController from './controllers/buttonsettingscontroller'; 22 | import StreamListController from './controllers/streamlistcontroller'; 23 | import AutocompletePanelController from './controllers/autocompletepanelcontroller'; 24 | import WhisperToastController from './controllers/whispertoastcontroller'; 25 | 26 | import goldenLayoutDragSource from './directives/goldenlayoutdragsourcedirective'; 27 | import { chatLineDirective, isntEmptyFilter, compileDirective } from './directives/chatlinedirective'; 28 | import onScrollDirective from './directives/onscrolldirective'; 29 | import buttonSettingsDirective from './directives/buttonsettingsdirective'; 30 | import streamListDirective from './directives/streamlistdirective'; 31 | import draggableDirective from './directives/draggabledirective'; 32 | import simpleScrollbarDirective from './directives/simplescrolldirective'; 33 | import autocompleteDirective from './directives/autocompletedirective'; 34 | import { throttledMousemoveDirective, throttledClickDirective, throttledKeydownDirective, throttledUserScrollDirective } from './directives/throttledevents'; 35 | import { timeAgoFilter, largeNumberFilter, durationFilter, uniqueFilter } from './directives/filters'; 36 | import dynamicStylesheetDirective from './directives/dynamicstylesheetdirective'; 37 | 38 | import ApiService from './services/apiservice'; 39 | import ChatService from './services/chatservice'; 40 | import KeyPressService from './services/keypressservice'; 41 | import ToastService from './services/toastservice'; 42 | import ThrottledDigestService from './services/throttleddigestservice'; 43 | import FFZSocketService from './services/ffzsocketservice'; 44 | 45 | import registerDarkMode from './themes/dark'; 46 | import registerLightMode from './themes/light'; 47 | 48 | const app = angular.module('mtApp', [angularAria, angularAnimate, angularMaterial, angularCookies, 'ui.sortable', 'angular-click-outside']); 49 | 50 | app.controller('MainController', MainController); 51 | app.controller('HomeController', HomeController); 52 | app.controller('WhisperController', WhisperController); 53 | app.controller('ChatController', ChatController); 54 | app.controller('StreamController', StreamController); 55 | app.controller('DialogController', DialogController); 56 | app.controller('SettingsDialogController', SettingsDialogController); 57 | app.controller('ButtonSettingsController', ButtonSettingsController); 58 | app.controller('StreamListController', StreamListController); 59 | app.controller('AutocompletePanelController', AutocompletePanelController); 60 | app.controller('WhisperToastController', WhisperToastController); 61 | 62 | app.directive('goldenLayoutDragSource', goldenLayoutDragSource); 63 | app.directive('chatLine', chatLineDirective); 64 | app.directive('onScroll', onScrollDirective); 65 | app.directive('buttonSettings', buttonSettingsDirective); 66 | app.directive('streamList', streamListDirective); 67 | app.directive('draggable', draggableDirective); 68 | app.directive('throttledMousemove', throttledMousemoveDirective); 69 | app.directive('throttledClick', throttledClickDirective); 70 | app.directive('throttledKeydown', throttledKeydownDirective); 71 | app.directive('throttledUserScroll', throttledUserScrollDirective); 72 | app.directive('simpleScrollbar', simpleScrollbarDirective); 73 | app.directive('autocomplete', autocompleteDirective); 74 | app.directive('compile', compileDirective); 75 | app.directive('dynamicStylesheet', dynamicStylesheetDirective); 76 | 77 | app.service('ApiService', ApiService); 78 | app.service('ChatService', ChatService); 79 | app.service('KeyPressService', KeyPressService); 80 | app.service('ToastService', ToastService); 81 | app.service('ThrottledDigestService', ThrottledDigestService); 82 | app.service('FFZSocketService', FFZSocketService); 83 | 84 | app.filter('isntEmpty', isntEmptyFilter); 85 | app.filter('timeAgo', timeAgoFilter); 86 | app.filter('largeNumber', largeNumberFilter); 87 | app.filter('duration', durationFilter); 88 | app.filter('unique', uniqueFilter); 89 | 90 | app.config($mdThemingProvider => { 91 | 'ngInject'; 92 | 93 | $mdThemingProvider.alwaysWatchTheme(true); 94 | registerDarkMode($mdThemingProvider); 95 | registerLightMode($mdThemingProvider); 96 | 97 | // $mdPanel.newPanelGroup('autocomplete', { maxOpen: 1 }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/js/languages.json: -------------------------------------------------------------------------------- 1 | { 2 | "ab": "Abkhaz", 3 | "aa": "Afar", 4 | "af": "Afrikaans", 5 | "ak": "Akan", 6 | "sq": "Albanian", 7 | "am": "Amharic", 8 | "ar": "Arabic", 9 | "an": "Aragonese", 10 | "hy": "Armenian", 11 | "as": "Assamese", 12 | "av": "Avaric", 13 | "ae": "Avestan", 14 | "ay": "Aymara", 15 | "az": "South", 16 | "bm": "Bambara", 17 | "ba": "Bashkir", 18 | "eu": "Basque", 19 | "be": "Belarusian", 20 | "bn": "Bengali", 21 | "bh": "Bihari", 22 | "bi": "Bislama", 23 | "bs": "Bosnian", 24 | "br": "Breton", 25 | "bg": "Bulgarian", 26 | "my": "Burmese", 27 | "ca": "Catalan", 28 | "ch": "Chamorro", 29 | "ce": "Chechen", 30 | "ny": "Chichewa", 31 | "zh": "Chinese", 32 | "cv": "Chuvash", 33 | "kw": "Cornish", 34 | "co": "Corsican", 35 | "cr": "Cree", 36 | "hr": "Croatian", 37 | "cs": "Czech", 38 | "da": "Danish", 39 | "dv": "Divehi", 40 | "nl": "Dutch", 41 | "dz": "Dzongkha", 42 | "en": "English", 43 | "eo": "Esperanto", 44 | "et": "Estonian", 45 | "ee": "Ewe", 46 | "fo": "Faroese", 47 | "fj": "Fijian", 48 | "fi": "Finnish", 49 | "fr": "French", 50 | "ff": "Fula", 51 | "gl": "Galician", 52 | "ka": "Georgian", 53 | "de": "German", 54 | "el": "Greek", 55 | "gn": "Guarana", 56 | "gu": "Gujarati", 57 | "ht": "Haitian", 58 | "ha": "Hausa", 59 | "he": "Hebrew", 60 | "hz": "Herero", 61 | "hi": "Hindi", 62 | "ho": "Hiri", 63 | "hu": "Hungarian", 64 | "ia": "Interlingua", 65 | "id": "Indonesian", 66 | "ie": "Interlingue", 67 | "ga": "Irish", 68 | "ig": "Igbo", 69 | "ik": "Inupiaq", 70 | "io": "Ido", 71 | "is": "Icelandic", 72 | "it": "Italian", 73 | "iu": "Inuktitut", 74 | "ja": "Japanese", 75 | "jv": "Javanese", 76 | "kl": "Kalaallisut", 77 | "kn": "Kannada", 78 | "kr": "Kanuri", 79 | "ks": "Kashmiri", 80 | "kk": "Kazakh", 81 | "km": "Khmer", 82 | "ki": "Kikuyu", 83 | "rw": "Kinyarwanda", 84 | "ky": "Kyrgyz", 85 | "kv": "Komi", 86 | "kg": "Kongo", 87 | "ko": "Korean", 88 | "ku": "Kurdish", 89 | "kj": "Kwanyama", 90 | "la": "Latin", 91 | "lb": "Luxembourgish", 92 | "lg": "Ganda", 93 | "li": "Limburgish", 94 | "ln": "Lingala", 95 | "lo": "Lao", 96 | "lt": "Lithuanian", 97 | "lu": "Luba", 98 | "lv": "Latvian", 99 | "gv": "Manx", 100 | "mk": "Macedonian", 101 | "mg": "Malagasy", 102 | "ms": "Malay", 103 | "ml": "Malayalam", 104 | "mt": "Maltese", 105 | "mi": "M", 106 | "mr": "Marathi", 107 | "mh": "Marshallese", 108 | "mn": "Mongolian", 109 | "na": "Nauru", 110 | "nv": "Navajo", 111 | "nb": "Norwegian", 112 | "nd": "North", 113 | "ne": "Nepali", 114 | "ng": "Ndonga", 115 | "nn": "Norwegian", 116 | "no": "Norwegian", 117 | "ii": "Nuosu", 118 | "nr": "South", 119 | "oc": "Occitan", 120 | "oj": "Ojibwe", 121 | "cu": "Old", 122 | "om": "Oromo", 123 | "or": "Oriya", 124 | "os": "Ossetian", 125 | "pa": "Panjabi", 126 | "pi": "P", 127 | "fa": "Persian", 128 | "pl": "Polish", 129 | "ps": "Pashto", 130 | "pt": "Portuguese", 131 | "qu": "Quechua", 132 | "rm": "Romansh", 133 | "rn": "Kirundi", 134 | "ro": "Romanian", 135 | "ru": "Russian", 136 | "sa": "Sanskrit", 137 | "sc": "Sardinian", 138 | "sd": "Sindhi", 139 | "se": "Northern", 140 | "sm": "Samoan", 141 | "sg": "Sango", 142 | "sr": "Serbian", 143 | "gd": "Scottish", 144 | "sn": "Shona", 145 | "si": "Sinhala", 146 | "sk": "Slovak", 147 | "sl": "Slovene", 148 | "so": "Somali", 149 | "st": "Southern", 150 | "es": "Spanish", 151 | "su": "Sundanese", 152 | "sw": "Swahili", 153 | "ss": "Swati", 154 | "sv": "Swedish", 155 | "ta": "Tamil", 156 | "te": "Telugu", 157 | "tg": "Tajik", 158 | "th": "Thai", 159 | "ti": "Tigrinya", 160 | "bo": "Tibetan", 161 | "tk": "Turkmen", 162 | "tl": "Tagalog", 163 | "tn": "Tswana", 164 | "to": "Tonga", 165 | "tr": "Turkish", 166 | "ts": "Tsonga", 167 | "tt": "Tatar", 168 | "tw": "Twi", 169 | "ty": "Tahitian", 170 | "ug": "Uyghur", 171 | "uk": "Ukrainian", 172 | "ur": "Urdu", 173 | "uz": "Uzbek", 174 | "ve": "Venda", 175 | "vi": "Vietnamese", 176 | "wa": "Walloon", 177 | "cy": "Welsh", 178 | "wo": "Wolof", 179 | "fy": "Western", 180 | "xh": "Xhosa", 181 | "yi": "Yiddish", 182 | "yo": "Yoruba", 183 | "za": "Zhuang", 184 | "zu": "Zulu", 185 | "zh-hk": "Chinese (Hong Kong)" 186 | } 187 | -------------------------------------------------------------------------------- /src/js/migrations.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { genNonce } from './helpers'; 3 | 4 | export const configMigrations = [ 5 | config => { 6 | console.log('Applying chatPreset id migration'); 7 | _.each(config.settings.chatPresets, preset => { 8 | if (!preset.id) preset.id = genNonce(); 9 | }); 10 | }, 11 | config => { 12 | console.log('Applying twitchnotify filter migration'); 13 | const chatPreset = _.find(config.settings.chatPresets, { id: 'default-chat' }); 14 | if (chatPreset && chatPreset.settings.messageFilters.indexOf('subs') === -1) { 15 | chatPreset.settings.messageFilters.push('subs'); 16 | } 17 | }, 18 | config => { 19 | console.log('Applying extra mentions migration'); 20 | config.settings.chatSettings.extraMentions = _.map(config.settings.chatSettings.extraMentions, mention => { 21 | let data = mention; 22 | let isRegex = /[\\^$.|?*+(){}[\]]/g.exec(mention); 23 | // only mark valid regexes as such 24 | if (isRegex) { 25 | try { 26 | RegExp(data); 27 | } catch (err) { 28 | isRegex = false; 29 | } 30 | } 31 | let type = isRegex ? 'regex' : 'word'; 32 | let ignoreCase = true; 33 | if (isRegex && data.includes('(?-i)')) ignoreCase = false; 34 | const isSurroundedByWB = /^\\b([^\\^$.|?*+(){}[\]]*)\\b$/.exec(mention); 35 | if (isSurroundedByWB) { 36 | type = 'word'; 37 | data = isSurroundedByWB[1]; 38 | } 39 | return { 40 | type, 41 | data, 42 | ignoreCase 43 | }; 44 | }); 45 | }, 46 | null, 47 | null, 48 | config => { 49 | console.log('Applying button showing migration'); 50 | _.each(config.settings.modButtons, button => { 51 | button.show = button.action.type === 'command' ? 'mod' : 'always'; 52 | }); 53 | _.each(config.settings.modCardButtons, button => { 54 | button.show = button.action.type === 'command' ? 'mod' : 'always'; 55 | }); 56 | _.each(config.settings.chatHeaderButtons, button => { 57 | button.show = 'always'; 58 | }); 59 | }, 60 | config => { 61 | console.log('Applying button bannable migration'); 62 | _.each(config.settings.modButtons, button => { 63 | if (button.action.type === 'command' && button.show === 'mod') { 64 | if (button.action.command.startsWith('/timeout') || button.action.command.startsWith('/ban')) { 65 | button.show = 'bannable'; 66 | } 67 | } 68 | }); 69 | _.each(config.settings.modCardButtons, button => { 70 | if (button.action.type === 'command' && button.show === 'mod') { 71 | if (button.action.command.startsWith('/timeout') || button.action.command.startsWith('/ban')) { 72 | button.show = 'bannable'; 73 | } 74 | } 75 | }); 76 | }, 77 | config => { 78 | console.log('Applying delete message migration'); 79 | _.each(config.settings.modButtons, button => { 80 | if (button.action.type === 'command') { 81 | if (button.action.command === '/timeout {{user.name}} 1') { 82 | button.action.command = '/delete {{tags.id}}'; 83 | } 84 | } 85 | }); 86 | }, 87 | config => { 88 | console.log('Applying delete message migration'); 89 | _.each(config.settings.modButtons, button => { 90 | if (button.action.type === 'command') { 91 | if (button.action.command === '/timeout {{user.name}} 1') { 92 | button.action.command = '/delete {{tags.id}}'; 93 | } 94 | if (button.action.command === '/delete {{tags.id}} 1') { 95 | button.action.command = '/delete {{tags.id}}'; 96 | } 97 | } 98 | }); 99 | } 100 | ]; 101 | 102 | export const layoutMigrations = []; 103 | 104 | export function migrateConfig(config) { 105 | if (!config.version) { 106 | config.version = 0; 107 | } 108 | while (config.version < configMigrations.length) { 109 | const migration = configMigrations[config.version]; 110 | if (migration) migration(config); 111 | config.version++; 112 | } 113 | } 114 | 115 | export function migrateLayouts(layouts) { 116 | _.each(layouts, layout => { 117 | if (!layout.version) { 118 | layout.version = 0; 119 | } 120 | while (layout.version < layoutMigrations.length) { 121 | const migration = layoutMigrations[layout.version]; 122 | if (migration) migration(layout); 123 | layout.version++; 124 | } 125 | }); 126 | } 127 | 128 | -------------------------------------------------------------------------------- /src/js/services/apiservice.js: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | 3 | export default class ApiService { 4 | constructor($http) { 5 | 'ngInject'; 6 | 7 | this.$http = $http; 8 | 9 | this.userCache = {}; // maps userID to a user object 10 | this.userPromises = {}; // maps a userID to a promise 11 | } 12 | 13 | get(url) { 14 | return this.$http.get(url); 15 | } 16 | 17 | twitchGet(endpoint, headers, token, query) { 18 | this.nothing = false; 19 | if (!headers) headers = {}; 20 | headers['Client-ID'] = config.auth.client_id; 21 | if (token) headers.Authorization = `OAuth ${token}`; 22 | if (!headers.Accept) headers.Accept = 'application/vnd.twitchtv.v5+json'; 23 | return this.$http.get(endpoint, { headers, params: query }); 24 | } 25 | 26 | twitchPost(endpoint, body, headers, token) { 27 | this.nothing = false; 28 | if (!headers) headers = {}; 29 | headers['Client-ID'] = config.auth.client_id; 30 | if (token) headers.Authorization = `OAuth ${token}`; 31 | if (!headers.Accept) headers.Accept = 'application/vnd.twitchtv.v5+json'; 32 | return this.$http.post(endpoint, body, { headers }); 33 | } 34 | 35 | twitchGetUserByName(name) { 36 | if (/^\w+$/.test(name)) { 37 | return this.twitchGet(`https://api.twitch.tv/kraken/users/?login=${name}`).then(response => { 38 | if (response.data.users) { 39 | return response.data.users[0]; 40 | } 41 | throw new Error(`User ${name} not found`); 42 | }); 43 | } 44 | throw new Error(`Invalid username ${name}`); 45 | } 46 | 47 | twitchGetUserByID(userID) { 48 | if (/^\d+$/.test(userID)) { 49 | if (!this.userPromises[userID]) { 50 | this.userPromises[userID] = this.twitchGet(`https://api.twitch.tv/kraken/users/${userID}`).then(response => { 51 | if (response.data) { 52 | this.userCache[userID] = response.data; 53 | return response.data; 54 | } 55 | throw new Error(`User ${userID} not found`); 56 | }); 57 | } 58 | return this.userPromises[userID]; 59 | } 60 | throw new Error(`Invalid user ID ${userID}`); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/js/services/chatservice.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import raven from 'raven-js'; 3 | import { EventEmitter } from 'events'; 4 | 5 | import { parseIRCMessage, jsonParseRecursive, sdbmCode, capitalizeFirst, genNonce, escapeHtml, formatTimeout, instantiateRegex, alwaysResolve, getFullName, safeLink, globalModTypes } from '../helpers'; 6 | import urlRegex from '../urlRegex'; 7 | 8 | const DEFAULTCOLORS = ['#e391b8', '#e091ce', '#da91de', '#c291db', '#ab91d9', '#9691d6', '#91a0d4', '#91b2d1', '#91c2cf', '#91ccc7', '#91c9b4', '#90c7a2', '#90c492', '#9dc290', '#aabf8f', '#b5bd8f', '#bab58f', '#b8a68e', '#b5998e', '#b38d8d']; 9 | export default class ChatService extends EventEmitter { 10 | constructor(ApiService, ThrottledDigestService, $sce, FFZSocketService) { 11 | 'ngInject'; 12 | 13 | super(); 14 | this.ApiService = ApiService; 15 | this.ThrottledDigestService = ThrottledDigestService; 16 | this.FFZSocketService = FFZSocketService; 17 | this.$sce = $sce; 18 | 19 | this.chatReceiveConnection = null; 20 | this.chatSendConnection = null; 21 | this.pubsubConnection = null; 22 | this.user = null; 23 | this.joinedChannels = {}; 24 | this.channelObjs = []; 25 | 26 | this.badges = {}; 27 | this.getBadges(); 28 | 29 | 30 | this.emotes = []; 31 | this.thirdPartyEmotes = new Map(); 32 | this.cheermotes = new Map(); 33 | 34 | this.channelEmotes = new Map(); 35 | this.emotesPromise = {}; 36 | 37 | this.globalUserState = {}; 38 | } 39 | 40 | init(user) { 41 | this.user = user; 42 | this.emotesPromise.global = this.getGlobalEmotes(); 43 | 44 | this.chatReceiveConnection = this.connectWebsocket('wss://irc-ws.chat.twitch.tv:443'); 45 | this.chatSendConnection = this.connectWebsocket('wss://irc-ws.chat.twitch.tv:443'); 46 | this.pubsubConnection = this.connectWebsocket('wss://pubsub-edge.twitch.tv'); 47 | 48 | this.FFZSocketService.setChatService(this); 49 | 50 | this.chatReceiveConnection.then(conn => { 51 | console.log('Chat receive connection opened'); 52 | conn.send('CAP REQ :twitch.tv/tags twitch.tv/commands'); 53 | conn.send(`PASS oauth:${user.token}`); 54 | conn.send(`NICK ${user.name}`); 55 | conn.name = 'chatReceiveConnection'; 56 | 57 | conn.addEventListener('message', event => { 58 | this.handleIRCMessage(conn, event.data); 59 | }); 60 | }); 61 | this.chatSendConnection.then(conn => { 62 | console.log('Chat send connection opened'); 63 | conn.send('CAP REQ :twitch.tv/tags twitch.tv/commands'); 64 | conn.send(`PASS oauth:${user.token}`); 65 | conn.send(`NICK ${user.name}`); 66 | conn.name = 'chatSendConnection'; 67 | 68 | conn.addEventListener('message', event => { 69 | this.handleIRCMessage(conn, event.data); 70 | }); 71 | 72 | window.injectChatMessage = msg => { 73 | this.handleIRCMessage(conn, msg); 74 | }; 75 | }); 76 | this.pubsubConnection.then(conn => { 77 | console.log('Pubsub connection opened'); 78 | this.pubsubSend('LISTEN', [`whispers.${user.id}`]); 79 | 80 | conn.addEventListener('message', event => { 81 | this.handlePubSubMessage(conn, event.data); 82 | }); 83 | 84 | setInterval(() => { 85 | this.pubsubSend('PING', undefined, true); 86 | }, 1000 * 60); 87 | }); 88 | 89 | this.joinChannel({ id: user.id, name: user.name }).then(channelObj => { 90 | this.emit(`join-${channelObj.name}`); 91 | this.emit(`join-${channelObj.id}`); 92 | }); 93 | 94 | this.on('ROOMSTATE', async parsed => { 95 | const channelObj = await this.joinedChannels[parsed.tags['room-id']]; 96 | if (channelObj) _.merge(channelObj.roomState, parsed.tags); 97 | else console.error('ROOMSTATE received for unknown channel', { id: parsed.tags['room-id'], name: parsed.param }); 98 | }); 99 | 100 | this.on('USERSTATE', async parsed => { 101 | const channelObj = await _.find(this.channelObjs, channel => `#${channel.name}` === parsed.param); 102 | if (channelObj) _.merge(channelObj.userState, parsed.tags); 103 | else console.error('USERSTATE received for unknown channel', { name: parsed.param }); 104 | }); 105 | 106 | this.on('GLOBALUSERSTATE', async parsed => { 107 | this.globalUserState = parsed.tags; 108 | }); 109 | } 110 | 111 | handleIRCMessage(connection, message) { 112 | const lines = message.split('\n'); 113 | _.each(lines, line => { 114 | line = line.trim(); 115 | if (line.length === 0) return; 116 | const parsed = parseIRCMessage(line.trim()); 117 | parsed.connection = connection; 118 | this.emit(parsed.command, parsed); 119 | if (parsed.param && parsed.param.length > 0) this.emit(`${parsed.command}-${parsed.param}`, parsed); 120 | 121 | if (parsed.command === 'PING') { 122 | connection.send('PONG'); 123 | } 124 | }); 125 | } 126 | 127 | handlePubSubMessage(connection, message) { 128 | const parsed = jsonParseRecursive(message); 129 | if (parsed.data) { 130 | const msg = parsed.data.message; 131 | if (msg) { 132 | const dataObject = msg.data || msg.data_object; 133 | const msgType = msg.type || dataObject.type; 134 | if (dataObject && msgType) { 135 | this.emit(`${msgType}`, parsed); 136 | } 137 | } 138 | if (parsed.data.topic) { 139 | this.emit(`${parsed.data.topic}`, parsed); 140 | } 141 | } 142 | if (parsed.type && parsed.nonce) { 143 | this.emit(`${parsed.type}-${parsed.nonce}`, parsed); 144 | } 145 | } 146 | 147 | connectWebsocket(url) { 148 | return new Promise(resolve => { 149 | const ws = new WebSocket(url); 150 | ws.addEventListener('open', () => { 151 | resolve(ws); 152 | }); 153 | ws.addEventListener('close', () => { 154 | console.log('Socket closed!', ws); 155 | }); 156 | ws.addEventListener('error', err => { 157 | console.error('Socket encountered an error!', err); 158 | }); 159 | }); 160 | } 161 | 162 | async pubsubSend(type, topics, skipReply) { 163 | const conn = await this.pubsubConnection; 164 | const nonce = genNonce(); 165 | conn.send(JSON.stringify({ type, data: { topics, auth_token: this.user.token }, nonce })); 166 | if (!skipReply) { 167 | return new Promise((resolve, reject) => { 168 | this.once(`RESPONSE-${nonce}`, msg => { 169 | if (msg.error) reject(msg.error); 170 | else resolve(); 171 | }); 172 | }); 173 | } 174 | return Promise.resolve(); 175 | } 176 | 177 | async chatSend(command) { 178 | const conn = await this.chatSendConnection; 179 | conn.send(command); 180 | } 181 | 182 | findChannelByName(name) { 183 | name = name.toLowerCase().replace('#', ''); 184 | return _.find(this.joinedChannels, { name }); 185 | } 186 | 187 | async joinChannel(channelObj) { 188 | // fill up missing properties 189 | if (!channelObj.name) channelObj.name = (await this.ApiService.twitchGetUserByID(channelObj.id)).name; 190 | if (!channelObj.id) channelObj.id = (await this.ApiService.twitchGetUserByName(channelObj.name))._id; 191 | if (!channelObj.roomState) channelObj.roomState = {}; 192 | if (!channelObj.userState) channelObj.userState = {}; 193 | this.FFZSocketService.joinChannel(channelObj); 194 | 195 | if (this.joinedChannels[channelObj.id]) return this.joinedChannels[channelObj.id]; 196 | this.chatReceiveConnection.then(conn => { 197 | conn.send(`JOIN #${channelObj.name}`); 198 | }); 199 | const chatJoinedPromise = new Promise(resolve => { 200 | this.once(`JOIN-#${channelObj.name}`, () => { 201 | resolve(); 202 | }); 203 | }); 204 | const pubsubJoinedPromise = this.pubsubSend('LISTEN', [`chat_moderator_actions.${this.user.id}.${channelObj.id}`]); 205 | const channelJoinedPromise = Promise.all([chatJoinedPromise, pubsubJoinedPromise]).then(() => channelObj); 206 | this.joinedChannels[channelObj.id] = channelJoinedPromise; 207 | this.emotesPromise[channelObj.id] = this.getChannelEmotes(channelObj); 208 | this.channelObjs.push(channelObj); 209 | return channelJoinedPromise; 210 | } 211 | 212 | // chat rendering tools 213 | 214 | getBadges(channelID) { 215 | let resource = 'global'; 216 | if (channelID) resource = `channels/${channelID}`; 217 | if (this.badges[resource]) return this.badges[resource]; 218 | const channelBadgesPromise = this.ApiService.twitchGet(`https://badges.twitch.tv/v1/badges/${resource}/display?language=en`).then(response => { 219 | if (response.data) return response.data.badge_sets; 220 | console.error(`Couldnt load badges for channel ${channelID}`, response); 221 | return {}; 222 | }); 223 | if (resource === 'global') { 224 | this.badges[resource] = channelBadgesPromise; 225 | return channelBadgesPromise; 226 | } 227 | this.badges[resource] = Promise.all([this.badges.global, channelBadgesPromise]).then(([globalBadges, channelBadges]) => { 228 | this.badges[resource] = _.merge({}, globalBadges, channelBadges); 229 | return this.badges[resource]; 230 | }); 231 | return this.badges[resource]; 232 | } 233 | 234 | processMessage(message) { 235 | const channelID = message.tags['room-id']; 236 | const channelName = message.param.slice(1); 237 | message.channel = { 238 | id: channelID, 239 | name: channelName 240 | }; 241 | const badgesOrPromise = this.getBadges(channelID); 242 | if (badgesOrPromise.then) { 243 | return badgesOrPromise.then(badges => this._processMessage(message, badges)).catch(err => { 244 | console.error(err); 245 | }); 246 | } 247 | return this._processMessage(message, badgesOrPromise); 248 | } 249 | 250 | _processMessage(message, badges) { 251 | if (!message.time) message.time = new Date(); 252 | if (!message.tags) message.tags = {}; 253 | if (!message.tags.classes) message.tags.classes = []; 254 | 255 | 256 | if (message.prefix) { 257 | const [username] = message.prefix.split('!'); 258 | 259 | const displayName = message.tags['display-name'] || capitalizeFirst(username); 260 | const fullName = getFullName(username, displayName); 261 | 262 | const userID = message.tags['user-id']; 263 | message.user = { 264 | name: username, 265 | id: userID, 266 | displayName, 267 | fullName, 268 | type: message.tags['user-type'], 269 | isMod: (message.tags.mod === '1') || (message.channel && userID === message.channel.id) || globalModTypes.includes(message.tags['user-type']) 270 | }; 271 | let color = message.tags.color; 272 | if (!color || color === '') { 273 | color = DEFAULTCOLORS[sdbmCode(message.user.id || username) % (DEFAULTCOLORS.length)]; 274 | } 275 | message.user.color = color; 276 | } 277 | 278 | if (message.tags.badges) { 279 | message.user.badges = []; 280 | message.tags.badges.split(',').forEach(badgeID => { 281 | const [badgeName, badgeVersion] = badgeID.split('/'); 282 | const badgeSet = badges[badgeName]; 283 | if (badgeSet) { 284 | const versionInfo = badgeSet.versions[badgeVersion]; 285 | if (versionInfo) { 286 | message.user.badges.push({ 287 | url: versionInfo.image_url_1x, 288 | title: versionInfo.title, 289 | name: badgeName 290 | }); 291 | } 292 | } 293 | }); 294 | } 295 | const actionmatch = /^\u0001ACTION (.*)\u0001$/.exec(message.trailing); 296 | if (actionmatch != null) { 297 | message.trailing = actionmatch[1]; 298 | message.tags.classes.push('action-msg'); 299 | } 300 | let html = ''; 301 | if (message.trailing) { 302 | const emotes = []; 303 | if (message.tags.emotes) { 304 | const emoteLists = message.tags.emotes.split('/'); 305 | for (let i = 0; i < emoteLists.length; i++) { 306 | const [emoteid, emotepositions] = emoteLists[i].split(':'); 307 | const positions = emotepositions.split(','); 308 | for (let j = 0; j < positions.length; j++) { 309 | let [start, end] = positions[j].split('-'); 310 | start = parseInt(start, 10); 311 | end = parseInt(end, 10); 312 | emotes.push({ 313 | start, 314 | end, 315 | id: emoteid 316 | }); 317 | } 318 | } 319 | } 320 | html = this.renderMessage(message, emotes); 321 | } 322 | if (message.type === 'timeout' || message.type === 'ban') { 323 | html += escapeHtml(formatTimeout(message)); 324 | } 325 | 326 | if (message.systemMsg) { 327 | message.systemHtml = message.systemMsg.replace(/^\w+\b/, match => `${match}`); 328 | } 329 | message.html = html; 330 | 331 | return message; 332 | } 333 | 334 | renderWord(message, word) { 335 | const holder = message.channel && this.channelEmotes.get(message.channel.id); 336 | const emote = this.thirdPartyEmotes.get(word) || (holder && holder.thirdPartyEmotes.get(word)) || message.channel && this.FFZSocketService.getChannelFeaturedEmotes(message.channel.name)[word]; 337 | if (emote) { 338 | return `${emote.code}`; 339 | } 340 | const bitMatch = /^(\w+?)(\d+)$/.exec(word); 341 | if (bitMatch && message.tags.bits) { 342 | const cheermote = this.cheermotes.get(bitMatch[1]) || (holder && holder.cheermotes.get(bitMatch[1])); 343 | if (cheermote) { 344 | const amount = parseInt(bitMatch[2], 10); 345 | // find the correct tier 346 | const cheerTier = _.findLast(cheermote.tiers, tier => tier.minBits <= amount); 347 | return `${cheerTier.name}` 348 | + `${amount}`; 349 | } 350 | } 351 | const mentionMatch = /^@(\w+)\b(.*)$/.exec(word); 352 | if (mentionMatch) { 353 | return `@${mentionMatch[1]}${escapeHtml(mentionMatch[2])}`; 354 | } 355 | const urlMatch = urlRegex.exec(word); 356 | if (urlMatch) { 357 | return safeLink(urlMatch[0]); 358 | } 359 | return escapeHtml(word); 360 | } 361 | 362 | renderMessage(message, emotes) { 363 | if (!message) return ''; 364 | // replace emotes 365 | const charArray = Array.from(message.trailing); 366 | for (let i = 0; i < emotes.length; ++i) { 367 | const emote = emotes[i]; 368 | const emoteName = charArray.slice(emote.start, emote.end + 1).join(''); 369 | charArray[emote.start] = `${emoteName}`; 370 | for (let k = emote.start + 1; k <= emote.end; ++k) charArray[k] = ''; 371 | } 372 | let html = ''; 373 | let word = ''; 374 | for (let i = 0; i < charArray.length; i++) { 375 | if (charArray[i] === undefined) { 376 | raven.captureMessage('charArray invalid: ', { 377 | extra: { 378 | message, 379 | charArray 380 | } 381 | }); 382 | } else if (charArray[i] === ' ') { 383 | html += `${this.renderWord(message, word)} `; 384 | word = ''; 385 | } else if (charArray[i].length > 5) { 386 | // pass through any HTML from twitch emotes 387 | html += `${this.renderWord(message, word)}`; 388 | html += charArray[i]; 389 | word = ''; 390 | } else { 391 | word += charArray[i]; 392 | } 393 | } 394 | html += this.renderWord(message, word); 395 | return html; 396 | } 397 | 398 | getGlobalEmotes() { 399 | return Promise.all([ 400 | alwaysResolve(this.getGlobalTwitchEmotes()), 401 | alwaysResolve(this.getGlobalFFZEmotes()), 402 | alwaysResolve(this.getGlobalBTTVEmotes()), 403 | alwaysResolve(this.getGlobalBits()) 404 | ]); 405 | } 406 | 407 | getChannelEmotes(channelObj) { 408 | const emoteHolder = { 409 | cheermotes: new Map(), 410 | thirdPartyEmotes: new Map(), 411 | emotes: [] 412 | }; 413 | this.channelEmotes.set(channelObj.id, emoteHolder); 414 | return Promise.all([ 415 | alwaysResolve(this.getChannelBits(channelObj, emoteHolder)), 416 | alwaysResolve(this.getChannelFFZEmotes(channelObj, emoteHolder)), 417 | alwaysResolve(this.getChannelBTTVEmotes(channelObj, emoteHolder)) 418 | ]); 419 | } 420 | 421 | getGlobalTwitchEmotes() { 422 | return this.ApiService.twitchGet(`https://api.twitch.tv/v5/users/${this.user.id}/emotes?on_site=1`, null, this.user.token).then(response => { 423 | _.each(response.data.emoticon_sets, (emoteSet, emoteSetId) => { 424 | _.each(emoteSet, emote => { 425 | emote.url = `https://static-cdn.jtvnw.net/emoticons/v1/${emote.id}/1.0`; 426 | emote.origin = 'twitch global'; 427 | emote.setID = emoteSetId; 428 | emote.code = instantiateRegex(emote.code); 429 | const prefixMatch = /^([a-z0-9]+|:-)([A-Z0-9]\w*)$/.exec(emote.code); 430 | if (prefixMatch) { 431 | emote.prefix = prefixMatch[1]; 432 | emote.prefixless = prefixMatch[2].toLowerCase(); 433 | } 434 | this.emotes.push(emote); 435 | }); 436 | }); 437 | this.emotes = _.sortBy(this.emotes, 'code'); 438 | }).catch(() => { 439 | 440 | }); 441 | } 442 | 443 | getGlobalFFZEmotes() { 444 | return this.ApiService.get('https://api.frankerfacez.com/v1/set/global').then(response => { 445 | _.each(response.data.default_sets, setID => { 446 | _.each(response.data.sets[setID].emoticons, emote => { 447 | const emoteObj = { 448 | id: emote.id, 449 | url: _.findLast(emote.urls), 450 | code: emote.name, 451 | origin: 'ffz global', 452 | setID 453 | }; 454 | this.emotes.push(emoteObj); 455 | this.thirdPartyEmotes.set(emote.name, emoteObj); 456 | }); 457 | }); 458 | }).catch(() => { 459 | 460 | }); 461 | } 462 | 463 | getGlobalBTTVEmotes() { 464 | return this.ApiService.get('https://api.betterttv.net/2/emotes').then(response => { 465 | _.each(response.data.emotes, emote => { 466 | const emoteObj = { 467 | id: emote.id, 468 | url: response.data.urlTemplate.replace('{{id}}', emote.id).replace('{{image}}', '1x'), 469 | code: emote.code, 470 | origin: 'bttv global', 471 | setID: 'global' 472 | }; 473 | this.emotes.push(emoteObj); 474 | this.thirdPartyEmotes.set(emote.code, emoteObj); 475 | }); 476 | }).catch(() => { 477 | 478 | }); 479 | } 480 | 481 | getGlobalBits() { 482 | return this.ApiService.twitchGet('https://api.twitch.tv/kraken/bits/actions').then(response => { 483 | _.each(response.data.actions, cheermote => { 484 | const scale = _.last(cheermote.scales); 485 | const background = 'dark'; 486 | const state = 'animated'; 487 | const cheerObj = { 488 | name: cheermote.prefix, 489 | id: cheermote.prefix.toLowerCase(), 490 | tiers: _.sortBy(_.map(cheermote.tiers, tier => ({ 491 | name: `${cheermote.prefix} ${tier.min_bits}`, 492 | minBits: tier.min_bits, 493 | url: tier.images[background][state][scale], 494 | color: tier.color 495 | })), 'minBits') 496 | }; 497 | this.cheermotes.set(cheerObj.id, cheerObj); 498 | }); 499 | }).catch(() => { 500 | 501 | }); 502 | } 503 | 504 | getChannelFFZEmotes(channelObj, holder) { 505 | return this.ApiService.get(`https://api.frankerfacez.com/v1/room/id/${channelObj.id}`).then(response => { 506 | _.each(response.data.sets, (set, setID) => { 507 | _.each(set.emoticons, emote => { 508 | const emoteObj = { 509 | id: emote.id, 510 | url: _.findLast(emote.urls), 511 | code: emote.name, 512 | origin: channelObj, 513 | setID 514 | }; 515 | holder.emotes.push(emoteObj); 516 | holder.thirdPartyEmotes.set(emote.name, emoteObj); 517 | }); 518 | }); 519 | }).catch(() => { 520 | 521 | }); 522 | } 523 | 524 | getChannelBTTVEmotes(channelObj, holder) { 525 | return this.ApiService.get(`https://api.betterttv.net/2/channels/${channelObj.name}`).then(response => { 526 | _.each(response.data.emotes, emote => { 527 | const emoteObj = { 528 | id: emote.id, 529 | url: response.data.urlTemplate.replace('{{id}}', emote.id).replace('{{image}}', '1x'), 530 | code: emote.code, 531 | origin: channelObj, 532 | setID: 'global' 533 | }; 534 | holder.emotes.push(emoteObj); 535 | holder.thirdPartyEmotes.set(emote.code, emoteObj); 536 | }); 537 | }).catch(() => { 538 | 539 | }); 540 | } 541 | 542 | getChannelBits(channelObj, holder) { 543 | return this.ApiService.twitchGet(`https://api.twitch.tv/kraken/bits/actions?channel_id=${channelObj.id}`).then(response => { 544 | _.each(response.data.actions, cheermote => { 545 | if (cheermote.type === 'channel_custom') { 546 | const scale = _.last(cheermote.scales); 547 | const background = 'dark'; 548 | const state = 'animated'; 549 | const cheerObj = { 550 | name: cheermote.prefix, 551 | id: cheermote.prefix.toLowerCase(), 552 | tiers: _.sortBy(_.map(cheermote.tiers, tier => ({ 553 | name: `${cheermote.prefix} ${tier.min_bits}`, 554 | minBits: tier.min_bits, 555 | url: tier.images[background][state][scale], 556 | color: tier.color 557 | })), 'minBits') 558 | }; 559 | holder.cheermotes.set(cheerObj.id, cheerObj); 560 | } 561 | }); 562 | }).catch(() => { 563 | 564 | }); 565 | } 566 | } 567 | -------------------------------------------------------------------------------- /src/js/services/ffzsocketservice.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export default class FFZSocketService { 4 | constructor($http) { 5 | 'ngInject'; 6 | 7 | this.$http = $http; 8 | this.ChatService = null; 9 | 10 | this.socket = null; 11 | this.socketPromise = null; 12 | this.messageID = 4; 13 | this.debounce = 1; 14 | this.lastReconnect = 0; 15 | 16 | this.followSets = {}; 17 | this.emoteSets = {}; 18 | this.channelFeaturedEmotes = {}; 19 | } 20 | 21 | setChatService(ChatService) { 22 | this.ChatService = ChatService; 23 | this.connect(); 24 | } 25 | 26 | connect() { 27 | this.socket = new WebSocket('wss://catbag.frankerfacez.com'); 28 | 29 | this.socketPromise = new Promise(resolve => { 30 | this.socket.addEventListener('open', () => { 31 | console.log('Connected to FFZ socket'); 32 | this.socket.send('1 hello ["modchat-1.0", false]'); 33 | // this.socket.send('2 setuser "cbenni"'); 34 | this.socket.send('3 ready 0'); 35 | resolve(this.socket); 36 | _.each(this.ChatService.channelObjs, channelObj => { 37 | this.joinChannel(channelObj); 38 | }); 39 | }); 40 | }); 41 | 42 | this.socket.addEventListener('message', message => { 43 | console.log('Received FFZ socket message', message); 44 | const [, command, data] = message.data.split(' '); 45 | if (!data) return; 46 | const parsedData = JSON.parse(data); // {lordmau5:[123,345,567]} 47 | 48 | if (command === 'follow_sets') { 49 | _.merge(this.followSets, parsedData); // followSets = {lordmau5:[123,345,567], cbenni: [123]} 50 | 51 | _.each(parsedData, async (emoteSets, channelName) => { // emoteSets = [123,345,567], channelName = lordmau5 52 | const channelFeaturedEmotes = await Promise.all(_.map(emoteSets, async emoteSet => { // emoteSet = 123 53 | if (!this.emoteSets[emoteSet]) { 54 | const result = await this.$http.get(`https://api.frankerfacez.com/v1/set/${emoteSet}`); 55 | this.emoteSets[emoteSet] = _.map(result.data.set.emoticons, emote => ({ 56 | id: emote.id, 57 | url: _.find(emote.urls), 58 | code: emote.name, 59 | origin: 'ffz featured', 60 | setID: emoteSet 61 | })); // [{...emote...}] 62 | } 63 | return this.emoteSets[emoteSet]; 64 | })).then(_.flatten); // [[{...emote...}],[{...emote...}],[{...emote...}]] -> [[{...emote...}]] 65 | const emoteMap = {}; 66 | _.each(channelFeaturedEmotes, emote => { 67 | emoteMap[emote.code] = emote; 68 | }); 69 | this.channelFeaturedEmotes[channelName] = emoteMap; 70 | }); 71 | } 72 | }); 73 | 74 | this.socket.addEventListener('close', reason => { 75 | console.log('FFZ socket disconnected:', reason); 76 | if (!reason.wasClean) { 77 | if (Date.now() - this.lastReconnect > 120000) this.debounce = 1; 78 | setTimeout(() => { 79 | this.connect(); 80 | }, this.debounce * 1000); 81 | this.debounce *= 2; 82 | this.lastReconnect = Date.now(); 83 | } 84 | }); 85 | } 86 | 87 | joinChannel(channelObj) { 88 | this.send(`sub "room.${channelObj.name}"`); 89 | } 90 | 91 | getChannelFeaturedEmotes(channelName) { 92 | return this.channelFeaturedEmotes[channelName] || {}; 93 | } 94 | 95 | async send(message) { 96 | (await this.socketPromise).send(`${this.messageID++} ${message}`); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/js/services/keypressservice.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'lodash'; 3 | 4 | export default class KeyPressService { 5 | constructor($document) { 6 | 'ngInject'; 7 | 8 | this.keysPressed = {}; 9 | this.keyWatchers = {}; 10 | 11 | $document.on('keyup keydown', $event => { 12 | const event = $event.originalEvent; 13 | if (event.target) { 14 | if ($(event.target).parents('.no-global-hotkeys').length > 0) return; 15 | } 16 | this.emit(event); 17 | }); 18 | } 19 | 20 | on(key, callback, priority = 0) { 21 | if (!this.keyWatchers[key]) this.keyWatchers[key] = []; 22 | const watcher = { priority, callback }; 23 | this.keyWatchers[key].push(watcher); 24 | this.keyWatchers[key] = _.orderBy(this.keyWatchers[key], ['priority'], ['desc']); 25 | return () => _.pull(this.keyWatchers[key], watcher); 26 | } 27 | 28 | emit(event) { 29 | if (!event.code) return; 30 | let handledBy = null; 31 | if (event.type === 'keyup') this.keysPressed[event.code] = undefined; 32 | else if (event.type === 'keydown') this.keysPressed[event.code] = 0; 33 | _.each(this.keyWatchers[event.code], watcher => { 34 | if (watcher.callback(event)) { 35 | if (event.type === 'keydown') this.keysPressed[event.code] = watcher.priority; 36 | handledBy = watcher.priority; 37 | return false; 38 | } 39 | return true; 40 | }); 41 | if (handledBy === null) { 42 | _.each(this.keyWatchers[event.type], watcher => { 43 | if (watcher.callback(event)) { 44 | if (event.type === 'keydown') this.keysPressed[event.code] = watcher.priority; 45 | return false; 46 | } 47 | return true; 48 | }); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/js/services/throttleddigestservice.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | function isParentOf(scopeA, scopeB) { 4 | if (!scopeA || !scopeB) return false; 5 | if (scopeA === scopeB) return true; 6 | return scopeB.$parent && isParentOf(scopeA, scopeB.$parent); 7 | } 8 | 9 | export default class ThrottledDigestService { 10 | constructor($document, $rootScope) { 11 | 'ngInject'; 12 | 13 | this.$rootScope = $rootScope; 14 | this.raf = null; 15 | this.scopesToDigest = []; 16 | this.scheduled = []; 17 | this.alwaysScheduled = []; 18 | this.unique = {}; 19 | } 20 | 21 | $apply(scope, fun) { 22 | if (fun) fun(); 23 | let inserted = false; 24 | for (let i = 0; i < this.scopesToDigest.length; ++i) { 25 | if (isParentOf(scope, this.scopesToDigest[i])) { 26 | if (inserted) { 27 | this.scopesToDigest[i] = null; 28 | } else { 29 | this.scopesToDigest[i] = scope; 30 | inserted = true; 31 | } 32 | } else if (isParentOf(this.scopesToDigest[i], scope)) { 33 | if (inserted) { 34 | console.error('Double digest: ', this.scopesToDigest); 35 | console.error('With scope added: ', scope); 36 | } else inserted = true; 37 | } 38 | } 39 | if (!inserted) this.scopesToDigest.push(scope); 40 | if (!this.raf) { 41 | this.raf = window.requestAnimationFrame(() => { 42 | // console.log('Starting RAF.'); 43 | _.each(this.scopesToDigest, scopeToDigest => { 44 | if (scopeToDigest) { 45 | // console.log('Digesting ', scopeToDigest); 46 | scopeToDigest.$digest(); 47 | } 48 | }); 49 | _.each(this.scheduled, scheduled => { scheduled(); }); 50 | _.each(this.alwaysScheduled, always => { always(); }); 51 | this.scheduled = []; 52 | this.unique = {}; 53 | this.raf = null; 54 | // console.log('RAF done.'); 55 | }); 56 | } 57 | } 58 | 59 | schedule(scope, fun) { 60 | this.scheduled.push(fun); 61 | this.$apply(scope); 62 | } 63 | 64 | scheduleOnce(id, fun) { 65 | if (this.unique[id]) return; 66 | this.schedule(fun); 67 | this.unique[id] = true; 68 | } 69 | 70 | always(fun) { 71 | this.alwaysScheduled.push(fun); 72 | return () => { 73 | _.pull(this.alwaysScheduled, fun); 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/js/services/toastservice.js: -------------------------------------------------------------------------------- 1 | import objectToastTemplate from '../../templates/objecttoast.html'; 2 | 3 | export default class ToastService { 4 | constructor($mdToast) { 5 | 'ngInject'; 6 | 7 | this.$mdToast = $mdToast; 8 | } 9 | 10 | showToast(obj) { 11 | if (typeof (obj) === 'object') { 12 | this.$mdToast.show({ 13 | template: objectToastTemplate, 14 | scope: { 15 | message: 'Success!', 16 | items: obj 17 | } 18 | }); 19 | } else { 20 | this.$mdToast.show(this.$mdToast.simple().textContent(obj).hideDelay(3000)); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/js/themes/dark.js: -------------------------------------------------------------------------------- 1 | export default function registerDarkMode($mdThemingProvider) { 2 | $mdThemingProvider.theme('dark') 3 | .primaryPalette('grey', { default: '900' }) 4 | .accentPalette('grey', { default: '700' }) 5 | .dark(); 6 | } 7 | -------------------------------------------------------------------------------- /src/js/themes/light.js: -------------------------------------------------------------------------------- 1 | export default function registerLightMode($mdThemingProvider) { 2 | $mdThemingProvider.theme('light'); 3 | } 4 | -------------------------------------------------------------------------------- /src/js/urlRegex.js: -------------------------------------------------------------------------------- 1 | // 2 | // Regular Expression for URL validation 3 | // 4 | // Author: Diego Perini 5 | // Updated: 2010/12/05 6 | // License: MIT 7 | // 8 | // Copyright (c) 2010-2013 Diego Perini (http://www.iport.it) 9 | // 10 | // Permission is hereby granted, free of charge, to any person 11 | // obtaining a copy of this software and associated documentation 12 | // files (the "Software"), to deal in the Software without 13 | // restriction, including without limitation the rights to use, 14 | // copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | // copies of the Software, and to permit persons to whom the 16 | // Software is furnished to do so, subject to the following 17 | // conditions: 18 | // 19 | // The above copyright notice and this permission notice shall be 20 | // included in all copies or substantial portions of the Software. 21 | // 22 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 24 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 25 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 26 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 27 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 28 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 29 | // OTHER DEALINGS IN THE SOFTWARE. 30 | // 31 | // the regular expression composed & commented 32 | // could be easily tweaked for RFC compliance, 33 | // it was expressly modified to fit & satisfy 34 | // these test for an URL shortener: 35 | // 36 | // http://mathiasbynens.be/demo/url-regex 37 | // 38 | // Notes on possible differences from a standard/generic validation: 39 | // 40 | // - utf-8 char class take in consideration the full Unicode range 41 | // - TLDs have been made mandatory so single names like "localhost" fails 42 | // - protocols have been restricted to ftp, http and https only as requested 43 | // 44 | // Changes: 45 | // 46 | // - IP address dotted notation validation, range: 1.0.0.0 - 223.255.255.255 47 | // first and last IP address of each class is considered invalid 48 | // (since they are broadcast/network addresses) 49 | // 50 | // - Added exclusion of private, reserved and/or local networks ranges 51 | // 52 | // - Made starting path slash optional (http://example.com?foo=bar) 53 | // 54 | // - Allow a dot (.) at the end of hostnames (http://example.com.) 55 | // 56 | // Compressed one-line versions: 57 | // 58 | // Javascript version 59 | // 60 | export default /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i; 61 | -------------------------------------------------------------------------------- /src/templates/autocompletetemplate.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | {{item.text}} 5 | 6 | 7 |
8 | -------------------------------------------------------------------------------- /src/templates/buttonsettings.html: -------------------------------------------------------------------------------- 1 | 105 | Add 106 | -------------------------------------------------------------------------------- /src/templates/chatline.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | {{::line.time | date : $parent.mainCtrl.getSetting('timeFormat')}} 7 | 8 | 9 | 10 | {{::modButton.tooltip}} 11 | {{::modButton.icon.code}} 12 | {{::modButton.icon.text}} 13 | 14 | 15 | 16 | 17 | Deny 18 | block 19 | 20 | 21 | Approve 22 | check 23 | 24 | 25 | 26 | 27 | 28 | 29 | {{::line.user.fullName}}: 30 | 31 | 32 | (By: {{chatCtrl.getModlogList(line)}}) 33 | 34 | 35 | 36 | 37 | 38 | 39 |
{{::modlog.created_by}}{{::chatCtrl.getModlogCommand(modlog)}}
40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /src/templates/chatwindow.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 | 8 | {{chatHeaderButton.tooltip}} 9 | {{chatHeaderButton.icon.code}} 10 | {{chatHeaderButton.icon.text}} 11 | 12 | 13 |
14 |
15 | {{chatCtrl.channelObj.name}} 16 |
17 |
18 | 19 | This channel is in {{chatCtrl.getLanguageName(chatCtrl.channelObj.roomState['broadcaster-lang'])}} only mode 20 | {{chatCtrl.channelObj.roomState['broadcaster-lang']}} 21 | 22 | 23 | This channel is in emote-only only mode 24 | 25 | 26 | 27 | This channel is in {{chatCtrl.channelObj.roomState['followers-only'] | duration:'minutes'}} followers-only only 28 | mode 29 | 30 | 31 | {{chatCtrl.channelObj.roomState['followers-only'] | duration:'minutes'}} 32 | 33 | 34 | This channel is in r9k mode 35 | r9k 36 | 37 | 38 | This channel is in {{chatCtrl.channelObj.roomState['slow'] | duration}} slow mode 39 | {{chatCtrl.channelObj.roomState['slow'] | duration}} 40 | 41 | 42 | This channel is in subscriber only mode 43 | $ 44 | 45 |
46 |
47 |
49 |
50 |
51 |
52 |
53 |
Chat is paused because of {{chatCtrl.isPaused}}
54 |
55 | 56 |
57 |
58 | 63 |
64 |
66 | 67 |
68 |
69 | 70 |
71 |
72 |
73 |
74 | 75 | insert_emoticon 76 | 77 |
78 |
79 |
80 | -------------------------------------------------------------------------------- /src/templates/homewindow.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 11 |
12 | 16 | 17 | settings 18 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 | 42 | 43 | Channels I mod (soon™) 44 | 45 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /src/templates/iconpicker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{iconPickerCtrl.icon}} 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/templates/iconpickerpanel.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{icon}} 5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /src/templates/icontemplate.html: -------------------------------------------------------------------------------- 1 | {{icon.code}} 2 | {{icon.text}} 3 | 4 | -------------------------------------------------------------------------------- /src/templates/objecttoast.html: -------------------------------------------------------------------------------- 1 | 2 | {{Message}}
3 | 4 | 5 | 6 | 7 | 8 |
{{key}}{{value}}
9 |
10 | -------------------------------------------------------------------------------- /src/templates/settingsdialog.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |

Settings

6 | 7 | 8 | close 9 | 10 |
11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 |
19 | 20 |
21 | 22 | dark mode 23 | light mode 24 | 25 |
26 |
27 |
28 | 29 |
30 | 31 | 32 | 33 |
34 |
35 |
36 | 37 |
38 | 39 | No correction 40 | Monochromatic 41 | HSL correction 42 | 43 |
44 | 45 | 46 | 47 |
48 |
49 |
50 | 53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 |
62 | 63 | hover 64 | in-line hotkey 65 | ctrl 66 | alt 67 | shift 68 | right ctrl 69 | right alt 70 | right shift 71 | 72 |
73 |
74 |
75 | 76 |
77 | 78 |
79 |
80 |
81 | 82 | 83 | 84 | 85 |
86 |
87 | 88 | 89 | 90 | 91 |
92 | 93 |
94 |
    95 |
  • 96 |
    97 |
    98 | reorder 99 |
    100 |
    101 | 102 | delete 103 | 104 |
    105 |
    106 |
    107 |
    108 | 109 | 110 | 111 | Text (surrounded by word boundaries) 112 | Text (anywhere) 113 | Regular expression 114 | 115 | 116 |
    117 |
    118 | 119 | 120 | 121 |
    122 |
    123 | 124 | case insensitive 125 | 126 |
    127 |
    128 |
  • 129 |
130 |
131 |
132 | 133 | Add 134 | 135 |
136 |
137 |
138 |
139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 |
    157 |
  • 158 |
    159 |
    160 | reorder 161 |
    162 |
    163 | 164 | delete 165 | 166 |
    167 |
    168 |
    169 |
    170 | 171 | 172 | 173 |
    174 |
    175 | 176 | 177 | 178 | Icon 179 | Text 180 | Custom image 181 | 182 | 183 |
    184 |
    185 | 188 | 189 | 190 | 191 | {{iconCode}} 192 | 193 | 194 | 195 |
    196 |
    197 | 198 | 199 | 200 |
    201 |
    202 | 203 | 204 | 205 |
    206 |
    207 |
    208 |
    209 | 210 | 211 | 212 | Chat 213 | Subscriptions 214 | Mentions 215 | Bots 216 | Modlogs 217 | Automod 218 | Bits 219 | 220 | 221 |
    222 | 225 |
    226 | Hide chat input 227 |
    228 |
    229 |
  • 230 |
231 | Add 232 |
233 |
234 | 235 | 236 | Export settings 237 |
238 | Import settings 239 |
240 | Reset settings 241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 | -------------------------------------------------------------------------------- /src/templates/streamlisttemplate.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |
9 | {{preset.name}} 10 | {{preset.icon.code}} 11 | {{preset.icon.text}} 12 | 13 |
14 |
16 | videocam 17 |
18 |
19 |
20 |
LIVE 21 |
22 |
23 |
24 |
{{stream.channel.status}}
25 |
26 | {{stream.viewers}} watching 27 | {{stream.channel.display_name}} playing 28 | {{stream.game}} 29 |
30 |
31 | {{stream.channel.display_name}} 32 |
33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /src/templates/streamwindow.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /src/templates/whispertoasttemplate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | New whisper from {{whisperToastCtrl.conversation.user.fullName}}! 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/templates/whisperwindow.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 |
{{conversation.lastMessage.id - (conversation.lastRead || 0)}} {{conversation.user.fullName}}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | 17 | menu 18 | 19 |
20 |
21 |

{{whisperCtrl.selectedConversation.user.fullName}}

22 |
23 |
24 | 25 | 26 | settings 27 | 28 | 29 | 30 | 31 | Open mod card in channel: 32 | 33 | 34 | 35 | 36 | {{chatCtrl.channelObj.name}} 37 | 38 | 39 | 40 | 41 |
42 |
43 |
44 |
45 |
{{line.date}}
46 | 47 | 48 | {{::line.time | date : $parent.mainCtrl.getSetting('timeFormat')}} 49 | 50 | 51 | 52 | 53 | {{::line.user.fullName}}: 54 | 55 |
56 |
57 |
58 | 59 |
60 |
61 |
62 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 7 | 8 | /* global __dirname */ 9 | function config(env) { 10 | return { 11 | entry: ['babel-polyfill', './src/js/index.js'], // './src/index.js'], 12 | output: { 13 | path: path.resolve(__dirname, `dist/${env}`), 14 | publicPath: '/', 15 | filename: '[name].[chunkhash].js' 16 | }, 17 | devtool: 'source-map', 18 | module: { 19 | rules: [ 20 | { 21 | test: /ui-sortable/, 22 | use: ['imports-loader?$UI=jquery-ui/ui/widgets/sortable'] 23 | }, 24 | { 25 | test: /draggable/, 26 | use: ['imports-loader?$UI=jquery-ui/ui/widgets/draggable'] 27 | }, 28 | { 29 | test: /\.js$/, 30 | exclude: /node_modules(?!\/webpack-dev-server)/, 31 | use: { 32 | loader: 'babel-loader', 33 | options: { 34 | presets: ['env'], 35 | plugins: ['angularjs-annotate', 'babel-polyfill'] 36 | } 37 | } 38 | }, 39 | { 40 | test: /templates/, 41 | use: 'raw-loader' 42 | }, 43 | { 44 | test: /html\/\w+\.html$/, 45 | use: ['file-loader?name=pages/[name].[ext]'], 46 | exclude: path.resolve(__dirname, 'src/html/index.html') 47 | }, 48 | { 49 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 50 | loader: 'url-loader' 51 | }, 52 | { 53 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 54 | loader: 'url-loader' 55 | }, 56 | { 57 | test: /\.scss$/, 58 | use: [{ 59 | loader: 'style-loader' // creates style nodes from JS strings 60 | }, { 61 | loader: 'css-loader' // translates CSS into CommonJS 62 | }, { 63 | loader: 'sass-loader' // compiles Sass to CSS 64 | }] 65 | }, 66 | { 67 | test: /\.less$/, 68 | use: [{ 69 | loader: 'style-loader' // creates style nodes from JS strings 70 | }, { 71 | loader: 'css-loader' // translates CSS into CommonJS 72 | }, { 73 | loader: 'less-loader' // compiles Less to CSS 74 | }] 75 | } 76 | ] 77 | }, 78 | plugins: [ 79 | new CleanWebpackPlugin([`dist/${env}`]), 80 | new HtmlWebpackPlugin({ template: './src/html/index.html' }), 81 | new ExtractTextPlugin({ 82 | filename: '[name].[contenthash].css', 83 | disable: env === 'development' 84 | }), 85 | new CopyWebpackPlugin([{ from: './src/assets', to: 'assets' }], { 86 | devServer: { 87 | outputPath: path.join(__dirname, 'dist/assets') 88 | } 89 | }), 90 | new webpack.ProvidePlugin({ 91 | 'window.jQuery': 'jquery' 92 | }) 93 | ] 94 | }; 95 | } 96 | 97 | module.exports = config; 98 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.config.js')('dev'); 2 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.config.js')('prod'); 2 | -------------------------------------------------------------------------------- /webpack.staging.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.config.js')('staging'); 2 | --------------------------------------------------------------------------------