Skip to content

Commit

Permalink
feat: apply modifiers and overlay zero-width emotes
Browse files Browse the repository at this point in the history
  • Loading branch information
6lr61 committed Sep 19, 2024
1 parent ac4bbad commit 4a797bc
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 117 deletions.
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions src/components/Emote.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<figure className="inline-block">
<img
className={twMerge(
"emote relative inline-block *:-my-2",
fragment.modifiers?.join(" ")
)}
{...fragment.small}
/>
{fragment.overlapping?.map((emote, index) => (
<img
key={index}
className="emote inline-block relative -ml-8"
{...emote.small}
/>
))}
</figure>
);
}
81 changes: 81 additions & 0 deletions src/components/Message.tsx
Original file line number Diff line number Diff line change
@@ -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 <TextSegment key={index} text={fragment.text} />;
case "emote":
return <TwitchEmote key={index} fragment={fragment} />;
case "mention":
return <MentionSegment key={index} text={fragment.text} />;
case "7tv-emote":
case "bttv-emote":
return <Emote key={index} fragment={fragment} />;
default:
return;
}
}

function MessageComponent({
message,
twitchBadges,
}: {
message: ChatMessage;
twitchBadges?: TwitchBadges;
}): ReactElement {
return (
<article className="flex flex-row gap-1">
<ProfilePicture login={message.chatter_user_login} />
<section className="flex-1 overflow-hidden rounded-md">
<header
className="flex px-2 gap-2 items-center bg-black/25"
style={{ backgroundColor: colorToRgba(message.color) }}
>
<BadgeList badges={message.badges} twitchBadges={twitchBadges} />
<UserName
login={message.chatter_user_login}
displayName={message.chatter_user_name}
/>
<Pronoun login={message.chatter_user_login} />
<ElapsedTime startingDate={message.timestamp} />
</header>
{message.reply && <Reply message={message.reply} />}
<section className="h-full bg-slate-800 break-words px-2 py-1">
{message.message.fragments
.slice(message.reply ? 1 : 0) // Drop the first mention fragment
.map((fragment, index) => fragmentToComponent(fragment, index))}
</section>
</section>
</article>
);
}

const Message = memo(MessageComponent);
export default Message;
114 changes: 9 additions & 105 deletions src/components/Messages.tsx
Original file line number Diff line number Diff line change
@@ -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 <TextSegment key={key} text={fragment.text} />;
case "emote":
return <TwitchEmote key={key} fragment={fragment} />;
case "mention":
return <MentionSegment key={key} text={fragment.text} />;
case "7tv-emote":
case "bttv-emote":
return (
<img
key={key}
className="relative inline-block -my-2"
{...fragment.small}
/>
);
default:
return;
}
}
import Message from "./Message";

export default function Messages(): React.ReactElement {
const { authState } = useContext(AuthContext);
const messages = useTwitchChat();
const ref = useRef<HTMLDivElement>(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) {
Expand All @@ -85,43 +19,13 @@ export default function Messages(): React.ReactElement {
return (
<section ref={ref} className="h-[600px] justify-end overflow-y-auto">
<article className="flex flex-col gap-1">
{messages.map(
({
chatter_user_login,
chatter_user_name,
color,
badges,
timestamp,
reply,
message,
}) => (
<article className="flex flex-row gap-1">
<ProfilePicture login={chatter_user_login} />
<section className="flex-1 overflow-hidden rounded-md">
<header
className="flex px-2 gap-2 items-center bg-black/25"
style={{ backgroundColor: colorToRgba(color) }}
>
<BadgeList badges={badges} twitchBadges={twitchBadges} />
<UserName
login={chatter_user_login}
displayName={chatter_user_name}
/>
<Pronoun login={chatter_user_login} />
<ElapsedTime startingDate={timestamp} />
</header>
{reply && <Reply message={reply} />}
<section className="h-full bg-slate-800 break-words px-2 py-1">
{parseFragments(message.fragments)
.slice(reply ? 1 : 0) // Drop the first mention fragment
.map((fragment, index) =>
fragmentToComponent(fragment, index)
)}
</section>
</section>
</article>
)
)}
{messages.map((message) => (
<Message
key={message.message_id}
message={message}
twitchBadges={twitchBadges}
/>
))}
</article>
</section>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/TwitchEmote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function TwitchEmote({
// TODO: Add fixed sizes?
return (
<img
className="relative inline-block -my-2"
className="emote relative inline-block -my-2"
src={url}
alt={fragment.text}
/>
Expand Down
2 changes: 0 additions & 2 deletions src/contexts/auth-state/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export default function AuthProvider({

if (message.type === "token") {
setAccessToken(message.data.token);
} else {
console.error("handleMessage:", message.data);
}
},
[setAccessToken]
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useBadges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AuthContext } from "../contexts/auth-state/AuthContext";

type BadgeKey = `${BadgeSet["set_id"]}/${Badge["id"]}`;
type BadgeValue = Omit<Badge, "id">;
type TwitchBadges = Map<BadgeKey, BadgeValue>;
export type TwitchBadges = Map<BadgeKey, BadgeValue>;

function makeBadgeMap(data: BadgeSet[]): TwitchBadges {
return new Map(
Expand Down
Loading

0 comments on commit 4a797bc

Please sign in to comment.