This commit is contained in:
2025-03-02 17:15:58 +00:00
commit e4253e6de2
26 changed files with 1806 additions and 0 deletions

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Chatters } from "@components/Chatters.tsx";
import { Contexts } from "@contexts/Contexts.tsx";
import type { Component } from "solid-js";
export const App: Component = () => {
return (
<main class="container">
<h1>Current Chatters</h1>
<Contexts>
<Chatters />
</Contexts>
</main>
);
};

214
src/components/Chatters.tsx Normal file
View File

@@ -0,0 +1,214 @@
import { TwitchServiceContext } from "@contexts/TwitchServiceContext";
import { UserContext } from "@contexts/UserContext";
import type { ChatterResponse } from "@services/twitch/responses/GetChattersResponse.ts";
import type { SubscriptionResponse } from "@services/twitch/responses/GetSubscriptionsResponse.ts";
import { useRequiredContext } from "@utils/useRequiredContext";
import { sortBy } from "es-toolkit";
import {
type Accessor,
type Component,
For,
type Setter,
Show,
createResource,
createSignal,
} from "solid-js";
import { ResourceComponent } from "./ResourceComponent.tsx";
const ExportDialog: Component<{
setRef: Setter<HTMLDialogElement | undefined>;
ref: Accessor<HTMLDialogElement | undefined>;
getJson: () => string;
}> = (props) => {
const [clipboardStatus, setClipboardStatus] = createSignal<
"Writing" | "Written" | "Initial"
>("Initial");
const copyClipboard = async () => {
setClipboardStatus("Writing");
await navigator.clipboard.writeText(props.getJson());
setClipboardStatus("Written");
setTimeout(() => setClipboardStatus("Initial"), 1000);
};
return (
<dialog ref={props.setRef}>
<article style={{ display: "grid", gap: "1rem" }}>
<div style={{ display: "flex" }}>
<button
type="button"
onClick={copyClipboard}
disabled={clipboardStatus() !== "Initial"}
>
<Show
when={clipboardStatus() === "Written"}
fallback={"Copy to Clipboard"}
>
Copied!
</Show>
</button>
<button
type="button"
onClick={() => props.ref()?.close()}
style={{ "margin-left": "auto" }}
>
Close
</button>
</div>
<pre
style={{ "max-height": "80vh", overflow: "auto", padding: "1rem" }}
>
{props.getJson()}
</pre>
</article>
</dialog>
);
};
const ChatttersView: Component<{
chatters: ChatterResponse[];
subscriptions: SubscriptionResponse[];
}> = (props) => {
const [selected, setSelected] = createSignal<string[]>([]);
const [search, setSearch] = createSignal<string>("");
const subs = sortBy(props.subscriptions, ["user_name"]);
const scrubs = sortBy(
props.chatters.filter((chat) =>
props.subscriptions.find((sub) => sub.user_id !== chat.user_id),
),
["user_name"],
);
const onChange = (id: string) => {
setSelected((prev: string[]) =>
prev.includes(id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const oneHundredClick = () => {
setSelected([
...subs.slice(0, 100).map((sub) => sub.user_id),
...scrubs.slice(0, 100 - subs.length).map((scrub) => scrub.user_id),
]);
};
const [dialogRef, setDialogRef] = createSignal<HTMLDialogElement>();
const getJson = () =>
JSON.stringify(
props.chatters
.filter((chat) => selected().includes(chat.user_id))
.map((chat) => chat.user_name),
null,
4,
);
return (
<>
<ExportDialog ref={dialogRef} setRef={setDialogRef} getJson={getJson} />
<div style={{ display: "grid", gap: "1rem" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<button
type="button"
onClick={() =>
setSelected(props.chatters.map((chat) => chat.user_id))
}
>
Select All
</button>
<button type="button" onClick={oneHundredClick}>
Select First 100
</button>
<button type="button" onClick={() => setSelected([])}>
Select None
</button>
<button
type="button"
onClick={() => dialogRef()?.showModal()}
style={{ "margin-left": "auto" }}
>
Export to JSON
</button>
</div>
<input
placeholder="Search"
onInput={(e) => setSearch(e.currentTarget.value.toLocaleLowerCase())}
/>
<article>
<h2>Subs</h2>
<For
each={subs.filter((sub) =>
search()
? sub.user_name.toLocaleLowerCase().includes(search())
: true,
)}
>
{(sub) => (
<label>
<input
type="checkbox"
onChange={() => onChange(sub.user_id)}
checked={selected().includes(sub.user_id)}
/>
{sub.user_name}
</label>
)}
</For>
</article>
<article>
<h2>Scrubs</h2>
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "1rem" }}>
<For
each={scrubs.filter((scrub) =>
search()
? scrub.user_name.toLocaleLowerCase().includes(search())
: true,
)}
>
{(scrub) => (
<label>
<input
type="checkbox"
onChange={() => onChange(scrub.user_id)}
checked={selected().includes(scrub.user_id)}
/>
{scrub.user_name}
</label>
)}
</For>
</div>
</article>
</div>
</>
);
};
export const Chatters: Component = () => {
const twitchService = useRequiredContext(TwitchServiceContext);
const user = useRequiredContext(UserContext);
const [resource] = createResource(async () => {
const chatters = await twitchService.getChatters(user.id);
const subscriptions = await twitchService.getSubscribers(
user.id,
chatters.map((e) => e.user_id),
);
return {
chatters,
subscriptions,
};
});
return (
<ResourceComponent resource={resource}>
{(response) => (
<ChatttersView
chatters={response().chatters}
subscriptions={response().subscriptions}
/>
)}
</ResourceComponent>
);
};

View File

@@ -0,0 +1,5 @@
import type { Component } from "solid-js";
export const Loading: Component = () => {
return "Loading...";
};

View File

@@ -0,0 +1,68 @@
import { Loading } from "@components/Loading.tsx";
import { parseError } from "@utils/parseError.ts";
import {
type Accessor,
type Component,
type JSX,
Match,
type Resource,
Show,
Switch,
} from "solid-js";
export const ResourceComponent = <TValue,>(props: {
children: (value: Accessor<NonNullable<TValue>>) => JSX.Element;
resource: Resource<TValue>;
errorComponent?: (e: Accessor<Error>) => JSX.Element;
loadingComponent?: () => JSX.Element;
fallback?: JSX.Element;
}) => {
const isError = () => {
const error = props.resource.error;
if (error) {
return parseError(error);
}
return undefined;
};
return (
<Switch fallback={props.fallback}>
<Match when={props.resource.loading}>
<Show when={props.loadingComponent} fallback={<Loading />}>
{(loadingComponent) => loadingComponent()()}
</Show>
</Match>
<Match when={isError()}>
{(error) => (
<Show
when={props.errorComponent}
fallback={<FallbackError error={error} />}
>
{(errorComponent) => errorComponent()(error)}
</Show>
)}
</Match>
<Match when={props.resource()}>{(value) => props.children(value)}</Match>
</Switch>
);
};
const FallbackError: Component<{ error: Accessor<unknown> }> = (props) => {
return (
<div role="alert" class="alert alert-error">
<div>
An error occurred
<details>
<summary>Click for details</summary>
<p>
{JSON.stringify(
props.error(),
Object.getOwnPropertyNames(props.error()),
2,
)}
</p>
</details>
</div>
</div>
);
};

11
src/contexts/Contexts.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { ParentComponent } from "solid-js";
import { TwitchServiceContextProvider } from "./TwitchServiceContext.tsx";
import { UserContextProvider } from "./UserContext.tsx";
export const Contexts: ParentComponent = (props) => {
return (
<TwitchServiceContextProvider>
<UserContextProvider>{props.children}</UserContextProvider>
</TwitchServiceContextProvider>
);
};

View File

@@ -0,0 +1,56 @@
import { TwitchApiService } from "@services/twitch/TwitchApiService.ts";
import {
type ParentComponent,
Show,
createContext,
createRenderEffect,
createSignal,
} from "solid-js";
const searchParams = new URLSearchParams({
client_id: import.meta.env["VITE_CLIENT_ID"],
redirect_uri: new URL(location.origin).toString(),
response_type: "token",
scope: "moderator:read:chatters channel:read:subscriptions",
});
const url = new URL(
`https://id.twitch.tv/oauth2/authorize?${searchParams.toString()}`,
);
export const TwitchServiceContext = createContext<TwitchApiService>();
export const TwitchServiceContextProvider: ParentComponent = (props) => {
const [twitchApiService, setTwitchApiService] =
createSignal<TwitchApiService>();
createRenderEffect(() => {
const devOverride = import.meta.env["VITE_TOKEN_OVERRIDE"];
if (devOverride) {
setTwitchApiService(new TwitchApiService(devOverride));
return;
}
const urlSearchParams = new URLSearchParams(location.hash.slice(1));
const accessToken = urlSearchParams.get("access_token");
if (accessToken) {
setTwitchApiService(new TwitchApiService(accessToken));
window.location.hash = "";
}
}, []);
return (
<Show
when={twitchApiService()}
fallback={
<p>
To use this application, please{" "}
<a href={url.toString()}>Connect with Twitch</a>
</p>
}
>
<TwitchServiceContext.Provider value={twitchApiService()}>
{props.children}
</TwitchServiceContext.Provider>
</Show>
);
};

View File

@@ -0,0 +1,23 @@
import { ResourceComponent } from "@components/ResourceComponent.tsx";
import type { UserResponse } from "@services/twitch/responses/GetUsersResponse.ts";
import { type ParentComponent, createContext, createResource } from "solid-js";
import { useRequiredContext } from "../utils/useRequiredContext.ts";
import { TwitchServiceContext } from "./TwitchServiceContext.tsx";
export const UserContext = createContext<UserResponse>();
export const UserContextProvider: ParentComponent = (props) => {
const twitchService = useRequiredContext(TwitchServiceContext);
const [resource] = createResource(async () => await twitchService.getUser());
return (
<ResourceComponent resource={resource}>
{(user) => (
<UserContext.Provider value={user()}>
{props.children}
</UserContext.Provider>
)}
</ResourceComponent>
);
};

8
src/index.tsx Normal file
View File

@@ -0,0 +1,8 @@
/* @refresh reload */
import "@picocss/pico";
import { render } from "solid-js/web";
import { App } from "./App.tsx";
const root = document.createElement("div");
document.body.appendChild(root);
render(() => <App />, root);

View File

@@ -0,0 +1,89 @@
import type {
ChatterResponse,
GetChattersResponse,
} from "./responses/GetChattersResponse.ts";
import type {
GetSubscriptionsResponse,
SubscriptionResponse,
} from "./responses/GetSubscriptionsResponse.ts";
import type {
GetUsersResponse,
UserResponse,
} from "./responses/GetUsersResponse.ts";
export class TwitchApiService {
baseUrl: string;
headers: HeadersInit;
constructor(accessToken: string) {
this.baseUrl = import.meta.env["VITE_TWITCH_API_BASE"];
this.headers = {
Authorization: `Bearer ${accessToken}`,
"Client-Id": import.meta.env["VITE_CLIENT_ID"],
};
}
private async parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(
`${response.status.toString()}: ${await response.text()}`,
);
}
return await response.json();
}
getUser = async (): Promise<UserResponse> => {
const url = new URL([this.baseUrl, "users"].join("/"));
const response = await fetch(url.toString(), { headers: this.headers });
const users = await this.parseResponse<GetUsersResponse>(response);
return users.data[0] as UserResponse;
};
getChatters = async (id: string): Promise<ChatterResponse[]> => {
const params = new URLSearchParams({
broadcaster_id: id,
moderator_id: id,
first: (1000).toString(),
});
const url = new URL([this.baseUrl, "chat", "chatters"].join("/"));
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
let parsed = await this.parseResponse<GetChattersResponse>(response);
let chatters = parsed.data;
while (parsed.pagination?.cursor) {
params.set("after", parsed.pagination.cursor);
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
parsed = await this.parseResponse<GetChattersResponse>(response);
chatters = chatters.concat(parsed.data);
}
return chatters;
};
getSubscribers = async (
id: string,
userIds: string[],
): Promise<SubscriptionResponse[]> => {
const url = new URL([this.baseUrl, "subscriptions"].join("/"));
let subscribers: SubscriptionResponse[] = [];
for (let index = 0; index < userIds.length; index += 100) {
const ids = userIds
.slice(index, index + 100)
.map((id) => ["user_id", id]);
const params = new URLSearchParams([["broadcaster_id", id]].concat(ids));
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
const parsed =
await this.parseResponse<GetSubscriptionsResponse>(response);
subscribers = subscribers.concat(parsed.data);
}
return subscribers;
};
}

View File

@@ -0,0 +1,13 @@
export interface ChatterResponse {
user_id: string;
user_login: string;
user_name: string;
}
export interface GetChattersResponse {
data: ChatterResponse[];
pagination?: {
cursor: string;
};
total: number;
}

View File

@@ -0,0 +1,23 @@
export interface SubscriptionResponse {
broadcaster_id: string;
broadcaster_login: string;
broadcaster_name: string;
gifter_id: string;
gifter_login: string;
gifter_name: string;
is_gift: boolean;
tier: string;
plan_name: string;
user_id: string;
user_name: string;
user_login: string;
}
export interface GetSubscriptionsResponse {
data: SubscriptionResponse[];
pagination: {
cursor: string;
};
total: number;
points: number;
}

View File

@@ -0,0 +1,17 @@
export interface UserResponse {
id: string;
login: string;
display_name: string;
type: string;
broadcaster_type: string;
description: string;
profile_image_url: string;
offline_image_url: string;
view_count: number;
email: string;
created_at: string;
}
export interface GetUsersResponse {
data: UserResponse[];
}

3
src/utils/parseError.ts Normal file
View File

@@ -0,0 +1,3 @@
export function parseError(error: unknown): Error {
return error instanceof Error ? error : new Error(JSON.stringify(error));
}

View File

@@ -0,0 +1,13 @@
import { type Context, useContext } from "solid-js";
export const useRequiredContext = <T>(context: Context<T | undefined>): T => {
const getContext = useContext(context);
if (getContext === undefined) {
throw new Error(
`${context.constructor.name} must be used within a provider instance`,
);
}
return getContext;
};