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

382
src/hooks/useTwitchChat.ts Normal file
View File

@@ -0,0 +1,382 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { StaticAuthProvider } from '@twurple/auth';
import { ChatClient } from '@twurple/chat';
import { getBadge, getBadgeDetail } from '../lib/badges';
import type { ChatMessage } from '../lib/types';
// Cache for user info - limit to 500 entries for memory efficiency
const userInfoCache = new Map<string, { displayName: string; profileImageUrl: string | null }>();
const MAX_CACHE_SIZE = 500;
const NOTIFICATION_AUDIO_VOLUME = 0.3;
function setUserInfoCache(userId: string, info: { displayName: string; profileImageUrl: string | null }) {
// Limit cache size to prevent memory leaks
if (userInfoCache.size >= MAX_CACHE_SIZE) {
const firstKey = userInfoCache.keys().next().value;
if (firstKey !== undefined) {
userInfoCache.delete(firstKey);
}
}
userInfoCache.set(userId, info);
}
// Async function to get user info
async function getUserInfo(userId: string, accessToken: string) {
if (userInfoCache.has(userId)) {
return userInfoCache.get(userId);
}
try {
const response = await fetch(`/api/user-info?userId=${userId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.ok) {
const data = await response.json();
setUserInfoCache(userId, data);
return data;
}
} catch (error) {
console.error('Failed to fetch user info:', error);
}
return null;
}
export function useTwitchChat(
channelName: string,
accessToken: string,
username: string,
onMessage: (message: ChatMessage) => void,
enabled: boolean = true,
mentionSoundEnabled: boolean = true
) {
const [isConnected, setIsConnected] = useState(false);
const [client, setClient] = useState<ChatClient | null>(null);
// Retrieve Client ID from env; it's required for twurple's AuthProvider
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
useEffect(() => {
if (!enabled) return;
if (!channelName || !accessToken || !username || !clientId) {
if (enabled && !clientId) {
console.error('Twitch Client ID is missing (PUBLIC_TWITCH_CLIENT_ID env var)');
}
return;
}
let active = true;
// Use StaticAuthProvider for client-side chat
const authProvider = new StaticAuthProvider(clientId, accessToken);
// Initialize the Twurple ChatClient
const newClient = new ChatClient({
authProvider,
channels: [channelName],
// reconnect handled by default in twurple
});
newClient.onConnect(() => {
if (!active) return;
console.log(`Connected to ${channelName}`);
setIsConnected(true);
});
newClient.onDisconnect(() => {
if (!active) return;
console.log('Disconnected from Twitch chat');
setIsConnected(false);
});
newClient.onMessage((channel, user, message, msg) => {
if (!active) return;
const replyInfo = msg.parentMessageId ? {
id: msg.parentMessageId,
author: msg.parentMessageUserDisplayName || msg.parentMessageUserName || '',
content: msg.parentMessageText || '',
} : undefined;
const mentionsUser = (
message.toLowerCase().includes(`@${username.toLowerCase()}`) ||
(replyInfo?.author.toLowerCase() === username.toLowerCase())
);
// Map Twitch emotes - Twurple provides emote data in msg.emotes
const twitchEmotes: Record<string, string[]> = {};
msg.emoteOffsets.forEach((offsets, emoteId) => {
twitchEmotes[emoteId] = offsets;
});
const isBot = msg.userInfo.badges.has('bot');
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: msg.isCheer ? `${message} [${msg.bits} bits]` : message,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
replyTo: replyInfo,
mentionsUser,
emotes: twitchEmotes,
isBot,
badges: badgeImages,
badgeDetails,
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
messageType: msg.isRedemption ? 'redemption' : (msg.isCheer ? 'cheer' : 'chat'),
rewardId: msg.isRedemption ? (msg.rewardId || undefined) : undefined,
};
if (!cachedUserInfo && msg.userInfo.userId) {
getUserInfo(msg.userInfo.userId, accessToken).then((userInfo) => {
if (userInfo?.profileImageUrl) {
onMessage({
...chatMessage,
authorAvatar: userInfo.profileImageUrl,
});
return;
}
onMessage(chatMessage);
}).catch(() => {
onMessage(chatMessage);
});
} else {
onMessage(chatMessage);
}
if (mentionsUser && mentionSoundEnabled) {
try {
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGG2S36+OZSgwOUKXh8bllHgU2jtXywH0pBSl+zPLaizsIGGS56+idTgwNU6rm8bllHAU5kdXx');
audio.volume = NOTIFICATION_AUDIO_VOLUME;
audio.play().catch(() => { });
} catch (e) { }
}
});
newClient.onAction((channel, user, message, msg) => {
if (!active) return;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: message,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
mentionsUser: false,
emotes: {},
isBot: msg.userInfo.badges.has('bot'),
badges: badgeImages,
badgeDetails,
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
messageType: 'chat',
};
onMessage(chatMessage);
});
newClient.onSub((channel, user, subInfo, msg) => {
if (!active) return;
const tier = subInfo.plan === 'Prime' ? 'Prime' : subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
const subMessage = `subscribed with ${subInfo.plan === 'Prime' ? 'Prime Gaming' : `a Tier ${tier} subscription`}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: subMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subscription',
};
onMessage(chatMessage);
});
newClient.onResub((channel, user, subInfo, msg) => {
if (!active) return;
const monthsCount = subInfo.months;
const monthLabel = monthsCount === 1 ? 'month' : 'months';
const resubMessage = `resubscribed for ${monthsCount} ${monthLabel}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: resubMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'resub',
};
onMessage(chatMessage);
});
newClient.onSubGift((channel, recipient, subInfo, msg) => {
if (!active) return;
const tier = subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
const giftMessage = `gifted a Tier ${tier} subscription to ${recipient}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: giftMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subgift',
};
onMessage(chatMessage);
});
// Handle community gifted subs (mass gifts)
newClient.onCommunitySub((channel, user, giftInfo, msg) => {
if (!active) return;
const tier = giftInfo.plan === '3000' ? '3' : giftInfo.plan === '2000' ? '2' : '1';
const giftMessage = `gifted ${giftInfo.count} Tier ${tier} subscriptions to the community!`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: giftMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subgift',
};
onMessage(chatMessage);
});
newClient.connect();
setClient(newClient);
return () => {
active = false;
if (newClient) {
newClient.quit();
}
};
}, [channelName, accessToken, username, onMessage, enabled, clientId]);
const sendMessage = useCallback(
async (message: string, replyToMessageId?: string) => {
if (!client || !isConnected) {
throw new Error('Not connected to chat');
}
try {
if (replyToMessageId) {
// Twurple supports replies natively
await client.say(channelName, message, { replyTo: replyToMessageId });
} else {
await client.say(channelName, message);
}
} catch (error) {
console.error('Failed to send message:', error);
throw error;
}
},
[client, isConnected, channelName]
);
return { isConnected, sendMessage };
}