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 |