├── src ├── components │ ├── root │ │ ├── index.ts │ │ └── component.tsx │ ├── chat-line │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx │ ├── chat-root │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx │ ├── chat-line-badges │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx │ ├── chat-line-emote │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx │ ├── chat-line-emotes │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx │ └── chat-line-content │ │ ├── index.ts │ │ ├── styles.module.scss │ │ └── component.tsx ├── hooks │ ├── use-chat-badges │ │ ├── index.ts │ │ └── hook.ts │ ├── use-message-content │ │ ├── index.ts │ │ ├── message-parser │ │ │ ├── index.ts │ │ │ └── message-parser.tsx │ │ └── hook.ts │ ├── use-message-store │ │ ├── index.ts │ │ └── hook.ts │ ├── use-twitch-connection │ │ ├── index.ts │ │ └── hook.ts │ └── use-third-party-emotes │ │ ├── index.ts │ │ └── hook.ts ├── react-app-env.d.ts ├── models │ ├── index.ts │ ├── chat-badge.ts │ ├── chat-emote-placement.ts │ ├── chat-message-user.ts │ ├── chat-emote.ts │ ├── fossabot.ts │ ├── third-party-emote.ts │ └── chat-message.ts ├── contexts │ └── third-party-emotes │ │ ├── index.ts │ │ ├── types.ts │ │ ├── context.tsx │ │ └── helpers.ts ├── util │ ├── request-error.ts │ ├── twitch-connection.ts │ ├── color-correction.ts │ └── api-client.ts ├── setupTests.ts ├── index.scss ├── index.tsx ├── theme.module.scss └── settings.ts ├── .env.example ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── netlify.toml ├── .prettierrc.json ├── .editorconfig ├── README.md ├── .gitignore ├── tsconfig.json ├── .vscode └── settings.json ├── package.json └── .eslintrc.json /src/components/root/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/hooks/use-chat-badges/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hook"; 2 | -------------------------------------------------------------------------------- /src/components/chat-line/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/components/chat-root/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/hooks/use-message-content/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hook"; 2 | -------------------------------------------------------------------------------- /src/hooks/use-message-store/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hook"; 2 | -------------------------------------------------------------------------------- /src/hooks/use-twitch-connection/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hook"; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/chat-line-badges/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/components/chat-line-emote/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/components/chat-line-emotes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /src/hooks/use-third-party-emotes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hook"; 2 | -------------------------------------------------------------------------------- /src/components/chat-line-content/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./component"; 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_TWITCH_LOGIN=aidenwallis 2 | REACT_APP_TWITCH_ID=87763385 3 | -------------------------------------------------------------------------------- /src/hooks/use-message-content/message-parser/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./message-parser"; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chat-message"; 2 | export * from "./chat-message-user"; 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenwallis/twitch-chat-widget/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenwallis/twitch-chat-widget/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenwallis/twitch-chat-widget/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/contexts/third-party-emotes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./context"; 2 | export * from "./types"; 3 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "https://chatwidget.fossadev.com/:splat" 4 | status = 302 5 | -------------------------------------------------------------------------------- /src/components/chat-line-emote/styles.module.scss: -------------------------------------------------------------------------------- 1 | .emote { 2 | vertical-align: text-bottom; 3 | line-height: 1; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/chat-root/styles.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100vw; 3 | position: fixed; 4 | left: 0; 5 | bottom: 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/chat-line-content/styles.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | color: #fff; 3 | // -webkit-text-stroke: var(--message-text-stroke) black; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 70, 6 | "bracketSpacing": false 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /src/models/chat-badge.ts: -------------------------------------------------------------------------------- 1 | export class ChatBadge { 2 | public constructor( 3 | public readonly name: string, 4 | public readonly version: string, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/models/chat-emote-placement.ts: -------------------------------------------------------------------------------- 1 | export class ChatEmotePlacement { 2 | public constructor( 3 | public readonly start: number, 4 | public readonly end: number, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/components/chat-line-badges/styles.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | margin-bottom: -4px; 3 | border-radius: 2px; 4 | margin-right: 4px; 5 | } 6 | 7 | .badges { 8 | margin-right: 3px; 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | This package is being replaced by [twitch-chat-widget-2](https://github.com/aidenwallis/twitch-chat-widget-2): an implementation based on Web Components, with some extra features. 4 | -------------------------------------------------------------------------------- /src/util/request-error.ts: -------------------------------------------------------------------------------- 1 | export class RequestError extends Error { 2 | public statusCode: number; 3 | public constructor(message: string, statusCode: number) { 4 | super(message); 5 | this.statusCode = statusCode; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/models/chat-message-user.ts: -------------------------------------------------------------------------------- 1 | export class ChatMessageUser { 2 | public constructor( 3 | public readonly id: string, 4 | public readonly login: string, 5 | public readonly displayName: string, 6 | public readonly color: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/components/chat-line-emotes/styles.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | color: #fff; 3 | display: block; 4 | text-align: center; 5 | padding: 3rem 0; 6 | img { 7 | height: 30rem; 8 | } 9 | // -webkit-text-stroke: var(--message-text-stroke) black; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/use-third-party-emotes/hook.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from "react"; 2 | import { 3 | ThirdPartyEmotesContext, 4 | ThirdPartyEmoteState, 5 | } from "../../contexts/third-party-emotes"; 6 | 7 | export function useThirdPartyEmotes(): ThirdPartyEmoteState { 8 | const context = useContext(ThirdPartyEmotesContext); 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-message-background: #{rgba(#1b1d20, 0.97)}; 3 | --message-transition-duration: 1s; 4 | --message-transition-delay: 15s; 5 | --chat-text-size: 1rem; 6 | --font-family: "Nunito", sans-serif; 7 | --message-padding: 0.7rem; 8 | --message-text-shadow: none; 9 | } 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/chat-line-emote/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styles from "./styles.module.scss"; 3 | 4 | interface Props { 5 | name: string; 6 | url: string; 7 | } 8 | 9 | export const ChatLineEmote: React.FunctionComponent = ( 10 | props: Props, 11 | ) => { 12 | return ( 13 | {props.name} 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /.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 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import {Root} from "./components/root"; 4 | import "./index.scss"; 5 | import {SETTINGS} from "./settings"; 6 | import styles from "./theme.module.scss"; 7 | 8 | ReactDOM.render( 9 | 10 |
11 | 12 |
13 |
, 14 | document.getElementById("root"), 15 | ); 16 | -------------------------------------------------------------------------------- /src/models/chat-emote.ts: -------------------------------------------------------------------------------- 1 | import {ChatEmotePlacement} from "./chat-emote-placement"; 2 | 3 | export enum ChatEmoteSize { 4 | Small = "1.0", 5 | Medium = "2.0", 6 | Large = "3.0", 7 | } 8 | 9 | export class ChatEmote { 10 | public constructor( 11 | public readonly id: string, 12 | public readonly placements: ChatEmotePlacement[], 13 | ) {} 14 | 15 | public getURL(size: ChatEmoteSize = ChatEmoteSize.Small): string { 16 | return `https://static-cdn.jtvnw.net/emoticons/v1/${this.id}/${size}`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/chat-line/styles.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | overflow: visible; 4 | margin-top: 5px; 5 | } 6 | 7 | .line { 8 | padding: var(--message-padding); 9 | line-height: 1.5; 10 | vertical-align: middle; 11 | border-radius: 4px; 12 | background: var(--color-message-background); 13 | word-wrap: break-word; 14 | text-shadow: var(--message-text-shadow); 15 | animation: var(--message-animation); 16 | // transform: translateY(5px); 17 | } 18 | 19 | .name { 20 | margin-right: 5px; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/chat-line-emotes/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ChatMessage} from "../../models"; 3 | import classes from "./styles.module.scss"; 4 | 5 | interface Props { 6 | message: ChatMessage; 7 | } 8 | 9 | export const ChatLineEmotes: React.FunctionComponent = ({ 10 | message, 11 | }: Props) => { 12 | return ( 13 |
14 | 15 | {message.parsedNodes.find( 16 | (node) => node !== null && typeof node === "object", 17 | )} 18 | 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/root/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ThirdPartyEmotesProvider} from "../../contexts/third-party-emotes"; 3 | import {SETTINGS} from "../../settings"; 4 | import {ChatRoot} from "../chat-root"; 5 | 6 | export const Root: React.FunctionComponent = () => { 7 | return ( 8 | 12 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/chat-line-content/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {useMessageContent} from "../../hooks/use-message-content"; 3 | import {ChatMessage} from "../../models"; 4 | import classes from "./styles.module.scss"; 5 | 6 | interface Props { 7 | message: ChatMessage; 8 | } 9 | 10 | export const ChatLineContent: React.FunctionComponent = ({ 11 | message, 12 | }: Props) => { 13 | const content = useMessageContent(message); 14 | 15 | return ( 16 | 20 | {content} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/models/fossabot.ts: -------------------------------------------------------------------------------- 1 | export interface CachedBadges { 2 | data: BadgeSet[]; 3 | } 4 | 5 | export interface BadgeSet { 6 | id: string; 7 | versions: BadgeVersion[]; 8 | } 9 | 10 | export interface BadgeVersion { 11 | id: string; 12 | title: TextNode[]; 13 | description: TextNode[]; 14 | asset_1x: ImageNode; 15 | } 16 | 17 | export interface ImageNode { 18 | alt?: string; 19 | url?: string; 20 | } 21 | 22 | export interface TextNode { 23 | text: string; 24 | } 25 | 26 | export type BadgeMaps = Record>; 27 | 28 | export function constructMapping(data: CachedBadges) { 29 | return (data?.data || []).reduce((acc, cur) => { 30 | acc[cur.id] = (cur.versions || []).reduce((acc, cur) => { 31 | acc[cur.id] = cur; 32 | return acc; 33 | }, {} as Record); 34 | return acc; 35 | }, {} as Record>); 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": true, 5 | "source.organizeImports": true 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "editor.formatOnSave": true, 10 | "editor.formatOnType": true 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.formatOnSave": true, 15 | "editor.formatOnType": true 16 | }, 17 | "[scss]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode", 19 | "editor.formatOnSave": true, 20 | "editor.formatOnType": true 21 | }, 22 | "[json]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode", 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true 26 | }, 27 | "eslint.validate": ["typescript", "typescriptreact"], 28 | "typescript.preferences.importModuleSpecifier": "auto" 29 | } 30 | -------------------------------------------------------------------------------- /src/models/third-party-emote.ts: -------------------------------------------------------------------------------- 1 | import {isEmoteOnly} from "../settings"; 2 | 3 | export enum ThirdPartyEmoteProvider { 4 | BetterTTV, 5 | FrankerFaceZ, 6 | SevenTV, 7 | } 8 | 9 | export class ThirdPartyEmote { 10 | public constructor( 11 | public readonly id: string, 12 | public readonly provider: ThirdPartyEmoteProvider, 13 | public readonly name: string, 14 | public readonly imageUrl: string, 15 | ) {} 16 | 17 | public static getFrankerfacezImageURL(emoteId: number) { 18 | return `https://cdn.frankerfacez.com/emote/${encodeURIComponent( 19 | emoteId, 20 | )}/${isEmoteOnly() ? "3" : "1"}`; 21 | } 22 | 23 | public static getBetterttvImageURL(emoteId: string) { 24 | return `https://cdn.betterttv.net/emote/${encodeURIComponent( 25 | emoteId, 26 | )}/${isEmoteOnly() ? "3" : "1"}x`; 27 | } 28 | 29 | public static getSevenTVImageURL(emoteId: string) { 30 | return `https://cdn.7tv.app/emote/${encodeURIComponent( 31 | emoteId, 32 | )}/${isEmoteOnly() ? "3" : "1"}x.webp`; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/theme.module.scss: -------------------------------------------------------------------------------- 1 | .default { 2 | font-family: "Nunito", sans-serif; 3 | --message-text-shadow: none; 4 | --message-animation: message-enter 0.15s ease, 5 | fade-out var(--message-transition-duration) ease 6 | var(--message-transition-delay) forwards; 7 | } 8 | 9 | .simple { 10 | font-family: sans-serif; 11 | --color-message-background: transparent; 12 | --message-padding: 0.1rem; 13 | --message-text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.95); 14 | --message-animation: fade-out var(--message-transition-duration) 15 | ease var(--message-transition-delay) forwards; 16 | } 17 | 18 | .emote, 19 | .emote_black { 20 | width: 100%; 21 | height: 100%; 22 | left: 0; 23 | top: 0; 24 | position: fixed; 25 | } 26 | 27 | .emote_black { 28 | background-color: #000; 29 | } 30 | 31 | @keyframes message-enter { 32 | from { 33 | transform: translateY(10px); 34 | opacity: 0; 35 | } 36 | to { 37 | opacity: 1; 38 | transform: translateY(0px); 39 | } 40 | } 41 | 42 | @keyframes fade-out { 43 | from { 44 | opacity: 1; 45 | } 46 | to { 47 | opacity: 0; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/chat-line-badges/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ChatBadge} from "../../models/chat-badge"; 3 | import type {BadgeMaps} from "../../models/fossabot"; 4 | import classes from "./styles.module.scss"; 5 | 6 | interface Props { 7 | channelBadges: BadgeMaps; 8 | globalBadges: BadgeMaps; 9 | badges: ChatBadge[]; 10 | } 11 | 12 | export const ChatLineBadges: React.FunctionComponent = ( 13 | props: Props, 14 | ) => { 15 | return ( 16 | 17 | {props.badges.map((badge) => { 18 | const badgeSrc = 19 | props?.channelBadges?.[badge.name]?.[badge.version] 20 | ?.asset_1x?.url || 21 | props?.globalBadges?.[badge.name]?.[badge.version]?.asset_1x 22 | ?.url || 23 | ""; 24 | if (!badgeSrc) return null; 25 | return ( 26 | 27 | {badge.name} 32 | 33 | ); 34 | })} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/hooks/use-twitch-connection/hook.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {ChatMessage} from "../../models"; 3 | import {TwitchConnection} from "../../util/twitch-connection"; 4 | 5 | interface HookCallbacks { 6 | onMessage(message: ChatMessage): void; 7 | onUserTimeout(login: string): void; 8 | onDeleteMessage(id: string): void; 9 | } 10 | 11 | export function useTwitchConnection( 12 | login: string, 13 | callbacks: HookCallbacks, 14 | ): TwitchConnection { 15 | const [connection, setConnection] = useState( 16 | new TwitchConnection(login), 17 | ); 18 | 19 | useEffect(() => { 20 | connection.connect(); 21 | return () => { 22 | connection.disconnect(); 23 | setConnection(new TwitchConnection(login)); 24 | }; 25 | // eslint-disable-next-line react-hooks/exhaustive-deps 26 | }, [login]); 27 | 28 | useEffect(() => { 29 | if (connection) { 30 | connection.onMessage(callbacks.onMessage); 31 | connection.onUserTimeout(callbacks.onUserTimeout); 32 | connection.onDeleteMessage(callbacks.onDeleteMessage); 33 | } 34 | }, [connection, callbacks]); 35 | 36 | return connection; 37 | } 38 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | let twitchID = "87763385"; 2 | let twitchLogin = "aiden"; 3 | const [customTwitchID, customTwitchLogin] = window.location.pathname 4 | .split("?")[0] 5 | .substring(1) 6 | .split("-"); 7 | 8 | const params = (window.location.search || "") 9 | .substring(1) 10 | .split("&") 11 | .reduce((acc, cur) => { 12 | const [key, value] = cur.split("=", 2); 13 | acc[decodeURIComponent(key)] = decodeURIComponent(value); 14 | return acc; 15 | }, {} as Record); 16 | 17 | if (customTwitchID && customTwitchLogin) { 18 | console.log("Found custom Twitch ID & login..."); 19 | twitchID = customTwitchID; 20 | twitchLogin = customTwitchLogin; 21 | } 22 | 23 | const validThemes = new Set([ 24 | "default", 25 | "simple", 26 | "emote", 27 | "emote_black", 28 | ]); 29 | 30 | // TODO: This sucks :) 31 | export const SETTINGS = { 32 | TWITCH_ID: twitchID, 33 | TWITCH_LOGIN: twitchLogin, 34 | theme: validThemes.has(params.theme || "default") 35 | ? params.theme || "default" 36 | : "default", 37 | }; 38 | 39 | export function isEmoteOnly() { 40 | return ( 41 | SETTINGS.theme === "emote" || SETTINGS.theme === "emote_black" 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/chat-root/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {useChatBadges} from "../../hooks/use-chat-badges"; 3 | import {useMessageStore} from "../../hooks/use-message-store"; 4 | import {useTwitchConnection} from "../../hooks/use-twitch-connection"; 5 | import {ChatLine} from "../chat-line"; 6 | import styles from "./styles.module.scss"; 7 | 8 | interface Props { 9 | channelID: string; 10 | login: string; 11 | } 12 | 13 | export const ChatRoot: React.FunctionComponent = ( 14 | props: Props, 15 | ) => { 16 | const [channelBadges, globalBadges] = useChatBadges( 17 | props.channelID, 18 | ); 19 | const messages = useMessageStore(); 20 | 21 | useTwitchConnection(props.login, { 22 | onMessage: messages.addMessage, 23 | onUserTimeout: messages.timeoutUser, 24 | onDeleteMessage: messages.deleteMessage, 25 | }); 26 | 27 | return ( 28 |
29 | {messages.getMessages().map((message) => ( 30 | 36 | ))} 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitch-chat-widget", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react": "^16.9.53", 12 | "@types/react-dom": "^16.9.8", 13 | "irc-message-ts": "3.0.6", 14 | "react": "^17.0.1", 15 | "react-dom": "^17.0.1", 16 | "react-scripts": "4.0.0", 17 | "typescript": "^4.0.3", 18 | "web-vitals": "^0.2.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@typescript-eslint/eslint-plugin": "^4.6.0", 46 | "@typescript-eslint/parser": "^4.6.0", 47 | "sass": "^1.43.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/hooks/use-chat-badges/hook.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import { 3 | BadgeMaps, 4 | CachedBadges, 5 | constructMapping, 6 | } from "../../models/fossabot"; 7 | import {ApiClient} from "../../util/api-client"; 8 | 9 | const api = new ApiClient({}); 10 | 11 | export function useChatBadges(channelID: string) { 12 | const [globalBadges, setGlobalBadges] = useState({}); 13 | const [channelBadges, setChannelBadges] = useState({}); 14 | const resp = useMemo( 15 | () => [channelBadges, globalBadges] as const, 16 | [channelBadges, globalBadges], 17 | ); 18 | 19 | useEffect(() => { 20 | api 21 | .get( 22 | "https://api.fossabot.com/v2/cached/twitch/badges/global", 23 | ) 24 | .then((res) => setGlobalBadges(constructMapping(res.body))) 25 | .catch((err) => 26 | console.error("Failed to get global badges", err), 27 | ); 28 | }, []); 29 | 30 | useEffect(() => { 31 | api 32 | .get( 33 | `https://api.fossabot.com/v2/cached/twitch/badges/users/${encodeURIComponent( 34 | channelID, 35 | )}`, 36 | ) 37 | .then((res) => setChannelBadges(constructMapping(res.body))) 38 | .catch((err) => console.error("Failed to get badges", err)); 39 | }, [channelID]); 40 | 41 | return resp; 42 | } 43 | -------------------------------------------------------------------------------- /src/contexts/third-party-emotes/types.ts: -------------------------------------------------------------------------------- 1 | import {ThirdPartyEmote} from "../../models/third-party-emote"; 2 | 3 | export interface ThirdPartyEmoteState { 4 | ffzUserEmotes: EmoteMap; 5 | ffzGlobalEmotes: EmoteMap; 6 | bttvUserEmotes: EmoteMap; 7 | bttvGlobalEmotes: EmoteMap; 8 | seventvUserEmotes: EmoteMap; 9 | seventvGlobalEmotes: EmoteMap; 10 | } 11 | 12 | export type EmoteMap = Record; 13 | 14 | export interface FrankerfacezEmoticon { 15 | name: string; 16 | id: number; 17 | } 18 | 19 | export interface FrankerfacezSet { 20 | emoticons: FrankerfacezEmoticon[]; 21 | } 22 | 23 | export interface FrankerfacezGlobalBody { 24 | default_sets: number[]; 25 | sets: Record; 26 | } 27 | 28 | export interface FrankerfacezUserBody { 29 | room: {set: number}; 30 | sets: Record; 31 | } 32 | 33 | export interface BetterttvEmote { 34 | id: string; 35 | code: string; 36 | imageType: string; 37 | userId: string; 38 | } 39 | 40 | export type BetterttvGlobalBody = BetterttvEmote[]; 41 | 42 | export interface BetterttvUserBody { 43 | channelEmotes: BetterttvEmote[]; 44 | sharedEmotes: BetterttvEmote[]; 45 | } 46 | 47 | export interface SeventvEmote { 48 | id: string; 49 | name: string; 50 | } 51 | 52 | export type SeventvGlobalBody = SeventvEmote[]; 53 | 54 | export type SeventvUserBody = SeventvEmote[]; 55 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "react-hooks"], 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": ["error", { 11 | "selector": "interface", 12 | "format": ["PascalCase"] 13 | }], 14 | "@typescript-eslint/array-type": "error", 15 | "@typescript-eslint/prefer-for-of": "off", 16 | "@typescript-eslint/prefer-non-null-assertion": "off", 17 | "@typescript-eslint/explicit-module-boundary-types": "off", 18 | "@typescript-eslint/no-empty-interface": "off", 19 | "@typescript-eslint/no-non-null-assertion": "off", 20 | "@typescript-eslint/explicit-function-return-type": "off", 21 | "@typescript-eslint/camelcase": "off", 22 | "camelcase": "off", 23 | "react-hooks/rules-of-hooks": "error", 24 | "react-hooks/exhaustive-deps": "warn", 25 | "react/no-typos": "error", 26 | "react/no-direct-mutation-state": "error", 27 | "react/no-deprecated": "error", 28 | "react/jsx-no-duplicate-props": "error", 29 | "import/first": "off" 30 | }, 31 | "parserOptions": { 32 | "ecmaVersion": 6, 33 | "ecmaFeatures": { 34 | "jsx": true 35 | } 36 | }, 37 | "settings": { 38 | "react": { 39 | "version": "detect" 40 | } 41 | }, 42 | "env": { 43 | "jest": true, 44 | "browser": true, 45 | "es6": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/chat-line/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ChatMessage} from "../../models"; 3 | import type {BadgeMaps} from "../../models/fossabot"; 4 | import {isEmoteOnly} from "../../settings"; 5 | import {ColorCorrection} from "../../util/color-correction"; 6 | import {ChatLineBadges} from "../chat-line-badges"; 7 | import {ChatLineContent} from "../chat-line-content"; 8 | import {ChatLineEmotes} from "../chat-line-emotes"; 9 | import classes from "./styles.module.scss"; 10 | 11 | interface Props { 12 | channelBadges: BadgeMaps; 13 | globalBadges: BadgeMaps; 14 | message: ChatMessage; 15 | } 16 | 17 | const colorCorrector = new ColorCorrection(); 18 | 19 | export const ChatLineComponent: React.FunctionComponent = ({ 20 | channelBadges, 21 | globalBadges, 22 | message, 23 | }: Props) => { 24 | if (isEmoteOnly()) { 25 | return ; 26 | } 27 | 28 | const color = message.user.color 29 | ? colorCorrector.calculate(message.user.color) 30 | : "grey"; 31 | return ( 32 |
33 |
34 | {message.badges.length > 0 && ( 35 | 40 | )} 41 | 42 | {message.user.displayName} 43 | {!message.isAction && ":"} 44 | 45 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export const ChatLine = React.memo(ChatLineComponent); 52 | -------------------------------------------------------------------------------- /src/hooks/use-message-store/hook.ts: -------------------------------------------------------------------------------- 1 | import {useMemo, useState} from "react"; 2 | import {ChatMessage} from "../../models"; 3 | import {isEmoteOnly} from "../../settings"; 4 | import {useMessageParser} from "../use-message-content"; 5 | import {isMessageEmpty} from "../use-message-content/message-parser"; 6 | 7 | const MAX_BUFFER = isEmoteOnly() ? 10 : 250; 8 | // const MAX_LIFETIME = 60 * 1000; 9 | const SLICE_LEVEL = -Math.abs(MAX_BUFFER - 1); 10 | const emoteOnly = isEmoteOnly(); 11 | 12 | export function useMessageStore() { 13 | const [messages, setMessages] = useState([]); 14 | const parser = useMessageParser(); 15 | 16 | const actions = useMemo( 17 | () => ({ 18 | getMessages() { 19 | return messages; 20 | }, 21 | addMessage(message: ChatMessage) { 22 | if (emoteOnly) { 23 | message.parsedNodes = parser(message, true); 24 | if (isMessageEmpty(message.parsedNodes)) { 25 | return; 26 | } 27 | } 28 | 29 | setMessages((messages) => { 30 | return [...messages.slice(SLICE_LEVEL), message]; 31 | }); 32 | }, 33 | timeoutUser(login: string) { 34 | if (login) { 35 | setMessages((messages) => 36 | messages.filter( 37 | (message) => message.user.login !== login, 38 | ), 39 | ); 40 | } else { 41 | setMessages([]); 42 | } 43 | }, 44 | deleteMessage(id: string) { 45 | setMessages((messages) => 46 | messages.filter((message) => message.id !== id), 47 | ); 48 | }, 49 | }), 50 | [messages, parser], 51 | ); 52 | 53 | return actions; 54 | } 55 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | React App 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/hooks/use-message-content/hook.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useMemo} from "react"; 2 | import {ChatMessage} from "../../models"; 3 | import {useThirdPartyEmotes} from "../use-third-party-emotes"; 4 | import {MessageParser} from "./message-parser"; 5 | 6 | export function useMessageContent( 7 | message: ChatMessage, 8 | emoteOnly = false, 9 | ) { 10 | const { 11 | bttvGlobalEmotes, 12 | bttvUserEmotes, 13 | ffzGlobalEmotes, 14 | ffzUserEmotes, 15 | seventvGlobalEmotes, 16 | seventvUserEmotes, 17 | } = useThirdPartyEmotes(); 18 | const emoteMap = useMemo( 19 | () => ({ 20 | ...bttvGlobalEmotes, 21 | ...ffzGlobalEmotes, 22 | ...ffzUserEmotes, 23 | ...bttvUserEmotes, 24 | ...seventvGlobalEmotes, 25 | ...seventvUserEmotes, 26 | }), 27 | [ 28 | bttvGlobalEmotes, 29 | bttvUserEmotes, 30 | ffzGlobalEmotes, 31 | ffzUserEmotes, 32 | seventvGlobalEmotes, 33 | seventvUserEmotes, 34 | ], 35 | ); 36 | 37 | return MessageParser.parseThirdPartyEmotes( 38 | emoteMap, 39 | MessageParser.parseEmotes(message), 40 | emoteOnly, 41 | ); 42 | } 43 | 44 | export function useMessageParser() { 45 | const { 46 | bttvGlobalEmotes, 47 | bttvUserEmotes, 48 | ffzGlobalEmotes, 49 | ffzUserEmotes, 50 | seventvGlobalEmotes, 51 | seventvUserEmotes, 52 | } = useThirdPartyEmotes(); 53 | const emoteMap = useMemo( 54 | () => ({ 55 | ...bttvGlobalEmotes, 56 | ...ffzGlobalEmotes, 57 | ...ffzUserEmotes, 58 | ...bttvUserEmotes, 59 | ...seventvGlobalEmotes, 60 | ...seventvUserEmotes, 61 | }), 62 | [ 63 | bttvGlobalEmotes, 64 | bttvUserEmotes, 65 | ffzGlobalEmotes, 66 | ffzUserEmotes, 67 | seventvGlobalEmotes, 68 | seventvUserEmotes, 69 | ], 70 | ); 71 | 72 | return useCallback( 73 | (message: ChatMessage, emoteOnly = false) => 74 | MessageParser.parseThirdPartyEmotes( 75 | emoteMap, 76 | MessageParser.parseEmotes(message), 77 | emoteOnly, 78 | ), 79 | [emoteMap], 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/contexts/third-party-emotes/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | get7TVGlobalEmotes, 4 | get7TVUserEmotes, 5 | getBTTVGlobalEmotes, 6 | getBTTVUserEmotes, 7 | getFFZGlobalEmotes, 8 | getFFZUserEmotes, 9 | } from "./helpers"; 10 | import {EmoteMap, ThirdPartyEmoteState} from "./types"; 11 | 12 | export const ThirdPartyEmotesContext = React.createContext( 13 | { 14 | bttvUserEmotes: {}, 15 | bttvGlobalEmotes: {}, 16 | ffzUserEmotes: {}, 17 | ffzGlobalEmotes: {}, 18 | seventvGlobalEmotes: {}, 19 | seventvUserEmotes: {}, 20 | }, 21 | ); 22 | 23 | interface Props { 24 | channelId: string; 25 | login: string; 26 | children: React.ReactNode; 27 | } 28 | 29 | export const ThirdPartyEmotesProvider: React.FunctionComponent = ( 30 | props: Props, 31 | ) => { 32 | const [ffzUserEmotes, setFfzUserEmotes] = React.useState( 33 | {}, 34 | ); 35 | const [ 36 | ffzGlobalEmotes, 37 | setFfzGlobalEmotes, 38 | ] = React.useState({}); 39 | const [ 40 | bttvUserEmotes, 41 | setBttvUserEmotes, 42 | ] = React.useState({}); 43 | const [ 44 | bttvGlobalEmotes, 45 | setBttvGlobalEmotes, 46 | ] = React.useState({}); 47 | const [ 48 | seventvGlobalEmotes, 49 | set7TVGlobalEmotes, 50 | ] = React.useState({}); 51 | const [ 52 | seventvUserEmotes, 53 | set7TVUserEmotes, 54 | ] = React.useState({}); 55 | 56 | React.useEffect(() => { 57 | getFFZGlobalEmotes().then(setFfzGlobalEmotes); 58 | getBTTVGlobalEmotes().then(setBttvGlobalEmotes); 59 | get7TVGlobalEmotes().then(set7TVGlobalEmotes); 60 | }, []); 61 | 62 | React.useEffect(() => { 63 | getFFZUserEmotes(props.channelId).then(setFfzUserEmotes); 64 | getBTTVUserEmotes(props.channelId).then(setBttvUserEmotes); 65 | get7TVUserEmotes(props.login).then(set7TVUserEmotes); 66 | }, [props.channelId, props.login]); 67 | 68 | return ( 69 | 79 | {props.children} 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/hooks/use-message-content/message-parser/message-parser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ChatLineEmote} from "../../../components/chat-line-emote"; 3 | import {EmoteMap} from "../../../contexts/third-party-emotes"; 4 | import {ChatMessage} from "../../../models"; 5 | import {isEmoteOnly} from "../../../settings"; 6 | 7 | export const isMessageEmpty = (nodes: React.ReactNode[]) => { 8 | for (const node of nodes) { 9 | if (node !== null && typeof node === "object") { 10 | return false; 11 | } 12 | } 13 | return true; 14 | }; 15 | 16 | export class MessageParser { 17 | public static parseThirdPartyEmotes( 18 | emotes: EmoteMap, 19 | splits: React.ReactNode[], 20 | emoteOnly?: boolean, 21 | ) { 22 | const res: React.ReactNode[] = []; 23 | const _matchWord = (word: string): React.ReactNode => { 24 | const emote = emotes[word]; 25 | if (emote) { 26 | return ( 27 | 28 | ); 29 | } 30 | return emoteOnly ? null : word; 31 | }; 32 | 33 | let buffer = ""; 34 | for (let i = 0; i < splits.length; ++i) { 35 | const char = splits[i]; 36 | if (!char) continue; 37 | 38 | if (char === " ") { 39 | res.push(_matchWord(buffer)); 40 | res.push(" "); 41 | buffer = ""; 42 | } else if (typeof char === "object") { 43 | res.push(char); 44 | buffer = ""; 45 | continue; 46 | } else { 47 | buffer += char; 48 | } 49 | } 50 | 51 | buffer && res.push(_matchWord(buffer)); 52 | 53 | return res; 54 | } 55 | 56 | public static parseEmotes(message: ChatMessage) { 57 | const split: React.ReactNode[] = Array.from(message.content); 58 | for (let i = 0; i < message.emotes.length; ++i) { 59 | const emote = message.emotes[i]; 60 | for (let j = 0; j < emote.placements.length; ++j) { 61 | const placement = emote.placements[j]; 62 | const name = split.slice(placement.start, placement.end + 1); 63 | split[placement.start] = ( 64 | 70 | ); 71 | 72 | for (let k = placement.start + 1; k <= placement.end; ++k) { 73 | split[k] = null; 74 | } 75 | } 76 | } 77 | 78 | return split; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/models/chat-message.ts: -------------------------------------------------------------------------------- 1 | import {IRCMessage} from "irc-message-ts"; 2 | import React from "react"; 3 | import {ChatBadge} from "./chat-badge"; 4 | import {ChatEmote} from "./chat-emote"; 5 | import {ChatEmotePlacement} from "./chat-emote-placement"; 6 | import {ChatMessageUser} from "./chat-message-user"; 7 | 8 | // eslint-disable-next-line no-control-regex 9 | const isActionRegex = /^\u0001ACTION (.*)\u0001$/; 10 | 11 | export class ChatMessage { 12 | public readonly badges: ChatBadge[] = []; 13 | public readonly id: string = ""; 14 | public readonly isAction: boolean = false; 15 | public readonly content: string = ""; 16 | public isEmoteOnly = false; 17 | public parsedNodes: React.ReactNode[] = []; 18 | public readonly emotes: ChatEmote[] = []; 19 | public readonly user: ChatMessageUser; 20 | public readonly createdAt = Date.now(); 21 | 22 | public constructor(private ircMessage: IRCMessage) { 23 | this.id = ircMessage.tags.id || ""; 24 | this.content = ircMessage.trailing; 25 | 26 | const actionMatch = ircMessage.trailing.match(isActionRegex); 27 | if (actionMatch) { 28 | this.content = actionMatch[1]; 29 | this.isAction = true; 30 | } 31 | 32 | this.user = new ChatMessageUser( 33 | ircMessage.tags["user-id"] || "", 34 | ircMessage.prefix?.split("!")[0] || "", 35 | ircMessage.tags["display-name"] || "", 36 | ircMessage.tags.color || "#666", 37 | ); 38 | 39 | this.parseBadges(); 40 | this.parseEmotes(); 41 | } 42 | 43 | private parseBadges() { 44 | const badgeSpl = (this.ircMessage.tags.badges || "").split(","); 45 | 46 | for (let i = 0; i < badgeSpl.length; ++i) { 47 | const [name, version] = badgeSpl[i].split("/"); 48 | if (!(name && version)) continue; 49 | this.badges.push(new ChatBadge(name, version)); 50 | } 51 | } 52 | 53 | private parseEmotes() { 54 | const emotesSpl = (this.ircMessage.tags.emotes || "").split("/"); 55 | 56 | for (let i = 0; i < emotesSpl.length; ++i) { 57 | const [emoteID, placementStr] = emotesSpl[i].split(":"); 58 | if (!(emoteID && placementStr)) continue; 59 | 60 | const placements: ChatEmotePlacement[] = []; 61 | const placementSpl = placementStr.split(","); 62 | for (let i = 0; i < placementSpl.length; ++i) { 63 | const [startStr, endStr] = placementSpl[i].split("-"); 64 | if (!(startStr && endStr)) continue; 65 | const start = parseInt(startStr); 66 | const end = parseInt(endStr); 67 | if (isNaN(start) || isNaN(end)) continue; 68 | placements.push(new ChatEmotePlacement(start, end)); 69 | } 70 | 71 | placements.length && 72 | this.emotes.push(new ChatEmote(emoteID, placements)); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/util/twitch-connection.ts: -------------------------------------------------------------------------------- 1 | import {parse as parseMessage} from "irc-message-ts"; 2 | import {ChatMessage} from "../models"; 3 | 4 | type MessageCallback = (message: ChatMessage) => void; 5 | 6 | enum ConnectionState { 7 | Connected, 8 | Connecting, 9 | Disconnected, 10 | } 11 | 12 | const MAX_RECONNECT_TIMEOUT = 10 * 1000; 13 | const newlineRx = /[\r\n]+/; 14 | 15 | export class TwitchConnection { 16 | private conn?: WebSocket; 17 | private connectionAttempts = 0; 18 | private forceDisconnect = false; 19 | private state = ConnectionState.Disconnected; 20 | private messageCallback?: MessageCallback; 21 | private userTimeoutCallback?: (login: string) => void; 22 | private deleteMessageCallback?: (id: string) => void; 23 | private connectionTimeout?: NodeJS.Timer; 24 | 25 | public constructor(private login: string) {} 26 | 27 | public connect(): void { 28 | if (this.forceDisconnect) return; 29 | if (this.state !== ConnectionState.Disconnected) return; 30 | 31 | ++this.connectionAttempts; 32 | 33 | this.state = ConnectionState.Connecting; 34 | // give 5s to connect 35 | this.connectionTimeout = setTimeout( 36 | () => this.handleDisconnect(), 37 | 5000, 38 | ); 39 | 40 | this.conn = new WebSocket("wss://irc-ws.chat.twitch.tv/"); 41 | 42 | this.conn.onopen = () => { 43 | this.connectionAttempts = 0; 44 | this.connectionTimeout && clearTimeout(this.connectionTimeout); 45 | 46 | this.send("CAP REQ :twitch.tv/tags twitch.tv/commands"); 47 | this.send("PASS oauth:123123132"); 48 | this.send("NICK justinfan123"); 49 | this.send("JOIN #" + this.login); 50 | }; 51 | 52 | this.conn.onmessage = (event) => { 53 | if (!event.data) return; 54 | const lines = event.data.split(newlineRx); 55 | for (let i = 0; i < lines.length; ++i) { 56 | this.handleLine(lines[i]); 57 | } 58 | }; 59 | 60 | this.conn.onerror = () => this.handleDisconnect(); 61 | this.conn.onclose = () => this.handleDisconnect(); 62 | } 63 | 64 | public onMessage(cb: MessageCallback): void { 65 | this.messageCallback = cb; 66 | } 67 | 68 | public onUserTimeout(cb: (login: string) => void) { 69 | this.userTimeoutCallback = cb; 70 | } 71 | 72 | public onDeleteMessage(cb: (id: string) => void) { 73 | this.deleteMessageCallback = cb; 74 | } 75 | 76 | private handleLine(line: string) { 77 | if (!line) return; 78 | const parsed = parseMessage(line); 79 | if (!parsed) return; 80 | 81 | switch (parsed.command) { 82 | case "PING": { 83 | return this.send(line.replace("PING", "PONG")); 84 | } 85 | 86 | case "PRIVMSG": { 87 | return ( 88 | this.messageCallback && 89 | this.messageCallback(new ChatMessage(parsed)) 90 | ); 91 | } 92 | 93 | case "CLEARCHAT": { 94 | return ( 95 | this.userTimeoutCallback && 96 | this.userTimeoutCallback(parsed.trailing || "") 97 | ); 98 | } 99 | 100 | case "CLEARMSG": { 101 | return ( 102 | parsed.tags["target-msg-id"] && 103 | this.deleteMessageCallback && 104 | this.deleteMessageCallback(parsed.tags["target-msg-id"]) 105 | ); 106 | } 107 | } 108 | } 109 | 110 | private handleDisconnect() { 111 | // prevent duplicate reconnection attempts 112 | if (this.state === ConnectionState.Disconnected) return; 113 | this.state = ConnectionState.Disconnected; 114 | 115 | this.conn && this.conn.close(); 116 | 117 | console.log("Disconnected from Twitch."); 118 | 119 | setTimeout( 120 | () => this.connect(), 121 | Math.min(this.connectionAttempts * 2000, MAX_RECONNECT_TIMEOUT), 122 | ); 123 | } 124 | 125 | public disconnect(): void { 126 | this.forceDisconnect = true; 127 | this.conn && this.conn.close(); 128 | } 129 | 130 | private send(line: string) { 131 | if (line.includes("\n")) return; 132 | 133 | if (this.conn && this.conn.readyState == WebSocket.OPEN) 134 | this.conn.send(line + "\r\n"); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/util/color-correction.ts: -------------------------------------------------------------------------------- 1 | const IS_COLOR = /^#[A-F0-9]+$/i; 2 | const NOT_COLOR = /[^A-F0-9]/gi; 3 | 4 | function rgbToHsl( 5 | r: number, 6 | g: number, 7 | b: number, 8 | ): [number, number, number] { 9 | // Convert RGB to HSL, not ideal but it's faster than HCL or full YIQ conversion 10 | // based on http://axonflux.com/handy-rgb-to-hsl-and-rgb-to-hsv-color-model-c 11 | r /= 255; 12 | g /= 255; 13 | b /= 255; 14 | const max = Math.max(r, g, b); 15 | const min = Math.min(r, g, b); 16 | const l = Math.min(Math.max(0, (max + min) / 2), 1); 17 | const d = Math.min(Math.max(0, max - min), 1); 18 | 19 | if (d === 0) { 20 | return [d, d, l]; 21 | } 22 | 23 | let h: number; 24 | switch (max) { 25 | case r: 26 | h = Math.min(Math.max(0, (g - b) / d + (g < b ? 6 : 0)), 6); 27 | break; 28 | case g: 29 | h = Math.min(Math.max(0, (b - r) / d + 2), 6); 30 | break; 31 | default: 32 | h = Math.min(Math.max(0, (r - g) / d + 4), 6); 33 | break; 34 | } 35 | h /= 6; 36 | 37 | let s = l > 0.5 ? d / (2 * (1 - l)) : d / (2 * l); 38 | s = Math.min(Math.max(0, s), 1); 39 | 40 | return [h, s, l]; 41 | } 42 | 43 | function hslToRgb( 44 | h: number, 45 | s: number, 46 | l: number, 47 | ): [number, number, number] { 48 | const hueToRgb = (pp: number, qq: number, t: number) => { 49 | if (t < 0) t += 1; 50 | if (t > 1) t -= 1; 51 | if (t < 1 / 6) return pp + (qq - pp) * 6 * t; 52 | if (t < 1 / 2) return qq; 53 | if (t < 2 / 3) return pp + (qq - pp) * (2 / 3 - t) * 6; 54 | return pp; 55 | }; 56 | 57 | if (s === 0) { 58 | const rgb = Math.round(Math.min(Math.max(0, 255 * l), 255)); // achromatic 59 | return [rgb, rgb, rgb]; 60 | } 61 | 62 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 63 | const p = 2 * l - q; 64 | return [ 65 | Math.round( 66 | Math.min(Math.max(0, 255 * hueToRgb(p, q, h + 1 / 3)), 255), 67 | ), 68 | Math.round(Math.min(Math.max(0, 255 * hueToRgb(p, q, h)), 255)), 69 | Math.round( 70 | Math.min(Math.max(0, 255 * hueToRgb(p, q, h - 1 / 3)), 255), 71 | ), 72 | ]; 73 | } 74 | 75 | export class ColorCorrection { 76 | private cache = new Map(); 77 | 78 | public calculate(color: string) { 79 | color = color.toLowerCase(); 80 | if (this.cache.has(color)) { 81 | return this.cache.get(color)!; 82 | } 83 | 84 | if (!IS_COLOR.test(color)) return color; 85 | 86 | let newColor = color; 87 | for (let i = 0; i < 20; ++i) { 88 | if (!this.shouldConvertColor(newColor)) { 89 | break; 90 | } 91 | newColor = this.convertColor(newColor); 92 | } 93 | 94 | this.cache.set(color, newColor); 95 | 96 | this.cache.size > 1000 && 97 | this.cache.delete(this.cache.entries().next().value[0]); 98 | 99 | return newColor; 100 | } 101 | 102 | private convertColor(color: string) { 103 | const FACTOR = 0.1; 104 | color = color.replace(NOT_COLOR, ""); 105 | 106 | if (color.length < 6) { 107 | color = 108 | color[0] + 109 | color[0] + 110 | color[1] + 111 | color[1] + 112 | color[2] + 113 | color[2]; 114 | } 115 | 116 | const r = parseInt(color.substr(0, 2), 16); 117 | const g = parseInt(color.substr(2, 2), 16); 118 | const b = parseInt(color.substr(4, 2), 16); 119 | 120 | const hsl = rgbToHsl(r, g, b); 121 | let l = 1 - (1 - FACTOR) * (1 - hsl[2]); 122 | l = Math.min(Math.max(0, l), 1); 123 | 124 | const rgb = hslToRgb(hsl[0], hsl[1], l); 125 | 126 | let rStr = rgb[0].toString(16); 127 | let gStr = rgb[1].toString(16); 128 | let bStr = rgb[2].toString(16); 129 | 130 | rStr = ("00" + rStr).substr(rStr.length); 131 | gStr = ("00" + gStr).substr(gStr.length); 132 | bStr = ("00" + bStr).substr(bStr.length); 133 | 134 | return `#${rStr}${gStr}${bStr}`; 135 | } 136 | 137 | private shouldConvertColor(color: string) { 138 | color = color.replace(NOT_COLOR, ""); 139 | if (color.length < 6) { 140 | color = 141 | color[0] + 142 | color[0] + 143 | color[1] + 144 | color[1] + 145 | color[2] + 146 | color[2]; 147 | } 148 | 149 | const r = parseInt(color.substr(0, 2), 16); 150 | const g = parseInt(color.substr(2, 2), 16); 151 | const b = parseInt(color.substr(4, 2), 16); 152 | const yiq = (r * 299 + g * 587 + b * 114) / 1000; 153 | return yiq < 128; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/util/api-client.ts: -------------------------------------------------------------------------------- 1 | import {RequestError} from "./request-error"; 2 | 3 | export interface ApiClientOptions { 4 | baseUrl?: string; 5 | headers?: Record; 6 | } 7 | 8 | export enum ApiClientMethod { 9 | GET = "GET", 10 | POST = "POST", 11 | PATCH = "PATCH", 12 | PUT = "PUT", 13 | DELETE = "DELETE", 14 | } 15 | 16 | export interface ApiClientRequestConfig extends ApiClientOptions { 17 | query?: Record; 18 | method: ApiClientMethod; 19 | url: string; 20 | body?: string | FormData; 21 | } 22 | 23 | export interface ApiClientResponse { 24 | body: T; 25 | status: number; 26 | statusText: string; 27 | headers: Record; 28 | } 29 | 30 | export class ApiClient { 31 | public headers: Record; 32 | public baseUrl: string; 33 | 34 | public constructor(options: ApiClientOptions = {}) { 35 | this.baseUrl = options.baseUrl || ""; 36 | this.headers = options.headers || {}; 37 | } 38 | 39 | public request( 40 | config: ApiClientRequestConfig, 41 | ): Promise> { 42 | return fetch( 43 | this.parseUrl( 44 | (config.baseUrl || this.baseUrl || "") + config.url, 45 | config.query, 46 | ), 47 | { 48 | method: config.method, 49 | body: config.body, 50 | headers: { 51 | ...this.headers, 52 | ...(config.headers || {}), 53 | }, 54 | }, 55 | ) 56 | .then((response) => 57 | response.json().then((body) => ({ 58 | body: body as T, 59 | status: response.status, 60 | statusText: response.statusText, 61 | headers: (response.headers as unknown) as Record< 62 | string, 63 | string 64 | >, 65 | })), 66 | ) 67 | .catch(this.handleError); 68 | } 69 | 70 | public get( 71 | url: string, 72 | qs?: Record, 73 | headers?: Record, 74 | ) { 75 | return this.request({ 76 | url: url, 77 | headers: headers, 78 | method: ApiClientMethod.GET, 79 | query: qs, 80 | }); 81 | } 82 | 83 | public post( 84 | url: string, 85 | body?: Record | string | FormData, 86 | qs?: Record, 87 | headers?: Record, 88 | ) { 89 | return this.request({ 90 | url: url, 91 | method: ApiClientMethod.POST, 92 | body: typeof body === "object" ? JSON.stringify(body) : body, 93 | headers: headers, 94 | query: qs, 95 | }); 96 | } 97 | 98 | public put( 99 | url: string, 100 | body?: Record | string | FormData, 101 | qs?: Record, 102 | headers?: Record, 103 | ) { 104 | return this.request({ 105 | url: url, 106 | method: ApiClientMethod.PUT, 107 | body: typeof body === "object" ? JSON.stringify(body) : body, 108 | headers: headers, 109 | query: qs, 110 | }); 111 | } 112 | 113 | public delete( 114 | url: string, 115 | qs?: Record, 116 | headers?: Record, 117 | ) { 118 | return this.request({ 119 | url: url, 120 | headers: headers, 121 | method: ApiClientMethod.DELETE, 122 | query: qs, 123 | }); 124 | } 125 | 126 | public patch( 127 | url: string, 128 | body?: Record | string | FormData, 129 | qs?: Record, 130 | headers?: Record, 131 | ) { 132 | return this.request({ 133 | url: url, 134 | method: ApiClientMethod.PATCH, 135 | body: typeof body === "object" ? JSON.stringify(body) : body, 136 | headers: headers, 137 | query: qs, 138 | }); 139 | } 140 | 141 | private parseUrl( 142 | base: string, 143 | query?: Record, 144 | ) { 145 | if (!query) { 146 | return base; 147 | } 148 | const params: string[] = []; 149 | for (const key of Object.keys(query)) { 150 | const value = query[key]; 151 | if (Array.isArray(value)) { 152 | for (const item of value as string[]) { 153 | params.push( 154 | `${encodeURIComponent(key)}=${encodeURIComponent(item)}`, 155 | ); 156 | } 157 | } else { 158 | params.push( 159 | `${encodeURIComponent(key)}=${ 160 | encodeURIComponent(value) as string 161 | }`, 162 | ); 163 | } 164 | } 165 | 166 | if (params.length) { 167 | base += "?" + params.join("&"); 168 | } 169 | 170 | return base; 171 | } 172 | 173 | private handleError(err: RequestError) { 174 | return Promise.reject( 175 | new RequestError(err.message, err.statusCode || 500), 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/contexts/third-party-emotes/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ThirdPartyEmote, 3 | ThirdPartyEmoteProvider, 4 | } from "../../models/third-party-emote"; 5 | import {ApiClient} from "../../util/api-client"; 6 | import { 7 | BetterttvGlobalBody, 8 | BetterttvUserBody, 9 | EmoteMap, 10 | FrankerfacezGlobalBody, 11 | FrankerfacezSet, 12 | FrankerfacezUserBody, 13 | SeventvGlobalBody, 14 | SeventvUserBody, 15 | } from "./types"; 16 | 17 | const api = new ApiClient({}); 18 | 19 | export const parseFFZSet = (set: FrankerfacezSet) => { 20 | const emoteMap: EmoteMap = {}; 21 | for (let i = 0; i < set?.emoticons?.length ?? 0; ++i) { 22 | const emote = set?.emoticons[i]; 23 | if (!emote) continue; 24 | 25 | emoteMap[emote.name] = new ThirdPartyEmote( 26 | emote.id.toString(), 27 | ThirdPartyEmoteProvider.FrankerFaceZ, 28 | emote.name, 29 | ThirdPartyEmote.getFrankerfacezImageURL(emote.id), 30 | ); 31 | } 32 | 33 | return emoteMap; 34 | }; 35 | 36 | export const getFFZGlobalEmotes = (): Promise => 37 | api 38 | .get( 39 | "https://api.frankerfacez.com/v1/set/global", 40 | ) 41 | .then((res) => 42 | (res.body?.default_sets ?? []) 43 | .map((setId) => 44 | parseFFZSet(res?.body?.sets[setId.toString()] || {}), 45 | ) 46 | .reduce((acc, cur) => ({...acc, ...cur}), {} as EmoteMap), 47 | ) 48 | .catch((error) => { 49 | console.error("Failed to get FFZ global emotes", error); 50 | return {}; 51 | }); 52 | 53 | export const getFFZUserEmotes = ( 54 | channelId: string, 55 | ): Promise => 56 | api 57 | .get( 58 | `https://api.frankerfacez.com/v1/room/id/${encodeURIComponent( 59 | channelId, 60 | )}`, 61 | ) 62 | .then((res) => 63 | parseFFZSet( 64 | res?.body?.sets[res.body.room.set ?? "".toString()] || { 65 | emoticons: [], 66 | }, 67 | ), 68 | ) 69 | .catch((error) => { 70 | console.error("Failed to get FFZ user emotes", error); 71 | return {}; 72 | }); 73 | 74 | export const getBTTVGlobalEmotes = (): Promise => 75 | api 76 | .get( 77 | "https://api.betterttv.net/3/cached/emotes/global", 78 | ) 79 | .then((res) => 80 | res.body.reduce((acc, cur) => { 81 | acc[cur.code] = new ThirdPartyEmote( 82 | cur.id, 83 | ThirdPartyEmoteProvider.BetterTTV, 84 | cur.code, 85 | ThirdPartyEmote.getBetterttvImageURL(cur.id), 86 | ); 87 | return acc; 88 | }, {} as EmoteMap), 89 | ) 90 | .catch((error) => { 91 | console.error("Failed to get BTTV global emotes", error); 92 | return {}; 93 | }); 94 | 95 | export const getBTTVUserEmotes = ( 96 | channelId: string, 97 | ): Promise => 98 | api 99 | .get( 100 | `https://api.betterttv.net/3/cached/users/twitch/${encodeURIComponent( 101 | channelId, 102 | )}`, 103 | ) 104 | .then((res) => 105 | [ 106 | ...(res?.body?.sharedEmotes ?? []), 107 | ...(res.body.channelEmotes ?? []), 108 | ].reduce((acc, cur) => { 109 | acc[cur.code] = new ThirdPartyEmote( 110 | cur.id, 111 | ThirdPartyEmoteProvider.BetterTTV, 112 | cur.code, 113 | ThirdPartyEmote.getBetterttvImageURL(cur.id), 114 | ); 115 | return acc; 116 | }, {} as EmoteMap), 117 | ) 118 | .catch((error) => { 119 | console.error("Failed to get BTTV user emotes", error); 120 | return {}; 121 | }); 122 | 123 | export const get7TVGlobalEmotes = (): Promise => 124 | api 125 | .get("https://api.7tv.app/v2/emotes/global") 126 | .then((res) => 127 | res.body.reduce((acc, cur) => { 128 | acc[cur.name] = new ThirdPartyEmote( 129 | cur.id, 130 | ThirdPartyEmoteProvider.BetterTTV, 131 | cur.name, 132 | ThirdPartyEmote.getSevenTVImageURL(cur.id), 133 | ); 134 | return acc; 135 | }, {} as EmoteMap), 136 | ) 137 | .catch((error) => { 138 | console.error("Failed to get 7TV global emotes", error); 139 | return {}; 140 | }); 141 | 142 | export const get7TVUserEmotes = (login: string): Promise => 143 | api 144 | .get( 145 | `https://api.7tv.app/v2/users/${encodeURIComponent( 146 | login, 147 | )}/emotes`, 148 | ) 149 | .then((res) => 150 | [...(res?.body ?? [])].reduce((acc, cur) => { 151 | acc[cur.name] = new ThirdPartyEmote( 152 | cur.id, 153 | ThirdPartyEmoteProvider.BetterTTV, 154 | cur.name, 155 | ThirdPartyEmote.getSevenTVImageURL(cur.id), 156 | ); 157 | return acc; 158 | }, {} as EmoteMap), 159 | ) 160 | .catch((error) => { 161 | console.error("Failed to get 7TV user emotes", error); 162 | return {}; 163 | }); 164 | --------------------------------------------------------------------------------