From 4a797bc3909821b1bb0218acf1722e0d022dfe91 Mon Sep 17 00:00:00 2001
From: Adrian <107351903+6lr61@users.noreply.github.com>
Date: Thu, 19 Sep 2024 12:25:34 +0200
Subject: [PATCH] feat: apply modifiers and overlay zero-width emotes
---
package-lock.json | 13 +-
package.json | 3 +-
src/components/Emote.tsx | 28 +++++
src/components/Message.tsx | 81 +++++++++++++
src/components/Messages.tsx | 114 ++----------------
src/components/TwitchEmote.tsx | 2 +-
src/contexts/auth-state/AuthProvider.tsx | 2 -
src/hooks/useBadges.ts | 2 +-
src/hooks/useTwitchChat.ts | 133 ++++++++++++++++++++-
src/utils/event-sub/events/chat/message.ts | 3 +-
src/utils/findThirdPartyEmotes.ts | 3 +-
11 files changed, 267 insertions(+), 117 deletions(-)
create mode 100644 src/components/Emote.tsx
create mode 100644 src/components/Message.tsx
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 (
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);