Commit current project

This commit is contained in:
2026-03-29 22:44:13 +02:00
parent b3bccb2ae3
commit 7f9469c07d
77 changed files with 20495 additions and 0 deletions

76
src/lib/badges.ts Normal file
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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
View 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
View 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
View 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
View 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
View 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
View 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;
}