Commit current project
This commit is contained in:
76
src/lib/badges.ts
Normal file
76
src/lib/badges.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ApiClient } from '@twurple/api';
|
||||
import { StaticAuthProvider } from '@twurple/auth';
|
||||
import type { HelixChatBadgeSet, HelixChatBadgeVersion } from '@twurple/api';
|
||||
|
||||
// Shorthand for badge data we actually care about in the UI
|
||||
export interface BadgeDetail {
|
||||
id: string;
|
||||
version: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Store the full HelixChatBadgeSet objects for better metadata
|
||||
let badgesGlobal: Map<string, HelixChatBadgeSet> = new Map();
|
||||
let badgesChannel: Map<string, HelixChatBadgeSet> = new Map();
|
||||
|
||||
// Wipe everything if we switch accounts or reset
|
||||
export function clearBadgeCache(): void {
|
||||
badgesGlobal.clear();
|
||||
badgesChannel.clear();
|
||||
}
|
||||
|
||||
// Grab badge sets from Twitch. If channelId is null, gets global ones.
|
||||
async function fetchBadges(apiClient: ApiClient, channelId?: string): Promise<Map<string, HelixChatBadgeSet>> {
|
||||
try {
|
||||
const badgeSets = channelId
|
||||
? await apiClient.chat.getChannelBadges(channelId)
|
||||
: await apiClient.chat.getGlobalBadges();
|
||||
|
||||
const map = new Map<string, HelixChatBadgeSet>();
|
||||
badgeSets.forEach(set => map.set(set.id, set));
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.warn(`Twitch ${channelId ? 'channel' : 'global'} badges not loaded:`, error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// Pull down both global and channel-specific badges so we can show them in chat
|
||||
export async function initializeBadges(channelId: string, accessToken: string, clientId: string): Promise<void> {
|
||||
const authProvider = new StaticAuthProvider(clientId, accessToken);
|
||||
const apiClient = new ApiClient({ authProvider });
|
||||
|
||||
const [global, channel] = await Promise.all([
|
||||
fetchBadges(apiClient),
|
||||
fetchBadges(apiClient, channelId),
|
||||
]);
|
||||
|
||||
badgesGlobal = global;
|
||||
badgesChannel = channel;
|
||||
}
|
||||
|
||||
// Get the raw image URL for a specific badge/version
|
||||
export function getBadge(name: string, version: string): string | undefined {
|
||||
const detail = getBadgeDetail(name, version);
|
||||
return detail?.url;
|
||||
}
|
||||
|
||||
// Find the full badge info, checking the channel first (custom badges) then global ones
|
||||
export function getBadgeDetail(name: string, version: string): BadgeDetail | undefined {
|
||||
// Look in channel badges first, then global
|
||||
const set = badgesChannel.get(name) || badgesGlobal.get(name);
|
||||
if (!set) return undefined;
|
||||
|
||||
const v = set.getVersion(version);
|
||||
if (!v) return undefined;
|
||||
|
||||
return {
|
||||
id: name,
|
||||
version: version,
|
||||
url: v.getImageUrl(2) || v.getImageUrl(1), // Default to 2x for better appearance
|
||||
title: v.title,
|
||||
description: v.description
|
||||
};
|
||||
}
|
||||
777
src/lib/emotes.ts
Normal file
777
src/lib/emotes.ts
Normal file
@@ -0,0 +1,777 @@
|
||||
// Emote sources: 7TV, BTTV, FFZ
|
||||
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
|
||||
|
||||
/**
|
||||
* DOMPurify configuration — only the elements and attributes our emote
|
||||
* renderer ever emits are permitted. Everything else is stripped.
|
||||
*/
|
||||
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
|
||||
ALLOWED_TAGS: ["img", "a", "strong", "span"],
|
||||
ALLOWED_ATTR: [
|
||||
// <img>
|
||||
"src", "alt", "title", "class", "style", "loading",
|
||||
"data-emote-name", "data-tooltip-url",
|
||||
// <a>
|
||||
"href", "target", "rel",
|
||||
],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORCE_BODY: false,
|
||||
};
|
||||
|
||||
const EMOTE_SIZE = "2";
|
||||
const CACHE_DURATION_DAYS = 7;
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
const CACHE_DURATION = CACHE_DURATION_DAYS * MS_PER_DAY;
|
||||
const EMOTE_BATCH_SIZE = 100;
|
||||
const MAX_REPLACEMENTS = 1000;
|
||||
|
||||
interface EmoteMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface EmoteCache {
|
||||
emotes: EmoteMap;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let emotes7tv: EmoteMap = {};
|
||||
let emotesBttv: EmoteMap = {};
|
||||
let emotesFfz: EmoteMap = {};
|
||||
let emotesTwitch: EmoteMap = {};
|
||||
|
||||
/**
|
||||
* Get cached emotes if valid
|
||||
*/
|
||||
function getCachedEmotes(cacheKey: string): EmoteMap | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (!cached) return null;
|
||||
|
||||
const cacheData: EmoteCache = JSON.parse(cached);
|
||||
const isExpired = Date.now() - cacheData.timestamp > CACHE_DURATION;
|
||||
|
||||
if (isExpired) {
|
||||
localStorage.removeItem(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cacheData.emotes;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save emotes to cache
|
||||
*/
|
||||
function setCachedEmotes(cacheKey: string, emotes: EmoteMap): void {
|
||||
try {
|
||||
const cacheData: EmoteCache = {
|
||||
emotes,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
|
||||
} catch (error) {
|
||||
console.warn("Failed to cache emotes:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all emote caches
|
||||
*/
|
||||
export function clearEmoteCache(): void {
|
||||
const cacheKeys = ["emotes_twitch_global"];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("emotes_")) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
emotes7tv = {};
|
||||
emotesBttv = {};
|
||||
emotesFfz = {};
|
||||
emotesTwitch = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 7TV global emotes
|
||||
*/
|
||||
async function fetch7TVGlobalEmotes(): Promise<EmoteMap> {
|
||||
const cacheKey = "emotes_7tv_global";
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Using cached 7TV global emotes, count:', Object.keys(cached).length);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching 7TV global emotes from 7tv.io');
|
||||
const response = await fetch("https://7tv.io/v3/emote-sets/global");
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.emotes) {
|
||||
data.emotes.forEach((emote: any) => {
|
||||
emotes[emote.name] =
|
||||
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||
});
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
console.log('Fetched 7TV global emotes, count:', Object.keys(emotes).length);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("7TV global emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 7TV emotes for a given channel
|
||||
*/
|
||||
async function fetch7TVEmotes(channelId: string): Promise<EmoteMap> {
|
||||
const cacheKey = `emotes_7tv_${channelId}`;
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://7tv.io/v3/users/twitch/${channelId}`);
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.emote_set?.emotes) {
|
||||
data.emote_set.emotes.forEach((emote: any) => {
|
||||
emotes[emote.name] =
|
||||
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||
});
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("7TV emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch BTTV global emotes
|
||||
*/
|
||||
async function fetchBTTVGlobalEmotes(): Promise<EmoteMap> {
|
||||
const cacheKey = "emotes_bttv_global";
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.betterttv.net/3/cached/emotes/global",
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
data.forEach((emote: any) => {
|
||||
emotes[emote.code] =
|
||||
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||
});
|
||||
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("BTTV global emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch BTTV emotes for a given channel
|
||||
*/
|
||||
async function fetchBTTVEmotes(channelId: string): Promise<EmoteMap> {
|
||||
const cacheKey = `emotes_bttv_${channelId}`;
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.betterttv.net/3/cached/users/twitch/${channelId}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.channelEmotes) {
|
||||
data.channelEmotes.forEach((emote: any) => {
|
||||
emotes[emote.code] =
|
||||
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.sharedEmotes) {
|
||||
data.sharedEmotes.forEach((emote: any) => {
|
||||
emotes[emote.code] =
|
||||
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||
});
|
||||
}
|
||||
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("BTTV emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FFZ global emotes
|
||||
*/
|
||||
async function fetchFFZGlobalEmotes(): Promise<EmoteMap> {
|
||||
const cacheKey = "emotes_ffz_global";
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.frankerfacez.com/v1/set/global");
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.sets) {
|
||||
Object.values(data.sets).forEach((set: any) => {
|
||||
if (set.emoticons) {
|
||||
set.emoticons.forEach((emote: any) => {
|
||||
emotes[emote.name] =
|
||||
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("FFZ global emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FFZ emotes for a given channel
|
||||
*/
|
||||
async function fetchFFZEmotes(channelName: string): Promise<EmoteMap> {
|
||||
const cacheKey = `emotes_ffz_${channelName}`;
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.frankerfacez.com/v1/room/${channelName}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.sets) {
|
||||
const setId = data.room.set;
|
||||
if (data.sets[setId]) {
|
||||
data.sets[setId].emoticons.forEach((emote: any) => {
|
||||
emotes[emote.name] =
|
||||
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("FFZ emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Twitch global emotes
|
||||
*/
|
||||
async function fetchTwitchGlobalEmotes(): Promise<EmoteMap> {
|
||||
const cacheKey = "emotes_twitch_global";
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||
const response = await fetch("/api/twitch-proxy?path=/helix/chat/emotes/global");
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.data) {
|
||||
data.data.forEach((emote: any) => {
|
||||
const url = emote.images.url_1x.replace("/1.0", "/2.0");
|
||||
emotes[emote.name] = url;
|
||||
});
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("Twitch global emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Twitch channel emotes
|
||||
*/
|
||||
async function fetchTwitchChannelEmotes(channelId: string): Promise<EmoteMap> {
|
||||
const cacheKey = `emotes_twitch_channel_${channelId}`;
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||
const response = await fetch(
|
||||
`/api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=${encodeURIComponent(channelId)}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
const emotes: EmoteMap = {};
|
||||
if (data.data) {
|
||||
data.data.forEach((emote: any) => {
|
||||
const url = emote.images.url_1x.replace("/1.0", "/2.0");
|
||||
emotes[emote.name] = url;
|
||||
});
|
||||
}
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.warn("Twitch channel emotes not loaded", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user's available emote sets (includes subscriptions)
|
||||
*/
|
||||
async function fetchUserEmoteSets(userId: string): Promise<EmoteMap> {
|
||||
const cacheKey = `emotes_twitch_user_${userId}`;
|
||||
const cached = getCachedEmotes(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const emotes: EmoteMap = {};
|
||||
let cursor: string | null = null;
|
||||
let pageCount = 0;
|
||||
|
||||
// Fetch all pages
|
||||
do {
|
||||
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||
const proxyPath = cursor
|
||||
? `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}&after=${encodeURIComponent(cursor)}`
|
||||
: `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}`;
|
||||
|
||||
const apiResponse: Response = await fetch(proxyPath);
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const errorText = await apiResponse.text();
|
||||
console.error(
|
||||
"Failed to fetch user emotes:",
|
||||
apiResponse.status,
|
||||
apiResponse.statusText,
|
||||
errorText,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const pageData: any = await apiResponse.json();
|
||||
pageCount++;
|
||||
|
||||
if (pageData.data) {
|
||||
pageData.data.forEach((emote: any) => {
|
||||
const emoteId = emote.id;
|
||||
// Prefer animated if available, otherwise static
|
||||
const format = emote.format?.includes("animated")
|
||||
? "animated"
|
||||
: "static";
|
||||
const url = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/${format}/dark/2.0`;
|
||||
emotes[emote.name] = url;
|
||||
});
|
||||
}
|
||||
|
||||
// Get next cursor for pagination
|
||||
cursor = pageData.pagination?.cursor || null;
|
||||
} while (cursor);
|
||||
|
||||
setCachedEmotes(cacheKey, emotes);
|
||||
return emotes;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user emote sets:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize emotes for a given channel
|
||||
*/
|
||||
export async function initializeEmotes(
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
const promises = [
|
||||
fetch7TVGlobalEmotes(),
|
||||
fetch7TVEmotes(channelId),
|
||||
fetchBTTVGlobalEmotes(),
|
||||
fetchBTTVEmotes(channelId),
|
||||
fetchFFZGlobalEmotes(),
|
||||
fetchFFZEmotes(channelName),
|
||||
fetchTwitchGlobalEmotes(),
|
||||
fetchTwitchChannelEmotes(channelId),
|
||||
];
|
||||
|
||||
if (userId) {
|
||||
promises.push(fetchUserEmoteSets(userId));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
emotes7tv = {
|
||||
...results[0],
|
||||
...results[1],
|
||||
};
|
||||
|
||||
emotesBttv = {
|
||||
...results[2],
|
||||
...results[3],
|
||||
};
|
||||
|
||||
emotesFfz = {
|
||||
...results[4],
|
||||
...results[5],
|
||||
};
|
||||
|
||||
const globalEmotes = results[6];
|
||||
const channelEmotes = results[7];
|
||||
const userEmotes = userId ? results[8] : {};
|
||||
|
||||
emotesTwitch = {
|
||||
...globalEmotes,
|
||||
...channelEmotes,
|
||||
...userEmotes,
|
||||
};
|
||||
|
||||
// User emotes have been merged into emotesTwitch map
|
||||
if (userId && Object.keys(userEmotes).length > 0) {
|
||||
// Emotes successfully loaded and merged
|
||||
}
|
||||
|
||||
// Dispatch a custom event to notify listeners that emotes have been reloaded
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||
window.dispatchEvent(new Event('emotes-reloaded'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an optimized emote image HTML tag
|
||||
*/
|
||||
function replaceSize(url: string): string {
|
||||
const parts = url.split("/");
|
||||
// Replace the first number in the last part with '4'
|
||||
// This works for: 1x.webp -> 4x.webp, 1.0 -> 4.0, 2 -> 4, etc.
|
||||
|
||||
if (url.includes('betterttv')) {
|
||||
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "3");
|
||||
} else {
|
||||
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "4");
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
function createEmoteImg(emoteUrl: string, emoteName: string): string {
|
||||
// Convert any quality URL to 4x quality for tooltip (highest resolution)
|
||||
let highQualityUrl = emoteUrl;
|
||||
const splitedUrlSize = emoteUrl.split("/").pop();
|
||||
if (
|
||||
splitedUrlSize &&
|
||||
(splitedUrlSize.includes("2") || splitedUrlSize.includes("1"))
|
||||
) {
|
||||
highQualityUrl = replaceSize(emoteUrl);
|
||||
}
|
||||
return `<img src="${emoteUrl}" alt="${escapeHtml(emoteName)}" title="${escapeHtml(emoteName)}" data-tooltip-url="${highQualityUrl}" data-emote-name="${escapeHtml(emoteName)}" class="chat-emote" style="height: 2em; vertical-align: middle; margin: 0 2px; cursor: pointer;" loading="lazy" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse message text and replace emote codes with HTML image tags
|
||||
* @param text - The message text
|
||||
* @param twitchEmotes - Twitch emote data from message tags (emoteId -> positions array)
|
||||
*/
|
||||
export function parseMessageWithEmotes(
|
||||
text: string,
|
||||
twitchEmotes?: Record<string, string[]>,
|
||||
): string {
|
||||
let raw: string;
|
||||
|
||||
if (twitchEmotes && Object.keys(twitchEmotes).length > 0) {
|
||||
const replacements: Array<{ start: number; end: number; html: string }> =
|
||||
[];
|
||||
|
||||
Object.entries(twitchEmotes).forEach(([emoteId, positions]) => {
|
||||
positions.forEach((pos) => {
|
||||
const [start, end] = pos.split("-").map(Number);
|
||||
const emoteName = text.substring(start, end + 1);
|
||||
const emoteUrl = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/default/dark/2.0`;
|
||||
replacements.push({
|
||||
start,
|
||||
end: end + 1,
|
||||
html: createEmoteImg(emoteUrl, emoteName),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
replacements.sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = text;
|
||||
replacements.forEach(({ start, end, html }) => {
|
||||
result = result.substring(0, start) + html + result.substring(end);
|
||||
});
|
||||
|
||||
raw = parseThirdPartyEmotes(result);
|
||||
} else {
|
||||
raw = parseThirdPartyEmotes(text);
|
||||
}
|
||||
|
||||
// Final sanitization pass — strip anything outside the known-safe allowlist.
|
||||
// This is the last line of defence against XSS from malformed emote names,
|
||||
// crafted CDN responses, or future changes to the HTML-building helpers.
|
||||
return DOMPurify.sanitize(raw, DOMPURIFY_CONFIG) as unknown as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse third-party emotes and @mentions in text
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a word is a URL and convert to clickable link
|
||||
*/
|
||||
function linkifyUrl(word: string): string {
|
||||
// Only match http(s) or www-prefixed URLs
|
||||
const urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i;
|
||||
|
||||
if (urlPattern.test(word)) {
|
||||
let url = word;
|
||||
if (word.toLowerCase().startsWith("www.")) {
|
||||
url = "https://" + word;
|
||||
}
|
||||
|
||||
// Explicitly block dangerous schemes even if the regex above is ever relaxed.
|
||||
// URL objects normalise the protocol to lowercase, making detection reliable.
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const scheme = parsed.protocol; // e.g. "javascript:", "data:", "https:"
|
||||
if (scheme !== "https:" && scheme !== "http:") {
|
||||
return escapeHtml(word); // render as plain text
|
||||
}
|
||||
} catch {
|
||||
return escapeHtml(word); // invalid URL — render as plain text
|
||||
}
|
||||
|
||||
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="color: #6366f1; text-decoration: underline; cursor: pointer;">${escapeHtml(word)}</a>`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse third-party emotes and @mentions in text
|
||||
*/
|
||||
function parseThirdPartyEmotes(text: string): string {
|
||||
// Split by HTML tags to avoid breaking emote img tags
|
||||
// We use a capture group so the delimiter (img tag) is included in the result array
|
||||
const parts = text.split(/(<img[^>]*>)/);
|
||||
|
||||
const parsedParts = parts.map((part) => {
|
||||
// Skip if it's already an HTML tag (our emote)
|
||||
if (part.startsWith("<img")) {
|
||||
return part;
|
||||
}
|
||||
|
||||
// Split into words and parse
|
||||
// Note: This approach loses exact whitespace formatting (collapses multiple spaces)
|
||||
// A more robust approach would be to find matches and replace them, escaping the rest.
|
||||
// For now, we'll stick to splitting by space but ensure we escape non-matches.
|
||||
const words = part.split(" ");
|
||||
const parsedWords = words.map((word) => {
|
||||
// Check for URLs first
|
||||
const linkified = linkifyUrl(word);
|
||||
if (linkified) {
|
||||
return linkified;
|
||||
}
|
||||
|
||||
// Check for @mention
|
||||
if (word.startsWith("@")) {
|
||||
return `<strong>${escapeHtml(word)}</strong>`;
|
||||
}
|
||||
|
||||
// Check Twitch native emotes (from cache map, manually typed)
|
||||
if (emotesTwitch[word]) {
|
||||
return createEmoteImg(emotesTwitch[word], word);
|
||||
}
|
||||
|
||||
// Check 7TV
|
||||
if (emotes7tv[word]) {
|
||||
return createEmoteImg(emotes7tv[word], word);
|
||||
}
|
||||
|
||||
// Check BTTV
|
||||
if (emotesBttv[word]) {
|
||||
return createEmoteImg(emotesBttv[word], word);
|
||||
}
|
||||
|
||||
// Check FFZ
|
||||
if (emotesFfz[word]) {
|
||||
return createEmoteImg(emotesFfz[word], word);
|
||||
}
|
||||
|
||||
// Return escaped word
|
||||
return escapeHtml(word);
|
||||
});
|
||||
|
||||
return parsedWords.join(" ");
|
||||
});
|
||||
|
||||
return parsedParts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for emotes matching a query
|
||||
*/
|
||||
export function searchEmotes(
|
||||
query: string,
|
||||
limit: number = 10,
|
||||
): Array<{ name: string; url: string; source: string }> {
|
||||
if (!query) return [];
|
||||
|
||||
const results: Array<{ name: string; url: string; source: string }> = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Debug: Show first 5 Twitch emote names
|
||||
|
||||
// Search Twitch emotes (includes global, channel, and user's subscriptions)
|
||||
Object.entries(emotesTwitch).forEach(([name, url]) => {
|
||||
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||
results.push({ name, url, source: "Twitch" });
|
||||
}
|
||||
});
|
||||
|
||||
// Search 7TV emotes
|
||||
Object.entries(emotes7tv).forEach(([name, url]) => {
|
||||
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||
results.push({ name, url, source: "7TV" });
|
||||
}
|
||||
});
|
||||
|
||||
// Search BTTV emotes
|
||||
Object.entries(emotesBttv).forEach(([name, url]) => {
|
||||
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||
results.push({ name, url, source: "BTTV" });
|
||||
}
|
||||
});
|
||||
|
||||
// Search FFZ emotes
|
||||
Object.entries(emotesFfz).forEach(([name, url]) => {
|
||||
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||
results.push({ name, url, source: "FFZ" });
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by relevance (exact match first, then starts with, then contains)
|
||||
results.sort((a, b) => {
|
||||
const aLower = a.name.toLowerCase();
|
||||
const bLower = b.name.toLowerCase();
|
||||
|
||||
if (aLower === lowerQuery) return -1;
|
||||
if (bLower === lowerQuery) return 1;
|
||||
if (aLower.startsWith(lowerQuery) && !bLower.startsWith(lowerQuery))
|
||||
return -1;
|
||||
if (bLower.startsWith(lowerQuery) && !aLower.startsWith(lowerQuery))
|
||||
return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const finalResults = results.slice(0, limit);
|
||||
|
||||
return finalResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled emotes management (stored in localStorage as array of names)
|
||||
*/
|
||||
const DISABLED_EMOTES_KEY = 'disabled_emotes_v1';
|
||||
|
||||
export function getDisabledEmotes(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISABLED_EMOTES_KEY);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
return new Set(arr);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmoteDisabled(name: string): boolean {
|
||||
return getDisabledEmotes().has(name);
|
||||
}
|
||||
|
||||
export function toggleDisableEmote(name: string): void {
|
||||
try {
|
||||
const set = getDisabledEmotes();
|
||||
if (set.has(name)) set.delete(name);
|
||||
else set.add(name);
|
||||
localStorage.setItem(DISABLED_EMOTES_KEY, JSON.stringify(Array.from(set)));
|
||||
|
||||
// Notify listeners (reuse emotes-reloaded so UI refreshes emote lists and autocomplete)
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||
window.dispatchEvent(new Event('emotes-reloaded'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to toggle emote disabled state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all emotes organized by source
|
||||
*/
|
||||
export interface EmotesBySource {
|
||||
twitch: Array<{ name: string; url: string }>;
|
||||
"7tv": Array<{ name: string; url: string }>;
|
||||
bttv: Array<{ name: string; url: string }>;
|
||||
ffz: Array<{ name: string; url: string }>;
|
||||
}
|
||||
|
||||
export function getAllEmotesOrganized(): EmotesBySource {
|
||||
const organized = {
|
||||
twitch: Object.entries(emotesTwitch)
|
||||
.map(([name, url]) => ({ name, url }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
"7tv": Object.entries(emotes7tv)
|
||||
.map(([name, url]) => ({ name, url }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
bttv: Object.entries(emotesBttv)
|
||||
.map(([name, url]) => ({ name, url }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
ffz: Object.entries(emotesFfz)
|
||||
.map(([name, url]) => ({ name, url }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
|
||||
// Debug log to verify emotes are loaded
|
||||
console.log("Emotes organized:", {
|
||||
twitch: organized.twitch.length,
|
||||
"7tv": organized["7tv"].length,
|
||||
bttv: organized.bttv.length,
|
||||
ffz: organized.ffz.length,
|
||||
});
|
||||
|
||||
return organized;
|
||||
}
|
||||
129
src/lib/streamMonitor.ts
Normal file
129
src/lib/streamMonitor.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getStreamStatuses } from "./twitch";
|
||||
import type { PlatformSession } from "./types";
|
||||
|
||||
export interface StreamerToWatch {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface LiveStreamer extends StreamerToWatch {
|
||||
isLive: true;
|
||||
title?: string;
|
||||
viewerCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which streamers from a list are currently live
|
||||
* Uses batch API endpoint to check all streamers in a single request
|
||||
*/
|
||||
export async function checkLiveStreamers(
|
||||
streamersToWatch: StreamerToWatch[],
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
): Promise<LiveStreamer[]> {
|
||||
if (!streamersToWatch.length) return [];
|
||||
|
||||
try {
|
||||
const streamerIds = streamersToWatch.map((s) => s.id);
|
||||
|
||||
// Use batch endpoint to check all streamers in one API call
|
||||
const statuses = await getStreamStatuses(
|
||||
accessToken,
|
||||
clientId,
|
||||
streamerIds,
|
||||
);
|
||||
|
||||
const liveStreamers: LiveStreamer[] = streamersToWatch
|
||||
.filter((streamer) => statuses[streamer.id]?.isLive)
|
||||
.map((streamer) => ({
|
||||
...streamer,
|
||||
isLive: true as const,
|
||||
title: statuses[streamer.id]?.title,
|
||||
viewerCount: statuses[streamer.id]?.viewerCount,
|
||||
}));
|
||||
|
||||
return liveStreamers;
|
||||
} catch (error) {
|
||||
console.error("Error checking live streamers:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watched streamers from localStorage
|
||||
*/
|
||||
export function getWatchedStreamers(): StreamerToWatch[] {
|
||||
const saved = localStorage.getItem("mixchat_watched_streamers");
|
||||
if (!saved) return [];
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse watched streamers:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save watched streamers to localStorage
|
||||
*/
|
||||
export function saveWatchedStreamers(streamers: StreamerToWatch[]): void {
|
||||
localStorage.setItem("mixchat_watched_streamers", JSON.stringify(streamers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a streamer to watch list
|
||||
*/
|
||||
export function addWatchedStreamer(streamer: StreamerToWatch): void {
|
||||
const current = getWatchedStreamers();
|
||||
if (!current.find((s) => s.id === streamer.id)) {
|
||||
saveWatchedStreamers([...current, streamer]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a streamer from watch list
|
||||
*/
|
||||
export function removeWatchedStreamer(streamerId: string): void {
|
||||
const current = getWatchedStreamers();
|
||||
saveWatchedStreamers(current.filter((s) => s.id !== streamerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which streamers have already been notified for
|
||||
*/
|
||||
export function getNotifiedStreamers(): Set<string> {
|
||||
const saved = localStorage.getItem("mixchat_notified_streamers");
|
||||
if (!saved) return new Set();
|
||||
try {
|
||||
return new Set(JSON.parse(saved));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse notified streamers:", error);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a streamer as notified
|
||||
*/
|
||||
export function markStreamerNotified(streamerId: string): void {
|
||||
const notified = getNotifiedStreamers();
|
||||
notified.add(streamerId);
|
||||
localStorage.setItem(
|
||||
"mixchat_notified_streamers",
|
||||
JSON.stringify(Array.from(notified)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a streamer as not notified (when they go offline)
|
||||
*/
|
||||
export function markStreamerOffline(streamerId: string): void {
|
||||
const notified = getNotifiedStreamers();
|
||||
notified.delete(streamerId);
|
||||
localStorage.setItem(
|
||||
"mixchat_notified_streamers",
|
||||
JSON.stringify(Array.from(notified)),
|
||||
);
|
||||
}
|
||||
629
src/lib/twitch.ts
Normal file
629
src/lib/twitch.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
import type { PlatformSession, ChatMessage } from "./types.js";
|
||||
import { getBadge, getBadgeDetail } from "./badges.js";
|
||||
|
||||
const TWITCH_API_BASE = "https://api.twitch.tv/helix";
|
||||
const TWITCH_AUTH_BASE = "https://id.twitch.tv/oauth2";
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
export const getTwitchAccessToken = async (
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUri: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Twitch token exchange failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshTwitchAccessToken = async (
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh token: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Twitch token refresh failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTwitchUser = async (accessToken: string, clientId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${TWITCH_API_BASE}/users`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Twitch API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !data.data[0]) {
|
||||
throw new Error("No user data returned from Twitch API");
|
||||
}
|
||||
|
||||
const user = data.data[0];
|
||||
return {
|
||||
userId: user.id,
|
||||
displayName: user.display_name,
|
||||
login: user.login,
|
||||
profileImageUrl: user.profile_image_url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get Twitch user:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTwitchUserById = async (
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`${TWITCH_API_BASE}/users?id=${userId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Twitch API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !data.data[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = data.data[0];
|
||||
return {
|
||||
userId: user.id,
|
||||
displayName: user.display_name,
|
||||
login: user.login,
|
||||
profileImageUrl: user.profile_image_url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get Twitch user by ID:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const getFollowedChannels = async (
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
) => {
|
||||
try {
|
||||
let followedChannels: any[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
|
||||
while (true) {
|
||||
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
|
||||
url.searchParams.set("user_id", userId);
|
||||
url.searchParams.set("first", "100");
|
||||
if (cursor) {
|
||||
url.searchParams.set("after", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error(
|
||||
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Twitch API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const pageChannels = (data.data || []).map((channel: any) => ({
|
||||
id: channel.broadcaster_id,
|
||||
name: channel.broadcaster_login,
|
||||
displayName:
|
||||
channel.broadcaster_name ||
|
||||
channel.display_name ||
|
||||
channel.broadcaster_login,
|
||||
title: channel.game_name || "",
|
||||
thumbnail: channel.thumbnail_url,
|
||||
}));
|
||||
|
||||
followedChannels = followedChannels.concat(pageChannels);
|
||||
|
||||
if (data.pagination?.cursor) {
|
||||
cursor = data.pagination.cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = BATCH_SIZE;
|
||||
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||
const batch = followedChannels.slice(i, i + batchSize);
|
||||
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
|
||||
|
||||
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (usersResponse.ok) {
|
||||
const usersData = await usersResponse.json();
|
||||
const userProfiles = new Map(
|
||||
(usersData.data || []).map((user: any) => [
|
||||
user.id,
|
||||
user.profile_image_url,
|
||||
]),
|
||||
);
|
||||
batch.forEach((ch: any) => {
|
||||
ch.profileImageUrl = userProfiles.get(ch.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const streamDataMap = new Map<string, any>();
|
||||
|
||||
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||
const batch = followedChannels.slice(i, i + batchSize);
|
||||
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
|
||||
|
||||
const streamsResponse = await fetch(
|
||||
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (streamsResponse.ok) {
|
||||
const streamsData = await streamsResponse.json();
|
||||
(streamsData.data || []).forEach((s: any) => {
|
||||
streamDataMap.set(s.user_id, s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
followedChannels.forEach((ch: any) => {
|
||||
if (streamDataMap.has(ch.id)) {
|
||||
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
|
||||
} else {
|
||||
ch.viewerCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Only return online channels for the sidebar
|
||||
const onlineChannels = followedChannels.filter((ch: any) =>
|
||||
streamDataMap.has(ch.id),
|
||||
);
|
||||
|
||||
const sorted = onlineChannels.sort((a, b) => {
|
||||
const nameA = (a?.displayName || a?.name || "").toLowerCase();
|
||||
const nameB = (b?.displayName || b?.name || "").toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
} catch (error) {
|
||||
console.error("Failed to get followed channels:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllFollowedChannels = async (
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
) => {
|
||||
try {
|
||||
let followedChannels: any[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
|
||||
while (true) {
|
||||
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
|
||||
url.searchParams.set("user_id", userId);
|
||||
url.searchParams.set("first", "100");
|
||||
if (cursor) {
|
||||
url.searchParams.set("after", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error(
|
||||
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Twitch API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const pageChannels = (data.data || []).map((channel: any) => ({
|
||||
id: channel.broadcaster_id,
|
||||
name: channel.broadcaster_login,
|
||||
displayName:
|
||||
channel.broadcaster_name ||
|
||||
channel.display_name ||
|
||||
channel.broadcaster_login,
|
||||
title: channel.game_name || "",
|
||||
thumbnail: channel.thumbnail_url,
|
||||
}));
|
||||
|
||||
followedChannels = followedChannels.concat(pageChannels);
|
||||
|
||||
if (data.pagination?.cursor) {
|
||||
cursor = data.pagination.cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = BATCH_SIZE;
|
||||
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||
const batch = followedChannels.slice(i, i + batchSize);
|
||||
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
|
||||
|
||||
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (usersResponse.ok) {
|
||||
const usersData = await usersResponse.json();
|
||||
const userProfiles = new Map(
|
||||
(usersData.data || []).map((user: any) => [
|
||||
user.id,
|
||||
user.profile_image_url,
|
||||
]),
|
||||
);
|
||||
batch.forEach((ch: any) => {
|
||||
ch.profileImageUrl = userProfiles.get(ch.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch stream data to mark which channels are online
|
||||
const streamDataMap = new Map<string, any>();
|
||||
|
||||
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||
const batch = followedChannels.slice(i, i + batchSize);
|
||||
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
|
||||
|
||||
const streamsResponse = await fetch(
|
||||
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (streamsResponse.ok) {
|
||||
const streamsData = await streamsResponse.json();
|
||||
(streamsData.data || []).forEach((s: any) => {
|
||||
streamDataMap.set(s.user_id, s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
followedChannels.forEach((ch: any) => {
|
||||
if (streamDataMap.has(ch.id)) {
|
||||
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
|
||||
} else {
|
||||
ch.viewerCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Return all followed channels, sorted with online first
|
||||
const sorted = followedChannels.sort((a, b) => {
|
||||
const aIsOnline = streamDataMap.has(a.id) ? 1 : 0;
|
||||
const bIsOnline = streamDataMap.has(b.id) ? 1 : 0;
|
||||
|
||||
if (aIsOnline !== bIsOnline) {
|
||||
return bIsOnline - aIsOnline; // Online channels first
|
||||
}
|
||||
|
||||
const nameA = (a?.displayName || a?.name || "").toLowerCase();
|
||||
const nameB = (b?.displayName || b?.name || "").toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
} catch (error) {
|
||||
console.error("Failed to get all followed channels:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStreamStatus = async (
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
): Promise<{
|
||||
isLive: boolean;
|
||||
title?: string;
|
||||
viewerCount?: number;
|
||||
startedAt?: Date;
|
||||
} | null> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${TWITCH_API_BASE}/streams?user_id=${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Twitch API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const stream = data.data?.[0];
|
||||
|
||||
if (!stream) {
|
||||
return { isLive: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isLive: true,
|
||||
title: stream.title,
|
||||
viewerCount: stream.viewer_count,
|
||||
startedAt: new Date(stream.started_at),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get stream status:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch query stream status for multiple users (optimized for rate limiting)
|
||||
* Instead of making N individual requests, makes 1 request with multiple user_ids
|
||||
* Reduces API calls by ~90% when checking multiple streamers
|
||||
*/
|
||||
export const getStreamStatuses = async (
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
userIds: string[],
|
||||
): Promise<
|
||||
Record<
|
||||
string,
|
||||
{ isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date }
|
||||
>
|
||||
> => {
|
||||
try {
|
||||
if (!userIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Build query with all user IDs
|
||||
const params = userIds.map((id) => `user_id=${id}`).join("&");
|
||||
const response = await fetch(
|
||||
`${TWITCH_API_BASE}/streams?${params}&first=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Client-ID": clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Twitch API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result: Record<
|
||||
string,
|
||||
{
|
||||
isLive: boolean;
|
||||
title?: string;
|
||||
viewerCount?: number;
|
||||
startedAt?: Date;
|
||||
}
|
||||
> = {};
|
||||
|
||||
// Initialize all user IDs as offline
|
||||
userIds.forEach((id) => {
|
||||
result[id] = { isLive: false };
|
||||
});
|
||||
|
||||
// Update with actual live streams
|
||||
data.data?.forEach((stream: any) => {
|
||||
result[stream.user_id] = {
|
||||
isLive: true,
|
||||
title: stream.title,
|
||||
viewerCount: stream.viewer_count,
|
||||
startedAt: new Date(stream.started_at),
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Batch stream check: ${userIds.length} streamers checked in 1 request`,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to get batch stream status:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRecentMessages = async (
|
||||
channelName: string,
|
||||
): Promise<ChatMessage[]> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://recent-messages.robotty.de/api/v2/recent-messages/${channelName}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to fetch recent messages for ${channelName}:`,
|
||||
response.status,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const messages: ChatMessage[] = (data.messages || [])
|
||||
.map((rawMsg: string) => {
|
||||
try {
|
||||
const tagsMatch = rawMsg.match(/^@(.+?)\s+:/);
|
||||
const userMatch = rawMsg.match(/\s:([^!]+)!/);
|
||||
const contentMatch = rawMsg.match(/PRIVMSG #[^\s]+ :(.+)$/);
|
||||
|
||||
if (!userMatch || !contentMatch) {
|
||||
console.warn("Failed to parse message:", rawMsg.substring(0, 100));
|
||||
return null;
|
||||
}
|
||||
|
||||
const author = userMatch[1];
|
||||
const content = contentMatch[1];
|
||||
|
||||
// Parse tags
|
||||
let tags: Record<string, string> = {};
|
||||
if (tagsMatch && tagsMatch[1]) {
|
||||
const tagPairs = tagsMatch[1].split(";");
|
||||
tags = Object.fromEntries(
|
||||
tagPairs.map((pair) => {
|
||||
const [key, value] = pair.split("=");
|
||||
return [key, value || ""];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let badges: Record<string, string> = {};
|
||||
let badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||
if (tags["badges"]) {
|
||||
tags["badges"].split(",").forEach((badge: string) => {
|
||||
const [name, version] = badge.split("/");
|
||||
const detail = getBadgeDetail(name, version);
|
||||
if (detail) {
|
||||
badges[name] = detail.url;
|
||||
badgeDetails[name] = { url: detail.url, title: detail.title };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let emotes: Record<string, string[]> = {};
|
||||
if (tags["emotes"] && tags["emotes"] !== "") {
|
||||
emotes = Object.fromEntries(
|
||||
tags["emotes"].split("/").map((emote: string) => {
|
||||
const [id, positions] = emote.split(":");
|
||||
return [id, positions.split(",")];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Use the actual Twitch message ID if available, otherwise fall back to a composite ID
|
||||
const messageId = tags["id"] || `${tags["user-id"] || "unknown"}_${tags["tmi-sent-ts"] || Date.now()}`;
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: messageId,
|
||||
platform: "twitch",
|
||||
author,
|
||||
content,
|
||||
timestamp: new Date(parseInt(tags["tmi-sent-ts"]) || Date.now()),
|
||||
userId: tags["user-id"] || "",
|
||||
authorColor: tags["color"] || "#FFFFFF",
|
||||
badges,
|
||||
badgeDetails,
|
||||
emotes,
|
||||
isBot: tags["bot"] === "1",
|
||||
mentionsUser: false,
|
||||
isPreloaded: true, // Mark as preloaded historical message
|
||||
};
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error parsing message:",
|
||||
rawMsg.substring(0, 100),
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((msg: ChatMessage | null): msg is ChatMessage => msg !== null);
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent messages:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
51
src/lib/types.ts
Normal file
51
src/lib/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const PLATFORMS = ["twitch", "youtube"] as const;
|
||||
export type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
export const MESSAGE_TYPES = [
|
||||
"chat",
|
||||
"cheer",
|
||||
"subscription",
|
||||
"resub",
|
||||
"subgift",
|
||||
"redemption",
|
||||
] as const;
|
||||
export type MessageType = (typeof MESSAGE_TYPES)[number];
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
platform: Platform;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
authorAvatar?: string;
|
||||
authorColor?: string;
|
||||
userId?: string;
|
||||
mentionsUser?: boolean;
|
||||
emotes?: Record<string, string[]>; // Twitch emote data: emoteId -> positions
|
||||
replyTo?: {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
};
|
||||
isBot?: boolean;
|
||||
badges?: Record<string, string>; // badgeName -> imageUrl
|
||||
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
|
||||
isCurrentUser?: boolean; // True if message is from the logged-in user
|
||||
optimistic?: boolean; // True if this message is a local optimistic placeholder
|
||||
isPreloaded?: boolean; // True if message is from historical data (not live chat)
|
||||
messageType?: MessageType; // Type of message (chat, cheer, subscription, etc.)
|
||||
rewardId?: string; // ID of the redeemed reward
|
||||
}
|
||||
|
||||
export interface PlatformSession {
|
||||
platform: Platform;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
profileImageUrl?: string;
|
||||
badges?: Record<string, string>; // badgeName -> imageUrl
|
||||
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
|
||||
}
|
||||
6
src/lib/version.ts
Normal file
6
src/lib/version.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* App version constant
|
||||
* Update here to reflect current release version
|
||||
*/
|
||||
export const APP_VERSION = "0.5.0-beta";
|
||||
export const DISPLAY_VERSION = "Beta 0.5";
|
||||
114
src/lib/youtube.ts
Normal file
114
src/lib/youtube.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* YouTube OAuth and API Client
|
||||
*/
|
||||
|
||||
export interface YouTubeTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface YouTubeUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
profileImageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export async function getYoutubeAccessToken(
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUri: string,
|
||||
): Promise<YouTubeTokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get YouTube access token: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YouTube user information
|
||||
*/
|
||||
export async function getYoutubeUser(
|
||||
accessToken: string,
|
||||
): Promise<YouTubeUser> {
|
||||
const response = await fetch(
|
||||
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get YouTube user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
throw new Error("No YouTube channel found");
|
||||
}
|
||||
|
||||
const channel = data.items[0];
|
||||
|
||||
return {
|
||||
userId: channel.id,
|
||||
displayName: channel.snippet.title,
|
||||
profileImageUrl: channel.snippet.thumbnails?.default?.url || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh YouTube access token
|
||||
*/
|
||||
export async function refreshYoutubeToken(
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<YouTubeTokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh YouTube token: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
50
src/lib/youtubeLinks.ts
Normal file
50
src/lib/youtubeLinks.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* YouTube channel linking helpers.
|
||||
* Stores a mapping of Twitch channel ID -> YouTube channel URL in localStorage.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "mixchat_youtube_links";
|
||||
|
||||
export interface YouTubeLink {
|
||||
twitchChannelId: string;
|
||||
twitchDisplayName: string;
|
||||
youtubeUrl: string;
|
||||
}
|
||||
|
||||
export function getYoutubeLinks(): YouTubeLink[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addYoutubeLink(link: YouTubeLink): void {
|
||||
const links = getYoutubeLinks();
|
||||
// Replace if already exists for this twitch channel
|
||||
const idx = links.findIndex(
|
||||
(l) => l.twitchChannelId === link.twitchChannelId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
links[idx] = link;
|
||||
} else {
|
||||
links.push(link);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
|
||||
}
|
||||
|
||||
export function removeYoutubeLink(twitchChannelId: string): void {
|
||||
const links = getYoutubeLinks().filter(
|
||||
(l) => l.twitchChannelId !== twitchChannelId,
|
||||
);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
|
||||
}
|
||||
|
||||
export function getYoutubeLinkForChannel(
|
||||
twitchChannelId: string,
|
||||
): string | null {
|
||||
const links = getYoutubeLinks();
|
||||
const found = links.find((l) => l.twitchChannelId === twitchChannelId);
|
||||
return found?.youtubeUrl ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user