diff --git a/src/App.tsx b/src/App.tsx
index 054493e..9bbdd57 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,18 +1,24 @@
import { useContext } from "react";
-import "./App.css";
import { AuthStateContext } from "./contexts/auth-state/AuthStateContext";
import LoginButton from "./components/LoginButton";
+import "./App.css";
+import { useEventSub } from "./hooks/useEventSub";
export default function App() {
- const context = useContext(AuthStateContext);
+ const authContext = useContext(AuthStateContext);
+ const { lastMessage } = useEventSub("channel.chat.message", {
+ broadcaster_user_id: authContext?.authState?.user.id,
+ user_id: authContext?.authState?.user.id,
+ });
- if (!context) {
+ if (!authContext) {
return
Missing AuthStateContext provider?
;
}
return (
<>
- Hello: {context.authState?.user.login}
+ Hello: {authContext.authState?.user.login}
+ Last message: {JSON.stringify(lastMessage)}
>
);
diff --git a/src/contexts/event-sub/EventSubContext.ts b/src/contexts/event-sub/EventSubContext.ts
new file mode 100644
index 0000000..a97fe6c
--- /dev/null
+++ b/src/contexts/event-sub/EventSubContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+import type { EventSub } from "../../utils/event-sub/EventSub";
+
+interface Value {
+ subscribe: typeof EventSub.instance.subscribe;
+}
+
+export const EventSubContext = createContext(null);
diff --git a/src/contexts/event-sub/EventSubProvider.tsx b/src/contexts/event-sub/EventSubProvider.tsx
new file mode 100644
index 0000000..dc7881e
--- /dev/null
+++ b/src/contexts/event-sub/EventSubProvider.tsx
@@ -0,0 +1,18 @@
+import { EventSub } from "../../utils/event-sub/EventSub";
+import { EventSubContext } from "./EventSubContext";
+
+interface Props {
+ children: React.ReactNode;
+}
+
+export default function EventSubProvider({
+ children,
+}: Props): React.ReactElement {
+ const subscribe = EventSub.instance.subscribe.bind(EventSub.instance);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/useEventSub.ts b/src/hooks/useEventSub.ts
new file mode 100644
index 0000000..0c80878
--- /dev/null
+++ b/src/hooks/useEventSub.ts
@@ -0,0 +1,52 @@
+import { useCallback, useContext, useEffect, useState } from "react";
+import { EventSubContext } from "../contexts/event-sub/EventSubContext";
+import { AuthStateContext } from "../contexts/auth-state/AuthStateContext";
+
+export function useEventSub(
+ type: string | string[],
+ condition: Record,
+ bufferSize = 50
+) {
+ const authStateContext = useContext(AuthStateContext);
+ const eventSubContext = useContext(EventSubContext);
+ const [messages, setMessages] = useState[]>([]);
+ const [lastMessage, setLastMessage] = useState>();
+
+ if (!authStateContext) {
+ throw new Error("useEventSub: Needs an AuthStateContext Provider!");
+ }
+
+ if (!eventSubContext) {
+ throw new Error("useEventSub: Needs a EventSubContext Provider!");
+ }
+
+ const { subscribe } = eventSubContext;
+
+ const handleMessage = useCallback(
+ (message: Record) => {
+ setMessages((messages) => [...messages.slice(-bufferSize + 1), message]);
+ setLastMessage(() => message);
+ },
+ [bufferSize]
+ );
+
+ useEffect(() => {
+ const { authState } = authStateContext;
+
+ if (!authState) {
+ return;
+ }
+
+ if (Object.values(condition).some((value) => value === undefined)) {
+ console.error(
+ "useEventSub: Was given a bad subscription condition:",
+ condition
+ );
+ return;
+ }
+
+ return subscribe(authState, type, condition, handleMessage);
+ }, [authStateContext, condition, handleMessage, subscribe, type]);
+
+ return { lastMessage, messages };
+}
diff --git a/src/main.tsx b/src/main.tsx
index de31ef7..cdedc3c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import AuthStateProvider from "./contexts/auth-state/AuthStateProvider.tsx";
+import EventSubProvider from "./contexts/event-sub/EventSubProvider.tsx";
const rootElement = document.querySelector("#root");
@@ -13,7 +14,9 @@ if (!(rootElement instanceof HTMLDivElement)) {
createRoot(rootElement).render(
-
+
+
+
);
diff --git a/src/utils/api/event-sub/subscribe.ts b/src/utils/api/event-sub/subscribe.ts
new file mode 100644
index 0000000..a577d1c
--- /dev/null
+++ b/src/utils/api/event-sub/subscribe.ts
@@ -0,0 +1,65 @@
+import type { AuthState } from "../../../contexts/auth-state/AuthStateContext";
+
+interface SubscriptionsResponse {
+ data: [
+ {
+ id: string;
+ status: "enabled";
+ type: string;
+ version: string;
+ condition: Record;
+ /** RFC3339 Date Time */
+ created_at: string;
+ transport: {
+ method: "websocket";
+ session_id: string;
+ };
+ connected_at: string;
+ cost: number;
+ }
+ ];
+ total: number;
+ total_cost: number;
+ max_total_cost: number;
+}
+
+export async function subscribe(
+ authState: AuthState,
+ sessionId: string,
+ type: string,
+ version: string,
+ condition: Record
+): Promise {
+ const response = await fetch(
+ "https://api.twitch.tv/helix/eventsub/subscriptions",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authState.token.value}`,
+ "Client-Id": authState.client.id,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ type,
+ version,
+ condition,
+ transport: {
+ method: "websocket",
+ session_id: sessionId,
+ },
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `subscriptions: Bad HTTP response ${response.status.toString()} ${
+ response.statusText
+ }`
+ );
+ }
+
+ const result = (await response.json()) as SubscriptionsResponse;
+
+ return result;
+}
diff --git a/src/utils/api/event-sub/unsubscribe.ts b/src/utils/api/event-sub/unsubscribe.ts
new file mode 100644
index 0000000..ba59c36
--- /dev/null
+++ b/src/utils/api/event-sub/unsubscribe.ts
@@ -0,0 +1,25 @@
+import type { AuthState } from "../../../contexts/auth-state/AuthStateContext";
+
+export async function unsubscribe(
+ authState: AuthState,
+ subscriptionId: string
+): Promise {
+ const url = new URL("https://api.twitch.tv/helix/eventsub/subscriptions");
+ url.searchParams.set("id", subscriptionId);
+
+ const response = await fetch(url, {
+ method: "DELETE",
+ headers: {
+ Authorization: `Bearer ${authState.token.value}`,
+ "Client-Id": authState.client.id,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `subscriptions: Bad HTTP response ${response.status.toString()} ${
+ response.statusText
+ }`
+ );
+ }
+}
diff --git a/src/utils/event-sub/EventSub.ts b/src/utils/event-sub/EventSub.ts
new file mode 100644
index 0000000..42cfcfd
--- /dev/null
+++ b/src/utils/event-sub/EventSub.ts
@@ -0,0 +1,181 @@
+import type { AuthState } from "../../contexts/auth-state/AuthStateContext";
+import { subscribe } from "../api/event-sub/subscribe";
+import type {
+ EventSubMessage,
+ KeepaliveMessage,
+ NotificationMessage,
+ WelcomeMessage,
+} from "./events/websocket";
+
+const EVENTSUB_WEBSOCKET_URI = "wss://eventsub.wss.twitch.tv/ws";
+
+const versions: Record = {
+ "channel.chat.clear_user_messages": "1",
+ "channel.chat.clear": "1",
+ "channel.chat.message": "1",
+ "channel.chat.message_delete": "1",
+} as const;
+
+type Callback = (event: Record) => void;
+
+function isMessageType(
+ message: EventSubMessage,
+ messageType: Message["metadata"]["message_type"]
+): message is Message {
+ return message.metadata.message_type === messageType;
+}
+
+export class EventSub {
+ private static _instance: EventSub | null = null;
+ private ws;
+ private listeners: Map>;
+ private _sessionId?: string;
+ private _awaitingResolvers: ((value: string) => void)[] = [];
+
+ private constructor() {
+ EventSub._instance = this;
+ this.ws = new WebSocket(EVENTSUB_WEBSOCKET_URI);
+ this.listeners = new Map>();
+
+ this.ws.addEventListener("open", (event) => {
+ console.debug("WebSocket: Open:", event);
+ });
+
+ this.ws.addEventListener("close", (event) => {
+ console.debug("WebSocket: Close:", event);
+ });
+
+ this.ws.addEventListener("message", (event: MessageEvent) => {
+ const message = JSON.parse(event.data) as EventSubMessage;
+
+ this.handleMessage(message);
+ });
+ }
+
+ public static get instance(): EventSub {
+ if (!EventSub._instance) {
+ return new EventSub();
+ }
+
+ return EventSub._instance;
+ }
+
+ set sessionId(id: string) {
+ this._sessionId = id;
+
+ if (this._awaitingResolvers.length > 0) {
+ this._awaitingResolvers.forEach((resolver) => {
+ resolver(id);
+ });
+
+ this._awaitingResolvers.length = 0;
+ }
+ }
+
+ async getSessionId(): Promise {
+ if (this._sessionId) {
+ return this._sessionId;
+ }
+
+ const promise = new Promise((resolve) => {
+ // TODO: Deal with a reject promise too
+ this._awaitingResolvers.push(resolve);
+ });
+
+ return promise;
+ }
+
+ private handleMessage(message: EventSubMessage) {
+ if (isMessageType(message, "session_welcome")) {
+ console.debug("WebSocket: Welcome Message:", message);
+ this.handleWelcomeMessage(message);
+ } else if (isMessageType(message, "session_keepalive")) {
+ console.debug(
+ "WebSocket: Keepalive:",
+ message.metadata.message_timestamp
+ );
+ } else if (isMessageType(message, "notification")) {
+ console.debug("WebSocket: Notification Message:", message);
+ this.handleNotificationMessage(message);
+ }
+ }
+
+ private handleWelcomeMessage(message: WelcomeMessage) {
+ this.sessionId = message.payload.session.id;
+ }
+
+ private handleNotificationMessage(message: NotificationMessage) {
+ const messageType = message.payload.subscription.type;
+
+ if (!this.listeners.has(messageType)) {
+ // No one is listening for this event?
+ console.debug(
+ "WebSocket: No Listeners for:",
+ messageType,
+ ", message dropped."
+ );
+ return;
+ }
+
+ const event = {
+ type: messageType,
+ ...message.payload.event,
+ };
+
+ this.listeners.get(messageType)?.forEach((listener) => {
+ listener(event);
+ });
+ }
+
+ private async addSubscription(
+ authState: AuthState,
+ type: string,
+ condition: Record
+ ): Promise {
+ if (!this.listeners.has(type)) {
+ this.listeners.set(type, new Set());
+
+ void subscribe(
+ authState,
+ await this.getSessionId(),
+ type,
+ versions[type],
+ condition
+ ).catch((error: unknown) => {
+ console.error(
+ "addSubscription: failed to add subscription for:",
+ type,
+ "Got error:",
+ error
+ );
+ });
+ }
+ }
+
+ // FIXME: These unsubscribe functions doesn't unsubscribe from EventSub
+ // if they're the last listener
+ subscribe(
+ authState: AuthState,
+ type: string | string[],
+ condition: Record,
+ callback: Callback
+ ): () => void {
+ if (Array.isArray(type)) {
+ type.forEach((type) => {
+ void this.addSubscription(authState, type, condition);
+ this.listeners.get(type)?.add(callback);
+ });
+
+ return () => {
+ type.forEach((type) => this.listeners.get(type)?.delete(callback));
+ };
+ } else {
+ void this.addSubscription(authState, type, condition);
+ this.listeners.get(type)?.add(callback);
+
+ return () => {
+ this.listeners.get(type)?.delete(callback);
+ };
+ }
+ }
+}
diff --git a/src/utils/event-sub/events/chat/_common.ts b/src/utils/event-sub/events/chat/_common.ts
new file mode 100644
index 0000000..4001ec2
--- /dev/null
+++ b/src/utils/event-sub/events/chat/_common.ts
@@ -0,0 +1,5 @@
+export interface ChatEventCommon {
+ broadcaster_user_id: string;
+ broadcaster_user_name: string;
+ broadcaster_user_login: string;
+}
diff --git a/src/utils/event-sub/events/chat/clear.ts b/src/utils/event-sub/events/chat/clear.ts
new file mode 100644
index 0000000..1eb0dfb
--- /dev/null
+++ b/src/utils/event-sub/events/chat/clear.ts
@@ -0,0 +1,19 @@
+import type { ChatEventCommon } from "./_common";
+import type { EventSubTransport } from "../transport";
+
+export interface ChatClearPayload {
+ subscription: {
+ id: string;
+ type: "channel.chat.clear";
+ version: "1";
+ status: "enabled";
+ cost: number;
+ condition: {
+ broadcaster_user_id: string;
+ user_id: string;
+ };
+ transport: EventSubTransport;
+ created_at: string;
+ };
+ event: ChatEventCommon;
+}
diff --git a/src/utils/event-sub/events/chat/clearUser.ts b/src/utils/event-sub/events/chat/clearUser.ts
new file mode 100644
index 0000000..5e9a253
--- /dev/null
+++ b/src/utils/event-sub/events/chat/clearUser.ts
@@ -0,0 +1,25 @@
+import type { ChatEventCommon } from "./_common";
+import type { EventSubTransport } from "../transport";
+
+export interface ChatClearUserMessagePayload {
+ subscription: {
+ id: string;
+ type: "channel.chat.clear_user_messages";
+ version: "1";
+ status: "enabled";
+ cost: number;
+ condition: {
+ broadcaster_user_id: string;
+ user_id: string;
+ };
+ transport: EventSubTransport;
+ created_at: string;
+ };
+ event: ChatClearUserMessageEvent;
+}
+
+interface ChatClearUserMessageEvent extends ChatEventCommon {
+ target_user_id: string;
+ target_user_name: string;
+ target_user_login: string;
+}
diff --git a/src/utils/event-sub/events/chat/message.ts b/src/utils/event-sub/events/chat/message.ts
new file mode 100644
index 0000000..0b58798
--- /dev/null
+++ b/src/utils/event-sub/events/chat/message.ts
@@ -0,0 +1,105 @@
+import type { ChatEventCommon } from "./_common";
+import type { EventSubTransport } from "../transport";
+
+export interface ChatMessagePayload {
+ subscription: {
+ id: string;
+ type: "channel.chat.message";
+ version: "1";
+ status: "enabled";
+ cost: number;
+ condition: {
+ broadcaster_user_id: string;
+ user_id: string;
+ };
+ transport: EventSubTransport;
+ created_at: string;
+ };
+ event: ChatMessageEvent;
+}
+
+interface ChatMessageEvent extends ChatEventCommon {
+ chatter_user_id: string;
+ chatter_user_name: string;
+ chatter_user_login: string;
+ /** Message UUID */
+ message_id: string;
+ message: {
+ /** Chat message in plain text */
+ text: string;
+ fragments: ChatFragment[];
+ };
+ message_type:
+ | "text"
+ | "channel_points_highlighted"
+ | "channel_points_sub_only"
+ | "user_intro"
+ | "power_ups_message_effect"
+ | "power_ups_gigantified_emote";
+ badges: {
+ set_id: string;
+ id: string;
+ /** Months subscribed */
+ info: string;
+ }[];
+ cheer?: {
+ bits: number;
+ };
+ color: string;
+ reply?: {
+ parent_message_id: string;
+ parent_message_body: string;
+ parent_user_id: string;
+ parent_user_name: string;
+ parent_user_login: string;
+ thread_message_id: string;
+ thread_user_id: string;
+ thread_user_name: string;
+ thread_user_login: string;
+ };
+ channel_points_custom_reward_id?: string;
+ channel_points_animation_id?: string;
+}
+
+export type ChatFragment =
+ | TextFragment
+ | CheermoteFragment
+ | EmoteFragment
+ | MentionFragment;
+
+export interface TextFragment {
+ type: "text";
+ text: string;
+}
+
+export interface CheermoteFragment {
+ type: "cheermote";
+ /** `${prefix}${bits}` */
+ text: string;
+ cheermote: {
+ prefix: string;
+ bits: number;
+ tier: number;
+ };
+}
+
+export interface EmoteFragment {
+ type: "emote";
+ text: string;
+ emote: {
+ id: string;
+ emote_set_id: string;
+ owner_id: string;
+ format: ["static"] | ["static", "animated"]; // Tuples might be bad idea?
+ };
+}
+
+export interface MentionFragment {
+ type: "mention";
+ text: string;
+ mention: {
+ user_id: string;
+ user_name: string;
+ user_login: string;
+ };
+}
diff --git a/src/utils/event-sub/events/chat/messageDelete.ts b/src/utils/event-sub/events/chat/messageDelete.ts
new file mode 100644
index 0000000..a432dbe
--- /dev/null
+++ b/src/utils/event-sub/events/chat/messageDelete.ts
@@ -0,0 +1,26 @@
+import type { ChatEventCommon } from "./_common";
+import type { EventSubTransport } from "../transport";
+
+export interface ChatMessageDeletePayload {
+ subscription: {
+ id: string;
+ type: "channel.chat.message_delete";
+ version: "1";
+ status: "enabled";
+ cost: number;
+ condition: {
+ broadcaster_user_id: string;
+ user_id: string;
+ };
+ transport: EventSubTransport;
+ created_at: string;
+ };
+ event: ChatMessageDeleteEvent;
+}
+
+interface ChatMessageDeleteEvent extends ChatEventCommon {
+ target_user_id: string;
+ target_user_name: string;
+ target_user_login: string;
+ message_id: string;
+}
diff --git a/src/utils/event-sub/events/transport.ts b/src/utils/event-sub/events/transport.ts
new file mode 100644
index 0000000..97865e9
--- /dev/null
+++ b/src/utils/event-sub/events/transport.ts
@@ -0,0 +1,4 @@
+export interface EventSubTransport {
+ method: string;
+ session_id: string;
+}
diff --git a/src/utils/event-sub/events/websocket.ts b/src/utils/event-sub/events/websocket.ts
new file mode 100644
index 0000000..ff67cf1
--- /dev/null
+++ b/src/utils/event-sub/events/websocket.ts
@@ -0,0 +1,52 @@
+import type { ChatClearPayload } from "./chat/clear";
+import type { ChatClearUserMessagePayload } from "./chat/clearUser";
+import type { ChatMessagePayload } from "./chat/message";
+import type { ChatMessageDeletePayload } from "./chat/messageDelete";
+
+export type EventSubMessage =
+ | WelcomeMessage
+ | KeepaliveMessage
+ | NotificationMessage;
+
+type NotificationPayload =
+ | ChatClearPayload
+ | ChatClearUserMessagePayload
+ | ChatMessagePayload
+ | ChatMessageDeletePayload;
+
+export interface WelcomeMessage {
+ metadata: {
+ message_id: string;
+ message_type: "session_welcome";
+ message_timestamp: string; // UTC date and time
+ };
+ payload: {
+ session: {
+ id: string;
+ status: string;
+ connected_at: string; // UTC date and time
+ keepalive_timeout_seconds: number;
+ reconnect_url: string | null;
+ };
+ };
+}
+
+export interface KeepaliveMessage {
+ metadata: {
+ message_id: string;
+ message_type: "session_keepalive";
+ message_timestamp: string;
+ };
+ payload: Record;
+}
+
+export interface NotificationMessage {
+ metadata: {
+ message_id: string; // Unique message identifier
+ message_type: "notification";
+ message_timestamp: string; // UTC date for when message sent
+ subscription_type: string; // Type of event sent in the message
+ subscription_version: string; // Version number for the subscription's definition
+ };
+ payload: NotificationPayload;
+}