diff --git a/eslint.config.mjs b/eslint.config.mjs
index 33e59d3..a6a2e63 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -32,6 +32,10 @@ export default tseslint.config(
{ allowConstantExport: true },
],
"@typescript-eslint/consistent-type-imports": ["error"],
+ "@typescript-eslint/prefer-literal-enum-member": [
+ "error",
+ { allowBitwiseExpressions: true },
+ ],
},
}
);
diff --git a/src/components/Emotes.tsx b/src/components/Emotes.tsx
new file mode 100644
index 0000000..83d0370
--- /dev/null
+++ b/src/components/Emotes.tsx
@@ -0,0 +1,31 @@
+import { useContext, type ReactElement } from "react";
+import { useBttvEmotes } from "../hooks/useBttvEmotes";
+import { AuthContext } from "../contexts/auth-state/AuthContext";
+import { useSevenTvEmotes } from "../hooks/useSevenTvEmotes";
+
+export default function Emotes(): ReactElement {
+ const { authState } = useContext(AuthContext);
+ const bttvEmotes = useBttvEmotes(authState?.user.id);
+ const sevenTvEmotes = useSevenTvEmotes(authState?.user.id);
+
+ return (
+
+ BetterTTV
+
+ {Object.entries(bttvEmotes).map(([name, emote]) => (
+ -
+
+
+ ))}
+
+ SevenTV
+
+ {Object.entries(sevenTvEmotes).map(([name, emote]) => (
+ -
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/hooks/useBttvEmotes.ts b/src/hooks/useBttvEmotes.ts
new file mode 100644
index 0000000..77c8cc9
--- /dev/null
+++ b/src/hooks/useBttvEmotes.ts
@@ -0,0 +1,116 @@
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+interface BetterTTVEmote {
+ id: string;
+ code: string;
+ imageType: string;
+ animated: boolean;
+ /** Used for global emotes */
+ userId?: string;
+ modifier?: boolean;
+ /** Used for shared and channel emotes */
+ user?: {
+ id: string;
+ name: string;
+ displayName: string;
+ providerId: string;
+ };
+ /** Omitted if the emote is 28x28 */
+ width?: number;
+ height?: number;
+}
+
+interface BetterTTVEmoteFragment {
+ type: "bttv-emote";
+ text: string;
+ animated: boolean;
+ global: boolean;
+ owner?: {
+ login: string;
+ name: string;
+ };
+ small: {
+ file: string;
+ height: number;
+ width: number;
+ };
+ big: {
+ file: string;
+ height: number;
+ width: number;
+ };
+}
+
+function makeBttvFragments(
+ emotes: BetterTTVEmote[],
+ global?: boolean
+): Record {
+ const entries = emotes.map(
+ (emote) =>
+ [
+ emote.code,
+ {
+ type: "bttv-emote",
+ text: emote.code,
+ animated: emote.animated,
+ global: global ?? false,
+ owner: emote.user && {
+ login: emote.user.name,
+ name: emote.user.displayName,
+ },
+ small: {
+ file: `https://cdn.betterttv.net/emote/${emote.id}/1x.webp`,
+ height: emote.height ?? 28,
+ width: emote.width ?? 28,
+ },
+ big: {
+ file: `https://cdn.betterttv.net/emote/${emote.id}/3x.webp`,
+ height: 3 * (emote.height ?? 28),
+ width: 3 * (emote.width ?? 28),
+ },
+ },
+ ] as const
+ );
+
+ return Object.fromEntries(entries);
+}
+
+const makeGlobalFragments = (emotes: BetterTTVEmote[]) =>
+ makeBttvFragments(emotes, true);
+
+const makeChannelFragments = (emotes: BetterTTVEmote[]) =>
+ makeBttvFragments(emotes, false);
+
+// TODO: Add some kind of runtime type checking here
+export function useBttvEmotes(userId?: string) {
+ const { data: global } = useQuery({
+ queryKey: ["bttvGlobalEmotes"],
+ queryFn: () =>
+ fetch("https://api.betterttv.net/3/cached/emotes/global").then(
+ (response) => response.json()
+ ),
+ select: makeGlobalFragments,
+ });
+
+ const { data: channel } = useQuery({
+ enabled: !!userId,
+ queryKey: ["bttvChannelEmotes", userId],
+ queryFn: () =>
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ fetch(`https://api.betterttv.net/3/cached/users/twitch/${userId}`)
+ .then((response) => response.json())
+ .then(
+ ({
+ channelEmotes,
+ sharedEmotes,
+ }: {
+ channelEmotes: BetterTTVEmote[];
+ sharedEmotes: BetterTTVEmote[];
+ }) => [...channelEmotes, ...sharedEmotes]
+ ),
+ select: makeChannelFragments,
+ });
+
+ return useMemo(() => ({ ...global, ...channel }), [global, channel]);
+}
diff --git a/src/hooks/useSevenTvEmotes.ts b/src/hooks/useSevenTvEmotes.ts
new file mode 100644
index 0000000..e7b85dd
--- /dev/null
+++ b/src/hooks/useSevenTvEmotes.ts
@@ -0,0 +1,259 @@
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+enum EmoteSetFlag {
+ None = 0,
+ /** Set is immutable, meaning it cannot be modified */
+ Immutable = 1,
+ /** Set is privileged, meaning it can only be modified by its owner */
+ Privileged = 1 << 1,
+ /** Set is personal, meaning its content can be used globally and it is subject to stricter content moderation rules */
+ Personal = 1 << 2,
+ /** Set is commercial, meaning it is sold and subject to extra rules on content ownership */
+ Commercial = 1 << 3,
+}
+
+enum ActiveEmoteFlag {
+ None = 0,
+ /** Emote is zero-width*/
+ ZeroWidth = 1,
+ /** Overrides Twitch Global emotes with the same name*/
+ OverrideTwitchGlobal = 1 << 16,
+ /** Overrides Twitch Subscriber emotes with the same name*/
+ OverrideTwitchSubscriber = 1 << 17,
+ /** Overrides BetterTTV emotes with the same name*/
+ OverrideBetterTTV = 1 << 18,
+}
+
+enum EmoteFlag {
+ None = 0,
+ /** The emote is private and can only be accessed by its owner, editors and moderators */
+ Private = 1,
+ /** The emote was verified to be an original creation by the uploader */
+ Authentic = 1 << 1,
+ /** The emote is recommended to be enabled as Zero-Width */
+ ZeroWidth = 1 << 8,
+ /** Sexually Suggesive */
+ ContentSexual = 1 << 16,
+ /** Rapid flashing */
+ ContentEpilepsy = 1 << 17,
+ /** Edgy or distasteful, may be offensive to some users */
+ ContentEdgy = 1 << 18,
+ /** Not allowed specifically on the Twitch platform */
+ ContentTwitchDisallowed = 1 << 24,
+}
+
+/*
+const EmoteFlagDescriptions = {
+ [EmoteFlag.Private]: "PRIVATE",
+ [EmoteFlag.ZeroWidth]: "ZERO_WIDTH",
+ [EmoteFlag.ContentSexual]: "SEXUALLY_SUGGESTIVE",
+ [EmoteFlag.ContentEpilepsy]: "EPILEPSY",
+ [EmoteFlag.ContentEdgy]: "EDGY_OR_DISASTEFUL",
+ [EmoteFlag.ContentTwitchDisallowed]: "TWITCH_DISALLOWED",
+} as const;
+ */
+
+enum EmoteLifecycle {
+ Deleted = -1,
+ Pending,
+ Processing,
+ Disabled,
+ Live,
+ Failed = -2,
+}
+
+interface SevenTvEmoteModel {
+ /** Object ID */
+ id: string;
+ /** Emote name, eg. KEKW */
+ name: string;
+ flags: ActiveEmoteFlag;
+ timestamp: number;
+ actor_id: string | null;
+ /** EmotePartialModel */
+ data: EmotePartialModel; // This was nullable in the old Go version of 7tv
+}
+
+interface EmoteSet {
+ /** Emote Set ID */
+ id?: string;
+ /** Emote Set Name */
+ name: string;
+ tags: string[];
+ flags: EmoteSetFlag;
+ /** Deprecated - Use flags */
+ immutable: boolean;
+ /** Deprecated - Use flags */
+ privileged: boolean;
+ emotes?: SevenTvEmoteModel[];
+ emote_count?: number;
+ capacity: number;
+ owner: UserPartial | null;
+}
+
+interface EmotePartialModel {
+ /** Object ID */
+ id: string;
+ /** Emote name, eg. KEKW */
+ name: string;
+ flags: EmoteFlag;
+ lifecycle: EmoteLifecycle;
+ /** Emote version state */
+ state: ("PERSONAL" | "NO_PERSONAL" | "LISTED")[];
+ listed: boolean;
+ animated: boolean;
+ owner: UserPartial;
+ /** Image Host */
+ host: {
+ /** Partial url? */
+ // "//cdn.7tv.app/emote/60a487509485e7cf2f5a6fa7";
+ url: string;
+ /** Image File */
+ files: {
+ name: string; // "1x.avif"
+ static_name: string; // "1x_static.avif"
+ width: number;
+ height: number;
+ frame_count?: number;
+ size?: number;
+ format: "AVIF" | "WEBP";
+ }[];
+ };
+}
+
+interface UserPartial {
+ id: string;
+ username: string;
+ display_name: string;
+ created_at?: number;
+ avatar_url?: string;
+ style: Record;
+ roles?: string[];
+ connections?: Connection[];
+}
+
+interface Connection {
+ id: string;
+ platform: "TWITCH" | "YOUTUBE" | "DISCORD";
+ username: string;
+ display_name: string;
+ linked_at: number;
+ emote_capacity: number;
+ emote_set_id: string | null;
+ emote_set: EmoteSet | null;
+}
+
+interface SevenTVEmoteFragment {
+ type: "7tv-emote";
+ text: string;
+ animated: boolean;
+ global: boolean;
+ owner: {
+ login: string;
+ name: string;
+ };
+ small: {
+ file: string;
+ height?: number;
+ width?: number;
+ };
+ big: {
+ file: string;
+ height?: number;
+ width?: number;
+ };
+}
+
+function filterEmote(emote: SevenTvEmoteModel): boolean {
+ return (
+ emote.data.listed &&
+ Boolean(emote.data.lifecycle & EmoteLifecycle.Live) &&
+ !(
+ emote.data.flags &
+ (EmoteFlag.ContentEdgy |
+ EmoteFlag.ContentEpilepsy |
+ EmoteFlag.ContentSexual |
+ EmoteFlag.ContentTwitchDisallowed)
+ )
+ );
+}
+
+function sizeOf(
+ filename: string,
+ files: SevenTvEmoteModel["data"]["host"]["files"]
+): { height?: number; width?: number } {
+ const file = files.find(({ name }) => name === filename);
+
+ return { height: file?.height, width: file?.width };
+}
+
+function makeSevenTvFragments(
+ rawEmotes: SevenTvEmoteModel[],
+ global?: boolean
+): Record {
+ const emotes = rawEmotes.filter(filterEmote).map(
+ (emote) =>
+ [
+ emote.name,
+ {
+ type: "7tv-emote",
+ text: emote.name,
+ animated: emote.data.animated,
+ global: global ?? false,
+ owner: {
+ login: emote.data.owner.username,
+ name: emote.data.owner.display_name,
+ },
+ small: {
+ file: `https:${emote.data.host.url}/1x.webp`,
+ ...sizeOf("1px.webp", emote.data.host.files),
+ },
+ big: {
+ file: `https:${emote.data.host.url}/4x.webp`,
+ ...sizeOf("4px.webp", emote.data.host.files),
+ },
+ },
+ ] as const
+ );
+
+ return Object.fromEntries(emotes);
+}
+
+const makeGlobalFragments = (emotes: SevenTvEmoteModel[]) =>
+ makeSevenTvFragments(emotes, true);
+
+const makeChannelFragments = (emotes: SevenTvEmoteModel[]) =>
+ makeSevenTvFragments(emotes, false);
+
+export function useSevenTvEmotes(userId?: string) {
+ const { data: global } = useQuery({
+ queryKey: ["sevenTvGlobalEmotes"],
+ queryFn: () =>
+ fetch("https://7tv.io/v3/emote-sets/global")
+ .then((response) => response.json())
+ .then(({ emotes }: { emotes: SevenTvEmoteModel[] }) => {
+ return emotes;
+ }),
+ select: makeGlobalFragments,
+ });
+
+ const { data: channel } = useQuery({
+ enabled: !!userId,
+ queryKey: ["sevenTvChannelEmotes", userId],
+ queryFn: () =>
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ fetch(`https://7tv.io/v3/users/twitch/${userId}`)
+ .then((response) => response.json())
+ .then(
+ ({
+ emote_set: { emotes },
+ }: {
+ emote_set: { emotes: SevenTvEmoteModel[] };
+ }) => emotes
+ ),
+ select: makeChannelFragments,
+ });
+
+ return useMemo(() => ({ ...global, ...channel }), [global, channel]);
+}
diff --git a/src/stores/emotes/betterttv.ts b/src/stores/emotes/betterttv.ts
new file mode 100644
index 0000000..251c92f
--- /dev/null
+++ b/src/stores/emotes/betterttv.ts
@@ -0,0 +1,74 @@
+export interface BetterTTVEmote {
+ id: string;
+ code: string;
+ imageType: string;
+ animated: boolean;
+ /** Used for global emotes */
+ userId?: string;
+ modifier?: boolean;
+ /** Used for shared and channel emotes */
+ user?: {
+ id: string;
+ name: string;
+ displayName: string;
+ providerId: string;
+ };
+ /** Omitted if the emote is 28x28 */
+ width?: number;
+ height?: number;
+}
+
+export interface BetterTTVEmoteFragment {
+ type: "bttv-emote";
+ text: string;
+ animated: boolean;
+ global: boolean;
+ owner?: {
+ login: string;
+ name: string;
+ };
+ small: {
+ file: string;
+ height: number;
+ width: number;
+ };
+ big: {
+ file: string;
+ height: number;
+ width: number;
+ };
+}
+
+export function makeBttvFragments(
+ emotes: BetterTTVEmote[],
+ global?: boolean
+): Record {
+ const entries = emotes.map(
+ (emote) =>
+ [
+ emote.code,
+ {
+ type: "bttv-emote",
+ text: emote.code,
+ animated: emote.animated,
+ global: global ?? false,
+ owner: emote.user && {
+ login: emote.user.name,
+ name: emote.user.displayName,
+ },
+ small: {
+ file: `https://cdn.betterttv.net/emote/${emote.id}/1x.webp`,
+ height: emote.height ?? 28,
+ width: emote.width ?? 28,
+ },
+ big: {
+ file: `https://cdn.betterttv.net/emote/${emote.id}/3x.webp`,
+ height: 3 * (emote.height ?? 28),
+ width: 3 * (emote.width ?? 28),
+ },
+ },
+ ] as const
+ );
+
+ return Object.fromEntries(entries);
+}
diff --git a/src/stores/emotes/emotes.ts b/src/stores/emotes/emotes.ts
new file mode 100644
index 0000000..4c204f2
--- /dev/null
+++ b/src/stores/emotes/emotes.ts
@@ -0,0 +1,77 @@
+import { create } from "zustand";
+import {
+ makeBttvFragments,
+ type BetterTTVEmote,
+ type BetterTTVEmoteFragment,
+} from "./betterttv";
+import {
+ makeSevenTvFragments,
+ type SevenTvEmoteModel,
+ type SevenTVEmoteFragment,
+} from "./seventv";
+
+interface EmoteState {
+ bttv: {
+ global: Record;
+ channel: Record;
+ };
+ sevenTv: {
+ global: Record;
+ channel: Record;
+ };
+ setBttv: (emotes: BetterTTVEmote[], global?: boolean) => void;
+ setSevenTv: (emotes: SevenTvEmoteModel[], global?: boolean) => void;
+}
+
+export const useEmotes = create()((set) => ({
+ bttv: {
+ global: {},
+ channel: {},
+ },
+ sevenTv: {
+ global: {},
+ channel: {},
+ },
+ setBttv: (rawEmotes: BetterTTVEmote[], global?: boolean) => {
+ const emotes = makeBttvFragments(rawEmotes, global);
+
+ set(({ bttv }) => {
+ if (global) {
+ return {
+ bttv: {
+ global: { ...bttv.global, ...emotes },
+ channel: bttv.channel,
+ },
+ };
+ } else {
+ return {
+ bttv: {
+ global: bttv.global,
+ channel: { ...bttv.channel, ...emotes },
+ },
+ };
+ }
+ });
+ },
+ setSevenTv: (rawEmotes: SevenTvEmoteModel[], global?: boolean) => {
+ const emotes = makeSevenTvFragments(rawEmotes, global);
+
+ set(({ sevenTv }: EmoteState) => {
+ if (global) {
+ return {
+ sevenTv: {
+ global: { ...sevenTv.global, ...emotes },
+ channel: sevenTv.channel,
+ },
+ };
+ } else {
+ return {
+ sevenTv: {
+ global: sevenTv.global,
+ channel: { ...sevenTv.channel, ...emotes },
+ },
+ };
+ }
+ });
+ },
+}));
diff --git a/src/stores/emotes/index.ts b/src/stores/emotes/index.ts
new file mode 100644
index 0000000..d4073fe
--- /dev/null
+++ b/src/stores/emotes/index.ts
@@ -0,0 +1,2 @@
+import { useEmotes } from "./emotes";
+export { useEmotes };
diff --git a/src/stores/emotes/seventv.ts b/src/stores/emotes/seventv.ts
new file mode 100644
index 0000000..9b433a0
--- /dev/null
+++ b/src/stores/emotes/seventv.ts
@@ -0,0 +1,216 @@
+export enum EmoteSetFlag {
+ None = 0,
+ /** Set is immutable, meaning it cannot be modified */
+ Immutable = 1,
+ /** Set is privileged, meaning it can only be modified by its owner */
+ Privileged = 1 << 1,
+ /** Set is personal, meaning its content can be used globally and it is subject to stricter content moderation rules */
+ Personal = 1 << 2,
+ /** Set is commercial, meaning it is sold and subject to extra rules on content ownership */
+ Commercial = 1 << 3,
+}
+
+export enum ActiveEmoteFlag {
+ None = 0,
+ /** Emote is zero-width*/
+ ZeroWidth = 1,
+ /** Overrides Twitch Global emotes with the same name*/
+ OverrideTwitchGlobal = 1 << 16,
+ /** Overrides Twitch Subscriber emotes with the same name*/
+ OverrideTwitchSubscriber = 1 << 17,
+ /** Overrides BetterTTV emotes with the same name*/
+ OverrideBetterTTV = 1 << 18,
+}
+
+export enum EmoteFlag {
+ None = 0,
+ /** The emote is private and can only be accessed by its owner, editors and moderators */
+ Private = 1,
+ /** The emote was verified to be an original creation by the uploader */
+ Authentic = 1 << 1,
+ /** The emote is recommended to be enabled as Zero-Width */
+ ZeroWidth = 1 << 8,
+ /** Sexually Suggesive */
+ ContentSexual = 1 << 16,
+ /** Rapid flashing */
+ ContentEpilepsy = 1 << 17,
+ /** Edgy or distasteful, may be offensive to some users */
+ ContentEdgy = 1 << 18,
+ /** Not allowed specifically on the Twitch platform */
+ ContentTwitchDisallowed = 1 << 24,
+}
+
+export const EmoteFlagDescriptions = {
+ [EmoteFlag.Private]: "PRIVATE",
+ [EmoteFlag.ZeroWidth]: "ZERO_WIDTH",
+ [EmoteFlag.ContentSexual]: "SEXUALLY_SUGGESTIVE",
+ [EmoteFlag.ContentEpilepsy]: "EPILEPSY",
+ [EmoteFlag.ContentEdgy]: "EDGY_OR_DISASTEFUL",
+ [EmoteFlag.ContentTwitchDisallowed]: "TWITCH_DISALLOWED",
+} as const;
+
+export enum EmoteLifecycle {
+ Deleted = -1,
+ Pending,
+ Processing,
+ Disabled,
+ Live,
+ Failed = -2,
+}
+
+export interface SevenTvEmoteModel {
+ /** Object ID */
+ id: string;
+ /** Emote name, eg. KEKW */
+ name: string;
+ flags: ActiveEmoteFlag;
+ timestamp: number;
+ actor_id: string | null;
+ /** EmotePartialModel */
+ data: EmotePartialModel; // This was nullable in the old Go version of 7tv
+}
+
+export interface EmoteSet {
+ /** Emote Set ID */
+ id?: string;
+ /** Emote Set Name */
+ name: string;
+ tags: string[];
+ flags: EmoteSetFlag;
+ /** Deprecated - Use flags */
+ immutable: boolean;
+ /** Deprecated - Use flags */
+ privileged: boolean;
+ emotes?: SevenTvEmoteModel[];
+ emote_count?: number;
+ capacity: number;
+ owner: UserPartial | null;
+}
+
+export interface EmotePartialModel {
+ /** Object ID */
+ id: string;
+ /** Emote name, eg. KEKW */
+ name: string;
+ flags: EmoteFlag;
+ lifecycle: EmoteLifecycle;
+ /** Emote version state */
+ state: ("PERSONAL" | "NO_PERSONAL" | "LISTED")[];
+ listed: boolean;
+ animated: boolean;
+ owner: UserPartial;
+ /** Image Host */
+ host: {
+ /** Partial url? */
+ // "//cdn.7tv.app/emote/60a487509485e7cf2f5a6fa7";
+ url: string;
+ /** Image File */
+ files: {
+ name: string; // "1x.avif"
+ static_name: string; // "1x_static.avif"
+ width: number;
+ height: number;
+ frame_count?: number;
+ size?: number;
+ format: "AVIF" | "WEBP";
+ }[];
+ };
+}
+
+export interface UserPartial {
+ id: string;
+ username: string;
+ display_name: string;
+ created_at?: number;
+ avatar_url?: string;
+ style: Record;
+ roles?: string[];
+ connections?: Connection[];
+}
+
+interface Connection {
+ id: string;
+ platform: "TWITCH" | "YOUTUBE" | "DISCORD";
+ username: string;
+ display_name: string;
+ linked_at: number;
+ emote_capacity: number;
+ emote_set_id: string | null;
+ emote_set: EmoteSet | null;
+}
+
+export interface SevenTVEmoteFragment {
+ type: "7tv-emote";
+ text: string;
+ animated: boolean;
+ global: boolean;
+ owner: {
+ login: string;
+ name: string;
+ };
+ small: {
+ file: string;
+ height?: number;
+ width?: number;
+ };
+ big: {
+ file: string;
+ height?: number;
+ width?: number;
+ };
+}
+
+function filterEmote(emote: SevenTvEmoteModel): boolean {
+ return (
+ emote.data.listed &&
+ Boolean(emote.data.lifecycle & EmoteLifecycle.Live) &&
+ !(
+ emote.data.flags &
+ (EmoteFlag.ContentEdgy |
+ EmoteFlag.ContentEpilepsy |
+ EmoteFlag.ContentSexual |
+ EmoteFlag.ContentTwitchDisallowed)
+ )
+ );
+}
+
+function sizeOf(
+ filename: string,
+ files: SevenTvEmoteModel["data"]["host"]["files"]
+): { height?: number; width?: number } {
+ const file = files.find(({ name }) => name === filename);
+
+ return { height: file?.height, width: file?.width };
+}
+
+export function makeSevenTvFragments(
+ rawEmotes: SevenTvEmoteModel[],
+ global?: boolean
+): Record {
+ const emotes = rawEmotes.filter(filterEmote).map(
+ (emote) =>
+ [
+ emote.name,
+ {
+ type: "7tv-emote",
+ text: emote.name,
+ animated: emote.data.animated,
+ global: global ?? false,
+ owner: {
+ login: emote.data.owner.username,
+ name: emote.data.owner.display_name,
+ },
+ small: {
+ file: `https:${emote.data.host.url}/1x.webp`,
+ ...sizeOf("1px.webp", emote.data.host.files),
+ },
+ big: {
+ file: `https:${emote.data.host.url}/4x.webp`,
+ ...sizeOf("4px.webp", emote.data.host.files),
+ },
+ },
+ ] as const
+ );
+
+ return Object.fromEntries(emotes);
+}
diff --git a/src/utils/seventv/getEmoteSet.ts b/src/utils/seventv/getEmoteSet.ts
new file mode 100644
index 0000000..d1a481a
--- /dev/null
+++ b/src/utils/seventv/getEmoteSet.ts
@@ -0,0 +1,25 @@
+import type { EmoteSet, SevenTvEmoteModel } from "../../stores/emotes/seventv";
+
+export async function getEmoteSet(
+ emoteSetId = "global"
+): Promise {
+ const response = await fetch(`https://7tv.io/v3/emote-sets/${emoteSetId}`);
+
+ if (!response.ok) {
+ throw new Error(
+ `7TV: Bad response: ${response.status.toString()}: ${response.statusText}`
+ );
+ }
+
+ const data = (await response.json()) as EmoteSet;
+ console.debug("7TV: Got Emote set Response:", data);
+
+ const emotes = data.emotes;
+
+ if (!emotes || emotes.length === 0) {
+ console.error("7TV: Emote set is empty?");
+ return [];
+ }
+
+ return emotes;
+}
diff --git a/src/utils/seventv/getUserConnection.ts b/src/utils/seventv/getUserConnection.ts
new file mode 100644
index 0000000..e04cb4b
--- /dev/null
+++ b/src/utils/seventv/getUserConnection.ts
@@ -0,0 +1,43 @@
+import type { EmoteSet, UserPartial } from "../../stores/emotes/seventv";
+
+interface Response {
+ id?: string;
+ platform: "TWITCH" | "YOUTUBE" | "DISCORD";
+ username: string;
+ display_name: string;
+ linked_at: number;
+ emote_capacity: number;
+ emote_set_id?: null; // no longer in use?
+ emote_set?: EmoteSet;
+ user: UserPartial;
+}
+
+export async function getUserConnection(
+ connectionId: string,
+ connectionPlatform = "twitch"
+): Promise {
+ const url = `https://7tv.io/v3/users/${connectionPlatform}/${connectionId}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(
+ `7TV: Bad response: ${response.status.toString()}: ${response.statusText}`
+ );
+ }
+
+ const data = (await response.json()) as Response;
+ console.debug("7TV: Got User Connect Response:", data);
+
+ if (data.emote_set?.id === undefined) {
+ console.error("7TV: No emote ID in the user connection response?");
+ return;
+ }
+
+ console.debug(`7TV: Reading in emote set: ${data.emote_set.name}`);
+
+ if (!data.emote_set.emotes || data.emote_set.emotes.length === 0) {
+ console.debug("7TV: Emote set is empty?");
+ }
+
+ return data.emote_set;
+}