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
;
+}
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,
};