From 096d4aefc73806042c576eb2b97978ac5f834c26 Mon Sep 17 00:00:00 2001 From: Adrian <107351903+6lr61@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:23:09 +0200 Subject: [PATCH] feat: render chat messages --- src/App.tsx | 26 ++- src/components/BadgeList.tsx | 37 +++ src/components/ElapsedTime.tsx | 34 +++ src/components/MentionSegment.tsx | 25 ++ src/components/Message.css | 239 ++++++++++++++++++++ src/components/Message.tsx | 70 ++++++ src/components/ProfilePicture.tsx | 23 ++ src/components/Reply.tsx | 23 ++ src/components/TextSegment.tsx | 12 + src/components/TwitchEmote.tsx | 21 ++ src/components/UserName.tsx | 20 ++ src/contexts/UserProfileContext.ts | 4 + src/contexts/UserProfileProvider.tsx | 43 ++++ src/contexts/badges/TwitchBadgeContext.ts | 9 + src/contexts/badges/TwitchBadgeProvider.tsx | 51 +++++ src/hooks/useTwitchChat.ts | 15 +- src/utils/api/getBadges.ts | 63 ++++++ src/utils/api/getUser.ts | 84 +++++++ src/utils/event-sub/EventSub.ts | 1 + 19 files changed, 789 insertions(+), 11 deletions(-) create mode 100644 src/components/BadgeList.tsx create mode 100644 src/components/ElapsedTime.tsx create mode 100644 src/components/MentionSegment.tsx create mode 100644 src/components/Message.css create mode 100644 src/components/Message.tsx create mode 100644 src/components/ProfilePicture.tsx create mode 100644 src/components/Reply.tsx create mode 100644 src/components/TextSegment.tsx create mode 100644 src/components/TwitchEmote.tsx create mode 100644 src/components/UserName.tsx create mode 100644 src/contexts/UserProfileContext.ts create mode 100644 src/contexts/UserProfileProvider.tsx create mode 100644 src/contexts/badges/TwitchBadgeContext.ts create mode 100644 src/contexts/badges/TwitchBadgeProvider.tsx create mode 100644 src/utils/api/getBadges.ts create mode 100644 src/utils/api/getUser.ts diff --git a/src/App.tsx b/src/App.tsx index 9bbdd57..f04b43b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,14 +2,13 @@ import { useContext } from "react"; import { AuthStateContext } from "./contexts/auth-state/AuthStateContext"; import LoginButton from "./components/LoginButton"; import "./App.css"; -import { useEventSub } from "./hooks/useEventSub"; +import { useTwitchChat } from "./hooks/useTwitchChat"; +import TwitchBadgeProvider from "./contexts/badges/TwitchBadgeProvider"; +import Message from "./components/Message"; export default function App() { const authContext = useContext(AuthStateContext); - const { lastMessage } = useEventSub("channel.chat.message", { - broadcaster_user_id: authContext?.authState?.user.id, - user_id: authContext?.authState?.user.id, - }); + const messages = useTwitchChat(); if (!authContext) { return

Missing AuthStateContext provider?

; @@ -17,9 +16,22 @@ export default function App() { return ( <> -

Hello: {authContext.authState?.user.login}

-

Last message: {JSON.stringify(lastMessage)}

+ {authContext.authState && ( +
+

Hello: {authContext.authState.user.login}

+
+

Chat Messages:

+ +
+ {messages.map((message) => ( + + ))} +
+
+
+
+ )} ); } diff --git a/src/components/BadgeList.tsx b/src/components/BadgeList.tsx new file mode 100644 index 0000000..cde0706 --- /dev/null +++ b/src/components/BadgeList.tsx @@ -0,0 +1,37 @@ +import { useContext } from "react"; +import { TwitchBadgeConext } from "../contexts/badges/TwitchBadgeContext"; + +interface Props { + // FIXME: Derive this from a single point of truth + badges: { + set_id: string; + id: string; + /** Months subscribed */ + info: string; + }[]; +} + +function makeKey(setId: string, id: string): `${string}/${string}` { + return `${setId}/${id}`; +} + +export default function BadgeList({ + badges, +}: Props): React.ReactElement | undefined { + const twitchBadges = useContext(TwitchBadgeConext); + + if (!twitchBadges || badges.length === 0) { + return; + } + + return ( +
+ {badges.map(({ set_id, id }) => ( + + ))} +
+ ); +} diff --git a/src/components/ElapsedTime.tsx b/src/components/ElapsedTime.tsx new file mode 100644 index 0000000..d3d674e --- /dev/null +++ b/src/components/ElapsedTime.tsx @@ -0,0 +1,34 @@ +import { type HTMLProps, useEffect, useState } from "react"; + +const UNA_MINUTA = 60_000; +const MINUTE_IN_MILLIS = 60_000; + +function passedTimeMinutes(startingDate: Date): number { + return Math.floor((Date.now() - startingDate.getTime()) / MINUTE_IN_MILLIS); +} + +export default function ElapsedTime({ + startingDate, +}: { + startingDate: Date; +} & HTMLProps): React.ReactElement { + const [minutes, setMinutes] = useState(passedTimeMinutes(startingDate)); + + useEffect(() => { + const timer = setInterval(() => { + setMinutes(() => passedTimeMinutes(startingDate)); + }, UNA_MINUTA); + + return () => { + clearInterval(timer); + }; + }, [startingDate]); + + return ( + <> + + {minutes > 0 ? `⧗ ${minutes.toString()}m` : "just now!"} + + + ); +} diff --git a/src/components/MentionSegment.tsx b/src/components/MentionSegment.tsx new file mode 100644 index 0000000..56710b2 --- /dev/null +++ b/src/components/MentionSegment.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { AuthStateContext } from "../contexts/auth-state/AuthStateContext"; + +interface MentionProps { + text: string; +} + +export default function MentionSegment({ + text, +}: MentionProps): React.ReactElement { + const mentioned = text.replace("@", "").toLocaleLowerCase(); + const authStateContext = useContext(AuthStateContext); + + return ( + + {text} + + ); +} diff --git a/src/components/Message.css b/src/components/Message.css new file mode 100644 index 0000000..8371871 --- /dev/null +++ b/src/components/Message.css @@ -0,0 +1,239 @@ +.message { + display: flex; + flex-direction: row; + margin: 0 0.3rem 0.3rem 0.3rem; + + &.first-message .profile-picture, + &.first-message .container { + font-weight: bold; + box-shadow: 0rem 0rem 1rem 0.5rem + var(--vscode-list-focusHighlightForeground); + } + + &.highlighted .profile-picture, + &.highlighted .container { + border-color: var(--vscode-list-focusHighlightForeground); + border-style: solid; + border-width: 3px; + font-weight: bold; + } + + .profile-picture { + aspect-ratio: 1; + border-radius: 12px; + height: 58px; + margin-right: 0.3rem; + + img { + border-radius: 10px; + position: relative; + } + } + + .container { + border-radius: 0.3rem; + flex: 1; + overflow: hidden; + } + + header { + align-items: center; + background-color: rgba(0, 0, 0, 0.25); + border-radius: 0.3rem 0.3rem 0rem 0rem; + display: flex; + font-weight: bold; + height: 1.5rem; + padding: 0 0.5rem; + + .badges { + display: inline-block; + height: 0.8rem; + text-wrap: nowrap; + + img { + margin-right: 0.2rem; + } + } + + p { + flex-grow: 1; + height: 100%; + line-height: 1.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .display-name { + font-weight: bold; + } + + .username, + .pronoun { + font-weight: normal; + margin-left: 0.3rem; + } + } + + .timer { + color: var(--vscode-disabledForeground); + flex-grow: 0; + font-size: small; + font-weight: normal; + margin-left: 0.3rem; + white-space: nowrap; + } + } + + .reply { + header { + background-color: rgba(0, 0, 0, 0.25); + font-size: 0.7rem; + padding: 0.2rem 0.3rem; + } + + .body { + background-color: rgba(0, 0, 0, 0.25); + font-size: 0.7rem; + font-style: italic; + line-height: 0.9rem; + padding: 0.2rem 0.5rem 0.3rem 0.5rem; + word-break: break-word; + } + } + + .body { + background-color: var(--vscode-sideBar-border); + color: var(--vscode-sideBar-foreground); + line-height: 1.1rem; + padding: 0.5rem; + word-break: break-word; + + &.cursive { + font-style: italic; + } + + &.cursive img { + transform: skew(-15deg); + } + + img { + margin: -0.5rem 0; + position: relative; + vertical-align: middle; + + &.gigantic { + display: block; + margin: 0 0 -0.5rem 0; + } + + &.flipx { + transform: scaleX(-1); + } + + &.flipy { + transform: scaleY(-1); + } + + &.wide { + height: 28px; + width: 114px; + } + + &.rotate { + transform: rotate(90deg); + } + + &.rotateLeft { + transform: rotate(-90deg); + } + + &.zeroSpace { + margin-left: -0.3rem; + } + + &.cursed { + filter: brightness(0.75) contrast(2.5) grayscale(1); + } + + &.party { + animation: partying 1.5s linear infinite; + } + + &.shake { + animation: shaking 0.5s step-start infinite; + } + + &.shake.party { + animation: partying 1.5s linear infinite, + shaking 0.5s step-start infinite; + } + } + + figure { + display: inline-block; + + .zeroWidth { + margin-left: -32px; + } + } + + figure.big { + .zeroWidth { + margin-left: -128px; + } + } + + .mention { + color: var(--vscode-sideBar-background); + background-color: var(--vscode-sideBar-foreground); + font-weight: bold; + padding: 2px; + border-radius: 2px; + } + } +} + +@keyframes partying { + 0% { + filter: sepia(0.5) hue-rotate(0deg) saturate(2.5); + } + to { + filter: sepia(0.5) hue-rotate(1turn) saturate(2.5); + } +} + +@keyframes shaking { + 0% { + translate: 0 1px; + } + 10% { + translate: 2px 0; + } + 20% { + translate: 1px -2px; + } + 30% { + translate: -2px 1px; + } + 40% { + translate: 0 -1px; + } + 50% { + translate: 2px 2px; + } + 60% { + translate: -1px -1px; + } + 70% { + translate: -2px 2px; + } + 80% { + translate: 2px 1px; + } + 90% { + translate: -1px -2px; + } + to { + translate: 1px 0; + } +} diff --git a/src/components/Message.tsx b/src/components/Message.tsx new file mode 100644 index 0000000..b2cfb5d --- /dev/null +++ b/src/components/Message.tsx @@ -0,0 +1,70 @@ +import type { ChatMessage } from "../hooks/useTwitchChat"; +import type { ChatFragment } from "../utils/event-sub/events/chat/message"; +import BadgeList from "./BadgeList"; +import ElapsedTime from "./ElapsedTime"; +import MentionSegment from "./MentionSegment"; +import ProfilePicture from "./ProfilePicture"; +import Reply from "./Reply"; +import TextSegment from "./TextSegment"; +import TwitchEmote from "./TwitchEmote"; +import UserName from "./UserName"; +import "./Message.css"; +import UserProfileProvider from "../contexts/UserProfileProvider"; + +interface Props { + message: ChatMessage; +} + +function colorToRgba(color?: string): string | undefined { + if (!color) { + return; + } + + // #aabbcc + 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: ChatFragment, + index: number +): React.ReactElement | undefined { + const key = `fragment-${index.toString()}`; + + switch (fragment.type) { + case "text": + return ; + case "emote": + return ; + case "mention": + return ; + default: + return; + } +} + +export default function Message({ message }: Props): React.ReactElement { + return ( + +
+ +
+
+ + + +
+ {message.reply && } +
+ {message.message.fragments.map((fragment, index) => + fragmentToComponent(fragment, index) + )} +
+
+
+
+ ); +} diff --git a/src/components/ProfilePicture.tsx b/src/components/ProfilePicture.tsx new file mode 100644 index 0000000..6056725 --- /dev/null +++ b/src/components/ProfilePicture.tsx @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { UserProfileContext } from "../contexts/UserProfileContext"; + +export default function ProfilePicture({ + size = 70, +}: { + size?: number; +}): React.ReactElement { + const userData = useContext(UserProfileContext); + + if (!userData) { + return
; + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Reply.tsx b/src/components/Reply.tsx new file mode 100644 index 0000000..026671d --- /dev/null +++ b/src/components/Reply.tsx @@ -0,0 +1,23 @@ +import type { ChatMessage } from "../hooks/useTwitchChat"; + +type MessageReply = Required["reply"]; + +function hasLocalizedName(message: MessageReply) { + return message.parent_user_name.toLowerCase() !== message.parent_user_login; +} + +export default function Reply({ + message, +}: { + message: MessageReply; +}): React.ReactElement { + return ( +
+
+ ↳ Replying to: @{message.parent_user_login} ( + {hasLocalizedName(message) && message.parent_user_login}) +
+
{message.parent_message_body}
+
+ ); +} diff --git a/src/components/TextSegment.tsx b/src/components/TextSegment.tsx new file mode 100644 index 0000000..ecae21c --- /dev/null +++ b/src/components/TextSegment.tsx @@ -0,0 +1,12 @@ +import type { HTMLProps } from "react"; + +interface TextProps { + text: string; +} + +export default function TextSegment({ + text, + ...props +}: TextProps & HTMLProps): React.ReactElement { + return {text}; +} diff --git a/src/components/TwitchEmote.tsx b/src/components/TwitchEmote.tsx new file mode 100644 index 0000000..8c07a7b --- /dev/null +++ b/src/components/TwitchEmote.tsx @@ -0,0 +1,21 @@ +import type { EmoteFragment } from "../utils/event-sub/events/chat/message"; + +interface TwitchEmoteProps { + fragment: EmoteFragment; + scale?: "1.0" | "2.0" | "3.0"; + format?: "default" | "static" | "animated"; + theme?: "light" | "dark"; +} + +export default function TwitchEmote({ + fragment, + scale = "1.0", + format = "default", + theme = "dark", +}: TwitchEmoteProps): React.ReactElement { + const emoteFormat = fragment.type.includes(format) ? format : "default"; + const url = `https://static-cdn.jtvnw.net/emoticons/v2/${fragment.emote.id}/${emoteFormat}/${theme}/${scale}`; + + // TODO: Add fixed sizes? + return {fragment.text}; +} diff --git a/src/components/UserName.tsx b/src/components/UserName.tsx new file mode 100644 index 0000000..f8410ee --- /dev/null +++ b/src/components/UserName.tsx @@ -0,0 +1,20 @@ +import type { ChatMessage } from "../hooks/useTwitchChat"; + +function hasLocalizedName(message: ChatMessage): boolean { + return message.chatter_user_name.toLowerCase() !== message.chatter_user_login; +} + +export default function UserName({ + message, +}: { + message: ChatMessage; +}): React.ReactElement { + return ( +

+ {message.chatter_user_name} + {hasLocalizedName(message) && ( + ({message.chatter_user_login}) + )} +

+ ); +} diff --git a/src/contexts/UserProfileContext.ts b/src/contexts/UserProfileContext.ts new file mode 100644 index 0000000..079e85e --- /dev/null +++ b/src/contexts/UserProfileContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import type { UserData } from "../utils/api/getUser"; + +export const UserProfileContext = createContext(null); diff --git a/src/contexts/UserProfileProvider.tsx b/src/contexts/UserProfileProvider.tsx new file mode 100644 index 0000000..3d21649 --- /dev/null +++ b/src/contexts/UserProfileProvider.tsx @@ -0,0 +1,43 @@ +import { useContext, useEffect, useState } from "react"; +import { UserProfileContext } from "./UserProfileContext"; +import { AuthStateContext } from "./auth-state/AuthStateContext"; +import { getUser, type UserData } from "../utils/api/getUser"; + +interface UserProfileProviderProps { + children: React.ReactNode; + login?: string; +} + +export default function UserProfileProvider({ + children, + login, +}: UserProfileProviderProps): React.ReactElement { + const authStateContext = useContext(AuthStateContext); + const authState = authStateContext?.authState; + const [userData, setUserData] = useState(null); + + // TODO: Add a clean-up function! + useEffect(() => { + if (authState) { + getUser( + authState.token.value, + authState.client.id, + login ?? authState.user.login + ) + .then((data) => { + setUserData(data); + }) + .catch((error: unknown) => { + console.error("UserProfile:", error); + }); + } else { + setUserData(null); + } + }, [authState, login]); + + return ( + + {children} + + ); +} diff --git a/src/contexts/badges/TwitchBadgeContext.ts b/src/contexts/badges/TwitchBadgeContext.ts new file mode 100644 index 0000000..241d520 --- /dev/null +++ b/src/contexts/badges/TwitchBadgeContext.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; +import type { Badge, BadgeSet } from "../../utils/api/getBadges"; + +export type TwitchBadges = Map< + `${BadgeSet["set_id"]}/${Badge["id"]}`, + Omit +>; + +export const TwitchBadgeConext = createContext(null); diff --git a/src/contexts/badges/TwitchBadgeProvider.tsx b/src/contexts/badges/TwitchBadgeProvider.tsx new file mode 100644 index 0000000..71fed8f --- /dev/null +++ b/src/contexts/badges/TwitchBadgeProvider.tsx @@ -0,0 +1,51 @@ +import { useContext, useEffect, useState } from "react"; +import { TwitchBadgeConext, type TwitchBadges } from "./TwitchBadgeContext"; +import { AuthStateContext } from "../auth-state/AuthStateContext"; +import { getBadges } from "../../utils/api/getBadges"; + +interface Props { + children: React.ReactNode; +} + +export default function TwitchBadgeProvider({ + children, +}: Props): React.ReactElement { + const [badges, setBadges] = useState(null); + const authStateContext = useContext(AuthStateContext); + + useEffect(() => { + if (!authStateContext?.authState) { + return; + } + + let keep = true; + + getBadges( + authStateContext.authState.token.value, + authStateContext.authState.client.id, + authStateContext.authState.user.id + ) + .then((sets) => { + const tmp = sets.flatMap(({ set_id, versions }) => + versions.map(({ id, ...rest }) => [`${set_id}/${id}`, rest] as const) + ); + + if (keep) { + setBadges(new Map(tmp)); + } + }) + .catch((error: unknown) => { + console.error(error); + }); + + return () => { + keep = false; + }; + }, [authStateContext]); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useTwitchChat.ts b/src/hooks/useTwitchChat.ts index 22b6c21..96e5753 100644 --- a/src/hooks/useTwitchChat.ts +++ b/src/hooks/useTwitchChat.ts @@ -8,12 +8,19 @@ import type { ChatMessageDeleteEvent } from "../utils/event-sub/events/chat/mess type ChatEvent = | ChatMessage - | ({ type: "channel.chat.clear" } & ChatEventCommon) - | ({ type: "channel.chat.clear_user_messages" } & ChatClearUserMessageEvent) - | ({ type: "channel.chat.message_delete" } & ChatMessageDeleteEvent); + | ({ type: "channel.chat.clear"; timestamp: Date } & ChatEventCommon) + | ({ + type: "channel.chat.clear_user_messages"; + timestamp: Date; + } & ChatClearUserMessageEvent) + | ({ + type: "channel.chat.message_delete"; + timestamp: Date; + } & ChatMessageDeleteEvent); -type ChatMessage = { +export type ChatMessage = { type: "channel.chat.message"; + timestamp: Date; } & ChatMessageEvent; const subscriptions = [ diff --git a/src/utils/api/getBadges.ts b/src/utils/api/getBadges.ts new file mode 100644 index 0000000..05a0a68 --- /dev/null +++ b/src/utils/api/getBadges.ts @@ -0,0 +1,63 @@ +const CHANNEL_BADGES_URL = "https://api.twitch.tv/helix/chat/badges"; +const GLOBAL_BADGES_URL = "https://api.twitch.tv/helix/chat/badges/global"; + +interface ValidBadgeResponse { + data: BadgeSet[]; +} + +export interface BadgeSet { + set_id: string; + versions: Badge[]; +} + +export interface Badge { + id: string; + image_url_1x: string; + image_url_2x: string; + image_url_4x: string; + title: string; + description: string; + click_action?: string; + click_url?: string; +} + +async function getBadgeSet( + accessToken: string, + clientId: string, + url: string | URL +): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Client-Id": clientId, + }, + }); + + if (!response.ok) { + throw new Error( + `getBadges: Bad HTTP response: ${response.status.toString()} ${ + response.statusText + }` + ); + } + + return (await response.json()) as ValidBadgeResponse; +} + +export async function getBadges( + accessToken: string, + clientId: string, + userId: string +): Promise { + const url = new URL(CHANNEL_BADGES_URL); + url.searchParams.set("broadcaster_id", userId); + + const channelBadges = await getBadgeSet(accessToken, clientId, url); + const globalBadges = await getBadgeSet( + accessToken, + clientId, + GLOBAL_BADGES_URL + ); + + return [...globalBadges.data, ...channelBadges.data]; +} diff --git a/src/utils/api/getUser.ts b/src/utils/api/getUser.ts new file mode 100644 index 0000000..cbeb365 --- /dev/null +++ b/src/utils/api/getUser.ts @@ -0,0 +1,84 @@ +const GET_USERS_URL = "https://api.twitch.tv/helix/users"; + +interface ValidGetUsersResponse { + data: UserData[]; +} + +export interface UserData { + id: string; + login: string; + display_name: string; + type: "admin" | "global_mod" | "staff" | ""; + broadcaster_type: "affiliate" | "partner" | ""; + description: string; + profile_image_url: string; + offline_image_url: string; + email?: string; + created_at: string; +} + +export async function getUser( + accessToken: string, + clientId: string, + loginName: string +): Promise { + const url = new URL(GET_USERS_URL); + url.searchParams.set("login", loginName); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Client-Id": clientId, + }, + }); + + if (!response.ok) { + throw new Error( + `getUser: Bad HTTP response: ${response.status.toString()} ${ + response.statusText + }` + ); + } + + const result = (await response.json()) as ValidGetUsersResponse; + + if (result.data.length === 0) { + throw new Error("getUser: No user data in response"); + } + + return result.data[0]; +} + +export async function getUsers( + accessToken: string, + clientId: string, + userIds: string[] +): Promise { + const url = new URL(GET_USERS_URL); + userIds.forEach((userId) => { + url.searchParams.append("id", userId); + }); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Client-Id": clientId, + }, + }); + + if (!response.ok) { + throw new Error( + `getUser: Bad HTTP response: ${response.status.toString()} ${ + response.statusText + }` + ); + } + + const result = (await response.json()) as ValidGetUsersResponse; + + if (result.data.length === 0) { + throw new Error("getUser: No user data in response"); + } + + return result.data; +} diff --git a/src/utils/event-sub/EventSub.ts b/src/utils/event-sub/EventSub.ts index 42cfcfd..550ff45 100644 --- a/src/utils/event-sub/EventSub.ts +++ b/src/utils/event-sub/EventSub.ts @@ -119,6 +119,7 @@ export class EventSub { const event = { type: messageType, + timestamp: new Date(message.metadata.message_timestamp), ...message.payload.event, };