├── .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 |
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 |
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 |
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 |
108 |
111 |
112 |
113 | );
114 | }
115 | }
116 |
117 | export default Modal;
118 |
--------------------------------------------------------------------------------
/src/assets/banhammer.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
165 |
166 |
169 |
170 |
173 |
174 |
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 |

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 |
291 | );
292 | }
293 | }
294 |
295 | export default Chat;
296 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component, Fragment } from "react";
2 | import stripCombiningMarks from "strip-combining-marks";
3 | import firebase from "@firebase/app";
4 |
5 | import laugh from "./assets/laugh.ogg";
6 | import Chat from "./components/Chat";
7 | import GeneralSettings from "./components/GeneralSettings";
8 | import ModeratorSettings from "./components/ModeratorSettings";
9 | import AdminSettings from "./components/AdminSettings";
10 |
11 | import AutoCompleteService from "./services/AutoCompleteService";
12 |
13 | import { auth } from "./firebase";
14 | import { loadState, saveState, validateMessage, isModerator } from "./util";
15 |
16 | const provider = new firebase.auth.GoogleAuthProvider();
17 |
18 | const audio = new Audio(laugh);
19 |
20 | class App extends Component {
21 | state = {
22 | authenticated: false,
23 | token: "",
24 | gateway: "",
25 | emotes: {},
26 | user: { state: "none" },
27 | errors: {},
28 | lastMessage: null,
29 | isBanned: false,
30 | messages: [],
31 | maxMessages: loadState("messageBufferSize") || 80,
32 | cooldown: false,
33 | chatFontSize: loadState("chatFontSize") || 15,
34 | openedModal: null,
35 | youtubeHidden: loadState("youtubeHidden") || false,
36 | mentionSoundsDisabled: loadState("mentionSoundsDisabled") || false
37 | };
38 |
39 | autoCompleteService = new AutoCompleteService();
40 |
41 | removeMessages = username => {
42 | this.setState({
43 | messages: this.state.messages.filter(msg => msg.username !== username)
44 | });
45 | };
46 |
47 | addChatMessage = message => {
48 | let { messages, maxMessages } = this.state;
49 | if (messages.push(message) === maxMessages + 1) messages.shift();
50 | this.setState({ messages });
51 | };
52 |
53 | toggleModal = key => {
54 | if (!key) return this.setState({ openedModal: null });
55 | if (!["generalSettings", "adminSettings", "moderatorSettings"].includes(key)) return;
56 | this.setState({ openedModal: key });
57 | };
58 |
59 | componentDidMount() {
60 | this.chat = React.createRef();
61 |
62 | this.autoCompleteService.init();
63 |
64 | auth.onAuthStateChanged(authUser => {
65 | if (authUser) {
66 | auth.currentUser.getIdToken(false).then(token => {
67 | this.setState({ token });
68 | this._requestInitData();
69 | });
70 | } else {
71 | this._requestInitData();
72 | this.setState({ authenticated: false });
73 | }
74 | });
75 |
76 | firebase.auth().onIdTokenChanged(user => {
77 | if (user) {
78 | user.getIdToken(false).then(token => {
79 | this.setState({ token });
80 | });
81 | }
82 | });
83 | }
84 |
85 | _handleLoginAttempt() {
86 | firebase
87 | .auth()
88 | .signInWithPopup(provider)
89 | .then(console.log)
90 | .catch(console.error);
91 | }
92 |
93 | _handleLogout() {
94 | firebase
95 | .auth()
96 | .signOut()
97 | .then(() => {
98 | console.log("[CxChat:Auth] Logged out successfully.");
99 | window.location.reload();
100 | });
101 | }
102 |
103 | _requestInitData() {
104 | fetch("https://api-production.iceposeidon.com/chat/init", {
105 | method: "GET",
106 | headers: {
107 | Accept: "application/json",
108 | authorization: this.state.token
109 | }
110 | })
111 | .then(res => res.json())
112 | .then(data => {
113 | const { user, emotes, gateway, accessToken } = data.data;
114 | if (accessToken) {
115 | localStorage.setItem("socketCluster.authToken", accessToken);
116 | if (this.socket) this.socket.authenticate(accessToken, console.log());
117 | }
118 |
119 | emotes.emoteNames = Object.keys(emotes.emojiMappings);
120 | emotes.modifierNames = Object.keys(emotes.modifiers);
121 |
122 | this.autoCompleteService.setEmotes(emotes.emojiMappings);
123 |
124 | this.setState({ user, emotes, gateway });
125 | this.updateBanStatus();
126 | this._establishGateway(gateway);
127 | });
128 | }
129 |
130 | _establishGateway(host) {
131 | if (this.socket) return; // Don't establish the socket more than once!
132 | var socket = window.socketCluster.create({
133 | hostname: "chat-gateway-dev.iceposeidon.com",
134 | secure: true,
135 | port: 443
136 | });
137 | this.socket = socket;
138 | const chatChannel = socket.subscribe("yell");
139 |
140 | socket.on("connect", function() {
141 | console.log("CONNECTED");
142 | });
143 |
144 | socket.on("raw", data => {
145 | try {
146 | const parsed = JSON.parse(data);
147 | if (parsed.t && parsed.t === "rotateToken") {
148 | this._requestInitData();
149 | }
150 | } catch (e) {
151 | console.error(e);
152 | }
153 | });
154 |
155 | socket.on("authStateChange", state => {
156 | if (state.newState === "unauthenticated") {
157 | this._requestInitData();
158 | }
159 | });
160 |
161 | chatChannel.watch(data => {
162 | try {
163 | switch (data.t) {
164 | case "ccm": // common chat message
165 | if (data.u === this.state.user.username) return;
166 | if (!data.b) data.b = [];
167 | this.chat.current.addChatMessage(data);
168 | break;
169 |
170 | case "ytc":
171 | this.chat.current.addYTMessage(data);
172 | break;
173 |
174 | case "pms":
175 | this.removeMessages(data.u);
176 | console.log(`${data.u} was banned.`);
177 | if (data.u !== this.state.user.username) return;
178 | this._requestInitData();
179 | // fall through and reload data
180 | case "cf":
181 | //chat features
182 | let emotes = this.state.emotes;
183 | emotes.features.sponsorMode = data.sponsorMode;
184 | this.setState({ emotes });
185 | break;
186 | case "tr":
187 | this._requestInitData();
188 | break;
189 |
190 | default:
191 | break;
192 | }
193 | } catch (e) {
194 | console.error(e);
195 | }
196 | });
197 | }
198 |
199 | _submitChatMessage = msg => {
200 | const { isBanned, cooldown, user, lastMessage } = this.state;
201 | if (isBanned) return;
202 |
203 | // Client-side cooldown mechanism to prevent mass-spam
204 | // 400ms slowmode for non-mods
205 | if (!isModerator(this.state.user)) {
206 | if (cooldown) return;
207 | this.setState({ cooldown: true });
208 | setTimeout(() => this.setState({ cooldown: false }), 400);
209 |
210 | // Clean out combining marks into normal text, and trim it
211 | msg = stripCombiningMarks(msg.trim());
212 |
213 | // Check if its valid, if not - reject and play EBZ laugh
214 | if (validateMessage(msg)) return audio.play();
215 |
216 | // No duplicate messages
217 | if (lastMessage === msg) return true;
218 | this.setState({ lastMessage: msg });
219 | }
220 | // Send the mesage
221 | this.chat.current.addChatMessage({
222 | t: "ccm",
223 | u: user.username,
224 | c: msg,
225 | b: user.badges
226 | });
227 |
228 | this.socket.emit("chat", JSON.stringify({ c: msg }));
229 | };
230 |
231 | updateBanStatus = () => {
232 | if (this.banTimeout) {
233 | clearTimeout(this.banTimeout);
234 | this.banTimeout = null;
235 | }
236 | if (this.state.user.state !== "complete") return this.setState({ isBanned: false });
237 | if (!this.state.user.punishments || this.state.user.punishments.length === 0)
238 | return this.setState({ isBanned: false });
239 |
240 | const punishment = this.state.user.punishments[0];
241 | console.log(punishment);
242 |
243 | switch (punishment.punishData) {
244 | case "permaban":
245 | return this.setState({ isBanned: true });
246 |
247 | case "timeout":
248 | const stillValid = punishment["end"] > Math.floor(Date.now() / 1000);
249 | if (stillValid) {
250 | this.banTimeout = setTimeout(
251 | this.updateBanStatus,
252 | (punishment["end"] - Math.floor(Date.now() / 1000)) * 1000
253 | );
254 | }
255 |
256 | return this.setState({ isBanned: stillValid });
257 |
258 | default:
259 | return this.setState({ isBanned: true });
260 | }
261 | };
262 |
263 | _submitUsername = username => {
264 | fetch("https://api-production.iceposeidon.com/chat/username", {
265 | method: "POST",
266 | headers: {
267 | "Content-Type": "application/json",
268 | authorization: this.state.token
269 | },
270 | body: JSON.stringify({
271 | username
272 | })
273 | })
274 | .then(res => res.json())
275 | .then(data => {
276 | if (data.success) {
277 | const { user } = data.data;
278 | this.setState({ user });
279 | this._requestInitData();
280 | } else {
281 | let ne = this.state.errors;
282 | ne["username"] = data.error;
283 | this.setState({ errors: ne });
284 | }
285 | });
286 | };
287 |
288 | setChatFontSize = size => {
289 | this.setState({ chatFontSize: size });
290 | };
291 |
292 | setMessageBuffer = size => {
293 | const newSize = Math.min(size, 250);
294 | this.setState({ messages: this.state.messages.slice(0, newSize) });
295 | this.setState({ maxMessages: Math.min(size, 250) });
296 | };
297 |
298 | toggleYoutubeHidden = () => {
299 | saveState("youtubeHidden", !this.state.youtubeHidden);
300 | this.setState({ youtubeHidden: !this.state.youtubeHidden });
301 | };
302 |
303 | toggleMentionSounds = () => {
304 | saveState("mentionSoundsDisabled", !this.state.mentionSoundsDisabled);
305 | this.setState({ mentionSoundsDisabled: !this.state.mentionSoundsDisabled });
306 | };
307 |
308 | render() {
309 | return (
310 |
311 |
325 |
333 |
342 |
343 |
364 |
365 |
366 | );
367 | }
368 | }
369 |
370 | export default App;
371 |
--------------------------------------------------------------------------------
/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React, { Component, Fragment } from "react";
2 | import Button from "@material/react-button";
3 | import Countdown from "react-countdown-now";
4 |
5 | import settingsIcon from "../assets/baseline-settings-20px.svg";
6 | import modIcon from "../assets/baseline-security-24px.svg";
7 | import adminIcon from "../assets/baseline-code-24px.svg";
8 | import EmoteIcon from "../assets/EmoteIcon";
9 | import "@material/react-button/dist/button.css";
10 |
11 | const AutoCompleteItems = ({ tabCompleteSubjects, tabCompletePtr, badges }) =>
12 | tabCompleteSubjects.length === 0 ? (
13 | Nothing here
14 | ) : (
15 | tabCompleteSubjects.map((x, i) => {
16 | switch (x.type) {
17 | case "emote":
18 | return (
19 |
20 |

21 | {x.value}
22 |
23 | );
24 | case "user":
25 | const badgeURL = x.data.badges.length !== 0 ? badges[x.data.badges[0]].image : null;
26 | return (
27 |
28 | {badgeURL ? (
29 |

30 | ) : (
31 | ""
32 | )}
33 | {x.value}
34 |
35 | );
36 |
37 | default:
38 | return null;
39 | }
40 | })
41 | );
42 |
43 | class Footer extends Component {
44 | state = {
45 | inputText: "",
46 | chatInput: "",
47 | wantedUsername: "",
48 | inputHistory: [],
49 | inputHistoryPtr: -1,
50 |
51 | tabCompleteActive: false,
52 | tabCompleteSubjects: [],
53 | tabCompletePtr: 0,
54 | tabCompleteWordStart: 0,
55 | tabCompleteWordEnd: 0
56 | };
57 |
58 | componentDidMount() {
59 | if (this.props.authenticated) {
60 | this.focusChatInput();
61 | }
62 | }
63 |
64 | focusChatInput() {
65 | this.chatInput.focus();
66 | }
67 |
68 | componentDidUpdate() {
69 | if (!this.props.emoteMenuVisible && this.props.user.state === "completed") {
70 | this.focusChatInput();
71 | } else if (this.props.user.state === "nodoc") {
72 | this.usernameInput.focus();
73 | }
74 |
75 | const { tabCompleteActive, tabCompleteWordEnd, tabCompleteSelectionUpdate } = this.state;
76 | if (tabCompleteActive && tabCompleteSelectionUpdate) {
77 | this.chatInput.setSelectionRange(tabCompleteWordEnd, tabCompleteWordEnd);
78 | this.setState({ tabCompleteSelectionUpdate: false });
79 | }
80 | }
81 |
82 | handleUsernameInput = event => {
83 | if (event.target.value.length < 17) {
84 | this.setState({ wantedUsername: event.target.value });
85 | }
86 | };
87 |
88 | handleChatInput = event => {
89 | if (event.target.value.length < 200) {
90 | this.setState({ chatInput: event.target.value, tabCompleteActive: false });
91 | }
92 | };
93 |
94 | handleSelect = () => {
95 | if (!this.chatInput);
96 | const { tabCompleteActive, tabCompleteWordStart, tabCompleteWordEnd } = this.state;
97 |
98 | if (
99 | tabCompleteActive &&
100 | (this.chatInput.selectionStart < tabCompleteWordStart ||
101 | this.chatInput.selectionEnd > tabCompleteWordEnd)
102 | ) {
103 | this.setState({ tabCompleteActive: false });
104 | }
105 | };
106 |
107 | handleTab(next = true) {
108 | const chatInput = this.chatInput;
109 | if (!chatInput) return;
110 |
111 | let {
112 | tabCompleteActive,
113 | tabCompleteSubjects,
114 | tabCompletePtr,
115 | tabCompleteWordStart,
116 | tabCompleteWordEnd
117 | } = this.state;
118 |
119 | const chatValue = this.chatInput.value;
120 | const cursorPosition = chatInput.selectionStart;
121 |
122 | if (!tabCompleteActive) {
123 | tabCompleteActive = true;
124 |
125 | // First order of business - find the point where our word starts
126 | const beforeCursor = chatValue.substr(0, cursorPosition);
127 | const wordStartIndex = beforeCursor.lastIndexOf(" ") + 1;
128 |
129 | // If the word start index is the cursorPosition, then we do not have a word before the cursor.
130 | if (cursorPosition === wordStartIndex) return;
131 |
132 | // Find where our word ends, then grab the word
133 | const afterWord = chatValue.substr(wordStartIndex, chatValue.length - wordStartIndex);
134 | let wordEndIndex = afterWord.indexOf(" ");
135 | wordEndIndex = wordEndIndex === -1 ? afterWord.length : wordEndIndex;
136 | const word = afterWord.substring(0, wordEndIndex);
137 |
138 | tabCompleteSubjects = this.props.autoCompleteService.tabComplete(word);
139 |
140 | tabCompleteWordStart = wordStartIndex;
141 | tabCompleteWordEnd = wordStartIndex + wordEndIndex;
142 | tabCompletePtr = -1;
143 | }
144 |
145 | let newText;
146 | if (tabCompleteSubjects.length !== 0) {
147 | if (next) {
148 | tabCompletePtr =
149 | tabCompletePtr === tabCompleteSubjects.length - 1 ? 0 : tabCompletePtr + 1;
150 | } else {
151 | tabCompletePtr =
152 | tabCompletePtr === 0 ? tabCompleteSubjects.length - 1 : tabCompletePtr - 1;
153 | }
154 |
155 | const beforeText = chatValue.substring(0, tabCompleteWordStart);
156 | const afterText = chatValue.substr(tabCompleteWordEnd, chatValue.length);
157 | const insertedText = tabCompleteSubjects[tabCompletePtr].value;
158 | newText = beforeText + insertedText + " " + afterText;
159 | tabCompleteWordEnd = tabCompleteWordStart + insertedText.length + 1;
160 | }
161 |
162 | this.setState({
163 | tabCompleteActive,
164 | tabCompleteSubjects,
165 | tabCompletePtr,
166 | tabCompleteWordStart,
167 | tabCompleteWordEnd,
168 | ...(newText ? { chatInput: newText } : {}),
169 | tabCompleteSelectionUpdate: true
170 | });
171 | }
172 |
173 | handleKeyDown = event => {
174 | let preventDefault = true;
175 |
176 | const { tabCompleteActive } = this.state;
177 |
178 | switch (event.key) {
179 | case "Enter":
180 | if (tabCompleteActive) {
181 | this.setState({ tabCompleteActive: false });
182 | break;
183 | }
184 |
185 | this.props._submitChatMessage(this.state.chatInput);
186 |
187 | this.setState({
188 | chatInput: "",
189 | inputHistoryPtr: -1,
190 | inputHistory: [this.state.chatInput, ...this.state.inputHistory].slice(0, 100),
191 | tabCompleteActive: false
192 | });
193 | break;
194 |
195 | case "ArrowUp":
196 | case "ArrowDown":
197 | const { inputHistoryPtr, inputHistory } = this.state;
198 | const isUp = event.key === "ArrowUp";
199 |
200 | if (tabCompleteActive) {
201 | this.handleTab(!isUp);
202 | break;
203 | }
204 |
205 | if (inputHistory.length === 0) return;
206 |
207 | const newPtr = Math.min(
208 | Math.max(inputHistoryPtr + (isUp ? 1 : -1), -1),
209 | inputHistory.length - 1
210 | );
211 | this.setState({
212 | inputHistoryPtr: newPtr,
213 | chatInput: newPtr === -1 ? "" : inputHistory[newPtr]
214 | });
215 | break;
216 |
217 | case "Tab":
218 | this.handleTab();
219 | break;
220 |
221 | default:
222 | preventDefault = false;
223 | }
224 |
225 | if (preventDefault) event.preventDefault();
226 |
227 | // some browsers are Pepega
228 | return false;
229 | };
230 |
231 | insertText = text => {
232 | if (this.state.chatInput.length + text.length < 200) {
233 | this.setState({ chatInput: `${this.state.chatInput}${text}`, tabCompleteActive: false });
234 | }
235 | };
236 |
237 | renderPunishment() {
238 | const punishment = this.props.user.punishments[0];
239 |
240 | return (
241 |
242 | {punishment.punishData === "timeout" ? (
243 |
244 | Timed out:
245 |
246 | ) : (
247 | "Permbanned"
248 | )}
249 |
250 | );
251 | }
252 |
253 | renderSettingsIcons = user => {
254 | if (!user.badges) return null;
255 | const isAdmin = user.badges.includes("admin");
256 | const isGlobalMod = isAdmin || user.badges.includes("globalmod");
257 | return (
258 |
259 | {isGlobalMod && (
260 |
this.props.toggleModal("moderatorSettings")}
262 | className="optionButton"
263 | alt="Moderation Settings"
264 | src={modIcon}
265 | />
266 | )}
267 | {isAdmin && (
268 |
this.props.toggleModal("adminSettings")}
270 | className="optionButton"
271 | alt="Admin Settings"
272 | src={adminIcon}
273 | />
274 | )}
275 |
276 | );
277 | };
278 |
279 | render() {
280 | return (
281 |
282 |
285 | {this.props.isBanned &&
286 | this.props.user.punishments &&
287 | this.props.user.punishments.length ? (
288 | this.renderPunishment()
289 | ) : this.props.user.state === "complete" ? (
290 | this.props.emotes.features.sponsorMode &&
291 | !this.props.user.badges.incluydes("sponsor") &&
292 | !this.props.user.badges.includes("admin") &&
293 | !this.props.user.badges.includes("globalmod") &&
294 | !this.props.user.badges.includes("cxstreamer") ? (
295 |
302 |
303 | Sponsor-only mode is enabled
304 |
305 |
306 | If you're already a sponsor, link it by typing{" "}
307 | !link [new chat username] in Ice's YouTube chat
308 |
309 |
310 | ) : (
311 |
312 | {this.state.tabCompleteActive ? (
313 |
323 | ) : null}
324 |
325 |
(this.chatInput = el)}
328 | value={this.state.chatInput}
329 | onKeyDown={this.handleKeyDown}
330 | onChange={this.handleChatInput}
331 | onSelect={this.handleSelect}
332 | placeholder="Send a message..."
333 | />
334 |
339 |
340 | )
341 | ) : (
342 |
343 | {this.props.user.state === "none" ? (
344 |
345 | ) : (
346 |
347 | {this.props.user.state === "nodoc" ? (
348 |
355 |
362 | Choose a username
363 |
364 |
365 | (this.usernameInput = el)}
367 | value={this.state.wantedUsername}
368 | onChange={this.handleUsernameInput}
369 | className="usernameInput"
370 | placeholder="Username"
371 | disabled={this.props.isBanned}
372 | />
373 |
379 |
380 | {this.props.errors.username && (
381 |
382 | {this.props.errors.username}
383 |
384 | )}
385 |
386 | ) : (
387 |
unknown error (state_unknown), refresh?
388 | )}
389 |
390 | )}
391 |
392 | )}
393 |
394 |
395 |
396 |
![]()
this.props.toggleModal("generalSettings")}
398 | className="optionButton"
399 | alt="General Settings"
400 | src={settingsIcon}
401 | />
402 | {this.renderSettingsIcons(this.props.user)}
403 | {/*

*/}
404 |
405 | {this.props.user.state === "complete" && (
406 |
407 | {this.props.user.badges.map((b, i) => {
408 | return (
409 |

415 | );
416 | })}
417 |
0
421 | ? this.props.emotes.badges[this.props.user.badges[0]].nameColor
422 | : this.props.generateColorFromString(this.props.user.username),
423 | fontFamily: "Open Sans",
424 | margin: 0,
425 | fontWeight: 600
426 | }}>
427 | {this.props.user.username}
428 |
429 |
430 | )}
431 |
432 |
433 |
434 | );
435 | }
436 | }
437 |
438 | export default Footer;
439 |
--------------------------------------------------------------------------------