├── .env-sample ├── .eslintrc.json ├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── api ├── server.ts └── utils.ts ├── assets └── images │ ├── badge.png │ ├── chat-fusion.png │ └── popup.png ├── chat ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── public │ └── index.html ├── src │ ├── App.tsx │ ├── Components │ │ ├── Chat │ │ │ ├── Chat.tsx │ │ │ └── index.ts │ │ ├── ChatDivider │ │ │ ├── ChatDivider.tsx │ │ │ └── index.ts │ │ ├── FilterToggle │ │ │ ├── FilterToggle.tsx │ │ │ └── index.ts │ │ └── Message │ │ │ ├── Message.tsx │ │ │ └── index.ts │ ├── Hooks │ │ └── UseMessageListener │ │ │ └── index.ts │ ├── assets │ │ ├── Icons │ │ │ ├── DisableFilter.tsx │ │ │ ├── EnableFilter.tsx │ │ │ ├── Kick.tsx │ │ │ ├── Reload.tsx │ │ │ ├── Save.tsx │ │ │ ├── Twitch.tsx │ │ │ └── YouTube.tsx │ │ └── react.svg │ ├── env.d.ts │ ├── index.css │ ├── main.tsx │ ├── utils │ │ └── faker.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── chrome-extension ├── background-script │ └── index.ts ├── content-script │ ├── Chat.ts │ └── index.ts ├── manifest.json └── popup │ ├── chat-fusion.png │ ├── github-mark.svg │ └── popup.html ├── package.json ├── types.ts ├── utils ├── ChromeUtils │ └── index.ts ├── DOMUtils │ └── index.ts ├── DateTime │ └── index.ts ├── Services │ └── Messages │ │ └── index.ts ├── Socket │ └── index.ts ├── StringUtils │ └── index.ts └── WebRTC │ └── index.ts └── vite.config.js /.env-sample: -------------------------------------------------------------------------------- 1 | VITE_STYLE_MODE= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier", 7 | "plugin:prettier/recommended" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 2020, 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["@typescript-eslint", "prettier"], 14 | "rules": { 15 | "prettier/prettier": "error" 16 | }, 17 | "env": { 18 | "node": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | 3 | * @agjs 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Code 17 | uses: actions/checkout@v2 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: "20.x" 23 | 24 | - name: Cache Node.js modules 25 | uses: actions/cache@v2 26 | with: 27 | path: ~/.npm 28 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-node- 31 | 32 | - name: Install Dependencies 33 | run: npm install 34 | 35 | - name: Run Linters 36 | run: npm run lint 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | chrome-extension/dist 2 | node_modules 3 | pnpm-lock.yaml 4 | .todo 5 | .env 6 | messages.json 7 | links.json 8 | api/*.js 9 | types.js -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | node_modules/.bin/lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.8.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | ### Added 8 | - dev dependency `cfx` in order to add the `copy-static` script in `package.json` 9 | - a popup file in the chrome extension to permit the user to directly open the react page. Now the link it's hardcoded 10 |

11 | 12 |

13 | 14 | - add a retry system to connect to the chat in `index.ts` in order to manage anomalies in case the component is loaded faster than the page 15 | - add a `sessionId` generated each time there is a "reload" of the page so the webapp inserts a divider to let the streamer knows where the 16 | chat session begins 17 | - add `ChatDivider` component to let the user know when the chat has been reloaded 18 | - add a footer on the chat that indicates if the autoreload is `on` or `off` 19 | - add a badge on the extension in order to detect if it is connected to a chat 20 | 21 |

22 | 23 |

24 | 25 | ### Changed 26 | - `build` script in order to build the Chrome extension and copy all the relevant files in the ui folder 27 | 28 | ### Notes 29 | - the ChatDivider and the footer needs a graphical review but they are both working 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Programmer Network 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat Fusion - Unified Chat For Multistreaming 2 | 3 | _Support this project by buying me a coffee on_ **[Patreon](https://patreon.com/programmer_network?utm_medium=clipboard_copy&utm_source=copyLink&utm_campaign=creatorshare_creator&utm_content=join_link)** 4 | 5 |

6 | 7 |

