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

+ +

SevenTV

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