diff --git a/package-lock.json b/package-lock.json index 02b1baa..336c05c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@tanstack/react-query": "^5.56.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -3914,6 +3915,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", + "integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", diff --git a/package.json b/package.json index ef68ac3..3eeac08 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dependencies": { "@tanstack/react-query": "^5.56.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/src/components/Emote.tsx b/src/components/Emote.tsx new file mode 100644 index 0000000..034a798 --- /dev/null +++ b/src/components/Emote.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from "react"; +import { twMerge } from "tailwind-merge"; +import type { ProcessedEmote } from "../hooks/useTwitchChat"; + +export default function Emote({ + fragment, +}: { + fragment: ProcessedEmote; +}): ReactElement { + return ( +
+ + {fragment.overlapping?.map((emote, index) => ( + + ))} +
+ ); +} diff --git a/src/components/Message.tsx b/src/components/Message.tsx new file mode 100644 index 0000000..4d7fe92 --- /dev/null +++ b/src/components/Message.tsx @@ -0,0 +1,81 @@ +import { memo, type ReactElement } from "react"; +import type { ChatMessage, Processed } from "../hooks/useTwitchChat"; +import ProfilePicture from "./ProfilePicture"; +import BadgeList from "./BadgeList"; +import Pronoun from "./Pronoun"; +import UserName from "./UserName"; +import ElapsedTime from "./ElapsedTime"; +import Reply from "./Reply"; +import TextSegment from "./TextSegment"; +import Emote from "./Emote"; +import MentionSegment from "./MentionSegment"; +import TwitchEmote from "./TwitchEmote"; +import type { TwitchBadges } from "../hooks/useBadges"; + +function colorToRgba(color?: string): string | undefined { + if (!color) { + return; + } + + const red = Number.parseInt(color.slice(1, 3), 16).toString(); + const green = Number.parseInt(color.slice(3, 5), 16).toString(); + const blue = Number.parseInt(color.slice(5), 16).toString(); + + return `rgba(${red}, ${green}, ${blue}, 0.25)`; +} + +function fragmentToComponent( + fragment: Processed, + index: number +): React.ReactElement | undefined { + switch (fragment.type) { + case "text": + return ; + case "emote": + return ; + case "mention": + return ; + case "7tv-emote": + case "bttv-emote": + return ; + default: + return; + } +} + +function MessageComponent({ + message, + twitchBadges, +}: { + message: ChatMessage; + twitchBadges?: TwitchBadges; +}): ReactElement { + return ( +
+ +
+
+ + + + +
+ {message.reply && } +
+ {message.message.fragments + .slice(message.reply ? 1 : 0) // Drop the first mention fragment + .map((fragment, index) => fragmentToComponent(fragment, index))} +
+
+
+ ); +} + +const Message = memo(MessageComponent); +export default Message; diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 4960631..c19293c 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,78 +1,12 @@ -import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useTwitchChat } from "../hooks/useTwitchChat"; -import ProfilePicture from "./ProfilePicture"; -import BadgeList from "./BadgeList"; -import UserName from "./UserName"; -import Pronoun from "./Pronoun"; -import ElapsedTime from "./ElapsedTime"; -import Reply from "./Reply"; -import TextSegment from "./TextSegment"; -import TwitchEmote from "./TwitchEmote"; -import MentionSegment from "./MentionSegment"; -import { - findThirdPartyEmotes, - type Fragment, -} from "../utils/findThirdPartyEmotes"; import { useBadges } from "../hooks/useBadges"; -import type { ChatFragment } from "../utils/event-sub/events/chat/message"; -import { useBttvEmotes } from "../hooks/useBttvEmotes"; -import { useSevenTvEmotes } from "../hooks/useSevenTvEmotes"; -import { AuthContext } from "../contexts/auth-state/AuthContext"; - -function colorToRgba(color?: string): string | undefined { - if (!color) { - return; - } - - const red = Number.parseInt(color.slice(1, 3), 16).toString(); - const green = Number.parseInt(color.slice(3, 5), 16).toString(); - const blue = Number.parseInt(color.slice(5), 16).toString(); - - return `rgba(${red}, ${green}, ${blue}, 0.25)`; -} - -function fragmentToComponent( - fragment: Fragment, - index: number -): React.ReactElement | undefined { - const key = `fragment-${index.toString()}`; - - switch (fragment.type) { - case "text": - return ; - case "emote": - return ; - case "mention": - return ; - case "7tv-emote": - case "bttv-emote": - return ( - - ); - default: - return; - } -} +import Message from "./Message"; export default function Messages(): React.ReactElement { - const { authState } = useContext(AuthContext); const messages = useTwitchChat(); const ref = useRef(null); const twitchBadges = useBadges(); - const bttvEmotes = useBttvEmotes(authState?.user.id); - const sevenTvEmotes = useSevenTvEmotes(authState?.user.id); - const emotes = useMemo( - () => ({ ...bttvEmotes, ...sevenTvEmotes }), - [bttvEmotes, sevenTvEmotes] - ); - const parseFragments = useCallback( - (fragments: ChatFragment[]) => findThirdPartyEmotes(fragments, emotes), - [emotes] - ); useEffect(() => { if (!ref.current) { @@ -85,43 +19,13 @@ export default function Messages(): React.ReactElement { return (
- {messages.map( - ({ - chatter_user_login, - chatter_user_name, - color, - badges, - timestamp, - reply, - message, - }) => ( -
- -
-
- - - - -
- {reply && } -
- {parseFragments(message.fragments) - .slice(reply ? 1 : 0) // Drop the first mention fragment - .map((fragment, index) => - fragmentToComponent(fragment, index) - )} -
-
-
- ) - )} + {messages.map((message) => ( + + ))}
); diff --git a/src/components/TwitchEmote.tsx b/src/components/TwitchEmote.tsx index a73408e..060bb59 100644 --- a/src/components/TwitchEmote.tsx +++ b/src/components/TwitchEmote.tsx @@ -19,7 +19,7 @@ export default function TwitchEmote({ // TODO: Add fixed sizes? return ( {fragment.text} diff --git a/src/contexts/auth-state/AuthProvider.tsx b/src/contexts/auth-state/AuthProvider.tsx index eeb18fc..4b9962f 100644 --- a/src/contexts/auth-state/AuthProvider.tsx +++ b/src/contexts/auth-state/AuthProvider.tsx @@ -36,8 +36,6 @@ export default function AuthProvider({ if (message.type === "token") { setAccessToken(message.data.token); - } else { - console.error("handleMessage:", message.data); } }, [setAccessToken] diff --git a/src/hooks/useBadges.ts b/src/hooks/useBadges.ts index 50af9dc..651f733 100644 --- a/src/hooks/useBadges.ts +++ b/src/hooks/useBadges.ts @@ -5,7 +5,7 @@ import { AuthContext } from "../contexts/auth-state/AuthContext"; type BadgeKey = `${BadgeSet["set_id"]}/${Badge["id"]}`; type BadgeValue = Omit; -type TwitchBadges = Map; +export type TwitchBadges = Map; function makeBadgeMap(data: BadgeSet[]): TwitchBadges { return new Map( diff --git a/src/hooks/useTwitchChat.ts b/src/hooks/useTwitchChat.ts index a839a34..fd65e11 100644 --- a/src/hooks/useTwitchChat.ts +++ b/src/hooks/useTwitchChat.ts @@ -1,10 +1,23 @@ -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { AuthContext } from "../contexts/auth-state/AuthContext"; -import type { ChatMessageEvent } from "../utils/event-sub/events/chat/message"; +import type { + ChatMessageEvent, + EmoteFragment, + TextFragment, +} from "../utils/event-sub/events/chat/message"; import { EventSubContext } from "../contexts/event-sub/EventSubContext"; import type { ChatEventCommon } from "../utils/event-sub/events/chat/_common"; import type { ChatClearUserMessageEvent } from "../utils/event-sub/events/chat/clearUser"; import type { ChatMessageDeleteEvent } from "../utils/event-sub/events/chat/messageDelete"; +import { useBttvEmotes, type BetterTTVEmoteFragment } from "./useBttvEmotes"; +import { + useSevenTvEmotes, + type SevenTVEmoteFragment, +} from "./useSevenTvEmotes"; +import { + findThirdPartyEmotes, + type Fragment, +} from "../utils/findThirdPartyEmotes"; type ChatEvent = | ChatMessage @@ -30,10 +43,114 @@ const subscriptions = [ "channel.chat.message_delete", ]; +export type Emote = + | EmoteFragment + | BetterTTVEmoteFragment + | SevenTVEmoteFragment; +export type Processed = Fragment & { + modifiers?: string[]; + overlapping?: SevenTVEmoteFragment[]; +}; +export type ProcessedEmote = (BetterTTVEmoteFragment | SevenTVEmoteFragment) & { + modifiers?: string[]; + overlapping?: SevenTVEmoteFragment[]; +}; + +const modifierClassNames: Record = { + "h!": "flipx", + "v!": "flipy", + "w!": "wide", + "r!": "rotate", + "l!": "rotateLeft", + "z!": "zeroSpace", + "c!": "cursed", + "s!": "shake", + "p!": "party", +}; + +function applyModifiers(fragments: Processed[]): Processed[] { + const copies = structuredClone(fragments); + const modifiers: BetterTTVEmoteFragment[] = []; + const whitespaces: TextFragment[] = []; + const result: Set = new Set(copies); + let previousEmote: Processed | null = null; + let previousModifier: BetterTTVEmoteFragment | null = null; + + for (const fragment of copies) { + if ( + (previousModifier || previousEmote) && + fragment.type === "text" && + fragment.text.trim().length === 0 + ) { + whitespaces.push(fragment); + continue; + } + + if ( + fragment.type !== "emote" && + fragment.type !== "bttv-emote" && + fragment.type !== "7tv-emote" + ) { + modifiers.length = 0; + whitespaces.length = 0; + previousEmote = null; + previousModifier = null; + continue; + } + + if (fragment.type === "bttv-emote" && fragment.modifier) { + modifiers.push(fragment); + previousEmote = null; + previousModifier = fragment; + continue; + } + + if (fragment.type === "7tv-emote" && fragment.zeroWidth && previousEmote) { + if (!previousEmote.overlapping) { + previousEmote.overlapping = []; + } + + previousEmote.overlapping.push(fragment); + result.delete(fragment); + whitespaces.forEach((whitespace) => result.delete(whitespace)); + whitespaces.length = 0; + + continue; + } + + fragment.modifiers = modifiers.map( + (fragment) => modifierClassNames[fragment.text] // FIXME: Might be undefined + ); + + { + modifiers.forEach((modifier) => result.delete(modifier)); + modifiers.length = 0; + } + + { + whitespaces.forEach((whitespace) => result.delete(whitespace)); + whitespaces.length = 0; + } + previousEmote = fragment; + } + + return Array.from(result); +} + export function useTwitchChat(bufferSize = 50, channelId?: string) { const { authState } = useContext(AuthContext); const eventSubContext = useContext(EventSubContext); const [messages, setMessages] = useState([]); + const bttvEmotes = useBttvEmotes(authState?.user.id); + const sevenTvEmotes = useSevenTvEmotes(authState?.user.id); + const emotes = useMemo( + () => ({ ...bttvEmotes, ...sevenTvEmotes }), + [bttvEmotes, sevenTvEmotes] + ); + const parseFragments = useCallback( + (fragments: Processed[]) => findThirdPartyEmotes(fragments, emotes), + [emotes] + ); const { subscribe } = eventSubContext; @@ -63,14 +180,22 @@ export function useTwitchChat(bufferSize = 50, channelId?: string) { case "channel.chat.message": setMessages((messages) => [ ...messages.slice(-bufferSize + 1), - messageEvent, + { + ...messageEvent, + message: { + ...messageEvent.message, + fragments: applyModifiers( + parseFragments(messageEvent.message.fragments) + ), + }, + }, ]); break; default: console.error("useTwitchChat: Called with unknown event:", event); } }, - [bufferSize] + [bufferSize, parseFragments] ); useEffect(() => { diff --git a/src/utils/event-sub/events/chat/message.ts b/src/utils/event-sub/events/chat/message.ts index f6062d5..71bd986 100644 --- a/src/utils/event-sub/events/chat/message.ts +++ b/src/utils/event-sub/events/chat/message.ts @@ -1,5 +1,6 @@ import type { ChatEventCommon } from "./_common"; import type { EventSubTransport } from "../transport"; +import type { Processed } from "../../../../hooks/useTwitchChat"; export interface ChatMessagePayload { subscription: { @@ -27,7 +28,7 @@ export interface ChatMessageEvent extends ChatEventCommon { message: { /** Chat message in plain text */ text: string; - fragments: ChatFragment[]; + fragments: Processed[]; }; message_type: | "text" diff --git a/src/utils/findThirdPartyEmotes.ts b/src/utils/findThirdPartyEmotes.ts index d8c595f..f22aeab 100644 --- a/src/utils/findThirdPartyEmotes.ts +++ b/src/utils/findThirdPartyEmotes.ts @@ -1,5 +1,6 @@ import { type BetterTTVEmoteFragment } from "../hooks/useBttvEmotes"; import { type SevenTVEmoteFragment } from "../hooks/useSevenTvEmotes"; +import type { Processed } from "../hooks/useTwitchChat"; import type { ChatFragment } from "./event-sub/events/chat/message"; export type Fragment = @@ -9,7 +10,7 @@ export type Fragment = type Emotes = Record; export function findThirdPartyEmotes( - fragments: ChatFragment[], + fragments: Processed[], emotes: Emotes ): Fragment[] { const names = Object.keys(emotes);