8 | 9 | Chat Fusion is a self-hosted solution designed to consolidate chat feeds from multiple platforms into a singular, unified interface. Built with a focus on customization and data privacy, the service does not rely on external services and can be easily tailored to your needs. 10 | 11 | ```mermaid 12 | sequenceDiagram 13 | participant CE as Chrome Extension 14 | participant LA as Localhost API (Port 3000) 15 | participant Client 16 | 17 | loop MutationObserver 18 | CE->>+Twitch: Observe 19 | CE->>+Kick: Observe 20 | CE->>+YouTube: Observe 21 | end 22 | 23 | CE->>LA: POST observed chat data 24 | 25 | loop Interval Polling 26 | Client->>LA: GET observed chat data 27 | LA->>Client: Return aggregated chat data 28 | end 29 | ``` 30 | 31 | ## How It Works 32 | 33 | Under the hood, Chat Fusion employs a Chrome content script to inject code into the web pages of various chat platforms like Twitch, YouTube, and Kick. The script utilizes the `MutationObserver` API to actively monitor changes to the chat DOM elements. 34 | 35 | Once a new chat message arrives, the script collects pertinent information—be it from Twitch, YouTube, or any other supported platform—and normalizes it into a standardized data structure. 36 | 37 | ```typescript 38 | export interface IMessage { 39 | id: string; 40 | platform: string; // The platform where the message originates, e.g., 'Twitch' 41 | content: string; // The textual content of the chat message 42 | emojis: string[]; // Any emojis included in the message 43 | author: string; // The username of the person who sent the message 44 | badge: string; // A URL pointing to the badge or avatar of the author 45 | } 46 | ``` 47 | 48 | ## Why This Matters 49 | 50 | The primary advantage is consistency. Regardless of the originating platform, each message is transformed into a common, predictable format. This ensures a seamless contract between the client and the API, facilitating easier integration and providing a streamlined user experience. 51 | 52 | ## Features 53 | 54 | - **Full Customizability**: Change the appearance, behavior, or add new platforms as per your requirements. 55 | - **Data Privacy**: Your data never leaves your server, ensuring complete privacy and security. 56 | - **Universal API Contract**: The uniform `IMessage` structure simplifies client-side development, making it easier to extend features or integrate with other services. 57 | 58 | By offering a consistent, user-friendly interface, Chat Fusion makes managing and participating in chats across multiple platforms simpler and more efficient than ever before. 59 | 60 | ## Setup 61 | 62 | ```bash 63 | pnpm i 64 | pnpm build # to build the chrome extension 65 | 66 | cd chat 67 | pnpm i # to install the dependencies for the chat 68 | ``` 69 | 70 | You have to run the client (React) code and the API (fastify) separetly. 71 | 72 | ```bash 73 | # In one terminal session 74 | pnpm server-dev # to run an api in development mode with live reload using Nodemon 75 | 76 | # In another 77 | cd chat 78 | pnpm dev # to run the chat react app 79 | ``` 80 | 81 | **Chrome Extension** 82 | 83 | - In your Chrome Browser, head to `chrome://extensions` 84 | - Enable `Developer mode` in the top right corner 85 | - In the top left corner, click on `Load unpacked` and load this very repository into there. Essentially, `manifest.json`, `src` and `dist` are the extension part of this repository. 86 | 87 | To ensure that the Chat Fusion Chrome Extension functions correctly, you'll need to open each platform chat in either a new tab or a separate window. This is essential because most streaming platforms, like YouTube, embed their chat interfaces within iframes. Due to browser security constraints, Chrome Content Scripts can only access the DOM of the parent page and not any embedded iframes. 88 | 89 | Steps to Setup: 90 | 91 | - Install the Chat Fusion Chrome Extension. 92 | - Run the API and client. 93 | - Open the chat interface of each streaming platform you're broadcasting to in a new tab or window. 94 | 95 | By doing this, the content script from the Chrome Extension will have the necessary access to query and traverse the DOM of these chats. This enables it to locate the specific elements required for scraping chat data. 96 | 97 | ### Using Fake Generated Messages for UI Development 98 | 99 | If you're working on the UI and need to see how it interacts with messages, you have the option to enable the generation of fake messages. You can set the `VITE_USE_DUMMY_DATA` environment variable to `"true"` either by exporting it in your terminal or by adding it to your `src/.env` file: 100 | 101 | **Setting in the `src/.env` file:** 102 | 103 | ```env 104 | VITE_USE_DUMMY_DATA="true" 105 | ``` 106 | 107 | Once this flag is set to `"true"`, the app will switch to using the `useMessageListenerDev` hook instead of the `useMessageListenerProd` hook. This will generate fake messages at regular intervals, allowing you to test the UI without requiring a backend service for messages. 108 | -------------------------------------------------------------------------------- /api/server.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import fastify, { FastifyReply, FastifyRequest } from "fastify"; 3 | import FastifySocketIO from "fastify-socket.io"; 4 | import cors from "@fastify/cors"; 5 | import { Socket } from "socket.io"; 6 | 7 | import { getRandomHexColor, parseLinks, saveLinks } from "./utils"; 8 | import { CustomFastifyInstance, IMessage, SocketType } from "../types"; 9 | 10 | const server = fastify({ logger: true }) as CustomFastifyInstance; 11 | const messages: IMessage[] = []; 12 | const savedMessages: IMessage[] = []; 13 | const userColors: { [key: string]: string } = {}; 14 | 15 | server.register(cors, { 16 | origin: true, 17 | }); 18 | 19 | server.register(FastifySocketIO, { 20 | cors: { 21 | origin: "*", 22 | methods: ["GET", "POST"], 23 | }, 24 | }); 25 | 26 | server.get("/api/messages", async (_: FastifyRequest, reply: FastifyReply) => { 27 | reply.send(messages); 28 | }); 29 | 30 | server.post( 31 | "/api/save-message", 32 | async (request: FastifyRequest, reply: FastifyReply) => { 33 | const message = request.body as IMessage; 34 | savedMessages.push(message); 35 | 36 | fs.writeFile("messages.json", JSON.stringify(savedMessages), (err) => { 37 | if (err) { 38 | console.log(err); 39 | } 40 | }); 41 | 42 | reply.send(savedMessages); 43 | } 44 | ); 45 | 46 | let reactSocket: Socket | null = null; 47 | let extensionSocket: Socket | null = null; 48 | 49 | server.ready((serverError) => { 50 | if (serverError) { 51 | console.log(serverError); 52 | process.exit(1); 53 | } 54 | 55 | server.io?.on("connection", (socket: Socket) => { 56 | socket.on("register", (type) => { 57 | if (type === SocketType.REACT) { 58 | reactSocket = socket; 59 | } else if (type === SocketType.EXTENSION) { 60 | extensionSocket = socket; 61 | } 62 | }); 63 | 64 | socket.on("message", async (message) => { 65 | if (socket === reactSocket) { 66 | extensionSocket?.emit("message", message); 67 | } else if (socket === extensionSocket) { 68 | if (!userColors[message.author]) { 69 | userColors[message.author] = getRandomHexColor(); 70 | } 71 | 72 | const links = parseLinks(message.content); 73 | if (links.length > 0) { 74 | await saveLinks(links); 75 | } 76 | 77 | reactSocket?.emit("message", { 78 | ...message, 79 | authorColor: userColors[message.author], 80 | }); 81 | } 82 | }); 83 | }); 84 | }); 85 | 86 | const start = async () => { 87 | try { 88 | await server.listen({ 89 | port: 3000, 90 | host: "::", 91 | }); 92 | server.log.info(`Server listening on http://localhost:3000/`); 93 | } catch (err) { 94 | server.log.error(err); 95 | process.exit(1); 96 | } 97 | }; 98 | 99 | start(); 100 | -------------------------------------------------------------------------------- /api/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import Linkify from "linkify-it"; 3 | import { promisify } from "util"; 4 | 5 | const readFileAsync = promisify(fs.readFile); 6 | const writeFileAsync = promisify(fs.writeFile); 7 | const linkify = Linkify(); 8 | 9 | const hexColors = [ 10 | "#F43F5E", 11 | "#F87171", 12 | "#F94144", // rose 13 | "#C084FC", 14 | "#A855F7", 15 | "#9333EA", // fuchsia 16 | "#8B5CF6", 17 | "#7C3AED", 18 | "#6D28D9", // purple 19 | "#9333EA", 20 | "#7C3AED", 21 | "#6D28D9", // violet 22 | "#6366F1", 23 | "#4F46E5", 24 | "#4338CA", // indigo 25 | "#3B82F6", 26 | "#2563EB", 27 | "#1D4ED8", // blue 28 | "#60A5FA", 29 | "#3B82F6", 30 | "#2563EB", // sky 31 | "#22D3EE", 32 | "#06B6D4", 33 | "#0891B2", // cyan 34 | "#2DD4BF", 35 | "#14B8A6", 36 | "#0D9488", // teal 37 | "#10B981", 38 | "#059669", 39 | "#047857", // emerald 40 | "#22C55E", 41 | "#16A34A", 42 | "#15803D", // green 43 | "#65A30D", 44 | "#4D7C0F", 45 | "#3F6212", // lime 46 | "#FACC15", 47 | "#EAB308", 48 | "#CA8A04", // yellow 49 | "#FDBA74", 50 | "#F97316", 51 | "#EA580C", // amber 52 | "#FB923C", 53 | "#F97316", 54 | "#EA580C", // orange 55 | "#EF4444", 56 | "#DC2626", 57 | "#B91C1C", // red 58 | ]; 59 | 60 | export const getRandomHexColor = (): string => { 61 | const randomIndex = Math.floor(Math.random() * hexColors.length); 62 | return hexColors[randomIndex]; 63 | }; 64 | 65 | export const parseLinks = (content: string): string[] => { 66 | const matches = linkify.match(content); 67 | 68 | return matches ? matches.map((match: { url: string }) => match.url) : []; 69 | }; 70 | 71 | export const saveLinks = async (newLinks: string[]): Promise => { 72 | try { 73 | const links = JSON.parse( 74 | await readFileAsync("links.json", "utf-8") 75 | ) as string; 76 | 77 | await writeFileAsync( 78 | "links.json", 79 | JSON.stringify([...new Set([...links, ...newLinks])]) 80 | ); 81 | } catch (err) { 82 | console.error(err); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /assets/images/badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Programmer-Network/Chat-Fusion/4635df55149f6d49d171d8c14b9b1900569afd06/assets/images/badge.png -------------------------------------------------------------------------------- /assets/images/chat-fusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Programmer-Network/Chat-Fusion/4635df55149f6d49d171d8c14b9b1900569afd06/assets/images/chat-fusion.png -------------------------------------------------------------------------------- /assets/images/popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Programmer-Network/Chat-Fusion/4635df55149f6d49d171d8c14b9b1900569afd06/assets/images/popup.png -------------------------------------------------------------------------------- /chat/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /chat/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /chat/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chat Fusion 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /chat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "popup-src", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "classnames": "^2.3.2", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.15", 19 | "@types/react-dom": "^18.2.7", 20 | "@typescript-eslint/eslint-plugin": "^6.0.0", 21 | "@typescript-eslint/parser": "^6.0.0", 22 | "@vitejs/plugin-react": "^4.0.3", 23 | "autoprefixer": "^10.4.16", 24 | "eslint": "^8.45.0", 25 | "eslint-plugin-react-hooks": "^4.6.0", 26 | "eslint-plugin-react-refresh": "^0.4.3", 27 | "postcss": "^8.4.31", 28 | "tailwindcss": "^3.3.3", 29 | "typescript": "^5.0.2", 30 | "vite": "^4.4.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /chat/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /chat/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + React + TS 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /chat/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useEffect, useState } from "react"; 2 | import { Chat } from "./Components/Chat"; 3 | import { useMessageListener } from "./Hooks/UseMessageListener"; 4 | import classNames from "classnames"; 5 | import { IMessage } from "../../types"; 6 | 7 | export const LevelContext = createContext(null); 8 | 9 | function App() { 10 | const { messages, sendMessage } = useMessageListener(); 11 | const [focusedMessage, setFocusedMessage] = useState(null); 12 | const [filter, setFilter] = useState(""); 13 | const [filtered, setFiltered] = useState([]); 14 | 15 | const handleFilterUser = async (message: IMessage) => { 16 | setFilter(filter ? "" : message.author); 17 | setFiltered( 18 | filter 19 | ? [] 20 | : messages.filter( 21 | (message: IMessage) => message.author === filter 22 | ) 23 | ); 24 | }; 25 | 26 | const handleOnAction = (action: string, data: unknown) => { 27 | if (action === "filter") { 28 | handleFilterUser(data as IMessage); 29 | } 30 | }; 31 | 32 | useEffect(() => { 33 | if (!filter) { 34 | setFiltered([]); 35 | return; 36 | } 37 | 38 | setFiltered( 39 | messages.filter((message: IMessage) => message.author === filter) 40 | ); 41 | }, [messages, filter]); 42 | 43 | return ( 44 |
50 |
51 | 59 |
60 |
61 | ); 62 | } 63 | 64 | export default App; 65 | -------------------------------------------------------------------------------- /chat/src/Components/Chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, FC, useState } from "react"; 2 | import { Message } from "../Message"; 3 | import classNames from "classnames"; 4 | import { ReloadIcon } from "../../assets/Icons/Reload"; 5 | import { IMessage } from "../../../../types"; 6 | 7 | export const Chat: FC<{ 8 | messages: IMessage[]; 9 | sendMessage: (message: string) => void; 10 | focusedMessage: IMessage | null; 11 | setFocusedMessage: (message: IMessage | null) => void; 12 | onAction: (action: string, data: unknown) => void; 13 | filter: string; 14 | }> = ({ messages, focusedMessage, setFocusedMessage, onAction, filter }) => { 15 | const messagesEndRef = useRef(null); 16 | const [autoScroll, setAutoScroll] = useState(true); 17 | 18 | const handleFocusMessage = (message: IMessage) => { 19 | setFocusedMessage(message.id === focusedMessage?.id ? null : message); 20 | }; 21 | 22 | const scrollToBottom = () => { 23 | if (!messagesEndRef.current) { 24 | return; 25 | } 26 | 27 | messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); 28 | }; 29 | 30 | useEffect(() => { 31 | if (!autoScroll) { 32 | return; 33 | } 34 | 35 | scrollToBottom(); 36 | }, [messages, autoScroll]); 37 | 38 | return ( 39 | <> 40 |
41 |
47 | {messages.map((message: IMessage, index: number) => { 48 | return ( 49 | 57 | ); 58 | })} 59 |
60 |
61 |
62 | 63 | {!focusedMessage && ( 64 |
65 | setAutoScroll(!autoScroll)} 67 | className={classNames( 68 | "w-12 text-gray-500 cursor-pointer", 69 | { 70 | "animate-spin !text-green-500": autoScroll, 71 | } 72 | )} 73 | /> 74 |
75 | )} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /chat/src/Components/Chat/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Chat"; 2 | -------------------------------------------------------------------------------- /chat/src/Components/ChatDivider/ChatDivider.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { IMessage } from "../../types"; 3 | 4 | export const ChatDivider: FC<{ 5 | message: IMessage; 6 | }> = ({ message }) => { 7 | const { platform } = message; 8 | 9 | return ( 10 |
11 | 12 | {platform} reloaded 13 | 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /chat/src/Components/ChatDivider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ChatDivider"; 2 | -------------------------------------------------------------------------------- /chat/src/Components/FilterToggle/FilterToggle.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { DisableFilterIcon } from "../../assets/Icons/DisableFilter"; 3 | import { EnableFilterIcon } from "../../assets/Icons/EnableFilter"; 4 | 5 | export const FilterToggle: FC<{ 6 | className?: string; 7 | filter: string; 8 | onToggle: () => void; 9 | }> = ({ className, onToggle, filter }) => { 10 | return ( 11 | { 14 | e.stopPropagation(); 15 | onToggle(); 16 | }} 17 | > 18 | {filter ? ( 19 | 20 | ) : ( 21 | 22 | )} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /chat/src/Components/FilterToggle/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FilterToggle"; 2 | -------------------------------------------------------------------------------- /chat/src/Components/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { IMessage } from "../../types"; 3 | import classNames from "classnames"; 4 | import { SaveIcon } from "../../assets/Icons/Save"; 5 | import { FilterToggle } from "../FilterToggle/FilterToggle"; 6 | import { TwitchIcon } from "../../assets/Icons/Twitch"; 7 | import { YouTubeIcon } from "../../assets/Icons/YouTube"; 8 | import { KickIcon } from "../../assets/Icons/Kick"; 9 | 10 | const getIcon = (platform: string) => { 11 | switch (platform) { 12 | case "twitch": 13 | return ; 14 | case "youtube": 15 | return ; 16 | case "kick": 17 | return ; 18 | default: 19 | return null; 20 | } 21 | }; 22 | 23 | export const Message: FC<{ 24 | message: IMessage; 25 | filter: string; 26 | onMessageClick: (message: IMessage) => void; 27 | onAction: (action: string, message: unknown) => void; 28 | focusedMessage: IMessage | null; 29 | }> = ({ message, focusedMessage, onMessageClick, onAction, filter }) => { 30 | const { content, author, badges } = message; 31 | const [hoveredId, setHoveredId] = useState(""); 32 | 33 | const handleSaveMessage = (message: IMessage) => { 34 | fetch("http://localhost:3000/api/save-message", { 35 | method: "POST", 36 | headers: { 37 | "Content-Type": "application/json", 38 | }, 39 | body: JSON.stringify(message), 40 | }); 41 | }; 42 | 43 | const isHovered = hoveredId === message.id; 44 | 45 | return ( 46 |
onMessageClick(message)} 48 | onMouseOver={() => setHoveredId(message.id)} 49 | onMouseLeave={() => setHoveredId("")} 50 | className={classNames( 51 | "flex flex-col cursor-pointer relative border-gray-500 px-4 py-2", 52 | { 53 | "px-16 py-16 w-4/12 border-8 border-l-8": 54 | focusedMessage && focusedMessage.id === message.id, 55 | hidden: focusedMessage && focusedMessage.id !== message.id, 56 | "border-indigo-700": message.platform === "twitch", 57 | "border-rose-700": message.platform === "youtube", 58 | "border-green-700": message.platform === "kick", 59 | } 60 | )} 61 | > 62 |
67 | {getIcon(message.platform)} 68 |
79 |
80 | {badges?.map((badge, index) => ( 81 | {badge} 82 | ))} 83 |
84 | {author} 85 | {isHovered && ( 86 |
87 |
{ 89 | e.stopPropagation(); 90 | handleSaveMessage(message); 91 | }} 92 | > 93 |
94 | 99 |
100 |
101 | onAction("filter", message)} 104 | /> 105 |
106 | )} 107 |
108 |
118 | {content} 119 |
120 |
121 |
122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /chat/src/Components/Message/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Message"; 2 | -------------------------------------------------------------------------------- /chat/src/Hooks/UseMessageListener/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import DummyMessageGenerator from "../../utils/faker"; 3 | import SocketUtils from "../../../../utils/Socket"; 4 | import { IMessage, SocketType } from "../../../../types"; 5 | 6 | const socket = new SocketUtils({ 7 | socketServer: "http://localhost:3000", 8 | socketType: SocketType.REACT, 9 | }); 10 | 11 | interface IUseMessageListener { 12 | messages: IMessage[]; 13 | sendMessage: (message: string) => void; 14 | } 15 | 16 | const useMessageListenerProd = (): IUseMessageListener => { 17 | const [messages, setMessages] = useState([]); 18 | 19 | useEffect(() => { 20 | socket.onMessage((message: IMessage) => { 21 | setMessages([...messages, message]); 22 | }); 23 | 24 | return () => { 25 | // TODO: fix 26 | // Cleanup: disconnect the socket when the component is unmounted 27 | // socket.disconnect(); 28 | }; 29 | }, [messages]); 30 | 31 | return { messages, sendMessage: socket.sendMessageFromUI }; 32 | }; 33 | 34 | const useMessageListenerDev = (): IUseMessageListener => { 35 | const [messages, setMessages] = useState([]); 36 | 37 | useEffect(() => { 38 | const intervalId = setInterval(() => { 39 | const newMessage = DummyMessageGenerator.generate(); 40 | setMessages((prevMessages) => [...prevMessages, newMessage]); 41 | }, 1000); 42 | 43 | return () => { 44 | clearInterval(intervalId); 45 | }; 46 | }, []); 47 | 48 | return { messages, sendMessage: () => {} }; 49 | }; 50 | 51 | export const useMessageListener = 52 | import.meta.env.VITE_USE_DUMMY_DATA === "true" 53 | ? useMessageListenerDev 54 | : useMessageListenerProd; 55 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/DisableFilter.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function DisableFilterIcon(props: SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/EnableFilter.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function EnableFilterIcon(props: SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/Kick.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function KickIcon(props: SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/Reload.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function ReloadIcon(props: SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/Save.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function SaveIcon(props: SVGProps) { 4 | return ( 5 | 10 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/Twitch.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function TwitchIcon(props: SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /chat/src/assets/Icons/YouTube.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function YouTubeIcon(props: SVGProps) { 4 | return ( 5 | 10 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /chat/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /chat/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_USE_DUMMY_DATA: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /chat/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Webkit browsers */ 6 | ::-webkit-scrollbar { 7 | width: 7px; 8 | } 9 | 10 | ::-webkit-scrollbar-track { 11 | background: #1b1f23; 12 | } 13 | 14 | ::-webkit-scrollbar-thumb { 15 | background: #4338ca; 16 | } 17 | 18 | ::-webkit-scrollbar-thumb:hover { 19 | background: #555; 20 | } 21 | -------------------------------------------------------------------------------- /chat/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /chat/src/utils/faker.ts: -------------------------------------------------------------------------------- 1 | import { IMessage } from "../types"; 2 | import { faker } from "@faker-js/faker"; 3 | 4 | export default class DummyMessageGenerator { 5 | public static generate(): IMessage { 6 | return { 7 | id: faker.string.uuid(), 8 | content: faker.lorem.sentence(), 9 | author: faker.person.fullName(), 10 | badge: faker.image.url(), 11 | emojis: Array.from( 12 | { length: faker.number.int({ min: 0, max: 5 }) }, 13 | () => faker.image.url() 14 | ), 15 | platform: faker.helpers.arrayElement(["twitch", "kick", "youtube"]), 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /chat/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /chat/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /chat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "../utils/DOMUtils", 25 | "src", 26 | "../chrome-extension", 27 | "../utils/DateTime", 28 | "../utils/ChromeUtils", 29 | "../utils/StringUtils", 30 | "../utils/Socket", 31 | "../types.ts" 32 | ], 33 | "references": [{ "path": "./tsconfig.node.json" }] 34 | } 35 | -------------------------------------------------------------------------------- /chat/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /chat/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /chrome-extension/background-script/index.ts: -------------------------------------------------------------------------------- 1 | import { SocketType } from "../../types"; 2 | import SocketUtils from "../../utils/Socket"; 3 | 4 | const socket = new SocketUtils({ 5 | socketServer: "http://localhost:3000", 6 | socketType: SocketType.EXTENSION, 7 | transports: ["websocket"], 8 | }); 9 | 10 | socket.onMessage((message) => { 11 | chrome.tabs.query( 12 | { 13 | url: "https://www.twitch.tv/popout/programmer_network/chat?popout=", 14 | }, 15 | (tabs) => { 16 | tabs.forEach((tab) => { 17 | const tabId = tab.id; 18 | if (!tabId) { 19 | return; 20 | } 21 | 22 | chrome.tabs.sendMessage(tabId, { 23 | type: "CHAT_FUSION_SEND_MESSAGE", 24 | payload: message, 25 | }); 26 | }); 27 | } 28 | ); 29 | }); 30 | 31 | chrome.runtime.onMessage.addListener((request, _, sendResponse) => { 32 | if (request.type === "CHAT_FUSION_CONTENT_SCRAPED") { 33 | socket.sendMessageFromBackgroundScript(request.payload); 34 | } 35 | 36 | if (request.type === "badge") { 37 | setBadgeStatus(request.status); 38 | sendResponse({ status: request.status }); 39 | } 40 | }); 41 | 42 | const setBadgeStatus = (status: boolean) => { 43 | if (status) { 44 | chrome.action.setBadgeText({ text: " " }); 45 | chrome.action.setBadgeTextColor({ color: "#FFFFFF" }); 46 | chrome.action.setBadgeBackgroundColor({ color: "#00FF00" }); 47 | return; 48 | } 49 | 50 | chrome.action.setBadgeText({ text: " " }); 51 | chrome.action.setBadgeTextColor({ color: "#FFFFFF" }); 52 | chrome.action.setBadgeBackgroundColor({ color: "#FF0000" }); 53 | }; 54 | -------------------------------------------------------------------------------- /chrome-extension/content-script/Chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IChromeUtils, 3 | IDOMUtils, 4 | IDateTimeUtils, 5 | IStringUtils, 6 | IWebRTCUtils, 7 | } from "../../types"; 8 | 9 | export default class ChatLoader { 10 | private intervalId: ReturnType | null = null; 11 | private retryCount = 0; 12 | private readonly maxRetries = 3; 13 | private readonly retryInterval = 1000; 14 | private readonly sessionId: string; 15 | 16 | constructor( 17 | private domUtils: IDOMUtils, 18 | private webRTCUtils: IWebRTCUtils, 19 | private chromeUtils: IChromeUtils, 20 | private stringUtils: IStringUtils, 21 | private dateTimeUtils: IDateTimeUtils 22 | ) { 23 | this.sessionId = this.stringUtils.makeId(10); 24 | } 25 | 26 | private isChatContainerAvailable(): boolean { 27 | return this.domUtils.getChatContainer() !== null; 28 | } 29 | 30 | private initializeChat(chatElement: Element): void { 31 | chrome.runtime.onMessage.addListener((message) => { 32 | if (message.type === "CHAT_FUSION_SEND_MESSAGE") { 33 | this.domUtils.sendMessageToChat(message.payload.content); 34 | } 35 | }); 36 | 37 | const chatObserver = new MutationObserver((mutations) => { 38 | mutations.forEach((mutation) => { 39 | if (!mutation.addedNodes.length) { 40 | return; 41 | } 42 | 43 | Array.from(mutation.addedNodes).forEach((chatNode) => { 44 | if (!(chatNode instanceof Element)) { 45 | return; 46 | } 47 | 48 | const content = this.domUtils.getMessageText(chatNode); 49 | if (!content) { 50 | return; 51 | } 52 | 53 | chrome.runtime.sendMessage({ 54 | type: "CHAT_FUSION_CONTENT_SCRAPED", 55 | payload: { 56 | sessionId: this.sessionId, 57 | id: Math.random().toString(36).substr(2, 9), 58 | timestamp: this.dateTimeUtils.getFriendlyTime(), 59 | platform: 60 | this.stringUtils.getPlatformNameByHostname(), 61 | content, 62 | emojis: this.domUtils.getMessageEmojis(chatNode), 63 | author: this.domUtils.getMessageAuthor(chatNode), 64 | badges: this.domUtils.getMessageBadges(chatNode), 65 | }, 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | chatObserver.observe(chatElement, { childList: true }); 72 | this.webRTCUtils.initWebRTCConnection(); 73 | } 74 | 75 | public start(): void { 76 | this.intervalId = setInterval(() => { 77 | if (this.retryCount > this.maxRetries) { 78 | clearInterval(this.intervalId!); 79 | this.chromeUtils.setBadgeStatus(false); 80 | return; 81 | } 82 | 83 | if (this.isChatContainerAvailable()) { 84 | const chat = this.domUtils.getChatContainer(); 85 | 86 | if (chat) { 87 | this.initializeChat(chat); 88 | } 89 | 90 | clearInterval(this.intervalId!); 91 | this.chromeUtils.setBadgeStatus(true); 92 | return; 93 | } 94 | 95 | this.retryCount++; 96 | }, this.retryInterval); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /chrome-extension/content-script/index.ts: -------------------------------------------------------------------------------- 1 | import ChatLoader from "./Chat"; 2 | import ChromeUtils from "../../utils/ChromeUtils"; 3 | import DOMUtils from "../../utils/DOMUtils"; 4 | import DateTimeUtils from "../../utils/DateTime"; 5 | import StringUtils from "../../utils/StringUtils"; 6 | import WebRTC from "../../utils/WebRTC"; 7 | 8 | const domUtils = new DOMUtils(); 9 | const webRTCUtils = new WebRTC(); 10 | const chromeUtils = new ChromeUtils(); 11 | const stringUtils = new StringUtils(); 12 | const dateTimeUtils = new DateTimeUtils(); 13 | 14 | new ChatLoader( 15 | domUtils, 16 | webRTCUtils, 17 | chromeUtils, 18 | stringUtils, 19 | dateTimeUtils 20 | ).start(); 21 | -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Chat Fusion", 4 | "description": "Chat Fusion for Streamers", 5 | "action": { 6 | "default_popup": "./dist/popup.html", 7 | "default_title": "Chat Fusion" 8 | }, 9 | "version": "1.0", 10 | "permissions": ["webRequest", "tabs"], 11 | "minimum_chrome_version": "116", 12 | "content_scripts": [ 13 | { 14 | "matches": [ 15 | "*://*.kick.com/*", 16 | "*://*.twitch.tv/*", 17 | "*://*.youtube.com/*" 18 | ], 19 | "js": ["./dist/content.js"] 20 | } 21 | ], 22 | "background": { 23 | "service_worker": "./dist/background.js" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /chrome-extension/popup/chat-fusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Programmer-Network/Chat-Fusion/4635df55149f6d49d171d8c14b9b1900569afd06/chrome-extension/popup/chat-fusion.png -------------------------------------------------------------------------------- /chrome-extension/popup/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /chrome-extension/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Chat Fusion 8 | 14 | 15 | 16 |
17 |
18 |
19 | Chat Fusion Logo 25 |
26 |
27 |
28 |
29 |

