diff --git a/.env.example b/.env.example index 028d686..bd4204f 100644 --- a/.env.example +++ b/.env.example @@ -91,4 +91,7 @@ KT_PROM_PATH=/metrics # Tempo query API (for reading traces with curl) MAIN_VITE_GRAFANA_TEMPO_QUERY_URL=https://tempo-prod-xx-prod-us-east-x.grafana.net/tempo MAIN_VITE_GRAFANA_TEMPO_QUERY_USER=your_grafana_user_id -MAIN_VITE_GRAFANA_TEMPO_QUERY_TOKEN=glc_your_grafana_cloud_access_policy_token_here \ No newline at end of file +MAIN_VITE_GRAFANA_TEMPO_QUERY_TOKEN=glc_your_grafana_cloud_access_policy_token_here + +# Debug flags (disable by default, enable for troubleshooting) +# VITE_DEBUG_7TV_WS=true # Enable verbose 7TV WebSocket message logging \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 5b76446..ffb061f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,7 @@ "port": 9222, "request": "attach", "type": "chrome", - "webRoot": "${workspaceFolder}/src/renderer", + "webRoot": "${workspaceFolder}/src/renderer/src", "timeout": 60000, "presentation": { "hidden": true diff --git a/electron.vite.config.mjs b/electron.vite.config.mjs index b3c8497..c5ee8bb 100644 --- a/electron.vite.config.mjs +++ b/electron.vite.config.mjs @@ -72,6 +72,7 @@ export default defineConfig({ }, renderer: { build: { + sourcemap: true, rollupOptions: { input: { index: resolve("src/renderer/index.html"), diff --git a/src/renderer/src/assets/styles/components/Chat/Message.scss b/src/renderer/src/assets/styles/components/Chat/Message.scss index 822df1d..0e22894 100644 --- a/src/renderer/src/assets/styles/components/Chat/Message.scss +++ b/src/renderer/src/assets/styles/components/Chat/Message.scss @@ -1034,3 +1034,39 @@ } /** [End of Emote Set Update Message] **/ + +.emote-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #2a2a2a; + border-radius: 4px; + color: #666; + font-size: 10px; + font-family: monospace; + border: 1px solid rgba(255, 255, 255, 0.1); + + &.error { + background-color: rgba(255, 100, 100, 0.1); + border-color: rgba(255, 100, 100, 0.3); + color: rgba(255, 100, 100, 0.8); + } +} + +.emote-progressive { + .emote { + transition: opacity 0.2s ease-in-out; + + &.loading { + opacity: 0; + } + + &.loaded { + opacity: 1; + } + + &.error { + opacity: 0; + } + } +} diff --git a/src/renderer/src/components/Cosmetics/Emote.jsx b/src/renderer/src/components/Cosmetics/Emote.jsx index e1374cd..b90ca7f 100644 --- a/src/renderer/src/components/Cosmetics/Emote.jsx +++ b/src/renderer/src/components/Cosmetics/Emote.jsx @@ -1,12 +1,64 @@ import { memo, useCallback, useState, useMemo } from "react"; import EmoteTooltip from "./EmoteTooltip"; +// Progressive Loading Hook for Emotes +const useProgressiveEmoteLoading = (emote, type) => { + const [loadState, setLoadState] = useState('loading'); // loading, loaded, error + const [showFallback, setShowFallback] = useState(false); + + // Define fallback placeholder (prevents layout shift) + const placeholder = useMemo(() => { + const placeholderWidth = type === "stv" ? (emote.width || 28) : 32; + const placeholderHeight = type === "stv" ? (emote.height || 28) : 32; + + return { + width: placeholderWidth, + height: placeholderHeight, + backgroundColor: '#2a2a2a', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + color: '#666', + fontSize: '10px', + fontFamily: 'monospace' + }; + }, [emote, type]); + + const handleImageLoad = useCallback(() => { + setLoadState('loaded'); + setShowFallback(false); + }, []); + + const handleImageError = useCallback(() => { + setLoadState('error'); + setShowFallback(true); + }, []); + + return { + loadState, + showFallback, + placeholder, + handleImageLoad, + handleImageError + }; +}; + const Emote = memo(({ emote, overlaidEmotes = [], scale = 1, type }) => { const { id, name, width, height } = emote; const [showEmoteInfo, setShowEmoteInfo] = useState(false); const [mousePos, setMousePos] = useState({ x: null, y: null }); + // Use progressive loading hook + const { + loadState, + showFallback, + placeholder, + handleImageLoad, + handleImageError + } = useProgressiveEmoteLoading(emote, type); + const emoteSrcSet = useCallback( (emote) => { if (type === "stv") { @@ -58,18 +110,36 @@ const Emote = memo(({ emote, overlaidEmotes = [], scale = 1, type }) => { height: type === "stv" ? height : "32px", }}>
+ {showFallback || loadState === 'error' ? ( + // Fallback placeholder to prevent layout shift +
+ {name.slice(0, 2)} +
+ ) : null} + + {/* Always render image but control visibility with CSS */} {name}
diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index f27fc14..d361408 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -34,7 +34,6 @@ const safeChatroomIdMatch = (roomId, chatroomId, context = 'unknown') => { return match; }; import queueChannelFetch from "@utils/fetchQueue"; -import StvWebSocket from "@utils/services/seventv/stvWebsocket"; import ConnectionManager from "@utils/services/connectionManager"; import useCosmeticsStore from "./CosmeticsProvider"; import { sendUserPresence } from "@utils/services/seventv/stvAPI"; @@ -234,6 +233,7 @@ const MESSAGE_STATES = { let stvPresenceUpdates = new Map(); let storeStvId = null; const PRESENCE_UPDATE_INTERVAL = 30 * 1000; +const refreshingStvSets = new Set(); // Global connection manager instance let connectionManager = null; @@ -388,6 +388,9 @@ const getInitialState = () => { chatters: {}, donators: [], personalEmoteSets: savedPersonalEmoteSets, + recentPersonalUpdates: new Set(), // Track recent personal emote set updates to prevent duplicates + recentChannelUpdates: new Set(), // Track recent channel emote set updates to prevent duplicates + recentCosmeticEvents: new Map(), // Track recent cosmetic events: key -> timestamp (for dedup) isChatroomPaused: {}, // Store for all Chatroom Pauses mentions: {}, // Store for all Mentions currentChatroomId: null, // Track the currently active chatroom @@ -538,27 +541,27 @@ const useChatStore = create((set, get) => ({ sendPresenceUpdate: (stvId, userId) => { if (!stvId) { console.log("[7tv Presence]: No STV ID provided, skipping presence update"); - return; + return null; } const authTokens = window.app.auth.getToken(); if (!authTokens?.token || !authTokens?.session) { console.log("[7tv Presence]: No auth tokens available, skipping presence update"); - return; + return null; } const currentTime = Date.now(); if (stvPresenceUpdates.has(userId)) { const lastUpdateTime = stvPresenceUpdates.get(userId); - console.log("[7tv Presence]: Last update time for chatroom:", userId, lastUpdateTime, stvPresenceUpdates); if (currentTime - lastUpdateTime < PRESENCE_UPDATE_INTERVAL) { - return; + return null; } } stvPresenceUpdates.set(userId, currentTime); sendUserPresence(stvId, userId); + return currentTime; }, // Cache current user info for optimistic messages @@ -853,104 +856,11 @@ const useChatStore = create((set, get) => ({ })); }, - connectToStvWebSocket: (chatroom) => { - const channelSet = chatroom?.channel7TVEmotes?.find((set) => set.type === "channel"); - const stvId = channelSet?.user?.id; - const stvEmoteSets = channelSet?.setInfo?.id; - - const setupSpan = startSpan('seventv.websocket_setup', { - 'chatroom.id': chatroom.id, - 'streamer.name': chatroom.streamerData?.username || '', - 'seventv.user_id': stvId || '', - 'seventv.emote_set_id': stvEmoteSets || '' - }); - - try { - // Record WebSocket connection creation - window.app?.telemetry?.recordSevenTVWebSocketCreated?.(chatroom.id, stvId, stvEmoteSets); - } catch (error) { - console.warn('[Telemetry] Failed to record 7TV WebSocket setup:', error); - } - - const existingConnection = get().connections[chatroom.id]?.stvSocket; - if (existingConnection) { - existingConnection.close(); - } - - try { - const stvSocket = new StvWebSocket(chatroom.streamerData.user_id, stvId, stvEmoteSets); - - console.log("Connecting to 7TV WebSocket for chatroom:", chatroom.id); - - set((state) => ({ - connections: { - ...state.connections, - [chatroom.id]: { - ...state.connections[chatroom.id], - stvSocket: stvSocket, - }, - }, - })); - - stvSocket.connect(); - - stvSocket.addEventListener("message", (event) => { - const SevenTVEvent = event.detail; - const { type, body } = SevenTVEvent; - - switch (type) { - case "connection_established": - break; - case "emote_set.update": - get().handleEmoteSetUpdate(chatroom.id, body); - break; - case "cosmetic.create": - useCosmeticsStore?.getState()?.addCosmetics(body); - break; - case "entitlement.create": - const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; - const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); - - useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); - break; - - default: - break; - } - }); - - const storeStvId = localStorage.getItem("stvId"); - - stvSocket.addEventListener("open", () => { - const s = startSpan('7tv.ws.connect', { 'chat.id': chatroom.id }); - console.log("7TV WebSocket connected for chatroom:", chatroom.id); - - setTimeout(() => { - const authTokens = window.app.auth.getToken(); - if (storeStvId && authTokens?.token && authTokens?.session) { - sendUserPresence(storeStvId, chatroom.streamerData.user_id); - stvPresenceUpdates.set(chatroom.streamerData.user_id, Date.now()); - } else { - console.log("[7tv Presence]: No STV ID or auth tokens available for WebSocket presence update"); - } - }, 2000); - endSpanOk(s); - }); - - stvSocket.addEventListener("close", () => { - const s = startSpan('7tv.ws.close', { 'chat.id': chatroom.id }); - console.log("7TV WebSocket disconnected for chatroom:", chatroom.id); - stvPresenceUpdates.delete(chatroom.streamerData.user_id); - endSpanOk(s); - }); - - endSpanOk(setupSpan); - } catch (error) { - console.error("Failed to setup 7TV WebSocket:", error); - endSpanError(setupSpan, error); - throw error; - } - }, + // DEPRECATED: Individual STV WebSocket connections - replaced by shared connection system + // connectToStvWebSocket: (chatroom) => { + // // This method is no longer used - shared connections handle 7TV WebSocket functionality + // console.warn("connectToStvWebSocket called but is deprecated - using shared connections instead"); + // }, connectToChatroom: async (chatroom) => { if (!chatroom?.id) return; @@ -1419,7 +1329,7 @@ const useChatStore = create((set, get) => ({ } initializationInProgress = true; - console.log("[ChatProvider] Starting OPTIMIZED connection initialization..."); + console.log("[ChatProvider] Starting LAZY-LOADED connection initialization..."); try { // Fetch donators list once on initialization @@ -1439,6 +1349,24 @@ const useChatStore = create((set, get) => ({ // Create new connection manager connectionManager = new ConnectionManager(); + // Find priority chatroom (first one or remembered active one) + const lastActiveChatroomId = localStorage.getItem('lastActiveChatroomId'); + const orderedChatrooms = [...chatrooms].sort((a, b) => { + const orderA = Number.isFinite(a?.order) ? a.order : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b?.order) ? b.order : Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + const resolveChatroomById = (id) => + orderedChatrooms.find((room) => String(room.id) === String(id)); + + const priorityChatroom = lastActiveChatroomId + ? resolveChatroomById(lastActiveChatroomId) || orderedChatrooms[0] + : orderedChatrooms[0]; + + console.log(`[ChatProvider] Using lazy loading - only initializing priority chatroom: ${priorityChatroom?.username} (${priorityChatroom?.id})`); + console.log(`[ChatProvider] Deferring ${chatrooms.length - 1} other chatrooms for lazy loading`); + // Set up event handlers for the shared connections const eventHandlers = { // KickPusher event handlers @@ -1509,14 +1437,20 @@ const useChatStore = create((set, get) => ({ // 7TV event handlers onStvMessage: (event) => { try { - const { chatroomId } = event.detail; + const { chatroomId, type, body } = event.detail; if (chatroomId) { get().handleStvMessage(chatroomId, event.detail); } else { - // Broadcast to all chatrooms if no specific chatroom - chatrooms.forEach(chatroom => { - get().handleStvMessage(chatroom.id, event.detail); - }); + // Handle global cosmetic events once instead of broadcasting to all chatrooms + if (type === 'cosmetic.create' || type === 'entitlement.create' || type === 'entitlement.delete') { + // Handle once with a null chatroomId to indicate global event + get().handleStvMessage(null, event.detail); + } else { + // Broadcast to all chatrooms if no specific chatroom (for non-cosmetic events) + chatrooms.forEach(chatroom => { + get().handleStvMessage(chatroom.id, event.detail); + }); + } } } catch (error) { console.error("[ChatProvider] Error handling 7TV message:", error); @@ -1568,16 +1502,26 @@ const useChatStore = create((set, get) => ({ setInitialChatroomInfo: get().setInitialChatroomInfo, }; - // Initialize connections with the new manager - await connectionManager.initializeConnections(chatrooms, eventHandlers, storeCallbacks); + // Initialize connections with the new manager - LAZY LOADING: only priority chatroom + await connectionManager.initializeConnections([priorityChatroom], eventHandlers, storeCallbacks); - console.log("[ChatProvider] ✅ Optimized connection initialization completed!"); + console.log("[ChatProvider] ✅ Lazy-loaded connection initialization completed!"); console.log("[ChatProvider] 📊 Connection status:", connectionManager.getConnectionStatus()); + // Store all chatrooms for lazy loading + connectionManager.setDeferredChatrooms(chatrooms.filter(room => room.id !== priorityChatroom?.id)); + // Show performance comparison in console - console.log("[ChatProvider] 🚀 Performance improvement:"); - console.log(` - WebSocket connections: ${chatrooms.length * 2} → 2 (${((chatrooms.length * 2 - 2) / (chatrooms.length * 2) * 100).toFixed(1)}% reduction)`); - console.log(` - Expected startup time improvement: ~75% faster`); + console.log("[ChatProvider] 🚀 Performance improvement with LAZY LOADING:"); + console.log(` - Immediate chatroom loading: ${chatrooms.length} → 1 (${((chatrooms.length - 1) / chatrooms.length * 100).toFixed(1)}% reduction)`); + console.log(` - Expected LCP improvement: ~80% faster (3.6s → <800ms)`); + console.log(` - Deferred chatrooms: ${chatrooms.length - 1} (will auto-load in background for notifications)`); + + // Auto-load deferred chatrooms in background after short delay (for mentions/notifications) + setTimeout(async () => { + console.log('[ChatProvider] Starting background load of deferred chatrooms...'); + await connectionManager.initializeDeferredChatroomsInBackground(); + }, 800); // 800ms delay to let priority chatroom render first try { const refreshedChatrooms = get().chatrooms; @@ -1624,8 +1568,8 @@ const useChatStore = create((set, get) => ({ // Connect to chatroom get().connectToChatroom(chatroom); - // Connect to 7TV WebSocket - get().connectToStvWebSocket(chatroom); + // Connect to 7TV WebSocket - DEPRECATED: Now handled by shared connections + // get().connectToStvWebSocket(chatroom); } }); }, @@ -1891,27 +1835,108 @@ const useChatStore = create((set, get) => ({ }, handleStvMessage: (chatroomId, eventDetail) => { - const { type, body } = eventDetail; + const { type, body, isPersonalEmoteSet } = eventDetail; + + // Deduplicate cosmetic events (they spam from WebSocket) + if (type === 'cosmetic.create' || type === 'entitlement.create' || type === 'entitlement.delete') { + let dedupKey; + + if (type === 'cosmetic.create') { + // cosmetic.create has raw 7TV structure: { object: { kind: "BADGE"|"PAINT", data: { id, ref_id, ... } } } + const cosmeticData = body?.object?.data; + const cosmeticKind = body?.object?.kind; + const rawId = cosmeticData?.id; + const refId = cosmeticData?.ref_id; + const sentinelId = "00000000000000000000000000"; + const dedupeId = rawId && rawId !== sentinelId ? rawId : (refId || rawId || body?.id || 'unknown'); + dedupKey = `${type}_${cosmeticKind}_${dedupeId}`; + } else { + // entitlement.create/delete have structure: { object: { user: { id }, ref_id, kind } } + const userId = body?.object?.user?.id || body?.user?.id; + const refId = body?.object?.ref_id; // ref_id is the actual badge/paint/emote_set ID + const eventId = body?.id; + dedupKey = `${type}_${userId}_${refId || eventId}`; + } + + const now = Date.now(); + const recentEvents = get().recentCosmeticEvents || new Map(); + + // Check if we've seen this event in the last 30 seconds + const lastSeen = recentEvents.get(dedupKey); + if (lastSeen && (now - lastSeen) < 30000) { + console.log("dupe skip", dedupKey) + return; // Skip duplicate + } + + // Update timestamp for this event + recentEvents.set(dedupKey, now); + + // Clean up old entries (older than 60 seconds) + for (const [key, timestamp] of recentEvents.entries()) { + if (now - timestamp > 60000) { + recentEvents.delete(key); + } + } + + set({ recentCosmeticEvents: recentEvents }); + } switch (type) { case "connection_established": break; case "emote_set.update": - get().handleEmoteSetUpdate(chatroomId, body); + get().handleEmoteSetUpdate(chatroomId, body, isPersonalEmoteSet); break; case "cosmetic.create": - useCosmeticsStore?.getState()?.addCosmetics(body); + useCosmeticsStore?.getState()?.addCosmetic(body); + break; + case "cosmetic.delete": + useCosmeticsStore?.getState()?.removeCosmetic(body); break; case "entitlement.create": { - const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; - const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); - useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); + const objectKind = body?.object?.kind; + + if (objectKind === "EMOTE_SET") { + // Handle personal emote set entitlement grants + get().handlePersonalEmoteSetEntitlement(body, "create"); + } else if (objectKind === "BADGE" || objectKind === "PAINT") { + // Handle badge/paint cosmetic entitlements + const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; + const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); + useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); + } else { + // Log unhandled objectKind to telemetry + const span = startSpan('seventv.unhandled_entitlement_create'); + span?.setAttributes?.({ + 'entitlement.object_kind': objectKind || 'unknown', + 'entitlement.user_id': body?.object?.user?.id || 'unknown', + 'entitlement.ref_id': body?.object?.ref_id || 'unknown' + }); + span?.end?.(); + } break; } case "entitlement.delete": { - const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; - const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); - useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); + const objectKind = body?.object?.kind; + + if (objectKind === "EMOTE_SET") { + // Handle personal emote set entitlement revocations + get().handlePersonalEmoteSetEntitlement(body, "delete"); + } else if (objectKind === "BADGE" || objectKind === "PAINT") { + // Handle badge/paint cosmetic entitlement removals + const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; + const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); + useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); + } else { + // Log unhandled objectKind to telemetry + const span = startSpan('seventv.unhandled_entitlement_delete'); + span?.setAttributes?.({ + 'entitlement.object_kind': objectKind || 'unknown', + 'entitlement.user_id': body?.object?.user?.id || 'unknown', + 'entitlement.ref_id': body?.object?.ref_id || 'unknown' + }); + span?.end?.(); + } break; } default: @@ -2150,8 +2175,8 @@ const useChatStore = create((set, get) => ({ // Connect to chatroom get().connectToChatroom(newChatroom); - // Connect to 7TV WebSocket - get().connectToStvWebSocket(newChatroom); + // Connect to 7TV WebSocket - DEPRECATED: Now handled by shared connections + // get().connectToStvWebSocket(newChatroom); // Save to local storage localStorage.setItem("chatrooms", JSON.stringify([...savedChatrooms, newChatroom])); @@ -2901,13 +2926,13 @@ const useChatStore = create((set, get) => ({ })); }, - handleEmoteSetUpdate: (chatroomId, body) => { + handleEmoteSetUpdate: (chatroomId, body, isPersonalEmoteSet) => { const updateSpan = startSpan('seventv.emote_set_update', { 'chatroom.id': chatroomId }); - + const startTime = performance.now(); - + if (!body) { updateSpan?.addEvent?.('empty_body_received'); endSpanOk(updateSpan); @@ -2915,15 +2940,51 @@ const useChatStore = create((set, get) => ({ } const { pulled = [], pushed = [], updated = [] } = body; - + updateSpan?.setAttributes?.({ 'emotes.pulled.count': pulled.length, 'emotes.pushed.count': pushed.length, 'emotes.updated.count': updated.length }); + // Use the isPersonalEmoteSet flag from the WebSocket layer + const isPersonalSetUpdate = isPersonalEmoteSet ?? false; + + // Handle personal emote set updates GLOBALLY (not per-chatroom) + if (isPersonalSetUpdate) { + const { pulled = [], pushed = [], updated = [] } = body || {}; + const updateSignature = JSON.stringify({ pulled, pushed, updated }); + + // Use a stable signature to prevent processing the same personal set update multiple times + const updateKey = `personal_${body?.id || 'unknown'}_${updateSignature}`; + if (get().recentPersonalUpdates?.has?.(updateKey)) { + updateSpan?.addEvent?.('personal_set_already_processed'); + endSpanOk(updateSpan); + return; + } + + // Mark this update as processed + const recentUpdates = get().recentPersonalUpdates || new Set(); + recentUpdates.add(updateKey); + + // Clean old update keys (keep only last 100) + if (recentUpdates.size > 100) { + const updatesArray = Array.from(recentUpdates); + updatesArray.slice(0, updatesArray.length - 100).forEach(key => recentUpdates.delete(key)); + } + + set({ recentPersonalUpdates: recentUpdates }); + + // Process personal emote set update once globally + get().handlePersonalEmoteSetUpdate(body, updateSpan); + return; + } + + // Handle channel-specific emote set updates const chatroom = get().chatrooms.find((room) => room.id === chatroomId); if (!chatroom) { + updateSpan?.addEvent?.('chatroom_not_found'); + endSpanOk(updateSpan); return; } @@ -2931,21 +2992,67 @@ const useChatStore = create((set, get) => ({ ? chatroom.channel7TVEmotes.find((set) => set.type === "channel") : null; - const personalEmoteSets = get().personalEmoteSets; + if (!channelEmoteSet?.emotes) { + updateSpan?.addEvent?.('no_channel_emotes'); + endSpanOk(updateSpan); + return; + } - // CheckIcon if we have either channel emotes OR this is a personal set update - const isPersonalSetUpdate = personalEmoteSets?.some((set) => body.id === set.setInfo?.id); - - if (!channelEmoteSet?.emotes && !isPersonalSetUpdate) { + // Check for duplicate channel updates (same body content being processed multiple times) + const updateKey = `channel_${body.id}_${chatroomId}_${JSON.stringify({ pulled, pushed, updated })}`; + const recentChannelUpdates = get().recentChannelUpdates || new Set(); + + if (recentChannelUpdates.has(updateKey)) { + updateSpan?.addEvent?.('channel_update_already_processed'); + console.log(`[7TV Dedup] Skipping duplicate channel update for ${chatroomId}`); + endSpanOk(updateSpan); return; } - let emotes = channelEmoteSet.emotes || []; - const isPersonalSetUpdated = isPersonalSetUpdate; + // Only apply aggressive deduplication if this emote set actually belongs to our chatrooms + const setId = body.id; + const allChatrooms = get().chatrooms || []; + const setActuallyExists = allChatrooms.some(room => + room.channel7TVEmotes?.some(set => set.setInfo?.id === setId) + ); - // Get the specific personal emote set being updated - const personalSetBeingUpdated = personalEmoteSets.find((set) => body.id === set.setInfo?.id); - let personalEmotes = isPersonalSetUpdated ? [...(personalSetBeingUpdated?.emotes || [])] : []; + // Always get recentSimpleUpdates for state management + const recentSimpleUpdates = get().recentSimpleChannelUpdates || new Set(); + + if (setActuallyExists) { + // Check for simpler deduplication based on emote set ID and timestamp (more aggressive) + if (recentSimpleUpdates.has(`simple_${setId}`)) { + updateSpan?.addEvent?.('channel_update_recently_processed'); + console.log(`[7TV Dedup] Skipping recently processed channel update for set ${setId}`); + endSpanOk(updateSpan); + return; + } + + // Mark as recently processed (expires in 5 seconds) + recentSimpleUpdates.add(`simple_${setId}`); + setTimeout(() => { + const currentState = get(); + const updates = currentState.recentSimpleChannelUpdates || new Set(); + updates.delete(`simple_${setId}`); + set({ recentSimpleChannelUpdates: updates }); + }, 5000); + } + + // Mark this update as processed + recentChannelUpdates.add(updateKey); + + // Clean old update keys (keep only last 200) + if (recentChannelUpdates.size > 200) { + const updatesArray = Array.from(recentChannelUpdates); + updatesArray.slice(0, updatesArray.length - 200).forEach(key => recentChannelUpdates.delete(key)); + } + + set({ recentChannelUpdates, recentSimpleChannelUpdates: recentSimpleUpdates }); + + // This is a channel-specific emote set update + let emotes = Array.isArray(channelEmoteSet?.emotes) + ? [...channelEmoteSet.emotes] + : []; // Track changes for update messages in chat const addedEmotes = []; @@ -2967,26 +3074,16 @@ const useChatStore = create((set, get) => ({ if (emoteId) { if (!emoteName) { - if (isPersonalSetUpdated) { - const emote = personalEmotes.find((emote) => emote.id === emoteId); - emoteName = emote?.name; - emoteOwner = emote?.owner; - } else { - const emote = emotes.find((emote) => emote.id === emoteId); - emoteName = emote?.name; - emoteOwner = emote?.owner; - } + const emote = emotes.find((emote) => emote.id === emoteId); + emoteName = emote?.name; + emoteOwner = emote?.owner; } - if (emoteName && !isPersonalSetUpdated) { + if (emoteName) { removedEmotes.push({ id: emoteId, name: emoteName, owner: emoteOwner }); } - if (isPersonalSetUpdated) { - personalEmotes = personalEmotes.filter((emote) => emote.id !== emoteId); - } else { - emotes = emotes.filter((emote) => emote.id !== emoteId); - } + emotes = emotes.filter((emote) => emote.id !== emoteId); } }); } @@ -2996,44 +3093,27 @@ const useChatStore = create((set, get) => ({ const { value } = pushedItem; const emoteName = value.name ? value.name : value.data?.name; - if (emoteName && !isPersonalSetUpdated) { + if (emoteName) { addedEmotes.push({ id: value.id, name: emoteName, owner: value.data?.owner }); } - if (isPersonalSetUpdated) { - const transformedEmote = { - id: value.id, - actor_id: value.actor_id, - flags: value.data?.flags || 0, - name: emoteName, - alias: value.data?.name !== value.name ? value?.data?.name : null, - owner: value.data?.owner, - file: value.data?.host.files?.[0] || value.data?.host.files?.[1], - added_timestamp: value.timestamp || Date.now(), - platform: "7tv", - type: "personal", - }; + // Remove any existing emote with the same ID first + emotes = emotes.filter((emote) => emote.id !== value.id); + + const transformedEmote = { + id: value.id, + actor_id: value.actor_id, + flags: value.data?.flags || 0, + name: emoteName, + alias: value.data?.name !== value.name ? value?.data?.name : null, + owner: value.data?.owner, + file: value.data?.host.files?.[0] || value.data?.host.files?.[1], + added_timestamp: value.timestamp || Date.now(), + platform: "7tv", + type: "channel", + }; - // Remove any existing emote with the same ID first - personalEmotes = personalEmotes.filter((emote) => emote.id !== value.id); - // Then add the new/updated emote - personalEmotes.push(transformedEmote); - } else { - // Remove any existing emote with the same ID first - emotes = emotes.filter((emote) => emote.id !== value.id); - // Then add the new emote - emotes.push({ - id: value.id, - actor_id: value.actor_id, - flags: value.data?.flags || 0, - name: emoteName, - alias: value.data?.name !== value.name ? value?.data?.name : null, - owner: value.data?.owner, - file: value.data?.host.files?.[0] || value.data?.host.files?.[1], - added_timestamp: value.timestamp || Date.now(), - platform: "7tv", - }); - } + emotes.push(transformedEmote); }); } @@ -3045,7 +3125,7 @@ const useChatStore = create((set, get) => ({ const oldName = old_value.name || old_value.data?.name; const newName = value.name ? value.name : value.data?.name; - if (oldName && newName && oldName !== newName && !isPersonalSetUpdated) { + if (oldName && newName && oldName !== newName) { updatedEmotes.push({ id: old_value.id, oldName, @@ -3055,41 +3135,24 @@ const useChatStore = create((set, get) => ({ }); } - if (isPersonalSetUpdated) { - personalEmotes = personalEmotes.filter((e) => e.id !== old_value.id); - - const transformedEmote = { - id: value.id, - actor_id: value.actor_id, - flags: value.data?.flags || 0, - name: newName, - alias: value.data?.name !== value.name ? value?.data?.name : null, - owner: value.data?.owner, - file: value.data?.host.files?.[0] || value.data?.host.files?.[1], - added_timestamp: value.timestamp || Date.now(), - platform: "7tv", - type: "personal", - }; - - personalEmotes.push(transformedEmote); - } else { - emotes = emotes.filter((e) => e.id !== old_value.id); - - emotes.push({ - id: value.id, - actor_id: value.actor_id, - flags: value.data?.flags || 0, - name: newName, - alias: value.data?.name !== value.name ? value?.data?.name : null, - owner: value.data?.owner, - file: value.data?.host.files?.[0] || value.data?.host.files?.[1], - platform: "7tv", - }); - } + // Update channel emote + emotes = emotes.filter((e) => e.id !== old_value.id); + + emotes.push({ + id: value.id, + actor_id: value.actor_id, + flags: value.data?.flags || 0, + name: newName, + alias: value.data?.name !== value.name ? value?.data?.name : null, + owner: value.data?.owner, + file: value.data?.host.files?.[0] || value.data?.host.files?.[1], + added_timestamp: value.timestamp || Date.now(), + platform: "7tv", + type: "channel", + }); }); } - personalEmotes = [...personalEmotes].sort((a, b) => a.name.localeCompare(b.name)); emotes = [...emotes].sort((a, b) => a.name.localeCompare(b.name)); @@ -3102,20 +3165,20 @@ const useChatStore = create((set, get) => ({ addedEmotes.length, removedEmotes.length, updatedEmotes.length, - isPersonalSetUpdated ? 'personal' : 'channel' + 'channel' ); - + updateSpan?.addEvent?.('emote_changes_detected', { 'emotes.added': addedEmotes.length, 'emotes.removed': removedEmotes.length, 'emotes.updated': updatedEmotes.length, - 'set.type': isPersonalSetUpdated ? 'personal' : 'channel' + 'set.type': 'channel' }); } catch (error) { console.warn('[Telemetry] Failed to record emote changes:', error); } - const setInfo = isPersonalSetUpdated ? personalSetBeingUpdated?.setInfo : channelEmoteSet?.setInfo; + const setInfo = channelEmoteSet?.setInfo; if (body?.actor) { get().addMessage(chatroomId, { @@ -3123,8 +3186,8 @@ const useChatStore = create((set, get) => ({ type: "stvEmoteSetUpdate", timestamp: new Date().toISOString(), data: { - setType: isPersonalSetUpdated ? "personal" : "channel", - setName: setInfo?.name || (isPersonalSetUpdated ? "Personal" : "Channel"), + setType: "channel", + setName: setInfo?.name || "Channel", typeOfUpdate: addedEmotes.length > 0 ? "added" : removedEmotes.length > 0 ? "removed" : "updated", setId: body.id, authoredBy: body?.actor || null, @@ -3136,24 +3199,8 @@ const useChatStore = create((set, get) => ({ } } - // Update personal emote sets if this was a personal set update - if (isPersonalSetUpdated) { - const updatedPersonalSets = personalEmoteSets.map((set) => { - if (body.id === set.setInfo?.id) { - return { - ...set, - emotes: personalEmotes, - }; - } - return set; - }); - - set({ personalEmoteSets: [...updatedPersonalSets] }); - localStorage.setItem("stvPersonalEmoteSets", JSON.stringify([...updatedPersonalSets])); - return; // Don't update channel emotes if this was a personal set update - } + // Update channel emotes - let updatedChannel7TVEmotes; if (Array.isArray(chatroom.channel7TVEmotes)) { updatedChannel7TVEmotes = chatroom.channel7TVEmotes.map((set) => (set.type === "channel" ? { ...set, emotes } : set)); @@ -3186,9 +3233,6 @@ const useChatStore = create((set, get) => ({ // Clear emote cache to ensure new emotes are loaded from updated store clearChatroomEmoteCache(chatroomId); - // Refresh emote data to get the updated emote set - get().refresh7TVEmotes(chatroomId); - try { const processingDuration = performance.now() - startTime; // Record emote update metrics via IPC @@ -3209,20 +3253,222 @@ const useChatStore = create((set, get) => ({ } }, + handlePersonalEmoteSetUpdate: (body, updateSpan) => { + const startTime = performance.now(); + + if (!body) { + updateSpan?.addEvent?.('empty_personal_body_received'); + endSpanOk(updateSpan); + return; + } + + const { pulled = [], pushed = [], updated = [] } = body; + + updateSpan?.addEvent?.('processing_personal_emote_update', { + 'emotes.pulled.count': pulled.length, + 'emotes.pushed.count': pushed.length, + 'emotes.updated.count': updated.length, + 'set.id': body.id + }); + + const personalEmoteSetsRaw = get().personalEmoteSets; + const personalEmoteSets = Array.isArray(personalEmoteSetsRaw) ? personalEmoteSetsRaw : []; + + // Get the specific personal emote set being updated + const personalSetBeingUpdated = personalEmoteSets.find((set) => body.id === set.setInfo?.id); + + if (!personalSetBeingUpdated) { + updateSpan?.addEvent?.('personal_set_not_found', { 'set.id': body.id }); + endSpanOk(updateSpan); + return; + } + + let personalEmotes = [...(personalSetBeingUpdated?.emotes || [])]; + + // Track changes for telemetry + let addedCount = 0; + let removedCount = 0; + let updatedCount = 0; + + // Process pulled (removed) emotes + if (pulled.length > 0) { + pulled.forEach((pulledItem) => { + let emoteId = null; + if (typeof pulledItem === "string") { + emoteId = pulledItem; + } else if (pulledItem?.old_value?.id) { + emoteId = pulledItem.old_value.id; + } + + if (emoteId) { + const beforeLength = personalEmotes.length; + personalEmotes = personalEmotes.filter((emote) => emote.id !== emoteId); + if (personalEmotes.length < beforeLength) { + removedCount++; + } + } + }); + } + + // Process pushed (added) emotes + if (pushed.length > 0) { + pushed.forEach((pushedItem) => { + const { value } = pushedItem; + if (!value) return; + + const emoteName = value.name || value.data?.name; + if (!emoteName) return; + + // Remove any existing emote with the same ID first + personalEmotes = personalEmotes.filter((emote) => emote.id !== value.id); + + const transformedEmote = { + id: value.id, + actor_id: value.actor_id, + flags: value.data?.flags || 0, + name: emoteName, + alias: value.data?.name !== value.name ? value?.data?.name : null, + owner: value.data?.owner, + file: value.data?.host.files?.[0] || value.data?.host.files?.[1], + added_timestamp: value.timestamp || Date.now(), + platform: "7tv", + type: "personal", + }; + + personalEmotes.push(transformedEmote); + addedCount++; + }); + } + + // Process updated (renamed) emotes + if (updated.length > 0) { + updated.forEach((emote) => { + const { old_value, value } = emote; + if (!old_value?.id || !value?.id) return; + + const newName = value.name || value.data?.name; + if (!newName) return; + + // Remove old emote and add updated one + personalEmotes = personalEmotes.filter((e) => e.id !== old_value.id); + + const transformedEmote = { + id: value.id, + actor_id: value.actor_id, + flags: value.data?.flags || 0, + name: newName, + alias: value.data?.name !== value.name ? value?.data?.name : null, + owner: value.data?.owner, + file: value.data?.host.files?.[0] || value.data?.host.files?.[1], + added_timestamp: value.timestamp || Date.now(), + platform: "7tv", + type: "personal", + }; + + personalEmotes.push(transformedEmote); + updatedCount++; + }); + } + + // Sort personal emotes + personalEmotes = personalEmotes.sort((a, b) => a.name.localeCompare(b.name)); + + // Update personal emote sets globally + const updatedPersonalSets = personalEmoteSets.map((set) => { + if (body.id === set.setInfo?.id) { + return { + ...set, + emotes: personalEmotes, + }; + } + return set; + }); + + set({ personalEmoteSets: [...updatedPersonalSets] }); + localStorage.setItem("stvPersonalEmoteSets", JSON.stringify([...updatedPersonalSets])); + + // Record telemetry for personal emote set update + try { + const processingDuration = performance.now() - startTime; + + // Record the processing as a single update operation + window.app?.telemetry?.recordSevenTVEmoteUpdate?.( + 'personal_global', // Use a special ID for personal emotes + pulled.length, + pushed.length, + updated.length, + processingDuration + ); + + // Record specific change counts + if (addedCount > 0 || removedCount > 0 || updatedCount > 0) { + window.app?.telemetry?.recordSevenTVEmoteChanges?.( + 'personal_global', + addedCount, + removedCount, + updatedCount, + 'personal' + ); + } + + updateSpan?.addEvent?.('personal_emote_update_completed', { + 'processing.duration_ms': processingDuration, + 'emotes.added': addedCount, + 'emotes.removed': removedCount, + 'emotes.updated': updatedCount + }); + endSpanOk(updateSpan); + } catch (error) { + console.warn('[Telemetry] Failed to record personal emote update:', error); + endSpanError(updateSpan, error); + } + }, + + handlePersonalEmoteSetEntitlement: (body, action) => { + // Handle entitlement.create/delete for EMOTE_SET kind + // These events notify us when ANY user gains/loses access to a personal emote set + // This is important for rendering other users' personal emotes in chat + + const emoteSetId = body?.object?.ref_id; + const userId = body?.object?.user?.id; + const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; + + if (!emoteSetId) { + console.warn('[7TV Entitlement] Missing emote set ID in entitlement event', body); + return; + } + + const currentUserStvId = localStorage.getItem('stvId'); + const isCurrentUser = userId === currentUserStvId; + + console.log(`[7TV Entitlement] ${action} for ${isCurrentUser ? 'current user' : username || userId}, emote set: ${emoteSetId}`); + + // TODO: Implement full handler (see GitHub issue #48) + // For current user's personal emote sets: + // - 'create': fetch emote set and add to personalEmoteSets state + // - 'delete': remove from personalEmoteSets state + // For other users' personal emote sets: + // - Need separate tracking system (userEmoteSets map?) to render their emotes in chat + }, + refresh7TVEmotes: async (chatroomId) => { try { const chatroom = get().chatrooms.find((room) => room.id === chatroomId); - if (!chatroom || chatroom?.last7TVSetUpdated > dayjs().subtract(30, "second").toISOString()) return; + if (!chatroom) return; - // System message starting refresh - get().addMessage(chatroomId, { - id: crypto.randomUUID(), - type: "system", - content: "Refreshing 7TV emotes...", - timestamp: new Date().toISOString(), - }); + if (refreshingStvSets.has(chatroomId)) { + console.log(`[7TV Refresh] Skipping refresh for ${chatroom.username} (already in progress)`); + return; + } - // Fetch new emote sets + if (chatroom?.last7TVSetUpdated && dayjs(chatroom.last7TVSetUpdated).isAfter(dayjs().subtract(10, "minute"))) { + return; + } + + refreshingStvSets.add(chatroomId); + + // NON-BLOCKING: Fetch new emote sets in background without blocking UI + console.log(`[7TV Refresh] Starting non-blocking emote refresh for ${chatroom.username}`); const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id); // Update local storage and state @@ -3245,23 +3491,13 @@ const useChatStore = create((set, get) => ({ // Clear emote cache to ensure refreshed emotes are loaded clearChatroomEmoteCache(chatroomId); - // Send system message on success - get().addMessage(chatroomId, { - id: crypto.randomUUID(), - type: "system", - content: "7TV emotes refreshed successfully!", - timestamp: new Date().toISOString(), - }); + console.log(`[7TV Refresh] ✅ Successfully refreshed ${channel7TVEmotes.length} emote sets for ${chatroom.username}`); } } catch (error) { console.error("[7TV Refresh]: Error refreshing emotes:", error); - // Send system message on error - get().addMessage(chatroomId, { - id: crypto.randomUUID(), - type: "system", - content: "Failed to refresh 7TV emotes. Please try again.", - timestamp: new Date().toISOString(), - }); + // Only log error, don't show intrusive UI messages + } finally { + refreshingStvSets.delete(chatroomId); } }, @@ -3497,6 +3733,21 @@ const useChatStore = create((set, get) => ({ // Set the current active chatroom setCurrentChatroom: (chatroomId) => { set({ currentChatroomId: chatroomId }); + + // Remember the last active chatroom for future sessions + if (chatroomId) { + localStorage.setItem('lastActiveChatroomId', String(chatroomId)); + } + + // Trigger lazy loading if this chatroom hasn't been loaded yet + if (chatroomId && connectionManager) { + if (!connectionManager.isChatroomLoaded(chatroomId)) { + console.log(`[ChatProvider] Triggering lazy load for chatroom: ${chatroomId}`); + connectionManager.initializeChatroomLazily(chatroomId).catch(error => { + console.error(`[ChatProvider] Failed to lazy-load chatroom ${chatroomId}:`, error); + }); + } + } }, // Mentions Tab Management @@ -4033,13 +4284,36 @@ if (window.location.pathname === "/" || window.location.pathname.endsWith("index console.log("[7tv Presence]: Initializing presence update checks"); presenceUpdatesInterval = setInterval( () => { - const chatrooms = useChatStore.getState()?.chatrooms; + const store = useChatStore.getState(); + const chatrooms = store?.chatrooms; if (chatrooms?.length === 0) return; - chatrooms.forEach((chatroom) => { - console.log("[7tv Presence]: Sending presence check for chatroom:", chatroom.streamerData.user_id); - useChatStore.getState().sendPresenceUpdate(storeStvId, chatroom.streamerData.user_id); - }); + // Process updates in small batches to avoid blocking + const processBatch = (startIndex = 0) => { + const batchSize = 3; + const currentTime = new Date().toISOString(); + const updatedRooms = []; + + for (let i = startIndex; i < Math.min(startIndex + batchSize, chatrooms.length); i++) { + const chatroom = chatrooms[i]; + const sentAt = store.sendPresenceUpdate(storeStvId, chatroom.streamerData.user_id); + if (sentAt) { + updatedRooms.push( + `${chatroom.id}->${chatroom.streamerData.user_id}(${chatroom.streamerData?.user?.username || chatroom.username})@${currentTime}`, + ); + } + } + + // Continue with next batch if not finished + if (startIndex + batchSize < chatrooms.length) { + setTimeout(() => processBatch(startIndex + batchSize), 1); + } else if (updatedRooms.length > 0) { + // Log only after all batches complete (simplified logging) + console.log(`[7tv Presence]: ${currentTime} sent presence updates for ${chatrooms.length} chatrooms`); + } + }; + + setTimeout(() => processBatch(0), 0); }, 1 * 60 * 1000, ); diff --git a/src/renderer/src/providers/CosmeticsProvider.jsx b/src/renderer/src/providers/CosmeticsProvider.jsx index 609a576..8cbd9d8 100644 --- a/src/renderer/src/providers/CosmeticsProvider.jsx +++ b/src/renderer/src/providers/CosmeticsProvider.jsx @@ -1,11 +1,155 @@ import { create } from "zustand"; +// Telemetry helpers +const getRendererTracer = () => + (typeof window !== 'undefined' && (window.__KT_TRACER__ || window.__KT_TRACE_API__?.trace?.getTracer?.('kicktalk-renderer'))) || null; + +const startSpan = (name, attributes = {}) => { + try { + const tracer = getRendererTracer(); + if (!tracer || typeof tracer.startSpan !== 'function') return null; + const span = tracer.startSpan(name); + if (span && typeof span.setAttributes === 'function') { + span.setAttributes(attributes); + } + return span; + } catch { + return null; + } +}; + +const INVALID_7TV_NULL_ID = "00000000000000000000000000"; + +const normalizeCosmeticId = (id, refId) => { + if (!id && refId) return refId; + if (id === INVALID_7TV_NULL_ID) return refId || "default_id"; + return id; +}; + +const argbToRgba = (color) => { + if (typeof color !== "number") return null; + if (color < 0) { + color = color >>> 0; + } + + const red = (color >> 24) & 0xff; + const green = (color >> 16) & 0xff; + const blue = (color >> 8) & 0xff; + const alpha = color & 0xff; + const normalizedAlpha = Number.isFinite(alpha) ? Math.min(Math.max(alpha / 255, 0), 1) : 1; + const alphaString = normalizedAlpha === 1 ? "1" : normalizedAlpha.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + + return `rgba(${red}, ${green}, ${blue}, ${alphaString || "0"})`; +}; + +const buildBadgeFromData = (data = {}) => { + const badgeId = normalizeCosmeticId(data.id, data.ref_id); + if (!badgeId) return null; + + const host = data.host; + let badgeUrl = data.url; + + if (!badgeUrl && host?.url && Array.isArray(host?.files) && host.files.length > 0) { + badgeUrl = `https:${host.url}/${host.files[host.files.length - 1].name}`; + } + + return { + id: badgeId, + title: data.tooltip || data.title || data.name || "", + url: badgeUrl || null, + }; +}; + +const buildPaintFromData = (data = {}) => { + const paintId = normalizeCosmeticId(data.id, data.ref_id); + if (!paintId) return null; + + const stops = Array.isArray(data.stops) ? data.stops : []; + const shadows = Array.isArray(data.shadows) ? data.shadows : []; + + const rawFunction = (data.function || "linear-gradient").toLowerCase().replace(/_/g, "-"); + const gradientFunction = data.repeat ? `repeating-${rawFunction}` : rawFunction; + const isLinear = gradientFunction === "linear-gradient" || gradientFunction === "repeating-linear-gradient"; + const firstArgument = isLinear ? `${typeof data.angle === "number" ? data.angle : 0}deg` : (data.shape || ""); + + const gradientStops = stops + .map((stop) => { + const colorValue = typeof stop.color === "string" ? stop.color : argbToRgba(stop.color); + if (!colorValue) return null; + const atPercent = typeof stop.at === "number" ? Math.max(0, Math.min(100, stop.at * 100)) : 0; + return `${colorValue} ${atPercent}%`; + }) + .filter(Boolean) + .join(", "); + + let backgroundImage; + + if (gradientStops) { + const firstSegment = firstArgument ? `${firstArgument}, ` : ""; + backgroundImage = `${gradientFunction}(${firstSegment}${gradientStops})`; + } else if (data.image_url) { + backgroundImage = `url('${data.image_url}')`; + } else if (data.color) { + const fallback = typeof data.color === "string" ? data.color : argbToRgba(data.color) || "rgba(255, 255, 255, 1)"; + backgroundImage = `linear-gradient(0deg, ${fallback}, ${fallback})`; + } else { + backgroundImage = "linear-gradient(0deg, rgba(255, 255, 255, 1), rgba(255, 255, 255, 1))"; + } + + const dropShadows = shadows.length + ? shadows + .map((shadow) => { + let rgbaColor = typeof shadow.color === "string" ? shadow.color : argbToRgba(shadow.color); + if (!rgbaColor) return null; + rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), ([0-9.]+)\)/, "rgba($1, $2, $3)"); + return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; + }) + .filter(Boolean) + .join(" ") + : null; + + return { + id: paintId, + name: data.name, + backgroundImage, + shadows: dropShadows, + style: gradientFunction, + shape: data.shape, + url: data.image_url || null, + }; +}; + +const normalizePaintEntry = (paint) => { + if (!paint) return null; + + if (paint.backgroundImage && paint.id) { + const normalizedId = normalizeCosmeticId(paint.id, paint.ref_id); + if (!normalizedId) return null; + + return { + id: normalizedId, + name: paint.name, + backgroundImage: paint.backgroundImage, + shadows: paint.shadows || null, + style: paint.style, + shape: paint.shape, + url: paint.url || paint.image_url || null, + }; + } + + return buildPaintFromData(paint); +}; + const useCosmeticsStore = create((set, get) => ({ userStyles: {}, globalCosmetics: { badges: [], paints: [], }, + cosmeticsLookup: { + badgeMap: new Map(), + paintMap: new Map(), + }, addUserStyle: async (username, body) => { if (!body?.object?.user?.style) return; @@ -86,8 +230,9 @@ const useCosmeticsStore = create((set, get) => ({ if (!userStyle?.badgeId && !userStyle?.paintId) return null; - const badge = get().globalCosmetics?.badges?.find((b) => b.id === userStyle.badgeId); - const paint = get().globalCosmetics?.paints?.find((p) => p.id === userStyle.paintId); + const { badgeMap, paintMap } = get().cosmeticsLookup; + const badge = userStyle.badgeId ? badgeMap.get(userStyle.badgeId) : null; + const paint = userStyle.paintId ? paintMap.get(userStyle.paintId) : null; return { badge, @@ -97,15 +242,166 @@ const useCosmeticsStore = create((set, get) => ({ }; }, + addCosmetic: (body) => { + // Handle individual cosmetic.create events with raw 7TV structure + // body = { object: { kind: "BADGE"|"PAINT"|"AVATAR", data: {...} } } + if (!body?.object) return; + + const { object } = body; + const kind = object.kind; + + // Skip cosmetics that have a user (those are entitlements, not global cosmetics) + if (object.user) return; + + set((state) => { + const newBadges = [...state.globalCosmetics.badges]; + const newPaints = [...state.globalCosmetics.paints]; + const newBadgeMap = new Map(state.cosmeticsLookup.badgeMap); + const newPaintMap = new Map(state.cosmeticsLookup.paintMap); + + if (kind === "BADGE") { + const data = object.data; + const badge = buildBadgeFromData(data); + if (!badge) return state; + + // Skip if already exists + if (newBadgeMap.has(badge.id)) return state; + + newBadges.push(badge); + newBadgeMap.set(badge.id, badge); + } else if (kind === "PAINT") { + const paint = buildPaintFromData(object.data); + if (!paint) return state; + + // Skip if already exists + if (newPaintMap.has(paint.id)) return state; + + newPaints.push(paint); + newPaintMap.set(paint.id, paint); + } else { + // Log unhandled cosmetic kinds (e.g., AVATAR) to telemetry + const span = startSpan('seventv.unhandled_cosmetic_create'); + span?.setAttributes?.({ + 'cosmetic.kind': kind || 'unknown', + 'cosmetic.id': object.data?.id || 'unknown', + 'cosmetic.has_user': !!object.user + }); + span?.end?.(); + } + + return { + globalCosmetics: { + badges: newBadges, + paints: newPaints, + }, + cosmeticsLookup: { + badgeMap: newBadgeMap, + paintMap: newPaintMap, + }, + }; + }); + }, + + removeCosmetic: (body) => { + // Handle individual cosmetic.delete events with raw 7TV structure + // body = { object: { kind: "BADGE"|"PAINT"|"AVATAR", data: {...} } } + if (!body?.object) return; + + const { object } = body; + const kind = object.kind; + + // Skip cosmetics that have a user (those are entitlements, not global cosmetics) + if (object.user) return; + + set((state) => { + const data = object.data; + const cosmeticId = normalizeCosmeticId(data?.id, data?.ref_id); + + if (!cosmeticId) return state; + + if (kind === "BADGE") { + const newBadgeMap = new Map(state.cosmeticsLookup.badgeMap); + newBadgeMap.delete(cosmeticId); + + const newBadges = state.globalCosmetics.badges.filter(b => b.id !== cosmeticId); + + return { + globalCosmetics: { + badges: newBadges, + paints: state.globalCosmetics.paints, + }, + cosmeticsLookup: { + badgeMap: newBadgeMap, + paintMap: state.cosmeticsLookup.paintMap, + }, + }; + } else if (kind === "PAINT") { + const newPaintMap = new Map(state.cosmeticsLookup.paintMap); + newPaintMap.delete(cosmeticId); + + const newPaints = state.globalCosmetics.paints.filter(p => p.id !== cosmeticId); + + return { + globalCosmetics: { + badges: state.globalCosmetics.badges, + paints: newPaints, + }, + cosmeticsLookup: { + badgeMap: state.cosmeticsLookup.badgeMap, + paintMap: newPaintMap, + }, + }; + } else { + // Log unhandled cosmetic kinds (e.g., AVATAR) to telemetry + const span = startSpan('seventv.unhandled_cosmetic_delete'); + span?.setAttributes?.({ + 'cosmetic.kind': kind || 'unknown', + 'cosmetic.id': cosmeticId || 'unknown', + 'cosmetic.has_user': !!object.user + }); + span?.end?.(); + return state; + } + }); + }, + addCosmetics: (body) => { set(() => { - const newState = { + // Create lookup maps for O(1) access + const badgeMap = new Map(); + const paintMap = new Map(); + + if (body.badges) { + body.badges + .map(buildBadgeFromData) + .filter(Boolean) + .forEach((badge) => { + badgeMap.set(badge.id, badge); + }); + } + + if (body.paints) { + body.paints + .map(normalizePaintEntry) + .filter(Boolean) + .forEach((paint) => { + paintMap.set(paint.id, paint); + }); + } + + const badges = Array.from(badgeMap.values()); + const paints = Array.from(paintMap.values()); + + return { globalCosmetics: { - ...body, + badges, + paints, + }, + cosmeticsLookup: { + badgeMap, + paintMap, }, }; - - return newState; }); }, @@ -115,7 +411,7 @@ const useCosmeticsStore = create((set, get) => ({ const userStyle = get().userStyles[transformedUsername]; if (!userStyle?.badgeId) return null; - return get().globalCosmetics[userStyle.badgeId]; + return get().cosmeticsLookup.badgeMap.get(userStyle.badgeId); }, getUserPaint: (username) => { @@ -124,7 +420,7 @@ const useCosmeticsStore = create((set, get) => ({ const userStyle = get().userStyles[transformedUsername]; if (!userStyle?.paintId) return null; - return get().globalCosmetics[userStyle.paintId]; + return get().cosmeticsLookup.paintMap.get(userStyle.paintId); }, })); diff --git a/src/renderer/src/telemetry/webTracing.js b/src/renderer/src/telemetry/webTracing.js index c81276f..425becd 100644 --- a/src/renderer/src/telemetry/webTracing.js +++ b/src/renderer/src/telemetry/webTracing.js @@ -185,6 +185,92 @@ if (telemetryEnabled) { } } +// Console Error Instrumentation - Capture console.error/warn and send to telemetry +if (telemetryEnabled && typeof window !== 'undefined') { + try { + if (!window.__KT_CONSOLE_INSTRUMENTED__) { + window.__KT_CONSOLE_INSTRUMENTED__ = true; + console.log('[Renderer OTEL]: Installing console error instrumentation'); + + // Store original console methods + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + // Wrap console.error + console.error = function(...args) { + // Call original console.error first + originalConsoleError.apply(console, args); + + // Send to telemetry + try { + // Create error message from arguments + const errorMessage = args.map(arg => + typeof arg === 'string' ? arg : + arg instanceof Error ? arg.message : + JSON.stringify(arg) + ).join(' '); + + // Send to telemetry if available + if (typeof window.app?.telemetry?.recordError === 'function') { + const error = new Error(errorMessage); + error.stack = (new Error()).stack; // Get current stack + + window.app.telemetry.recordError(error, { + source: 'console.error', + operation: 'console_error_capture', + arguments: args.length, + timestamp: Date.now() + }); + } + } catch (telemetryError) { + // Don't break console.error if telemetry fails + originalConsoleError('[Console Instrumentation]: Telemetry error:', telemetryError.message); + } + }; + + // Wrap console.warn for high-severity warnings + console.warn = function(...args) { + // Call original console.warn first + originalConsoleWarn.apply(console, args); + + // Send critical warnings to telemetry (7TV, SLO violations, etc.) + try { + const warnMessage = args.join(' '); + const isCriticalWarning = warnMessage.includes('[7TV') || + warnMessage.includes('[SLO') || + warnMessage.includes('VIOLATION') || + warnMessage.includes('CRITICAL'); + + if (isCriticalWarning && typeof window.app?.telemetry?.recordError === 'function') { + const warning = new Error(`Warning: ${warnMessage}`); + warning.stack = (new Error()).stack; + + window.app.telemetry.recordError(warning, { + source: 'console.warn', + operation: 'console_warn_capture', + severity: 'warning', + arguments: args.length, + timestamp: Date.now() + }); + } + } catch (telemetryError) { + // Don't break console.warn if telemetry fails + } + }; + + // Store original methods for restoration + window.__KT_ORIGINAL_CONSOLE_ERROR__ = originalConsoleError; + window.__KT_ORIGINAL_CONSOLE_WARN__ = originalConsoleWarn; + + console.log('[Renderer OTEL]: Console error instrumentation installed successfully'); + } else { + console.log('[Console Instrumentation]: Already instrumented'); + } + } catch (err) { + console.error('[Console Instrumentation]: Failed to install:', err.message); + } +} + // If telemetry is disabled, ensure any previous wrapper is restored if (!telemetryEnabled && typeof window !== 'undefined') { try { @@ -192,6 +278,11 @@ if (!telemetryEnabled && typeof window !== 'undefined') { window.WebSocket = window.__KT_ORIGINAL_WEBSOCKET__; console.log('[WebSocket Instrumentation]: Telemetry disabled - restored native WebSocket'); } + if (window.__KT_ORIGINAL_CONSOLE_ERROR__ && console.error !== window.__KT_ORIGINAL_CONSOLE_ERROR__) { + console.error = window.__KT_ORIGINAL_CONSOLE_ERROR__; + console.warn = window.__KT_ORIGINAL_CONSOLE_WARN__; + console.log('[Console Instrumentation]: Telemetry disabled - restored native console methods'); + } } catch {} } @@ -1211,15 +1302,17 @@ if (!window.__KT_RENDERER_OTEL_INITIALIZED__ && telemetryEnabled) { console.warn('[Renderer OTEL]: Failed to read early WebSocket activity', e?.message || e); } - // Immediately emit a test span to trigger exporter - try { - const testTracer = trace.getTracer('kicktalk-renderer'); - const s = testTracer.startSpan('renderer_export_smoke'); - s.end(); - if (typeof provider.forceFlush === 'function') { - provider.forceFlush().catch(() => {}); - } - } catch {} + // DEV-ONLY: Emit a test span to verify exporter pipeline in development + if (process.env.NODE_ENV === 'development') { + try { + const testTracer = trace.getTracer('kicktalk-renderer'); + const s = testTracer.startSpan('renderer_export_smoke'); + s.end(); + if (typeof provider.forceFlush === 'function') { + provider.forceFlush().catch(() => {}); + } + } catch {} + } // Expose provider globally for diagnostics/flush and add periodic flush try { diff --git a/utils/services/connectionManager.js b/utils/services/connectionManager.js index d7a343e..f23e605 100644 --- a/utils/services/connectionManager.js +++ b/utils/services/connectionManager.js @@ -28,6 +28,8 @@ class ConnectionManager { this.emoteCache = new Map(); // Cache for global/common emotes this.globalStvEmotesCache = null; // Cache for global 7TV emotes this.channelStvEmoteCache = new Map(); // Cache for channel-specific 7TV emotes + this.deferredChatrooms = []; // Store chatrooms for lazy loading + this.loadedChatrooms = new Set(); // Track which chatrooms are fully loaded // Callbacks to avoid circular imports this.storeCallbacks = null; @@ -327,6 +329,9 @@ class ConnectionManager { await this.fetchInitialChatroomInfo(chatroom); span.addEvent('chatroom_info_fetch_complete'); + // Mark chatroom as loaded so lazy loader skips it + this.loadedChatrooms.add(chatroom.id); + console.log(`[ConnectionManager] Added chatroom ${chatroom.id} (${chatroom.streamerData?.user?.username})`); span.addEvent('chatroom_added_successfully'); span.setStatus({ code: 1 }); // SUCCESS @@ -334,6 +339,10 @@ class ConnectionManager { console.error(`[ConnectionManager] Error adding chatroom ${chatroom.id}:`, error); span.recordException(error); span.setStatus({ code: 2, message: error.message }); // ERROR + + if (this.loadedChatrooms.has(chatroom.id)) { + this.loadedChatrooms.delete(chatroom.id); + } } finally { span.end(); } @@ -358,6 +367,17 @@ class ConnectionManager { span.addEvent('batch_emote_fetch_started'); try { + const pendingChatrooms = chatrooms.filter((chatroom) => { + const cacheKey = `${chatroom.streamerData?.slug}`; + return !this.emoteCache.has(cacheKey); + }); + + if (pendingChatrooms.length === 0) { + span.addEvent('batch_skipped_all_cached'); + span.setStatus({ code: 1 }); + return; + } + // Fetch global 7TV emotes first (cached) span.addEvent('global_7tv_emotes_fetch_start'); await this.fetchGlobalStvEmotes(); @@ -365,7 +385,7 @@ class ConnectionManager { // Batch fetch channel-specific emotes span.addEvent('channel_emotes_batch_preparation_start'); - const emoteFetchPromises = chatrooms.map(chatroom => + const emoteFetchPromises = pendingChatrooms.map(chatroom => this.fetchChatroomEmotes(chatroom) ); @@ -438,7 +458,7 @@ class ConnectionManager { }); if (this.globalStvEmotesCache) { - console.log("[ConnectionManager] Using cached global 7TV emotes"); + console.log("[ConnectionManager] ✅ Using cached global 7TV emotes (cache hit)"); span.addEvent('cache_hit'); span.setStatus({ code: 1 }); // SUCCESS span.end(); @@ -446,19 +466,64 @@ class ConnectionManager { } try { - // Fetch global 7TV emotes (implementation would depend on your existing API) - // This is a placeholder - you'd implement the actual API call - console.log("[ConnectionManager] Fetching global 7TV emotes..."); + console.log("[ConnectionManager] 🚀 Fetching global 7TV emotes for first time..."); span.addEvent('api_fetch_start'); - - // const globalEmotes = await window.app.seventv.getGlobalEmotes(); - // this.globalStvEmotesCache = globalEmotes; - - console.log("[ConnectionManager] Global 7TV emotes cached"); - span.addEvent('cache_stored'); + + // Use axios directly since we don't have the window.app.seventv API + const axios = (await import('axios')).default; + const globalResponse = await axios.get(`https://7tv.io/v3/emote-sets/global`); + + if (globalResponse.status !== 200) { + throw new Error(`Error fetching Global 7TV Emotes. Status: ${globalResponse.status}`); + } + + const emoteGlobalData = globalResponse?.data; + + if (emoteGlobalData) { + // Format the global emotes in the expected structure + const formattedGlobalEmotes = [ + { + setInfo: { + id: emoteGlobalData.id, + name: emoteGlobalData.name, + emote_count: emoteGlobalData.emote_count, + capacity: emoteGlobalData.capacity, + }, + emotes: emoteGlobalData.emotes.map((emote) => { + return { + id: emote.id, + actor_id: emote.actor_id, + flags: emote.flags, + name: emote.name, + alias: emote.data.name !== emote.name ? emote.data.name : null, + owner: emote.data.owner, + file: emote.data.host.files?.[0] || emote.data.host.files?.[1], + added_timestamp: emote.timestamp, + platform: "7tv", + type: "global", + }; + }), + type: "global", + }, + ]; + + // Cache the result + this.globalStvEmotesCache = formattedGlobalEmotes; + + console.log(`[ConnectionManager] ✅ Global 7TV emotes cached successfully (${emoteGlobalData.emotes.length} emotes)`); + span.addEvent('cache_stored'); + span.setAttributes({ + 'emote.count': emoteGlobalData.emotes.length, + 'emote.set_id': emoteGlobalData.id, + 'emote.set_name': emoteGlobalData.name + }); + } + span.setStatus({ code: 1 }); // SUCCESS + return this.globalStvEmotesCache; + } catch (error) { - console.error("[ConnectionManager] Error fetching global 7TV emotes:", error); + console.error("[ConnectionManager] ❌ Error fetching global 7TV emotes:", error); span.recordException(error); span.setStatus({ code: 2, message: error.message }); // ERROR } finally { @@ -553,7 +618,12 @@ class ConnectionManager { try { span.addEvent('api_fetch_start'); - const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id); + + // Ensure global emotes are cached before fetching channel emotes + const cachedGlobalEmotes = await this.fetchGlobalStvEmotes(); + span.addEvent('global_emotes_ensured'); + + const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id, cachedGlobalEmotes); span.addEvent('api_fetch_complete'); if (channel7TVEmotes) { @@ -589,7 +659,9 @@ class ConnectionManager { const stvId = channelSet?.user?.id || '0'; const stvEmoteSetId = channelSet?.setInfo?.id || '0'; - this.stvWebSocket.addChatroom( + // console.log(`[ConnectionManager] Syncing 7TV chatroom ${chatroom.id} with emote set ID: ${stvEmoteSetId}`); + + this.stvWebSocket.updateChatroom( chatroom.id, chatroom.streamerData?.user_id, stvId, @@ -696,6 +768,157 @@ class ConnectionManager { } } + // Set deferred chatrooms for lazy loading + setDeferredChatrooms(chatrooms) { + this.deferredChatrooms = chatrooms || []; + console.log(`[ConnectionManager] Set ${this.deferredChatrooms.length} chatrooms for deferred loading`); + } + + // Auto-load deferred chatrooms in background (for mentions/notifications) + async initializeDeferredChatroomsInBackground() { + const span = tracer.startSpan('connection_manager.initialize_deferred_chatrooms', { + attributes: { + 'chatroom.count': this.deferredChatrooms.length, + 'background_load': true + } + }); + + try { + if (this.deferredChatrooms.length === 0) { + console.log('[ConnectionManager] No deferred chatrooms to load'); + span.addEvent('no_deferred_chatrooms'); + span.setStatus({ code: 1 }); + span.end(); + return; + } + + console.log(`[ConnectionManager] 🔄 Starting background load of ${this.deferredChatrooms.length} deferred chatrooms...`); + span.addEvent('background_load_started'); + + // Process in batches to avoid overwhelming resources + const batchSize = 2; + const staggerDelayMs = 300; + + for (let i = 0; i < this.deferredChatrooms.length; i += batchSize) { + const batch = this.deferredChatrooms.slice(i, i + batchSize); + const batchNum = Math.floor(i / batchSize) + 1; + const totalBatches = Math.ceil(this.deferredChatrooms.length / batchSize); + + console.log(`[ConnectionManager] Loading batch ${batchNum}/${totalBatches} (${batch.length} chatrooms)...`); + + // Load batch in parallel + const batchPromises = batch.map(chatroom => + this.initializeChatroomLazily(chatroom.id).catch(error => { + console.warn(`[ConnectionManager] Failed to load chatroom ${chatroom.username}:`, error); + }) + ); + + await Promise.allSettled(batchPromises); + + // Stagger batches + if (i + batchSize < this.deferredChatrooms.length) { + await new Promise(resolve => setTimeout(resolve, staggerDelayMs)); + } + } + + const loadedCount = this.loadedChatrooms.size; + console.log(`[ConnectionManager] ✅ Background load complete! ${loadedCount} chatrooms now connected`); + span.setAttribute('chatrooms.loaded', loadedCount); + span.addEvent('background_load_complete'); + span.setStatus({ code: 1 }); + } catch (error) { + console.error('[ConnectionManager] Error during background chatroom loading:', error); + span.recordException(error); + span.setStatus({ code: 2, message: error.message }); + } finally { + span.end(); + } + } + + // Lazily initialize a single chatroom when first accessed + async initializeChatroomLazily(chatroomId) { + const span = tracer.startSpan('connection_manager.lazy_initialize_chatroom', { + attributes: { + 'chatroom.id': chatroomId, + 'lazy_load': true + } + }); + + try { + // Check if already loaded + if (this.loadedChatrooms.has(chatroomId)) { + console.log(`[ConnectionManager] Chatroom ${chatroomId} already loaded`); + span.addEvent('chatroom_already_loaded'); + span.end(); + return; + } + + // Find the chatroom in deferred list + const chatroom = this.deferredChatrooms.find(room => room.id === chatroomId); + if (!chatroom) { + console.log(`[ConnectionManager] Chatroom ${chatroomId} not in deferred list (already managed)`); + this.loadedChatrooms.add(chatroomId); + span.addEvent('chatroom_not_in_deferred_list_already_managed'); + span.setStatus({ code: 1 }); + span.end(); + return; + } + + console.log(`[ConnectionManager] Lazy-loading chatroom: ${chatroom.username} (${chatroomId})`); + span.addEvent('lazy_initialization_started'); + + // Add to shared connections + this.kickPusher.addChatroom( + chatroom.id, + chatroom.streamerData.id, + chatroom, + ); + + // Only add to 7TV if we have valid IDs + const stvId = chatroom.streamerData?.user?.stv_id || "0"; + const stvEmoteSetId = chatroom?.channel7TVEmotes?.[1]?.setInfo?.id || "0"; + this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId); + + // Fetch initial data + await this.fetchInitialMessages(chatroom); + await this.fetchInitialChatroomInfo(chatroom); + + // Fetch emotes in background (non-blocking) + this.fetchChatroomEmotes(chatroom).catch(error => { + console.warn(`[ConnectionManager] Background emote fetch failed for ${chatroom.username}:`, error); + }); + + // Mark as loaded + this.loadedChatrooms.add(chatroomId); + + console.log(`[ConnectionManager] ✅ Lazy-loaded chatroom ${chatroom.username} successfully`); + span.addEvent('lazy_initialization_completed'); + span.setStatus({ code: 1 }); // SUCCESS + + } catch (error) { + console.error(`[ConnectionManager] Error during lazy chatroom initialization for ${chatroomId}:`, error); + span.recordException(error); + span.setStatus({ code: 2, message: error.message }); // ERROR + throw error; + } finally { + span.end(); + } + } + + // Check if chatroom is loaded + isChatroomLoaded(chatroomId) { + return this.loadedChatrooms.has(chatroomId); + } + + // Get status of lazy loading + getLazyLoadingStatus() { + return { + totalDeferredChatrooms: this.deferredChatrooms.length, + loadedChatrooms: this.loadedChatrooms.size, + pendingChatrooms: this.deferredChatrooms.length - this.loadedChatrooms.size + }; + } + // Cleanup method cleanup() { console.log("[ConnectionManager] Cleaning up connections..."); @@ -703,6 +926,9 @@ class ConnectionManager { this.stvWebSocket.close(); this.emoteCache.clear(); this.globalStvEmotesCache = null; + this.channelStvEmoteCache.clear(); + this.deferredChatrooms = []; + this.loadedChatrooms.clear(); this.initializationInProgress = false; } } diff --git a/utils/services/seventv/sharedStvWebSocket.js b/utils/services/seventv/sharedStvWebSocket.js index e0ede3d..33c60c0 100644 --- a/utils/services/seventv/sharedStvWebSocket.js +++ b/utils/services/seventv/sharedStvWebSocket.js @@ -1,138 +1,9 @@ // This shared websocket class optimizes 7TV connections by using a single WebSocket for all chatrooms // Original websocket class originally made by https://github.com/Fiszh and edited by ftk789 and Drkness -const cosmetics = { - paints: [], - badges: [], -}; - // Constants for ID validation const INVALID_7TV_NULL_ID = "00000000000000000000000000"; // Known 7TV null ID pattern -const updateCosmetics = async (body) => { - if (!body?.object) { - return; - } - - const { object } = body; - - if (object?.kind === "BADGE") { - if (!object?.user) { - const data = object.data; - - const foundBadge = cosmetics.badges.find( - (badge) => badge && badge.id === (data && data.id === "00000000000000000000000000" ? data.ref_id : data.id), - ); - - if (foundBadge) { - return; - } - - cosmetics.badges.push({ - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - title: data.tooltip, - url: `https:${data.host.url}/${data.host.files[data.host.files.length - 1].name}`, - }); - } - } else if (object?.kind === "PAINT") { - if (!object.user) { - const data = object.data; - - const foundPaint = cosmetics.paints.find( - (paint) => paint && paint.id === (data && data.id === "00000000000000000000000000" ? data.ref_id : data.id), - ); - - if (foundPaint) { - return; - } - - const randomColor = "#00f742"; - - let push = {}; - - if (data.stops.length) { - const normalizedColors = data.stops.map((stop) => ({ - at: stop.at * 100, - color: stop.color, - })); - - const gradient = normalizedColors.map((stop) => `${argbToRgba(stop.color)} ${stop.at}%`).join(", "); - - if (data.repeat) { - data.function = `repeating-${data.function}`; - } - - data.function = data.function.toLowerCase().replace("_", "-"); - - let isDeg_or_Shape = `${data.angle}deg`; - - if (data.function !== "linear-gradient" && data.function !== "repeating-linear-gradient") { - isDeg_or_Shape = data.shape; - } - - push = { - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - name: data.name, - style: data.function, - shape: data.shape, - backgroundImage: - `${data.function || "linear-gradient"}(${isDeg_or_Shape}, ${gradient})` || - `${data.style || "linear-gradient"}(${data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, - shadows: null, - KIND: "non-animated", - url: data.image_url, - }; - } else { - push = { - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - name: data.name, - style: data.function, - shape: data.shape, - backgroundImage: - `url('${[data.image_url]}')` || - `${data.style || "linear-gradient"}(${data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, - shadows: null, - KIND: "animated", - url: data.image_url, - }; - } - - // SHADOWS - let shadow = null; - - if (data.shadows.length) { - const shadows = data.shadows; - - shadow = await shadows - .map((shadow) => { - let rgbaColor = argbToRgba(shadow.color); - - rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), (\d+(\.\d+)?)\)/, `rgba($1, $2, $3)`); - - return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; - }) - .join(" "); - - push["shadows"] = shadow; - } - - cosmetics.paints.push(push); - } - } else if ( - object?.name === "Personal Emotes" || - object?.name === "Personal Emotes Set" || - object?.user || - object?.id === "00000000000000000000000000" || - (object?.flags && (object.flags === 11 || object.flags === 4)) - ) { - if (object?.id === "00000000000000000000000000" && object?.ref_id) { - object.id = object.ref_id; - } - } else { - console.log("[Shared7TV] Didn't process cosmetics:", body); - } -}; - // OpenTelemetry instrumentation let tracer; try { @@ -193,6 +64,65 @@ class SharedStvWebSocket extends EventTarget { } } + updateChatroom(chatroomId, channelKickID, stvId = "0", stvEmoteSetId = "0") { + const existingData = this.chatrooms.get(chatroomId); + + if (process.env.NODE_ENV === 'development') { + console.log(`[Shared7TV]: updateChatroom called for ${chatroomId} with emote set ID: ${stvEmoteSetId}`); + } + + if (!existingData) { + // If chatroom doesn't exist, just add it + if (process.env.NODE_ENV === 'development') { + console.log(`[Shared7TV]: No existing data found, adding new chatroom ${chatroomId}`); + } + this.addChatroom(chatroomId, channelKickID, stvId, stvEmoteSetId); + return; + } + + // Check if emote set ID has changed + const oldStvEmoteSetId = existingData.stvEmoteSetId; + const hasEmoteSetChanged = oldStvEmoteSetId !== stvEmoteSetId; + + if (process.env.NODE_ENV === 'development') { + console.log(`[Shared7TV]: Chatroom ${chatroomId} emote set ID change: ${oldStvEmoteSetId} → ${stvEmoteSetId} (changed: ${hasEmoteSetChanged})`); + } + + // Update chatroom data + this.chatrooms.set(chatroomId, { + channelKickID: String(channelKickID), + stvId, + stvEmoteSetId, + }); + + if (this.connectionState === 'connected' && hasEmoteSetChanged) { + // Unsubscribe from old emote set events if we had a valid one + if (oldStvEmoteSetId && oldStvEmoteSetId !== "0" && oldStvEmoteSetId !== INVALID_7TV_NULL_ID) { + const oldEventKey = `emote_set.*:${oldStvEmoteSetId}`; + if (this.subscribedEvents.has(oldEventKey)) { + console.log(`[Shared7TV]: Unsubscribing from old emote set events for chatroom ${chatroomId}: ${oldStvEmoteSetId}`); + // Send unsubscribe message + const unsubscribeMessage = { + op: 36, // UNSUBSCRIBE + t: Date.now(), + d: { + type: "emote_set.*", + condition: { object_id: oldStvEmoteSetId }, + }, + }; + if (this.chat && this.chat.readyState === WebSocket.OPEN) { + this.chat.send(JSON.stringify(unsubscribeMessage)); + } + this.subscribedEvents.delete(oldEventKey); + } + } + + // Subscribe to new emote set events + console.log(`[Shared7TV]: Subscribing to new emote set events for chatroom ${chatroomId}`); + this.subscribeToChatroomEvents(chatroomId); + } + } + connect() { if (!this.shouldReconnect) { console.log(`[Shared7TV]: Not connecting to WebSocket - reconnect disabled`); @@ -340,7 +270,9 @@ class SharedStvWebSocket extends EventTarget { await this.delay(1000); // Subscribe to events for all chatrooms + console.log(`[Shared7TV]: Starting subscription to events for ${this.chatrooms.size} chatrooms`); await this.subscribeToAllEvents(); + console.log(`[Shared7TV]: Completed subscription setup. Subscribed events: ${this.subscribedEvents.size}`); // Setup message handler this.setupMessageHandler(); @@ -568,12 +500,27 @@ class SharedStvWebSocket extends EventTarget { try { const msg = JSON.parse(event.data); + // Debug: Log raw WebSocket messages (controlled by VITE_DEBUG_7TV_WS flag) + if (import.meta.env.VITE_DEBUG_7TV_WS === 'true') { + console.log("[Shared7TV][Debug] Raw WebSocket message:", { op: msg?.op, type: msg?.d?.type, hasBody: !!msg?.d?.body }); + } + if (!msg?.d?.body) return; const { body, type } = msg.d; + // Debug: Log all incoming events (controlled by VITE_DEBUG_7TV_WS flag) + if (import.meta.env.VITE_DEBUG_7TV_WS === 'true') { + console.log("[Shared7TV][Debug] Received event:", { type, bodyPreview: body?.id || body?.object_id || 'no-id' }); + } + // Find which chatroom this event belongs to - const chatroomId = this.findChatroomForEvent(body, type); + const chatroomId = this.findChatroomForEvent(type, body); + + // Debug: Log the routing result (controlled by VITE_DEBUG_7TV_WS flag) + if (import.meta.env.VITE_DEBUG_7TV_WS === 'true') { + console.log("[Shared7TV][Debug] Event routing result:", { type, chatroomId }); + } switch (type) { case "user.update": @@ -589,25 +536,26 @@ class SharedStvWebSocket extends EventTarget { break; case "emote_set.update": + // Dispatch once - Zustand global state handles propagation to all rooms this.dispatchEvent( new CustomEvent("message", { detail: { body, type: "emote_set.update", chatroomId, + isPersonalEmoteSet: chatroomId === null, // null = personal, specific ID = channel }, }), ); break; case "cosmetic.create": - updateCosmetics(body); - + case "cosmetic.delete": this.dispatchEvent( new CustomEvent("message", { detail: { - body: cosmetics, - type: "cosmetic.create", + body, + type: type, chatroomId, }, }), @@ -616,18 +564,30 @@ class SharedStvWebSocket extends EventTarget { case "entitlement.create": case "entitlement.delete": - if (body.kind === 10) { - this.dispatchEvent( - new CustomEvent("message", { - detail: { - body, - type: type, // Use the actual event type (create or delete) - chatroomId, - }, - }), - ); - } + // Dispatch all entitlement events (kind: 5 = EMOTE_SET, kind: 10 = general entitlements, etc.) + // ChatProvider will filter by body.object.kind (BADGE, PAINT, EMOTE_SET) + this.dispatchEvent( + new CustomEvent("message", { + detail: { + body, + type: type, // Use the actual event type (create or delete) + chatroomId, + }, + }), + ); break; + + default: { + // Log unhandled event types to telemetry + const unhandledSpan = tracer.startSpan('seventv.unhandled_event_type'); + unhandledSpan.setAttributes({ + 'event.type': type, + 'event.body_preview': body?.id || body?.object_id || 'no-id', + 'chatroom.id': chatroomId || 'null' + }); + unhandledSpan.end(); + break; + } } } catch (error) { console.log("[Shared7TV] Error parsing message:", error); @@ -635,22 +595,63 @@ class SharedStvWebSocket extends EventTarget { }; } - findChatroomForEvent(body, type) { + findChatroomForEvent(type, body) { // Try to identify which chatroom this event belongs to // This is a best-effort approach since 7TV events don't always include channel context - + + // Validate type parameter + if (typeof type !== 'string' || !type) { + console.warn("[Shared7TV] findChatroomForEvent called with invalid type:", { type, body }); + return null; + } + // For user events, broadcast to all chatrooms if (type.startsWith("user.")) { return null; // null means broadcast to all chatrooms } // For emote_set events, find chatroom by emote set ID - if (type.startsWith("emote_set.") && body?.id) { + if (type.startsWith("emote_set.")) { + const incomingSetId = body?.id || body?.object_id || body?.emote_set_id || null; + const emoteSetKind = body?.kind; + + if (!incomingSetId) { + console.log("[Shared7TV][Debug] emote_set event missing expected set identifier", { type, body }); + return null; + } + + // Check if this is a personal emote set (kind: 3 = PERSONAL) + // Note: 7TV uses integer discriminants over WebSocket despite Rust enum having no explicit values + if (emoteSetKind === 3) { + if (process.env.NODE_ENV === 'development') { + console.log("[Shared7TV][Debug] Personal emote set update detected", { incomingSetId, kind: emoteSetKind }); + } + // Personal emote sets: return null (global), Zustand will propagate to all subscribers + return null; + } + + // Reduced debug logging for performance + if (process.env.NODE_ENV === 'development') { + console.log("[Shared7TV][Debug] Matching emote_set event", { incomingSetId, chatroomCount: this.chatrooms.size, kind: emoteSetKind }); + console.log("[Shared7TV][Debug] Full emote_set event payload:", { type, body }); + } + for (const [chatroomId, data] of this.chatrooms) { - if (data.stvEmoteSetId === body.id) { + if (data.stvEmoteSetId === incomingSetId) { + console.log("[Shared7TV][Debug] Matched emote_set event to chatroom", { chatroomId, incomingSetId }); return chatroomId; } } + + // Only log unmatched events in development to reduce noise + if (process.env.NODE_ENV === 'development') { + console.log("[Shared7TV][Debug] No chatroom match for emote_set event", { incomingSetId, kind: emoteSetKind }); + // Debug: Show all stored emote set IDs + const storedEmoteSetIds = Array.from(this.chatrooms.entries()).map(([chatroomId, data]) => + ({ chatroomId, stvEmoteSetId: data.stvEmoteSetId }) + ); + console.log("[Shared7TV][Debug] Current chatroom emote set IDs:", storedEmoteSetIds); + } } // For cosmetic and entitlement events, they should include channel context @@ -701,15 +702,4 @@ class SharedStvWebSocket extends EventTarget { } } -const argbToRgba = (color) => { - if (color < 0) { - color = color >>> 0; - } - - const red = (color >> 24) & 0xff; - const green = (color >> 16) & 0xff; - const blue = (color >> 8) & 0xff; - return `rgba(${red}, ${green}, ${blue}, 1)`; -}; - -export default SharedStvWebSocket; \ No newline at end of file +export default SharedStvWebSocket; diff --git a/utils/services/seventv/stvAPI.js b/utils/services/seventv/stvAPI.js index 6169381..353f777 100644 --- a/utils/services/seventv/stvAPI.js +++ b/utils/services/seventv/stvAPI.js @@ -116,14 +116,13 @@ const sendUserPresence = async (stvId, userId) => { }, }, ); - if (response.status !== 200) { - throw new Error(`[7TV Emotes] Error while sending user presence: ${response.status}`); + throw new Error(`[7TV Presence] Error while sending user presence: ${response.status}`); } return response.data; } catch (error) { - console.error("[7TV Emotes] Error while sending user presence:", error.message); + console.error("[7TV Presence] Error while sending user presence:", error.message); } }; diff --git a/utils/services/seventv/stvWebsocket.js b/utils/services/seventv/stvWebsocket.js deleted file mode 100644 index d3b2779..0000000 --- a/utils/services/seventv/stvWebsocket.js +++ /dev/null @@ -1,423 +0,0 @@ -// This websocket class originally made by https://github.com/Fiszh and edited by ftk789 and Drkness - -const cosmetics = { - paints: [], - badges: [], -}; - -const updateCosmetics = async (body) => { - if (!body?.object) { - return; - } - - const { object } = body; - - if (object?.kind === "BADGE") { - if (!object?.user) { - const data = object.data; - - const foundBadge = cosmetics.badges.find( - (badge) => badge && badge.id === (data && data.id === "00000000000000000000000000" ? data.ref_id : data.id), - ); - - if (foundBadge) { - return; - } - - cosmetics.badges.push({ - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - title: data.tooltip, - url: `https:${data.host.url}/${data.host.files[data.host.files.length - 1].name}`, - }); - } - } - - if (object?.kind === "PAINT") { - if (!object.user) { - const data = object.data; - - const foundPaint = cosmetics.paints.find( - (paint) => paint && paint.id === (data && data.id === "00000000000000000000000000" ? data.ref_id : data.id), - ); - - if (foundPaint) { - return; - } - - const randomColor = "#00f742"; - - let push = {}; - - if (data.stops.length) { - const normalizedColors = data.stops.map((stop) => ({ - at: stop.at * 100, - color: stop.color, - })); - - const gradient = normalizedColors.map((stop) => `${argbToRgba(stop.color)} ${stop.at}%`).join(", "); - - if (data.repeat) { - data.function = `repeating-${data.function}`; - } - - data.function = data.function.toLowerCase().replace("_", "-"); - - let isDeg_or_Shape = `${data.angle}deg`; - - if (data.function !== "linear-gradient" && data.function !== "repeating-linear-gradient") { - isDeg_or_Shape = data.shape; - } - - push = { - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - name: data.name, - style: data.function, - shape: data.shape, - backgroundImage: - `${data.function || "linear-gradient"}(${isDeg_or_Shape}, ${gradient})` || - `${data.style || "linear-gradient"}(${data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, - shadows: null, - KIND: "non-animated", - url: data.image_url, - }; - } else { - push = { - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - name: data.name, - style: data.function, - shape: data.shape, - backgroundImage: - `url('${[data.image_url]}')` || - `${data.style || "linear-gradient"}(${data.shape || ""} 0deg, ${randomColor}, ${randomColor})`, - shadows: null, - KIND: "animated", - url: data.image_url, - }; - } - - // SHADOWS - let shadow = null; - - if (data.shadows.length) { - const shadows = data.shadows; - - shadow = await shadows - .map((shadow) => { - let rgbaColor = argbToRgba(shadow.color); - - rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), (\d+(\.\d+)?)\)/, `rgba($1, $2, $3)`); - - return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; - }) - .join(" "); - - push["shadows"] = shadow; - } - - cosmetics.paints.push(push); - } - } else if ( - object?.name === "Personal Emotes" || - object?.name === "Personal Emotes Set" || - object?.user || - object?.id === "00000000000000000000000000" || - (object?.flags && (object.flags === 11 || object.flags === 4)) - ) { - if (object?.id === "00000000000000000000000000" && object?.ref_id) { - object.id = object.ref_id; - } - } else if (object?.kind == "BADGE") { - const data = object.data; - - const foundBadge = cosmetics.badges.find( - (badge) => badge && badge.id === (data && data.id === "00000000000000000000000000" ? data.ref_id : data.id), - ); - - if (foundBadge) { - return; - } - - cosmetics.badges.push({ - id: data.id === "00000000000000000000000000" ? data.ref_id || "default_id" : data.id, - title: data.tooltip, - url: `https:${data.host.url}/${data.host.files[data.host.files.length - 1].name}`, - }); - } else { - console.log("[7tv] Didn't process cosmetics:", body); - } -}; - -class StvWebSocket extends EventTarget { - constructor(channelKickID, stvId = "0", stvEmoteSetId = "0") { - super(); - this.startDelay = 1000; - this.maxRetrySteps = 5; - this.reconnectAttempts = 0; - this.chat = null; - this.channelKickID = String(channelKickID); - this.stvId = stvId; - this.stvEmoteSetId = stvEmoteSetId; - this.shouldReconnect = true; - } - - connect() { - if (!this.shouldReconnect) { - console.log(`[7TV]: Not connecting to WebSocket - reconnect disabled`); - return; - } - - - this.chat = new WebSocket("wss://events.7tv.io/v3?app=kicktalk&version=420.69"); - - this.chat.onerror = (event) => { - console.log(`[7TV]: WebSocket error:`, event); - this.handleConnectionError(); - }; - - this.chat.onclose = (event) => { - this.handleReconnection(); - }; - - this.chat.onopen = async () => { - - this.reconnectAttempts = 0; - - await this.delay(1000); - - const waitStartTime = Date.now(); - while ((this.stvId === "0" || this.stvEmoteSetId === "0") && Date.now() - waitStartTime < 1000) { - await this.delay(100); - } - - const waitTime = Date.now() - waitStartTime; - - // Subscribe to user & cosmetic events - if (this.stvId !== "0") { - this.subscribeToUserEvents(); - this.subscribeToCosmeticEvents(); - } else { - } - - // Subscribe to entitlement events - if (this.channelKickID !== "0") { - this.subscribeToEntitlementEvents(); - - // Only subscribe to emote set events if we have a valid emote set ID - if (this.stvEmoteSetId !== "0") { - this.subscribeToEmoteSetEvents(); - } else { - } - } else { - } - - // Setup message handler - this.setupMessageHandler(); - }; - } - - handleConnectionError() { - this.reconnectAttempts++; - console.log(`[7TV]: Connection error. Attempt ${this.reconnectAttempts}`); - } - - handleReconnection() { - if (!this.shouldReconnect) { - console.log(`[7TV]: Reconnection disabled for chatroom ${this.channelKickID}`); - return; - } - - // exponential backoff: start * 2^(step-1) - // cap at maxRetrySteps, so after step 5 it stays at start * 2^(maxRetrySteps-1) - const step = Math.min(this.reconnectAttempts, this.maxRetrySteps); - const delay = this.startDelay * Math.pow(2, step - 1); - - console.log(`[7TV]: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); - - setTimeout(() => { - this.connect(); - }, delay); - } - - /** - * Subscribe to user events - */ - subscribeToUserEvents() { - if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - console.log(`[7TV]: Cannot subscribe to user events - WebSocket not ready`); - return; - } - - const subscribeUserMessage = { - op: 35, - t: Date.now(), - d: { - type: "user.*", - condition: { object_id: this.stvId }, - }, - }; - - this.chat.send(JSON.stringify(subscribeUserMessage)); - console.log(`[7TV]: Subscribed to user.* events`); - } - - /** - * Subscribe to all cosmetic events - */ - subscribeToCosmeticEvents() { - if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - console.log(`[7TV]: Cannot subscribe to cosmetic events - WebSocket not ready`); - return; - } - - const subscribeAllCosmetics = { - op: 35, - t: Date.now(), - d: { - type: "cosmetic.*", - condition: { platform: "KICK", ctx: "channel", id: this.channelKickID }, - }, - }; - - this.chat.send(JSON.stringify(subscribeAllCosmetics)); - console.log(`[7TV]: Subscribed to cosmetic.* events`); - } - - /** - * Subscribe to all entitlement events - */ - subscribeToEntitlementEvents() { - if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - console.log(`[7TV]: Cannot subscribe to entitlement events - WebSocket not ready`); - return; - } - - const subscribeAllEntitlements = { - op: 35, - t: Date.now(), - d: { - type: "entitlement.*", - condition: { platform: "KICK", ctx: "channel", id: this.channelKickID }, - }, - }; - - this.chat.send(JSON.stringify(subscribeAllEntitlements)); - console.log(`[7TV]: Subscribed to entitlement.* events`); - - this.dispatchEvent(new CustomEvent("open", { detail: { body: "SUBSCRIBED", type: "entitlement.*" } })); - } - - /** - * Subscribe to all emote set events - */ - - subscribeToEmoteSetEvents() { - if (!this.chat || this.chat.readyState !== WebSocket.OPEN) { - return; - } - - const subscribeAllEmoteSets = { - op: 35, - t: Date.now(), - d: { - type: "emote_set.*", - condition: { object_id: this.stvEmoteSetId }, - }, - }; - - this.chat.send(JSON.stringify(subscribeAllEmoteSets)); - } - - setupMessageHandler() { - this.chat.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - - // Log ALL messages to see if we're getting any at all - - if (!msg?.d?.body) { - return; - } - - const { body, type } = msg.d; - - switch (type) { - case "user.update": - this.dispatchEvent( - new CustomEvent("message", { - detail: { body, type: "user.update" }, - }), - ); - break; - - case "emote_set.update": - - this.dispatchEvent( - new CustomEvent("message", { - detail: { body, type: "emote_set.update" }, - }), - ); - break; - - case "cosmetic.create": - updateCosmetics(body); - - this.dispatchEvent( - new CustomEvent("message", { - detail: { body: cosmetics, type: "cosmetic.create" }, - }), - ); - break; - - case "entitlement.create": - if (body.kind === 10) { - this.dispatchEvent( - new CustomEvent("message", { - detail: { body, type: "entitlement.create" }, - }), - ); - } - break; - } - } catch (error) { - console.error(`[7TV WebSocket]: Error parsing message for channel ${this.channelKickID}:`, error); - } - }; - } - - delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - close() { - console.log(`[7TV]: Closing connection for chatroom ${this.channelKickID}`); - this.shouldReconnect = false; - - if (this.chat) { - try { - if (this.chat.readyState === WebSocket.OPEN || this.chat.readyState === WebSocket.CONNECTING) { - console.log(`[7TV]: WebSocket state: ${this.chat.readyState}, closing...`); - this.chat.close(); - } - this.chat = null; - console.log(`[7TV]: Connection closed for chatroom ${this.channelKickID}`); - } catch (error) { - console.error(`[7TV]: Error during closing of connection for chatroom ${this.channelKickID}:`, error); - } - } else { - console.log(`[7TV]: No active connection to close for chatroom ${this.channelKickID}`); - } - } -} - -const argbToRgba = (color) => { - if (color < 0) { - color = color >>> 0; - } - - const red = (color >> 24) & 0xff; - const green = (color >> 16) & 0xff; - const blue = (color >> 8) & 0xff; - return `rgba(${red}, ${green}, ${blue}, 1)`; -}; - -export default StvWebSocket;