├── .env ├── .firebaserc ├── .vscode └── settings.json ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── assets │ ├── click.ogg │ ├── laugh.ogg │ ├── baseline-code-24px.svg │ ├── baseline-people-24px.svg │ ├── baseline-launch-24px.svg │ ├── baseline-security-24px.svg │ ├── baseline-build-24px.svg │ ├── baseline-tag_faces-24px.svg │ ├── EmoteIcon.js │ ├── baseline-settings-20px.svg │ ├── close.svg │ ├── banhammer.svg │ └── cxlogo.svg ├── index.css ├── firebase.js ├── modal.css ├── cxemote.css ├── index.js ├── util.js ├── components │ ├── EmoteMenu.js │ ├── ModerationTools.js │ ├── Debug.js │ ├── AdminSettings.js │ ├── ModeratorSettings.js │ ├── GeneralSettings.js │ ├── ChatList.js │ ├── Chat.js │ └── Footer.js ├── logo.svg ├── services │ └── AutoCompleteService.js ├── serviceWorker.js ├── chat.css └── App.js ├── .prettierrc.json ├── firebase.json ├── .gitignore ├── package.json ├── .firebase └── hosting.YnVpbGQ.cache └── README.md /.env: -------------------------------------------------------------------------------- 1 | GENERATE_SOURCEMAP=false 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "cx-chat-204113" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": false 4 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CxNetwork/cx-chat-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/click.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CxNetwork/cx-chat-frontend/HEAD/src/assets/click.ogg -------------------------------------------------------------------------------- /src/assets/laugh.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CxNetwork/cx-chat-frontend/HEAD/src/assets/laugh.ogg -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "printWidth": 95, 4 | "tabWidth": 2, 5 | "singleQuote": false, 6 | "trailingComma": "none", 7 | "jsxBracketSameLine": true, 8 | "parser": "babylon", 9 | "noSemi": false, 10 | "rcVerbose": true 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/baseline-code-24px.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/baseline-people-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/baseline-launch-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/baseline-security-24px.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/assets/baseline-build-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Cx Chat", 3 | "name": "Cx Network: Chat", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#7400ff", 14 | "background_color": "#212121" 15 | } 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: "Open Sans", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from "@firebase/app"; 2 | import "@firebase/auth"; 3 | 4 | let config = { 5 | apiKey: "AIzaSyAIxF2RdkNkDZ-WOVydh3igROrsR0kVPA4", 6 | authDomain: "chat.iceposeidon.com", 7 | databaseURL: "https://cx-chat-204113.firebaseio.com", 8 | projectId: "cx-chat-204113", 9 | storageBucket: "cx-chat-204113.appspot.com", 10 | messagingSenderId: "762730644662" 11 | }; 12 | 13 | firebase.initializeApp(config); 14 | 15 | export default firebase; 16 | 17 | export const auth = firebase.auth(); 18 | -------------------------------------------------------------------------------- /src/assets/baseline-tag_faces-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/EmoteIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default ({ color }) => ( 4 | 5 | 6 | 7 | 8 | ); -------------------------------------------------------------------------------- /src/modal.css: -------------------------------------------------------------------------------- 1 | .generic-modal { 2 | z-index: 99999; 3 | color: white; 4 | position: absolute; 5 | top: 90px; 6 | left: 20%; 7 | right: 20%; 8 | bottom: 90px; 9 | background-color: #181818; 10 | padding: 40px; 11 | border-radius: 6px; 12 | } 13 | 14 | .modal-overlay { 15 | z-index: 99999; 16 | position: fixed; 17 | top: 0; 18 | left: 0; 19 | right: 0; 20 | bottom: 0; 21 | background-color:rgba(0, 0, 0, 0.8); 22 | } 23 | 24 | 25 | .ReactModal__Overlay { 26 | opacity: 0; 27 | transition: opacity 300ms ease-in-out; 28 | } 29 | 30 | .ReactModal__Overlay--after-open{ 31 | opacity: 1; 32 | } 33 | 34 | .ReactModal__Overlay--before-close{ 35 | opacity: 0; 36 | } 37 | -------------------------------------------------------------------------------- /src/cxemote.css: -------------------------------------------------------------------------------- 1 | .emoteMenuRoot { 2 | position: absolute; 3 | bottom: 125px; 4 | right: 5px; 5 | max-width: 400px; 6 | max-height: 600px; 7 | overflow: hidden; 8 | } 9 | 10 | .emoji-mart { 11 | border: 1px solid #181818; 12 | background: #272727; 13 | color: #d2d2d2; 14 | } 15 | 16 | .emoji-mart-category-label span { 17 | background-color: #272727; 18 | } 19 | 20 | .emoji-mart-search input { 21 | background: #181818; 22 | border: 1px solid #181818; 23 | color: #d2d2d2; 24 | } 25 | 26 | .emoji-mart-category .emoji-mart-emoji:hover:before { 27 | background-color: #181818; 28 | } 29 | 30 | .emoji-mart-emoji { 31 | background-repeat: no-repeat; 32 | } -------------------------------------------------------------------------------- /src/assets/baseline-settings-20px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import "@material/react-switch/dist/switch.css"; 5 | import "rc-slider/assets/index.css"; 6 | import App from "./App"; 7 | import * as serviceWorker from "./serviceWorker"; 8 | 9 | ReactDOM.render(, document.getElementById("root")); 10 | 11 | // If you want your app to work offline and load faster, you can change 12 | // unregister() to register() below. Note this comes with some pitfalls. 13 | // Learn more about service workers: http://bit.ly/CRA-PWA 14 | serviceWorker.unregister(); 15 | 16 | // Disable logging in production. 17 | function noop() {} 18 | 19 | if (process.env.NODE_ENV !== "development") { 20 | console.log = noop; 21 | console.warn = noop; 22 | console.error = noop; 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cxchat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@firebase/app": "^0.3.5", 7 | "@firebase/auth": "^0.8.0", 8 | "@material/button": "^0.42.0", 9 | "@material/react-button": "^0.7.1", 10 | "@material/react-switch": "^0.7.1", 11 | "emoji-mart": "^2.9.1", 12 | "rc-slider": "^8.6.4", 13 | "react": "^16.6.3", 14 | "react-contextmenu": "^2.10.0", 15 | "react-countdown-now": "^1.3.0", 16 | "react-dom": "^16.6.3", 17 | "react-emoji-render": "^0.5.0", 18 | "react-key-handler": "^1.2.0-beta.3", 19 | "react-modal": "^3.7.1", 20 | "react-scripts": "2.1.1", 21 | "react-window": "^1.2.4", 22 | "strip-combining-marks": "^1.0.0" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "development": [ 35 | "last 1 chrome versions" 36 | ], 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not ie <= 11", 41 | "not op_mini all" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 24 | Cx Network: Chat 25 | 26 | 27 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const racismRegex = /(?:n|𝗇|𝐍|η|Ν|𝐧|n|𝓷­|𝖭|n͏|n|ꓠ|𝚗|🆖|🇳)(?:i|i­­|­i­|i­­|ι|i|i|I­|_|y|🇮|𝓲||𝐢𝗂|і|j|ï|1|ı|🆖|l|\||\u005c|\u002f|\u007c|!|¡){1,50}(?:g|🇬|ɡ|𝐠|g͏|ɢ|ġ|𝓰|ģ|­g|­­g|ǧ|ǵ){2,}(?:3|🇪|€|ε|ε|𝒆|e|é|ě|𝐞|ȩ|­­E|ė|е){1,50}(?:r|ŕ|𝐫|ř|ŗ|ṙ|𝓻|🇷)/gim; 2 | 3 | export function randStr(len) { 4 | let s = ""; 5 | while (s.length < len) 6 | s += Math.random() 7 | .toString(36) 8 | .substr(2, len - s.length); 9 | return s; 10 | } 11 | 12 | export function isRacist(str) { 13 | str = str.toLowerCase().replace(/[\u200B-\u200D\uFEFF]/g, ""); 14 | if (str.includes("nigger")) return true; 15 | return racismRegex.test(str.replace(/\s/g, "").replace(/[^A-Za-z]/g, "")); 16 | } 17 | 18 | // Returns *true* if it shouldn't be sent. 19 | export function validateMessage(msg) { 20 | if (msg.length < 1 || msg.length > 200) return true; 21 | if (isRacist(msg)) return true; 22 | if (["r/ipcj", " ipcj", "swat", "dox", "้", "็"].some(w => msg.toLowerCase().includes(w))) { 23 | return true; 24 | } 25 | return false; 26 | } 27 | 28 | export const loadState = key => { 29 | try { 30 | const serializedState = localStorage.getItem(key); 31 | if (serializedState === null) { 32 | return undefined; 33 | } 34 | return JSON.parse(serializedState); 35 | } catch (err) { 36 | return undefined; 37 | } 38 | }; 39 | 40 | export const saveState = (key, state) => { 41 | try { 42 | const serializedState = JSON.stringify(state); 43 | localStorage.setItem(key, serializedState); 44 | } catch (err) { 45 | return undefined; 46 | } 47 | }; 48 | 49 | export const isModerator = ({ badges, state, username }) => { 50 | if (state !== "complete") return false; 51 | if (username === "Ice_Poseidon") return false; 52 | 53 | return badges.includes("globalmod") || badges.includes("admin"); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/EmoteMenu.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { NimblePicker } from "emoji-mart"; 3 | import "emoji-mart/css/emoji-mart.css"; 4 | import twitterData from "emoji-mart/data/twitter.json"; 5 | import "../cxemote.css"; 6 | 7 | class EmoteMenu extends Component { 8 | state = { 9 | filterText: "" 10 | }; 11 | 12 | componentDidMount() { 13 | document.addEventListener("mouseup", this.handleMouseClick); 14 | } 15 | 16 | componentWillUnmount() { 17 | document.removeEventListener("mouseup", this.handleMouseClick); 18 | } 19 | 20 | handleMouseClick = e => { 21 | if (!this.rootRef) return; 22 | 23 | if (!this.rootRef.contains(e.target)) { 24 | setImmediate(this.props.onClose); 25 | } 26 | }; 27 | 28 | onEmojiSelected = emoji => { 29 | // Select text to type - if it is custom type the id, otherwise type emoji. 30 | const text = emoji.custom ? emoji.id : emoji.native; 31 | 32 | this.props.onEmojiSelected(text); 33 | }; 34 | 35 | render() { 36 | const customEmotes = Object.entries(this.props.emotes.emojiMappings).map( 37 | ([name, imageUrl]) => ({ 38 | key: name, 39 | name, 40 | imageUrl, 41 | text: name, 42 | short_names: [name], 43 | keywords: [name] 44 | }) 45 | ); 46 | 47 | return ( 48 | (this.rootRef = el)}> 49 |
50 | {/*

Hi

51 | this.filterInput = el}>*/} 52 | 60 |
61 |
62 | ); 63 | } 64 | } 65 | 66 | export default EmoteMenu; 67 | -------------------------------------------------------------------------------- /src/components/ModerationTools.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import closeIcon from "../assets/close.svg"; 3 | 4 | export default class ModerationTools extends Component { 5 | state = { 6 | timeoutTime: 600 // seconds 7 | }; 8 | 9 | performAction = (action, args) => { 10 | fetch("https://api-production.iceposeidon.com/chat/moderate", { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | authorization: this.props.token 15 | }, 16 | body: JSON.stringify({ 17 | action, 18 | ...args, 19 | username: this.props.user.username 20 | }) 21 | }) 22 | .then(() => { 23 | if (!this) return; 24 | this.setState({ message: `${action} executed :SMILE:!` }); 25 | }) 26 | .catch(ex => { 27 | if (!this) return; 28 | this.setState({ message: `${action} failed to execute!` }); 29 | console.log(`failed to execute mod action: ${ex}`); 30 | }); 31 | }; 32 | 33 | render() { 34 | return ( 35 |
36 |
37 | 45 | {this.props.user.username} 46 |
47 | 48 | {this.state.message ? this.state.message : ""} 49 | 50 |
51 | 54 | 57 |
58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Debug.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { randStr } from "../util"; 4 | 5 | class Debug extends Component { 6 | state = { 7 | dummyMessages: false 8 | }; 9 | 10 | componentDidMount() { 11 | this.isDev = process.env.NODE_ENV === "development"; 12 | } 13 | 14 | randomMessage() { 15 | return { 16 | b: [], 17 | c: randStr(Math.floor(Math.random() * 150)), 18 | t: "ccm", 19 | u: "Testing User" 20 | }; 21 | } 22 | 23 | toggleDummy = () => { 24 | this.setState({ dummyMessages: !this.state.dummyMessages }); 25 | if (this.dummyInterval) { 26 | clearInterval(this.dummyInterval); 27 | this.dummyInterval = null; 28 | console.log("Dummy messages off."); 29 | } else { 30 | console.log("Dummy messages toggled on."); 31 | this.dummyInterval = setInterval(() => this.props.addMsg(this.randomMessage()), 100); 32 | } 33 | }; 34 | 35 | setFontSize = ({ target: { value } }) => { 36 | this.props.setChatFontSize(value); 37 | }; 38 | 39 | render() { 40 | if (!this.isDev) return null; 41 | const { dummyMessages } = this.state; 42 | return ( 43 |
55 |

Debug

56 |

62 | Toggle Dummy Messages 63 |

64 | 65 |
66 | Font Size: 67 | 75 |
76 |
77 | ); 78 | } 79 | } 80 | 81 | export default Debug; 82 | -------------------------------------------------------------------------------- /src/components/AdminSettings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactModal from "react-modal"; 3 | import Button from "@material/react-button"; 4 | import Switch from "@material/react-switch"; 5 | 6 | import { saveState } from "../util"; 7 | import "../modal.css"; 8 | 9 | class Modal extends Component { 10 | componentDidMount() { 11 | ReactModal.setAppElement("#root"); 12 | } 13 | 14 | openModal = () => { 15 | this.setState({ modalIsOpen: true }); 16 | }; 17 | 18 | closeModal = () => { 19 | this.setState({ modalIsOpen: false }); 20 | }; 21 | 22 | setFontSize = ({ target: { value } }) => { 23 | saveState("chatFontSize", value); 24 | this.props.setChatFontSize(value); 25 | }; 26 | 27 | toggleSponsorMode = () => { 28 | fetch("https://api-production.iceposeidon.com/chat/toggleSponsorMode", { 29 | method: "POST", 30 | headers: { 31 | authorization: this.props.token 32 | } 33 | }); 34 | }; 35 | 36 | render() { 37 | return ( 38 | this.props.toggleModal(null)} 44 | className="generic-modal" 45 | overlayClassName="modal-overlay" 46 | contentLabel="Example Modal"> 47 |
54 |

Admin Settings

55 |
56 |

Sponsor-only mode

57 | 62 |
63 | 66 |
67 |
68 | ); 69 | } 70 | } 71 | 72 | export default Modal; 73 | -------------------------------------------------------------------------------- /.firebase/hosting.YnVpbGQ.cache: -------------------------------------------------------------------------------- 1 | asset-manifest.json,1546990951000,d5e529b2dd31dcff765185484f7261de4b2214d4a8d685b1fd6c327b6e1ac1ce 2 | favicon.ico,1546990932000,b72f7455f00e4e58792d2bca892abb068e2213838c0316d6b7a0d6d16acd1955 3 | index.html,1546990951000,c8519d54f1a600c17d9456e0513660ddb725c10074c4c9d1dac6e5a24d4759d7 4 | manifest.json,1546990932000,ab69b7bb3d7e748353a8bc64d09545bfe33cedb538d3a79abae5231d108f70d8 5 | precache-manifest.3ca5acf9ac09e3e63666378e42b484de.js,1546990951000,f4a270b6c711d61663f110614a0fe815bd031e43c88101290570147757babf96 6 | service-worker.js,1546990951000,6b38c7cb78eec33e6d2a2b81f1f38a19e59dfed10208b3e4a75b40af41600b08 7 | static/css/main.7e5cc046.chunk.css,1546990951000,9df5f6d402faa60aeb86b996463b56f3907b1d3b072813e30923613b8fc60897 8 | static/css/1.02cb883a.chunk.css,1546990951000,5ca405de5aee4c0e351ec11bc5a3fe8deccb377d0a0c4f879c718f9d35215dea 9 | static/js/runtime~main.229c360f.js,1546990951000,c6b29b1ec3b87034232234e13e29f694047c3cd7d73968a680851c26b80729e1 10 | static/media/banhammer.606c72f2.svg,1546990951000,2e20c0fcf0d13467b00f043157d4d0eae53a7744843dae74101b298e4fc1c59a 11 | static/media/baseline-code-24px.2ef32772.svg,1546990951000,b26184c3191f11c8a39e8859be2573f40463a46047e91c16f879b30b182ad415 12 | static/media/baseline-launch-24px.ca7c9c7e.svg,1546990951000,331e5065aded45ed0018c76832cc84397d1f485e1319acbbf49920da0407f4f3 13 | static/media/baseline-security-24px.69b7412f.svg,1546990951000,c9ca8b1bf6339e71899b11cc3275f556a85db14fed00529b48e5e714c545eb43 14 | static/media/baseline-settings-20px.4a890b87.svg,1546990951000,0ae29ce9083c382f7f9d0060ecbb5e70ca9a39433fa25e5810d6a5a795cf55cb 15 | static/media/click.53c8ce09.ogg,1546990951000,4563621570ff732191a2c537ede1a8ff8da95ce3ec297776b423c7d603a1c45c 16 | static/media/close.f4ff11bf.svg,1546990951000,b0e2b1bbe5ff22b13beaadba4522fd5f62da347c970a76e14f26d3e56b9129f3 17 | static/media/cxlogo.83c9644a.svg,1546990951000,f7bf309ee251ea40ffc465764f127364e36271989f2d26c0eb300e87f885b8c5 18 | static/js/main.3f93a73c.chunk.js,1546990951000,9f7d4e45936b8571437e08b95eec5e000185ef69ca423e09d0adcf679fd97f29 19 | static/media/laugh.2a123832.ogg,1546990951000,b1a353a5995aa975c1ece7d311ed9dd0656606e0732cb02692363aedf9779a17 20 | static/js/1.9c497a21.chunk.js,1546990951000,6c576acfbc0dcf5daa8913ae8784ab0f397fc42dfe08ee6cb0e25c3cc0ded427 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cx-chat-frontend 2 | This was our frontend react app for the Cx chat on chat.iceposeidon.com - we're deprecating this chat on the website so we're making the frontend open source. The server-side socket and API is remaining private, but see the backend section below on how to design and orchestrate the server-side. 3 | 4 | ## Built by... 5 | Ciaran (chat rendering, tokenization, autocomplete, emote menu) 6 | Phineas (backend, chat socket, api, authentication, ui design) 7 | segfault (user settings panel, mod/admin panel, help with chat rendering and name colors) 8 | 9 | ## Features 10 | - Dynamic emotes, loaded in from our API 11 | - Emote autocompletion on tab 12 | - User mentioning with autocomplete 13 | - In-chat badges and name colors that link to those badges 14 | - Full fledged user system 15 | - Unique username section 16 | - JWT socket + API route authentication 17 | - SocketCluster client capabilities 18 | - Full-fledged moderation system 19 | - Moderation, admin panels 20 | - Subscriber/Sponsor-only mode 21 | - Dynamic user badges 22 | - Name colors based on username hash 23 | - Time-outs/permban system 24 | - Chat message buffers 25 | - High performance chat message rendering 26 | - Client-side filter 27 | - Emote selection menu (using EmojiMart) 28 | - Firebase authentication 29 | 30 | ## Backend 31 | The chat is websocket-based using [SocketCluster](https://github.com/SocketCluster/socketcluster) and UWS. There is also an API component used to get initial data and certain methods, like the moderation system and username selection. An example init api request goes like this: 32 | 33 | - User requests /chat/init endpoint with Firebase authentication token 34 | - Server checks email extracted Firebase token, responds with correct state 35 | - State could be that the user account exists, returning the details, or it could be that they're not signed up 36 | - If not signed up yet, the client will prompt the user to choose a username then send it to an API route 37 | - If everything's good to go, the API will respond with the user's information (username, punishment info, emotes, badges, etc) 38 | - It also responds with an authToken, which is a JWT token signed by the server which resolves to the user's username and badges, which we use on the SocketCluster side to ingest and publish chat messages 39 | - We also use the authToken for every API request 40 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/ModeratorSettings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactModal from "react-modal"; 3 | import Button from "@material/react-button"; 4 | 5 | import { saveState } from "../util"; 6 | import "../modal.css"; 7 | 8 | class Modal extends Component { 9 | state = { 10 | unbanUser: "", 11 | unbanUserStatus: "" 12 | }; 13 | 14 | componentDidMount() { 15 | ReactModal.setAppElement("#root"); 16 | } 17 | 18 | openModal = () => { 19 | this.setState({ modalIsOpen: true }); 20 | }; 21 | 22 | closeModal = () => { 23 | this.setState({ modalIsOpen: false }); 24 | }; 25 | 26 | setFontSize = ({ target: { value } }) => { 27 | saveState("chatFontSize", value); 28 | this.props.setChatFontSize(value); 29 | }; 30 | 31 | doUnban = (e) => { 32 | e.preventDefault(); 33 | 34 | const setStatus = (msg) => { 35 | this.setState({ unbanUserStatus: msg }); 36 | } 37 | 38 | const { unbanUser } = this.state; 39 | if (unbanUser.trim() === '') return setStatus("Cannot unban empty name!"); 40 | 41 | setStatus("Attempting to unban"); 42 | 43 | 44 | fetch("https://api-production.iceposeidon.com/chat/moderate", { 45 | method: "POST", 46 | headers: { 47 | "Content-Type": "application/json", 48 | authorization: this.props.token 49 | }, 50 | body: JSON.stringify({ 51 | action: "unban", 52 | username: unbanUser 53 | }) 54 | }) 55 | .then((data) => { 56 | if (!this) return; 57 | 58 | if (data.status === 404) 59 | return setStatus("User not found!"); 60 | 61 | setStatus("Unbanned!"); 62 | }) 63 | .catch(ex => { 64 | if (!this) return; 65 | setStatus("Failed to unban. Check console"); 66 | console.log(`failed to execute mod action: ${ex}`); 67 | }); 68 | }; 69 | 70 | render() { 71 | return ( 72 | this.props.toggleModal(null)} 78 | className="generic-modal" 79 | overlayClassName="modal-overlay" 80 | contentLabel="Example Modal"> 81 |
88 |

Moderator Settings

89 |
90 |
91 |
92 | Person to unban / unpunish:{" "} 93 | this.setState({ unbanUser: value })} 96 | type="text" 97 | placeholder="Username" 98 | /> 99 | {" "} 100 | 103 | {" "} 104 | {this.state.unbanUserStatus} 105 |
106 |
107 |
108 | 111 |
112 |
113 | ); 114 | } 115 | } 116 | 117 | export default Modal; 118 | -------------------------------------------------------------------------------- /src/assets/banhammer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/GeneralSettings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactModal from "react-modal"; 3 | import Button from "@material/react-button"; 4 | import Switch from "@material/react-switch"; 5 | import Slider from "rc-slider/lib/Slider"; 6 | 7 | import { saveState } from "../util"; 8 | import "../modal.css"; 9 | 10 | class Modal extends Component { 11 | componentDidMount() { 12 | ReactModal.setAppElement("#root"); 13 | } 14 | 15 | openModal = () => { 16 | this.setState({ modalIsOpen: true }); 17 | }; 18 | 19 | closeModal = () => { 20 | this.setState({ modalIsOpen: false }); 21 | }; 22 | 23 | setFontSize = value => { 24 | saveState("chatFontSize", value); 25 | this.props.setChatFontSize(value); 26 | }; 27 | 28 | setMessageBuffer = value => { 29 | saveState("messageBufferSize", value); 30 | this.props.setMessageBuffer(value); 31 | }; 32 | 33 | render() { 34 | return ( 35 | this.props.toggleModal(null)} 41 | className="generic-modal" 42 | overlayClassName="modal-overlay" 43 | contentLabel="Example Modal"> 44 |
52 |

General Settings

53 |
54 |
55 |

56 | Font Size ({this.props.chatFontSize}) 57 |

58 | 64 |
65 |
66 |

Show YouTube Messages

67 | 73 |
74 |
75 |

76 | @mention Sound Effects 77 |

78 | 83 |
84 |
85 |

86 | Message Buffer Size (set lower if you're lagging) ({this.props.messageBuffer}) 87 |

88 | 94 |
95 |
96 | 99 | 102 |
103 |
104 | ); 105 | } 106 | } 107 | 108 | export default Modal; 109 | -------------------------------------------------------------------------------- /src/services/AutoCompleteService.js: -------------------------------------------------------------------------------- 1 | const CLEANUP_RATE = 15 * 60 * 1000; 2 | const USER_PURGE_TIME = 10 * 60 * 1000; 3 | 4 | export default class AutoCompleteService { 5 | usersSortStale = true; 6 | usersSorted = []; 7 | users = new Map(); 8 | emptyMentionUsers = []; 9 | emotes = new Map(); 10 | 11 | init() { 12 | window.test = this; 13 | setInterval(this.cleanup, CLEANUP_RATE); 14 | } 15 | 16 | setEmotes(emotes) { 17 | this.emotes.clear(); 18 | 19 | // Map preserves insertion order - so we can do our sort now for better perf 20 | const sortedNames = Object.keys(emotes) 21 | .map(x => [x.toLowerCase(), x]) 22 | .sort(([a], [b]) => this.sort(a, b)); 23 | 24 | for (const name of sortedNames) { 25 | const [lowerCaseName, captializedName] = name; 26 | const url = emotes[captializedName]; 27 | 28 | this.emotes.set(lowerCaseName, { type: "emote", value: captializedName, url }); 29 | } 30 | } 31 | 32 | sort = (a, b) => { 33 | // First order - length 34 | if (a.length > b.length) return 1; 35 | if (a.length < b.length) return -1; 36 | 37 | // Second order - Alphabetical 38 | if (a === b) return 0; 39 | return a > b ? 1 : -1; 40 | }; 41 | 42 | // This function will only be called when we actually begin a 43 | // tab search, and will then only be called afterwards if a tab 44 | // search is called with an expired cached last version. 45 | generateUsersIndex() { 46 | // Not stale, no need to do anything. 47 | if (!this.usersSortStale) return; 48 | 49 | this.usersSorted = Array.from(this.users.keys()).sort(this.sort); 50 | this.usersSortStale = false; 51 | } 52 | 53 | addUser(username, data) { 54 | const usernameLowerCase = username.toLowerCase(); 55 | 56 | // New user, need to flag the index as invalid. 57 | if (!this.users.has(usernameLowerCase)) { 58 | this.usersSortStale = true; 59 | } 60 | 61 | // Add to users object 62 | this.users.set(usernameLowerCase, { 63 | type: "user", 64 | value: `@${username}`, 65 | lastSpokeAt: Date.now(), 66 | data 67 | }); 68 | 69 | // Add to the list of users to show when initially just @'ing 70 | this.emptyMentionUsers = [ 71 | ...this.emptyMentionUsers.filter(x => x !== username), 72 | username 73 | ].slice(-10); 74 | } 75 | 76 | tabComplete(initial) { 77 | initial = initial.trim().toLowerCase(); 78 | if (initial.length === 0) return []; 79 | 80 | const isTagMention = initial[0] === "@"; 81 | if (isTagMention) initial = initial.substr(1); // remove @ 82 | const response = []; 83 | 84 | // Handle emote names 85 | if (!isTagMention) { 86 | let maxEmotes = 10; 87 | for (const key of this.emotes.keys()) { 88 | if (key.startsWith(initial)) { 89 | response.push(this.emotes.get(key)); 90 | 91 | if (--maxEmotes === 0) break; 92 | } 93 | } 94 | } 95 | 96 | // Generate the user index if required. 97 | this.generateUsersIndex(); 98 | 99 | // Handle user names 100 | let maxUsers = 7; 101 | 102 | for (const key of this.usersSorted) { 103 | if (key.startsWith(initial)) { 104 | response.push(this.users.get(key)); 105 | 106 | if (--maxUsers === 0) break; 107 | } 108 | } 109 | 110 | return response; 111 | } 112 | 113 | // Clean the users list in order to prevent OOM 114 | cleanup = () => { 115 | for (const [username, data] of this.users.entries()) { 116 | const { lastSpokeAt } = data; 117 | 118 | // If still in date, keep 119 | if (Date.now() - lastSpokeAt < USER_PURGE_TIME) continue; 120 | 121 | // Out of date - delete 122 | if (this.emptyMentionUsers.includes(username)) { 123 | this.emptyMentionUsers = this.emptyMentionUsers.filter(x => x !== username); 124 | } 125 | 126 | if (this.usersSorted.includes(username)) { 127 | this.usersSorted = this.usersSorted.filter(x => x !== username); 128 | } 129 | 130 | this.users.delete(username); 131 | } 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/assets/cxlogo.svg: -------------------------------------------------------------------------------- 1 | Asset 2cxlogofinal -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/ChatList.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { Twemoji } from "react-emoji-render"; 3 | import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu"; 4 | 5 | import "../chat.css"; 6 | import banhammerIcon from "../assets/banhammer.svg"; 7 | import ModerationTools from "./ModerationTools"; 8 | import { isModerator } from "../util"; 9 | 10 | // TODO: Had to switch PureComponent to Component to work. 11 | // Switch back? would a memoized functional component be better? 12 | export class ChatList extends Component { 13 | scrollDiv = React.createRef(); 14 | state = { 15 | scrolledManually: false 16 | }; 17 | 18 | renderTokens(tokens, badges) { 19 | return tokens.map((token, key) => { 20 | switch (token.type) { 21 | case "LINK": 22 | return ( 23 | 24 | {token.url.toString()} 25 | 26 | ); 27 | 28 | case "EMOTE": 29 | const base = ( 30 | {token.textRepresentation} 37 | ); 38 | 39 | // Return base if no modifiers 40 | if (token.modifiers.length < 1) return base; 41 | 42 | // Stack modifiers on top 43 | return ( 44 | 45 | {base} 46 | {token.modifiers.map((x, i) => ( 47 | {x} 53 | ))} 54 | 55 | ); 56 | 57 | case "HIGHLIGHT": 58 | return ( 59 | 60 | {token.value} 61 | 62 | ); 63 | 64 | case "TEXT": 65 | return {token.value}; 66 | 67 | case "YOUTUBE_MESSAGE": 68 | return ( 69 | 70 | {token.content} 71 | 72 | ); 73 | 74 | default: 75 | return null; 76 | } 77 | }); 78 | } 79 | 80 | componentDidUpdate() { 81 | if (this.state.scrolledManually) return; 82 | if (this.state.moderationTarget) return; 83 | this.scrollToBottom(); 84 | } 85 | 86 | onScroll = () => { 87 | if (this.didJustScroll) { 88 | this.didJustScroll = false; 89 | 90 | if (this.state.scrolledManually) { 91 | this.setState({ scrolledManually: false }); 92 | } 93 | 94 | return; 95 | } 96 | 97 | const atBottom = 98 | this.scrollDiv.current.scrollTop >= 99 | this.scrollDiv.current.scrollHeight - this.scrollDiv.current.offsetHeight; 100 | this.setState({ scrolledManually: !atBottom }); 101 | }; 102 | 103 | openModMenu = user => { 104 | this.setState({ 105 | moderationTarget: user 106 | }); 107 | }; 108 | 109 | closeModMenu = () => { 110 | this.setState({ 111 | moderationTarget: null 112 | }); 113 | 114 | this.scrollToBottom(); 115 | }; 116 | 117 | scrollToBottom = force => { 118 | if (this.state.moderationTarget && !force) return; 119 | 120 | if (force && this.state.moderationTarget) { 121 | this.setState({ moderationTarget: null }); 122 | } 123 | 124 | this.didJustScroll = true; 125 | this.scrollDiv.current.scrollTop = this.scrollDiv.current.scrollHeight; 126 | }; 127 | 128 | render() { 129 | return ( 130 |
131 |
136 |
137 | {this.props.messages.map(m => { 138 | return ( 139 |
140 | {isModerator(this.props.user) ? 141 | 142 | 156 | 159 | : {this.renderTokens(m.content, m.badges)} 160 | 161 | 162 | 163 | TimeOut {m.username} for 5min 164 | 165 | 166 | 167 | TimeOut {m.username} for 10min 168 | 169 | 170 | 171 | TimeOut {m.username} for 30min 172 | 173 | 174 | 175 | TimeOut {m.username} for 1h 176 | 177 | 178 | : 179 | 180 | 194 | : {this.renderTokens(m.content, m.badges)} 195 | } 196 |
197 | ); 198 | })} 199 |
200 |
201 | 202 | {this.state.scrolledManually || this.state.moderationTarget ? ( 203 | 212 | ) : null} 213 | 214 | {this.state.moderationTarget ? ( 215 | this.closeModMenu()} 219 | /> 220 | ) : null} 221 |
222 | ); 223 | } 224 | } 225 | 226 | export default ChatList; 227 | -------------------------------------------------------------------------------- /src/chat.css: -------------------------------------------------------------------------------- 1 | *:focus { 2 | outline: none; 3 | } 4 | 5 | a:link { 6 | text-decoration: none; 7 | color: cyan; 8 | } 9 | 10 | a:visited { 11 | text-decoration: none; 12 | color: cyan; 13 | } 14 | 15 | a:hover { 16 | text-decoration: underline; 17 | color: cyan; 18 | } 19 | 20 | a:active { 21 | text-decoration: underline; 22 | color: cyan; 23 | } 24 | 25 | .header { 26 | z-index: 1000; 27 | height: 55px; 28 | width: 100%; 29 | top: 0; 30 | /*position: fixed;*/ 31 | display: flex; 32 | position: absolute; 33 | align-items: center; 34 | background-color: #181818; 35 | } 36 | 37 | .streamerHeaderName { 38 | color: #707070; 39 | font-family: "Open Sans"; 40 | font-weight: 600; 41 | font-size: 16px; 42 | margin-left: 15px; 43 | } 44 | 45 | .footer { 46 | flex-direction: column; 47 | justify-content: space-evenly; 48 | align-items: center; 49 | bottom: 0; 50 | background-color: #181818; 51 | width: 100%; 52 | height: 120px; 53 | position: absolute; 54 | display: flex; 55 | } 56 | 57 | .chatBar { 58 | display: flex; 59 | width: 90%; 60 | height: 45px; 61 | border-radius: 4px; 62 | border-width: 2px; 63 | border-style: solid; 64 | border-color: #282828; 65 | background-color: #212121; 66 | } 67 | 68 | .chatBarInput { 69 | color: #fff; 70 | font-family: "Open Sans"; 71 | background: transparent; 72 | border: none; 73 | margin-left: 9px; 74 | font-size: 16px; 75 | width: 100%; 76 | height: 100%; 77 | } 78 | 79 | .usernameInput { 80 | padding-left: 5px; 81 | color: #fff; 82 | font-family: "Open Sans"; 83 | background: transparent; 84 | border-radius: 4px; 85 | border-width: 2px; 86 | border-style: solid; 87 | border-color: #282828; 88 | background-color: #212121; 89 | font-size: 16px; 90 | width: 90%; 91 | height: 30px; 92 | } 93 | 94 | .chatBarInput::-webkit-input-placeholder { 95 | background-color: transparent; 96 | pointer-events: none; 97 | font-weight: 600; 98 | font-family: "Open Sans"; 99 | font-size: 16px; 100 | } 101 | 102 | .chatExperience { 103 | width: 90%; 104 | display: flex; 105 | flex-direction: row; 106 | align-items: center; 107 | } 108 | 109 | .emoteButton { 110 | margin: auto 0; 111 | height: 48px; 112 | padding: 9px; 113 | } 114 | 115 | .optionButtons { 116 | display: flex; 117 | width: 20%; 118 | justify-content: space-around; 119 | max-width: 100px; 120 | } 121 | 122 | .optionButton { 123 | width: 30px; 124 | height: 30px; 125 | } 126 | 127 | .chatUserDisplay { 128 | display: flex; 129 | margin-left: auto; 130 | } 131 | 132 | .iconButton { 133 | background: none; 134 | border: none; 135 | } 136 | 137 | /* The Container for the Header, Chat, and Footer */ 138 | 139 | .flex-container { 140 | display: flex; 141 | flex-direction: column; 142 | flex-wrap: nowrap; 143 | justify-content: flex-start; 144 | align-content: space-between; 145 | align-items: center; 146 | } 147 | 148 | /* The container for the Chat Message items */ 149 | .chat-flex-container { 150 | /* width: 100vw;*/ 151 | max-width:100%; 152 | height: calc(100vh - 175px); 153 | margin-left: 11px; 154 | margin-top: 55px; 155 | margin-bottom: 120px; 156 | display: flex; 157 | flex-direction: column; 158 | flex-wrap: nowrap; 159 | justify-content: flex-end; 160 | align-content: flex-start; 161 | align-items: stretch; 162 | } 163 | 164 | .chat-list-item { 165 | padding-bottom: 10px; 166 | font-size: inherit; 167 | overflow-wrap: break-word; 168 | } 169 | 170 | .emote { 171 | vertical-align: middle; 172 | max-height: 32px; 173 | margin: -.5rem 0; 174 | } 175 | 176 | .emote-modifier { 177 | position: absolute; 178 | bottom: 0; 179 | right: 0; 180 | user-select: none; 181 | } 182 | 183 | .emote-stacked-container { 184 | position: relative; 185 | } 186 | 187 | .highlight { 188 | padding: 3px 5px; 189 | background-color: #181818; 190 | border-radius: 5px; 191 | color: #b163ff; 192 | } 193 | 194 | .badge { 195 | width: 18px; 196 | height: 18px; 197 | align-self: center; 198 | margin-right: 3px; 199 | vertical-align: middle; 200 | } 201 | 202 | .chat-items { 203 | position: relative; 204 | bottom: 0px; 205 | overflow-y: scroll; 206 | min-height: calc(100vh - 175px); 207 | } 208 | 209 | .chat-restrict { 210 | margin-right: 6px; 211 | } 212 | 213 | .button-unstyled { 214 | outline: none; 215 | border: none; 216 | background: none; 217 | cursor: pointer; 218 | padding: 0px 0px; 219 | color: inherit; 220 | text-align: inherit; 221 | align-items: inherit; 222 | font: inherit; 223 | } 224 | 225 | .scrollDownContainer { 226 | display: block; 227 | position: absolute; 228 | left: 0; 229 | right: 0; 230 | background-color: rgba(0.2, 0.2, 0.2, 0.4); 231 | text-align: center; 232 | cursor: pointer; 233 | width: 100%; 234 | } 235 | 236 | .chat-area-container { 237 | width: 100vw; 238 | } 239 | 240 | 241 | .chat-items::-webkit-scrollbar { 242 | width: 0.6em; 243 | } 244 | 245 | .chat-items::-webkit-scrollbar-track { 246 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 247 | background-color: #181818; 248 | } 249 | 250 | .chat-items::-webkit-scrollbar-thumb { 251 | background-color: #353535; 252 | outline: 1px solid slategrey; 253 | } 254 | 255 | .mod-tools-toggle { 256 | width: 18px; 257 | height: 18px; 258 | vertical-align: middle; 259 | padding-right: 4px; 260 | padding-left: 4px; 261 | } 262 | 263 | .moderation-tools { 264 | position: absolute; 265 | top: 55px; 266 | right: 0px; 267 | 268 | width: 200px; 269 | background-color: #7f00ff; 270 | color: #fff; 271 | } 272 | 273 | .moderation-header { 274 | vertical-align: middle; 275 | padding: 3px 3px 3px 3px; 276 | } 277 | 278 | .moderation-exit { 279 | width: 24px; 280 | height: 24px; 281 | vertical-align: middle; 282 | padding-right: 3px; 283 | } 284 | 285 | .moderation-username { 286 | margin-left: 2px; 287 | } 288 | 289 | .moderation-actions { 290 | display: flex; 291 | flex-direction: column; 292 | } 293 | 294 | .moderation-action { 295 | margin: 2px 3px; 296 | } 297 | 298 | .timedOutBar { 299 | color: #FFFFFF; 300 | } 301 | 302 | .react-contextmenu { 303 | background-color: #262626; 304 | background-clip: padding-box; 305 | border: 1px solid rgba(0,0,0,.15); 306 | border-radius: .25rem; 307 | color: #373a3c; 308 | font-size: 16px; 309 | margin: 2px 0 0; 310 | min-width: 160px; 311 | outline: none; 312 | opacity: 0; 313 | padding: 5px 0; 314 | pointer-events: none; 315 | text-align: left; 316 | transition: opacity 250ms ease !important; 317 | visibility: hidden; 318 | } 319 | 320 | .react-contextmenu.react-contextmenu--visible { 321 | opacity: 1; 322 | pointer-events: auto; 323 | z-index: 9999; 324 | visibility: visible; 325 | } 326 | 327 | .react-contextmenu-item { 328 | background: 0 0; 329 | border: 0; 330 | color: #fff; 331 | cursor: pointer; 332 | font-weight: 400; 333 | line-height: 1.5; 334 | padding: 3px 20px; 335 | text-align: inherit; 336 | white-space: nowrap; 337 | } 338 | 339 | .react-contextmenu-item.react-contextmenu-item--active, 340 | .react-contextmenu-item.react-contextmenu-item--selected { 341 | color: #fff; 342 | background-color: magenta; 343 | border-color: magenta; 344 | text-decoration: none; 345 | } 346 | 347 | .react-contextmenu-item.react-contextmenu-item--disabled, 348 | .react-contextmenu-item.react-contextmenu-item--disabled:hover { 349 | background-color: transparent; 350 | border-color: rgba(0,0,0,.15); 351 | color: #878a8c; 352 | } 353 | 354 | .react-contextmenu-item--divider { 355 | border-bottom: 1px solid rgba(0,0,0,.15); 356 | cursor: inherit; 357 | margin-bottom: 3px; 358 | padding: 2px 0; 359 | } 360 | .react-contextmenu-item--divider:hover { 361 | background-color: transparent; 362 | border-color: rgba(0,0,0,.15); 363 | } 364 | 365 | .react-contextmenu-item.react-contextmenu-submenu { 366 | padding: 0; 367 | } 368 | 369 | .react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item { 370 | } 371 | 372 | .react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after { 373 | content: "▶"; 374 | display: inline-block; 375 | position: absolute; 376 | right: 7px; 377 | } 378 | 379 | .autocomplete { 380 | position: absolute; 381 | 382 | top: auto; 383 | bottom: 100%; 384 | 385 | margin-bottom: 10px; 386 | 387 | width: 300px; 388 | background-color: #181818; 389 | padding: 5px; 390 | } 391 | 392 | .autocomplete div { 393 | color: #FFFFFF; 394 | padding: 2px; 395 | } 396 | 397 | .autocomplete-preview-img { 398 | max-height: 32px; 399 | max-width: 32px; 400 | vertical-align: middle; 401 | padding-right: 5px; 402 | width: auto; 403 | height: auto; 404 | } -------------------------------------------------------------------------------- /src/components/Chat.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, Component } from "react"; 2 | import KeyHandler, { KEYDOWN, KEYUP } from "react-key-handler"; 3 | 4 | import EmoteMenu from "./EmoteMenu"; 5 | import Footer from "./Footer"; 6 | import ChatList from "./ChatList"; 7 | import Debug from "./Debug"; 8 | import cxLogo from "../assets/cxlogo.svg"; 9 | import launchIcon from "../assets/baseline-launch-24px.svg"; 10 | import click from "../assets/click.ogg"; 11 | const mentionSound = new Audio(click); 12 | 13 | const colors = ["#fff3aa", "#ffd0a8", "#ffb1b1", "#d9d1ff", "#b7efff"]; 14 | 15 | const allowedHosts = [ 16 | /^(.*\.)?reddit\.(com|co\.uk)$/i, 17 | /^(.*\.)?iceposeidon\.com$/i, 18 | /^(.*\.)?youtube\.com$/i, 19 | /^(.*\.)?youtu\.be$/i, 20 | /^(.*\.)?discordapp\.com$/i, 21 | /^(.*\.)?twitter\.com$/i 22 | ]; 23 | 24 | function generateColorFromString(str) { 25 | let sum = 0; 26 | for (let x = 0; x < str.length; x++) sum += x * str.charCodeAt(x); 27 | return colors[sum % colors.length]; 28 | } 29 | 30 | class Chat extends Component { 31 | state = { 32 | emoteMenuVisible: false, 33 | contextMenuVisible: false, 34 | messagesPaused: false 35 | }; 36 | 37 | messageId = 0; 38 | footer = React.createRef(); 39 | lastNotifiedTime = -1000; 40 | 41 | ctrlDown = e => { 42 | if ( 43 | this.props.user.badges && 44 | (this.props.user.badges.includes("admin") || 45 | this.props.user.badges.includes("globalmod")) 46 | ) { 47 | e.preventDefault(); 48 | this.setState({ messagesPaused: true }); 49 | } 50 | return; 51 | }; 52 | 53 | ctrlUp = e => { 54 | if ( 55 | this.props.user.badges && 56 | (this.props.user.badges.includes("admin") || 57 | this.props.user.badges.includes("globalmod")) 58 | ) { 59 | e.preventDefault(); 60 | if (!this.state.contextMenuVisible) this.setState({ messagesPaused: false }); 61 | } 62 | }; 63 | 64 | handleModContextMenuOpen = () => { 65 | this.setState({ messagesPaused: true, contextMenuVisible: true }); 66 | }; 67 | 68 | handleModContextMenuClose = () => { 69 | this.setState({ messagesPaused: false, contextMenuVisible: false }); 70 | }; 71 | 72 | handleModContextMenuClick = (e, data) => { 73 | console.log("timing out " + data.username + "for " + data.time + " minutes"); 74 | }; 75 | 76 | setEmoteMenuVisible(visible) { 77 | this.setState({ emoteMenuVisible: visible }); 78 | } 79 | 80 | onEmoteSelected = emoteText => { 81 | this.footer.current.insertText(`${emoteText} `); 82 | }; 83 | 84 | tokenize(text, badges) { 85 | const words = text.split(/ /g); 86 | const tokens = []; 87 | 88 | let currentText = ""; 89 | 90 | const purgeTextBuffer = startSpace => { 91 | // Save current text onto token list and clear buffer 92 | tokens.push({ type: "TEXT", value: currentText }); 93 | currentText = startSpace ? " " : ""; 94 | }; 95 | 96 | for (const word of words) { 97 | const wordLen = word.length; 98 | const sepIndex = word.indexOf(":"); 99 | 100 | if (word.startsWith("https://") || word.startsWith("http://")) { 101 | try { 102 | const parsedURL = new URL(word); 103 | 104 | if ( 105 | this.validateURL(parsedURL) || 106 | (badges && (badges.includes("admin") || badges.includes("globalmod"))) 107 | ) { 108 | purgeTextBuffer(true); 109 | tokens.push({ type: "LINK", url: parsedURL }); 110 | } 111 | 112 | // TODO: do we want to just bin the URL off if it isn't allowed by the filter? 113 | continue; 114 | } catch (ex) { 115 | // not a URL! fall through 116 | } 117 | } 118 | 119 | const withoutPunctuation = word.replace(/[.,!$%&;:-]/g, ""); 120 | if ( 121 | this.props.user.state === "complete" && 122 | (withoutPunctuation.toLowerCase() === this.props.user.username.toLowerCase() || 123 | (withoutPunctuation.length > 1 && 124 | withoutPunctuation[0] === "@" && 125 | withoutPunctuation.substr(1, word.length - 1).toLowerCase() === 126 | this.props.user.username.toLowerCase())) 127 | ) { 128 | // Purge text buf 129 | purgeTextBuffer(true); 130 | 131 | tokens.push({ type: "HIGHLIGHT", value: `${word}` }); 132 | this.onMentioned(); 133 | 134 | continue; 135 | } 136 | 137 | let emote = word; 138 | let modifiers = ""; 139 | if (sepIndex > -1 && sepIndex < wordLen - 1) { 140 | emote = word.substr(0, sepIndex); 141 | modifiers = word.substr(sepIndex + 1); 142 | } 143 | 144 | if (this.props.emotes.emoteNames.indexOf(emote) > -1) { 145 | // Purge any text in the buffer before writing emote 146 | purgeTextBuffer(true); 147 | 148 | // Take our modifiers, validate them against the list and take max 4 149 | const validModifiers = modifiers 150 | .split(":") 151 | .filter(x => this.props.emotes.modifierNames.indexOf(x) > -1) 152 | .slice(0, 4); 153 | 154 | // Put emote onto token list 155 | tokens.push({ 156 | type: "EMOTE", 157 | name: emote, 158 | modifiers: validModifiers, 159 | textRepresentation: `${emote}${ 160 | validModifiers.length > 0 ? ":" : "" 161 | }${validModifiers.join(":")}` 162 | }); 163 | } else { 164 | // It's just text, add to text buffer 165 | currentText += word + " "; 166 | } 167 | } 168 | 169 | purgeTextBuffer(); 170 | 171 | return tokens; 172 | } 173 | 174 | onMentioned = () => { 175 | if (Date.now() - this.lastNotifiedTime < 1000) return; 176 | this.lastNotifiedTime = Date.now(); 177 | 178 | // Play mention sound unless user has opted-out 179 | console.log(this.props.mentionSounds); 180 | if (!this.props.mentionSoundsDisabled) mentionSound.play(); 181 | }; 182 | 183 | addChatMessage = data => { 184 | const color = 185 | data.b.length > 0 186 | ? this.props.emotes.badges[data.b[0]].nameColor 187 | : generateColorFromString(data.u); 188 | 189 | if (this.props.emotes.emojiMappings) { 190 | const message = { 191 | username: data.u, 192 | color, 193 | content: this.tokenize(data.c, data.b), 194 | badges: data.b, 195 | key: this.messageId++ 196 | }; 197 | 198 | this.props.autoCompleteService.addUser(data.u, { badges: data.b }); 199 | 200 | if (!this.state.messagesPaused) this.props.addChatMessage(message); 201 | } 202 | }; 203 | 204 | validateURL = url => { 205 | // For now, just check our host list 206 | return allowedHosts.find(regex => regex.test(url.host)); 207 | }; 208 | 209 | addYTMessage = data => { 210 | if (!this.props.youtubeHidden && !this.state.messagesPaused) { 211 | this.props.addChatMessage({ 212 | username: `(YT) ${data.u}`, 213 | color: "#797979", 214 | content: [{ type: "YOUTUBE_MESSAGE", content: data.c }], 215 | badges: [], 216 | key: this.messageId++ 217 | }); 218 | } 219 | }; 220 | 221 | render() { 222 | return ( 223 | 224 | 225 | 226 | 227 | 232 |
233 | Cx Logo 234 |

Ice Poseidon

235 | 239 | window.open( 240 | "https://chat.iceposeidon.com/?popped_out=1", 241 | "cxchat", 242 | "height=1000,width=500" 243 | ) 244 | } 245 | alt="Pop out" 246 | /> 247 |
248 | 249 | {/* Chat Area */} 250 |
251 | { 261 | this.footer.current.insertText(`@${username} `); 262 | this.footer.current.focusChatInput(); 263 | }} 264 | /> 265 | {this.state.emoteMenuVisible ? ( 266 | this.setEmoteMenuVisible(false)} 270 | /> 271 | ) : null} 272 |
273 | 274 |