Chat Fusion

30 |
31 |
32 |
33 |
34 |

Your Fusion Companion

35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 | Chat Visualizer 50 |
51 |
52 | GitHub Logo 61 |
62 |
63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-fusion", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "vite build && cpx ./chrome-extension/popup/*.{svg,png} chrome-extension/dist", 6 | "build-server": "tsc --target ES2018 --module CommonJS --outDir ./ --strict --esModuleInterop api/server.ts", 7 | "server-dev": "nodemon --watch 'api/server.ts' --exec \"tsc --target ES2018 --module CommonJS --outDir ./ --strict --esModuleInterop api/server.ts && node ./api/server.js\"", 8 | "lint": "eslint 'chrome-extension/**/*.ts' 'api/**/*.ts' 'chat/**/*.{ts,tsx}' --quiet", 9 | "format": "prettier --write '**/*.{ts,tsx,json}'", 10 | "prepare": "husky install" 11 | }, 12 | "lint-staged": { 13 | "chrome-extension/**/*.ts": [ 14 | "eslint --fix", 15 | "prettier --write" 16 | ], 17 | "api/**/*.ts": [ 18 | "eslint --fix", 19 | "prettier --write" 20 | ], 21 | "chat/**/*.{ts,tsx,svg}": [ 22 | "eslint --fix", 23 | "prettier --write" 24 | ], 25 | "**/*.json": [ 26 | "prettier --write" 27 | ] 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "lint-staged" 32 | } 33 | }, 34 | "author": { 35 | "name": "Aleksandar Grbic", 36 | "email": "hi@programmer.network", 37 | "url": "https://programmer.network" 38 | }, 39 | "devDependencies": { 40 | "@faker-js/faker": "^8.2.0", 41 | "@types/chrome": "^0.0.248", 42 | "@types/linkify-it": "^3.0.4", 43 | "@types/node": "^20.8.7", 44 | "@types/socket.io-client": "^3.0.0", 45 | "@typescript-eslint/eslint-plugin": "^6.9.0", 46 | "@typescript-eslint/parser": "^6.9.0", 47 | "cpx": "^1.5.0", 48 | "eslint": "^8.52.0", 49 | "eslint-config-prettier": "^9.0.0", 50 | "eslint-plugin-prettier": "^5.0.1", 51 | "eslint-plugin-react-hooks": "^4.6.0", 52 | "eslint-plugin-react-refresh": "^0.4.3", 53 | "faker": "@faker-js/faker", 54 | "husky": "^8.0.3", 55 | "lint-staged": "^15.0.2", 56 | "nodemon": "^2.0.15", 57 | "typescript": "^5.2.2", 58 | "vite": "^4.5.0" 59 | }, 60 | "dependencies": { 61 | "@fastify/cors": "8.4.0", 62 | "fastify": "^4.24.3", 63 | "fastify-cors": "^6.1.0", 64 | "fastify-socket.io": "^5.0.0", 65 | "linkify-it": "^4.0.1", 66 | "socket.io": "^4.7.2", 67 | "socket.io-client": "^4.7.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Server } from "socket.io"; 3 | 4 | export interface IDOMUtils { 5 | getSelectors(): ISelectorConfig; 6 | getChatContainer(): Element | null; 7 | getMessageText(node: Element): string; 8 | getMessageEmojis(node: Element): string[]; 9 | getMessageAuthor(node: Element): string; 10 | getMessageBadges(node: Element): string[]; 11 | } 12 | 13 | export interface IDateTimeUtils { 14 | getFriendlyTime(): string; 15 | } 16 | 17 | export interface IStringUtils { 18 | makeId(length: number): string; 19 | getPlatformNameByHostname(): string; 20 | } 21 | 22 | export interface IChromeUtils { 23 | setBadgeStatus(status: boolean): void; 24 | } 25 | 26 | export interface IWebRTCUtils { 27 | initWebRTCConnection(): Promise; 28 | } 29 | 30 | export interface ISelectorConfig { 31 | chatContainer: string; 32 | messageAuthor: string; 33 | messageAuthorBadge: string; 34 | messageText: string; 35 | chatEmojis: string; 36 | } 37 | 38 | export interface IMessage { 39 | sessionId: string; 40 | id: string; 41 | platform: string; 42 | timestamp: string; 43 | content: string; 44 | emojis: string[]; 45 | author: string; 46 | badges: string[]; 47 | authorColor?: string; 48 | } 49 | 50 | export enum SocketType { 51 | REACT = "SOCKET_TYPE_REACT", 52 | EXTENSION = "SOCKET_TYPE_EXTENSION", 53 | } 54 | 55 | export interface CustomFastifyInstance extends FastifyInstance { 56 | io?: Server; 57 | } 58 | -------------------------------------------------------------------------------- /utils/ChromeUtils/index.ts: -------------------------------------------------------------------------------- 1 | export default class ChromeUtils { 2 | setBadgeStatus(status: boolean) { 3 | chrome.runtime.sendMessage({ status: status, type: "badge" }); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /utils/DOMUtils/index.ts: -------------------------------------------------------------------------------- 1 | import { ISelectorConfig } from "../../types"; 2 | 3 | export default class DOMUtils { 4 | private selectors: ISelectorConfig; 5 | constructor() { 6 | this.selectors = this.getSelectors(); 7 | } 8 | 9 | /** 10 | * Retrieves the CSS selectors based on the hostname for a specific platform. 11 | * @param {string} hostname - The hostname of the website. 12 | * @returns {object|null} An object containing CSS selectors or null if the hostname is not recognized. 13 | */ 14 | getSelectors(): ISelectorConfig { 15 | const defaultConfig: ISelectorConfig = { 16 | chatContainer: "", 17 | messageAuthor: "", 18 | messageAuthorBadge: "", 19 | messageText: "", 20 | chatEmojis: "", 21 | }; 22 | 23 | const configMap: { [key: string]: ISelectorConfig } = { 24 | "www.twitch.tv": { 25 | chatContainer: ".chat-scrollable-area__message-container", 26 | messageAuthor: ".chat-line__message .chat-author__display-name", 27 | messageAuthorBadge: ".chat-line__message .chat-badge", 28 | messageText: 29 | ".chat-line__message [data-a-target='chat-line-message-body']", 30 | chatEmojis: ".chat-line__message img:not(.chat-badge)", 31 | }, 32 | "www.youtube.com": { 33 | chatContainer: 34 | "yt-live-chat-app yt-live-chat-renderer #chat-messages yt-live-chat-item-list-renderer #items", 35 | messageAuthor: "yt-live-chat-author-chip #author-name", 36 | messageAuthorBadge: ".yt-live-chat-app #author-photo", 37 | messageText: ".yt-live-chat-text-message-renderer #message", 38 | chatEmojis: ".emoji", 39 | }, 40 | "kick.com": { 41 | chatContainer: "#chatroom > div:nth-child(2) > div:first-child", 42 | messageAuthor: 43 | "#chatroom .chat-message-identity .chat-entry-username", 44 | messageAuthorBadge: "", 45 | messageText: "#chatroom .chat-entry-content", 46 | chatEmojis: "", 47 | }, 48 | }; 49 | 50 | return configMap[window.location.hostname] || defaultConfig; 51 | } 52 | 53 | getChatContainer() { 54 | if (!this.selectors.chatContainer) { 55 | return null; 56 | } 57 | 58 | return document.querySelector(this.selectors.chatContainer); 59 | } 60 | 61 | getMessageText(node: Element): string { 62 | if (!this.selectors.messageText) { 63 | return ""; 64 | } 65 | 66 | const selectedNode = node.querySelector( 67 | this.selectors.messageText 68 | ) as HTMLElement; 69 | 70 | if (!selectedNode) { 71 | return ""; 72 | } 73 | 74 | return selectedNode.innerText || ""; 75 | } 76 | 77 | getMessageEmojis(node: Element) { 78 | if (!this.selectors.chatEmojis) { 79 | return []; 80 | } 81 | 82 | return Array.from( 83 | new Set( 84 | Array.from( 85 | node.querySelectorAll( 86 | this.selectors.chatEmojis 87 | ) as NodeListOf 88 | ).map((img) => img.src) 89 | ) 90 | ); 91 | } 92 | 93 | getMessageAuthor(node: Element) { 94 | if (!this.selectors.messageAuthor) { 95 | return ""; 96 | } 97 | 98 | return ( 99 | node.querySelector(this.selectors.messageAuthor)?.textContent || "" 100 | ); 101 | } 102 | 103 | getMessageBadges(node: Element) { 104 | if (!this.selectors.messageAuthorBadge) { 105 | return []; 106 | } 107 | 108 | return Array.from( 109 | node.querySelectorAll( 110 | this.selectors.messageAuthorBadge 111 | ) as NodeListOf 112 | ).map((img) => img.src); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /utils/DateTime/index.ts: -------------------------------------------------------------------------------- 1 | export default class DateTimeUtils { 2 | getFriendlyTime = (): string => { 3 | const now = new Date(); 4 | const hours = String(now.getHours()).padStart(2, "0"); 5 | const minutes = String(now.getMinutes()).padStart(2, "0"); 6 | const seconds = String(now.getSeconds()).padStart(2, "0"); 7 | 8 | return `${hours}:${minutes}:${seconds}`; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /utils/Services/Messages/index.ts: -------------------------------------------------------------------------------- 1 | import { IMessage } from "../../../types"; 2 | 3 | export default class HTTPMessageService { 4 | public static postMessage(message: IMessage): Promise { 5 | return fetch("http://localhost:3000/api/messages", { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | body: JSON.stringify(message), 11 | }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /utils/Socket/index.ts: -------------------------------------------------------------------------------- 1 | import io, { Socket } from "socket.io-client"; 2 | import { IMessage, SocketType } from "../../types"; 3 | 4 | class SocketUtils { 5 | socket: Socket; 6 | socketType: SocketType; 7 | keepAliveIntervalId: ReturnType | null = null; 8 | keepAliveIntervalSeconds: number = 20; 9 | 10 | constructor(options: { 11 | socketServer: string; 12 | socketType: SocketType; 13 | transports?: string[]; 14 | }) { 15 | const { socketServer, socketType, transports } = options; 16 | 17 | this.socket = io(socketServer, { 18 | transports, 19 | }); 20 | 21 | this.socketType = socketType; 22 | this.addListeners(); 23 | } 24 | 25 | private addListeners = () => { 26 | this.onConnect(); 27 | this.onDisconnect(); 28 | this.onError(); 29 | }; 30 | 31 | private keepAlive = () => { 32 | this.clearKeepAlive(); 33 | this.keepAliveIntervalId = setInterval(() => { 34 | if (!this.socket) { 35 | return; 36 | } 37 | 38 | this.socket.emit("keepalive"); 39 | }, this.keepAliveIntervalSeconds * 1000); 40 | }; 41 | 42 | private clearKeepAlive = () => { 43 | if (!this.keepAliveIntervalId) { 44 | return; 45 | } 46 | 47 | clearInterval(this.keepAliveIntervalId); 48 | this.keepAliveIntervalId = null; 49 | }; 50 | 51 | private onConnect = () => { 52 | this.socket.on("connect", () => { 53 | this.keepAlive(); 54 | this.socket.emit("register", this.socketType); 55 | }); 56 | }; 57 | 58 | private onError = () => { 59 | this.socket.on("connect_error", (error) => { 60 | console.log("Connect Error:", error); 61 | }); 62 | }; 63 | 64 | public sendMessageFromBackgroundScript = (message: IMessage) => { 65 | this.socket.emit("message", message); 66 | }; 67 | 68 | public sendMessageFromUI = (message: string): void => { 69 | this.socket.emit("message", { 70 | content: message, 71 | }); 72 | }; 73 | 74 | public onMessage(callback: (message: IMessage) => void): void { 75 | this.socket.on("message", callback); 76 | } 77 | 78 | private onDisconnect = () => { 79 | this.socket.on("disconnect", () => { 80 | this.clearKeepAlive(); 81 | }); 82 | }; 83 | 84 | public disconnect = (): void => { 85 | this.socket.off("connect"); 86 | this.socket.off("connect_error"); 87 | this.socket.off("message"); 88 | this.socket.disconnect(); 89 | }; 90 | } 91 | 92 | export default SocketUtils; 93 | -------------------------------------------------------------------------------- /utils/StringUtils/index.ts: -------------------------------------------------------------------------------- 1 | export default class StringUtils { 2 | /** 3 | * Utility used to generate a sessionId. 4 | * @param {length} length - The length of the ID to generate. 5 | * @returns the generated ID. 6 | */ 7 | makeId(length: number): string { 8 | let result = ""; 9 | const characters = 10 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 11 | const charactersLength = characters.length; 12 | 13 | for (let i = 0; i < length; i++) { 14 | result += characters.charAt( 15 | Math.floor(Math.random() * charactersLength) 16 | ); 17 | } 18 | 19 | return result; 20 | } 21 | 22 | /** 23 | * Maps the hostname to a human-readable platform name. 24 | * @returns {string|null} A string representing the platform name or null if the hostname is not recognized. 25 | */ 26 | getPlatformNameByHostname(): string { 27 | try { 28 | return ( 29 | { 30 | "www.twitch.tv": "twitch", 31 | "www.youtube.com": "youtube", 32 | "kick.com": "kick", 33 | }[window.location.hostname] || "" 34 | ); 35 | } catch (_) { 36 | return ""; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /utils/WebRTC/index.ts: -------------------------------------------------------------------------------- 1 | export default class WebRTCUtils { 2 | /** 3 | * Initialize a keep-alive mechanism for an RTCDataChannel. 4 | * @param {RTCDataChannel} dataChannel - The data channel to keep alive. 5 | */ 6 | private initKeepAlive(dataChannel: RTCDataChannel) { 7 | setInterval(() => { 8 | if (document.hidden) { 9 | dataChannel.send("KEEPALIVE"); 10 | } 11 | }, 800); 12 | } 13 | 14 | private handleMessage() {} 15 | private handleOpen() {} 16 | private handleClose() {} 17 | 18 | /** 19 | * Set up the local RTCPeerConnection. 20 | * @param {RTCPeerConnection} local - The local RTCPeerConnection. 21 | * @param {RTCPeerConnection} remote - The remote RTCPeerConnection. 22 | */ 23 | private async setLocalConnection( 24 | local: RTCPeerConnection, 25 | remote: RTCPeerConnection 26 | ): Promise { 27 | await local.setLocalDescription(await local.createOffer()); 28 | 29 | if (!local.localDescription) { 30 | throw new Error("Local description is null"); 31 | } 32 | 33 | await remote.setRemoteDescription(local.localDescription); 34 | } 35 | 36 | /** 37 | * Set up the remote RTCPeerConnection. 38 | * @param {RTCPeerConnection} local - The local RTCPeerConnection. 39 | * @param {RTCPeerConnection} remote - The remote RTCPeerConnection. 40 | */ 41 | private async setRemoteConnection( 42 | local: RTCPeerConnection, 43 | remote: RTCPeerConnection 44 | ): Promise { 45 | await remote.setLocalDescription(await remote.createAnswer()); 46 | 47 | if (!remote.localDescription) { 48 | throw new Error("Local description is null"); 49 | } 50 | 51 | await local.setRemoteDescription(remote.localDescription); 52 | } 53 | 54 | /** 55 | * Initialize a WebRTC connection and return an RTCDataChannel. 56 | * @returns {Promise} - A promise that resolves to an initialized RTCDataChannel. 57 | */ 58 | async initWebRTCConnection(): Promise { 59 | return new Promise((resolve, reject) => { 60 | try { 61 | const local = new RTCPeerConnection(); 62 | const remote = new RTCPeerConnection(); 63 | 64 | const sendChannel = local.createDataChannel("sendChannel"); 65 | sendChannel.onopen = this.handleOpen; 66 | sendChannel.onmessage = this.handleMessage; 67 | sendChannel.onclose = this.handleClose; 68 | sendChannel.onopen = () => { 69 | sendChannel.send("CONNECTED"); 70 | }; 71 | 72 | local.onicecandidate = async ({ candidate }) => { 73 | if (!candidate) { 74 | return; 75 | } 76 | await remote.addIceCandidate(candidate); 77 | }; 78 | 79 | remote.onicecandidate = async ({ candidate }) => { 80 | if (!candidate) { 81 | return; 82 | } 83 | await local.addIceCandidate(candidate); 84 | }; 85 | 86 | remote.ondatachannel = ({ channel }) => { 87 | this.initKeepAlive(channel); 88 | channel.onmessage = this.handleMessage; 89 | channel.onopen = this.handleOpen; 90 | channel.onclose = this.handleClose; 91 | resolve(channel); 92 | }; 93 | 94 | this.setLocalConnection(local, remote).then(() => 95 | this.setRemoteConnection(local, remote) 96 | ); 97 | } catch (error) { 98 | reject(error); 99 | } 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | build: { 5 | rollupOptions: { 6 | input: { 7 | background: "chrome-extension/background-script/index.ts", 8 | content: "chrome-extension/content-script/index.ts", 9 | }, 10 | output: { 11 | dir: "chrome-extension/dist", 12 | format: "commonjs", 13 | entryFileNames: `[name].js`, 14 | }, 15 | }, 16 | }, 17 | esbuild: { 18 | target: "es6", 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------