From 93f2790f4ec813ca79c60aa59ac12f6bda0b28fe Mon Sep 17 00:00:00 2001 From: BP602 Date: Tue, 23 Sep 2025 13:15:48 +0200 Subject: [PATCH 1/9] perf(cosmetics): optimize 7TV cosmetics lookups with O(1) hashmaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n) .find() operations with O(1) Map.get() lookups for badges and paints. This eliminates performance lag when rendering messages with 7TV cosmetics by: - Adding cosmeticsLookup state with badgeMap and paintMap hashmaps - Populating lookup maps in addCosmetics() when cosmetics are loaded - Replacing .find() calls with Map.get() in getUserStyle, getUserBadge, getUserPaint Fixes chat lag after recent 7TV styling bug fix by transforming complexity from O(n × messages) to O(1 × messages) for cosmetic lookups. --- .../src/providers/CosmeticsProvider.jsx | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/providers/CosmeticsProvider.jsx b/src/renderer/src/providers/CosmeticsProvider.jsx index 609a576..5fdf00a 100644 --- a/src/renderer/src/providers/CosmeticsProvider.jsx +++ b/src/renderer/src/providers/CosmeticsProvider.jsx @@ -6,6 +6,10 @@ const useCosmeticsStore = create((set, get) => ({ badges: [], paints: [], }, + cosmeticsLookup: { + badgeMap: new Map(), + paintMap: new Map(), + }, addUserStyle: async (username, body) => { if (!body?.object?.user?.style) return; @@ -86,8 +90,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, @@ -99,10 +104,30 @@ const useCosmeticsStore = create((set, get) => ({ addCosmetics: (body) => { set(() => { + // Create lookup maps for O(1) access + const badgeMap = new Map(); + const paintMap = new Map(); + + if (body.badges) { + body.badges.forEach(badge => { + badgeMap.set(badge.id, badge); + }); + } + + if (body.paints) { + body.paints.forEach(paint => { + paintMap.set(paint.id, paint); + }); + } + const newState = { globalCosmetics: { ...body, }, + cosmeticsLookup: { + badgeMap, + paintMap, + }, }; return newState; @@ -115,7 +140,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 +149,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); }, })); From b9f249a2d00841520d8c94bfb981cde761d72e12 Mon Sep 17 00:00:00 2001 From: BP602 Date: Wed, 1 Oct 2025 00:05:43 +0200 Subject: [PATCH 2/9] perf(7tv): migrate to shared WebSocket connections and fix entitlement handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactoring to improve 7TV WebSocket performance and fix cosmetic event processing: **Shared WebSocket Migration:** - Replace per-chatroom 7TV WebSocket connections with single shared connection - Remove individual StvWebSocket class (423 lines deleted) - Update sharedStvWebSocket with proper event routing and subscription management - Deprecate connectToStvWebSocket() method in ChatProvider - Update ConnectionManager to use updateChatroom() instead of addChatroom() **Entitlement Event Handling:** - Fix cosmetic event deduplication using ref_id instead of all-zeros id field - Remove kind === 10 filter that was blocking EMOTE_SET entitlements - Route BADGE/PAINT events to cosmetics store, EMOTE_SET to dedicated handler - Add handlePersonalEmoteSetEntitlement() placeholder for future implementation - Handle global cosmetic events once instead of broadcasting to all chatrooms **Performance Improvements:** - Add deduplication for cosmetic events (30s window, 60s cleanup) - Add dedicated tracking for personal and channel emote set updates - Reduce console log spam by 82% (2900+ → 500 lines) - Add VITE_DEBUG_7TV_WS flag for optional verbose WebSocket logging **Telemetry Enhancements:** - Add console.error/console.warn instrumentation in webTracing - Capture critical warnings and errors for telemetry **Related:** - Issue #48: Full implementation needed for personal emote set entitlement sync - Builds on commit 93f2790 (hashmap optimization for cosmetics lookups) This refactor eliminates redundant WebSocket connections while fixing several bugs in how 7TV cosmetic and entitlement events are processed. --- .env.example | 5 +- src/renderer/src/providers/ChatProvider.jsx | 689 ++++++++++++------- src/renderer/src/telemetry/webTracing.js | 106 +++ utils/services/connectionManager.js | 4 +- utils/services/seventv/sharedStvWebSocket.js | 187 ++++- utils/services/seventv/stvAPI.js | 5 +- utils/services/seventv/stvWebsocket.js | 423 ------------ 7 files changed, 721 insertions(+), 698 deletions(-) delete mode 100644 utils/services/seventv/stvWebsocket.js 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/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index f27fc14..609cc5f 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"; @@ -388,6 +387,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 +540,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 +855,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; @@ -1509,14 +1418,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); @@ -1624,8 +1539,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); } }); }, @@ -1893,6 +1808,36 @@ const useChatStore = create((set, get) => ({ handleStvMessage: (chatroomId, eventDetail) => { const { type, body } = eventDetail; + // Deduplicate cosmetic events (they spam from WebSocket) + if (type === 'cosmetic.create' || type === 'entitlement.create' || type === 'entitlement.delete') { + const userId = body?.object?.user?.id || body?.user?.id; + const eventId = body?.id; + const refId = body?.object?.ref_id; // For entitlements, ref_id is the actual badge/paint/emote_set ID + + // Create dedup key: eventType_userId_refId (use refId instead of eventId for entitlements) + const 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) { + 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; @@ -1903,15 +1848,31 @@ const useChatStore = create((set, get) => ({ 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); + 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); + } 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); + } break; } default: @@ -2150,8 +2111,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])); @@ -2905,9 +2866,9 @@ const useChatStore = create((set, get) => ({ 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 +2876,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 }); + const personalEmoteSetsRaw = get().personalEmoteSets; + const personalEmoteSets = Array.isArray(personalEmoteSetsRaw) ? personalEmoteSetsRaw : []; + + // Check if this is a personal emote set update + const isPersonalSetUpdate = personalEmoteSets?.some((set) => body.id === set.setInfo?.id); + + // Handle personal emote set updates GLOBALLY (not per-chatroom) + if (isPersonalSetUpdate) { + // Use a static flag to prevent processing the same personal set update multiple times + const updateKey = `personal_${body.id}_${Date.now()}`; + 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 +2928,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; + } + + // 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(); - // 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) { + 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 +3010,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 +3029,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 +3061,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 +3071,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 +3101,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 +3122,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 +3135,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 +3169,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,6 +3189,204 @@ 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); @@ -4033,13 +4211,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/telemetry/webTracing.js b/src/renderer/src/telemetry/webTracing.js index c81276f..f7ec25d 100644 --- a/src/renderer/src/telemetry/webTracing.js +++ b/src/renderer/src/telemetry/webTracing.js @@ -185,6 +185,107 @@ 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'); + + // Test console instrumentation after a brief delay to ensure telemetry bridge is ready + setTimeout(() => { + try { + if (typeof window.app?.telemetry?.recordError === 'function') { + console.log('[Console Instrumentation]: Testing telemetry integration...'); + // This should trigger our console.error wrapper + console.error('[Test] Console error instrumentation test - this should appear in telemetry'); + } else { + console.log('[Console Instrumentation]: Telemetry bridge not ready yet'); + } + } catch (testError) { + console.log('[Console Instrumentation]: Test failed:', testError.message); + } + }, 2000); + } 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 +293,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 {} } diff --git a/utils/services/connectionManager.js b/utils/services/connectionManager.js index d7a343e..6bc522c 100644 --- a/utils/services/connectionManager.js +++ b/utils/services/connectionManager.js @@ -589,7 +589,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, diff --git a/utils/services/seventv/sharedStvWebSocket.js b/utils/services/seventv/sharedStvWebSocket.js index e0ede3d..3fc4608 100644 --- a/utils/services/seventv/sharedStvWebSocket.js +++ b/utils/services/seventv/sharedStvWebSocket.js @@ -193,6 +193,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 +399,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 +629,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,15 +665,34 @@ class SharedStvWebSocket extends EventTarget { break; case "emote_set.update": - this.dispatchEvent( - new CustomEvent("message", { - detail: { - body, - type: "emote_set.update", - chatroomId, - }, - }), - ); + // Handle personal emote sets that should be broadcast to all chatrooms + if (chatroomId === 'BROADCAST_TO_ALL') { + // Broadcast to all connected chatrooms + for (const [roomId] of this.chatrooms) { + this.dispatchEvent( + new CustomEvent("message", { + detail: { + body, + type: "emote_set.update", + chatroomId: roomId, + isPersonalEmoteSet: true, + }, + }), + ); + } + } else if (chatroomId) { + // Normal channel-specific emote set update + this.dispatchEvent( + new CustomEvent("message", { + detail: { + body, + type: "emote_set.update", + chatroomId, + isPersonalEmoteSet: false, + }, + }), + ); + } break; case "cosmetic.create": @@ -616,17 +711,17 @@ 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; } } catch (error) { @@ -635,22 +730,62 @@ 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) + if (emoteSetKind === 3) { + if (process.env.NODE_ENV === 'development') { + console.log("[Shared7TV][Debug] Personal emote set update detected", { incomingSetId, kind: emoteSetKind }); + } + // Personal emote sets should be broadcast to all chatrooms since they affect the user globally + return 'BROADCAST_TO_ALL'; + } + + // 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 @@ -712,4 +847,4 @@ const argbToRgba = (color) => { 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; From 0a15261fb1fa5e7e505e0b64450de8d95bbddbdd Mon Sep 17 00:00:00 2001 From: BP602 Date: Wed, 1 Oct 2025 00:18:13 +0200 Subject: [PATCH 3/9] fix(telemetry): prevent test code from polluting production telemetry Remove synthetic test emissions that were flooding production telemetry: - Remove setTimeout block that emitted synthetic console.error after 2 seconds - Gate renderer_export_smoke test span behind NODE_ENV === 'development' check - Document remaining test code as DEV-ONLY These test blocks were useful during development but should not run in production as they create false positives in telemetry data. --- src/renderer/src/telemetry/webTracing.js | 35 ++++++++---------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/telemetry/webTracing.js b/src/renderer/src/telemetry/webTracing.js index f7ec25d..425becd 100644 --- a/src/renderer/src/telemetry/webTracing.js +++ b/src/renderer/src/telemetry/webTracing.js @@ -263,21 +263,6 @@ if (telemetryEnabled && typeof window !== 'undefined') { window.__KT_ORIGINAL_CONSOLE_WARN__ = originalConsoleWarn; console.log('[Renderer OTEL]: Console error instrumentation installed successfully'); - - // Test console instrumentation after a brief delay to ensure telemetry bridge is ready - setTimeout(() => { - try { - if (typeof window.app?.telemetry?.recordError === 'function') { - console.log('[Console Instrumentation]: Testing telemetry integration...'); - // This should trigger our console.error wrapper - console.error('[Test] Console error instrumentation test - this should appear in telemetry'); - } else { - console.log('[Console Instrumentation]: Telemetry bridge not ready yet'); - } - } catch (testError) { - console.log('[Console Instrumentation]: Test failed:', testError.message); - } - }, 2000); } else { console.log('[Console Instrumentation]: Already instrumented'); } @@ -1317,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 { From f04b485f938b0252a0b53b3e78bd7864d88e915e Mon Sep 17 00:00:00 2001 From: BP602 Date: Wed, 1 Oct 2025 02:18:42 +0200 Subject: [PATCH 4/9] feat(emotes): add progressive loading with placeholders Implement progressive image loading for emotes to improve perceived performance and prevent layout shift during load. Changes: - Add useProgressiveEmoteLoading hook with 4 states (placeholder, loading, loaded, error) - Render fallback placeholders (2-char abbreviation) during load and on error - Smooth opacity transitions when images finish loading - SCSS styling for placeholder states with error indicator Impact: - Eliminates layout shift from async emote loading - Better UX with visual feedback during image load - Graceful degradation for failed emote loads --- .../styles/components/Chat/Message.scss | 36 ++++++++ .../src/components/Cosmetics/Emote.jsx | 82 ++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) 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..90c2f4d 100644 --- a/src/renderer/src/components/Cosmetics/Emote.jsx +++ b/src/renderer/src/components/Cosmetics/Emote.jsx @@ -1,12 +1,70 @@ import { memo, useCallback, useState, useMemo } from "react"; import EmoteTooltip from "./EmoteTooltip"; +// Progressive Loading Hook for Emotes +const useProgressiveEmoteLoading = (emote, type) => { + const [loadState, setLoadState] = useState('placeholder'); // placeholder, 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); + }, []); + + const handleImageLoadStart = useCallback(() => { + setLoadState('loading'); + }, []); + + return { + loadState, + showFallback, + placeholder, + handleImageLoad, + handleImageError, + handleImageLoadStart + }; +}; + 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, + handleImageLoadStart + } = useProgressiveEmoteLoading(emote, type); + const emoteSrcSet = useCallback( (emote) => { if (type === "stv") { @@ -58,18 +116,38 @@ 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}
From 6611478c67166aad475d740adf831bf82ba2ee64 Mon Sep 17 00:00:00 2001 From: BP602 Date: Wed, 1 Oct 2025 22:58:52 +0200 Subject: [PATCH 5/9] fix: restore 7tv paint gradients --- .vscode/launch.json | 2 +- electron.vite.config.mjs | 1 + .../src/components/Cosmetics/Emote.jsx | 14 +- src/renderer/src/providers/ChatProvider.jsx | 60 +++- .../src/providers/CosmeticsProvider.jsx | 291 +++++++++++++++++- utils/services/seventv/sharedStvWebSocket.js | 203 ++---------- 6 files changed, 361 insertions(+), 210 deletions(-) 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/components/Cosmetics/Emote.jsx b/src/renderer/src/components/Cosmetics/Emote.jsx index 90c2f4d..b90ca7f 100644 --- a/src/renderer/src/components/Cosmetics/Emote.jsx +++ b/src/renderer/src/components/Cosmetics/Emote.jsx @@ -3,7 +3,7 @@ import EmoteTooltip from "./EmoteTooltip"; // Progressive Loading Hook for Emotes const useProgressiveEmoteLoading = (emote, type) => { - const [loadState, setLoadState] = useState('placeholder'); // placeholder, loading, loaded, error + const [loadState, setLoadState] = useState('loading'); // loading, loaded, error const [showFallback, setShowFallback] = useState(false); // Define fallback placeholder (prevents layout shift) @@ -35,17 +35,12 @@ const useProgressiveEmoteLoading = (emote, type) => { setShowFallback(true); }, []); - const handleImageLoadStart = useCallback(() => { - setLoadState('loading'); - }, []); - return { loadState, showFallback, placeholder, handleImageLoad, - handleImageError, - handleImageLoadStart + handleImageError }; }; @@ -61,8 +56,7 @@ const Emote = memo(({ emote, overlaidEmotes = [], scale = 1, type }) => { showFallback, placeholder, handleImageLoad, - handleImageError, - handleImageLoadStart + handleImageError } = useProgressiveEmoteLoading(emote, type); const emoteSrcSet = useCallback( @@ -142,9 +136,7 @@ const Emote = memo(({ emote, overlaidEmotes = [], scale = 1, type }) => { decoding="async" onLoad={handleImageLoad} onError={handleImageError} - onLoadStart={handleImageLoadStart} style={{ - position: loadState === 'loaded' ? 'static' : 'absolute', opacity: loadState === 'loaded' ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index 609cc5f..ba04889 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -1806,22 +1806,36 @@ 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') { - const userId = body?.object?.user?.id || body?.user?.id; - const eventId = body?.id; - const refId = body?.object?.ref_id; // For entitlements, ref_id is the actual badge/paint/emote_set ID + 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}`; + } - // Create dedup key: eventType_userId_refId (use refId instead of eventId for entitlements) - const 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 } @@ -1842,10 +1856,13 @@ const useChatStore = create((set, get) => ({ 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 objectKind = body?.object?.kind; @@ -1858,6 +1875,15 @@ const useChatStore = create((set, get) => ({ 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; } @@ -1872,6 +1898,15 @@ const useChatStore = create((set, get) => ({ 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; } @@ -2862,7 +2897,7 @@ const useChatStore = create((set, get) => ({ })); }, - handleEmoteSetUpdate: (chatroomId, body) => { + handleEmoteSetUpdate: (chatroomId, body, isPersonalEmoteSet) => { const updateSpan = startSpan('seventv.emote_set_update', { 'chatroom.id': chatroomId }); @@ -2883,11 +2918,8 @@ const useChatStore = create((set, get) => ({ 'emotes.updated.count': updated.length }); - const personalEmoteSetsRaw = get().personalEmoteSets; - const personalEmoteSets = Array.isArray(personalEmoteSetsRaw) ? personalEmoteSetsRaw : []; - - // Check if this is a personal emote set update - const isPersonalSetUpdate = personalEmoteSets?.some((set) => body.id === set.setInfo?.id); + // Use the isPersonalEmoteSet flag from the WebSocket layer + const isPersonalSetUpdate = isPersonalEmoteSet ?? false; // Handle personal emote set updates GLOBALLY (not per-chatroom) if (isPersonalSetUpdate) { diff --git a/src/renderer/src/providers/CosmeticsProvider.jsx b/src/renderer/src/providers/CosmeticsProvider.jsx index 5fdf00a..8cbd9d8 100644 --- a/src/renderer/src/providers/CosmeticsProvider.jsx +++ b/src/renderer/src/providers/CosmeticsProvider.jsx @@ -1,5 +1,145 @@ 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: { @@ -102,6 +242,129 @@ 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(() => { // Create lookup maps for O(1) access @@ -109,28 +372,36 @@ const useCosmeticsStore = create((set, get) => ({ const paintMap = new Map(); if (body.badges) { - body.badges.forEach(badge => { - badgeMap.set(badge.id, badge); - }); + body.badges + .map(buildBadgeFromData) + .filter(Boolean) + .forEach((badge) => { + badgeMap.set(badge.id, badge); + }); } if (body.paints) { - body.paints.forEach(paint => { - paintMap.set(paint.id, paint); - }); + body.paints + .map(normalizePaintEntry) + .filter(Boolean) + .forEach((paint) => { + paintMap.set(paint.id, paint); + }); } - const newState = { + const badges = Array.from(badgeMap.values()); + const paints = Array.from(paintMap.values()); + + return { globalCosmetics: { - ...body, + badges, + paints, }, cosmeticsLookup: { badgeMap, paintMap, }, }; - - return newState; }); }, diff --git a/utils/services/seventv/sharedStvWebSocket.js b/utils/services/seventv/sharedStvWebSocket.js index 3fc4608..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 { @@ -665,44 +536,26 @@ class SharedStvWebSocket extends EventTarget { break; case "emote_set.update": - // Handle personal emote sets that should be broadcast to all chatrooms - if (chatroomId === 'BROADCAST_TO_ALL') { - // Broadcast to all connected chatrooms - for (const [roomId] of this.chatrooms) { - this.dispatchEvent( - new CustomEvent("message", { - detail: { - body, - type: "emote_set.update", - chatroomId: roomId, - isPersonalEmoteSet: true, - }, - }), - ); - } - } else if (chatroomId) { - // Normal channel-specific emote set update - this.dispatchEvent( - new CustomEvent("message", { - detail: { - body, - type: "emote_set.update", - chatroomId, - isPersonalEmoteSet: false, - }, - }), - ); - } + // 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, }, }), @@ -723,6 +576,18 @@ class SharedStvWebSocket extends EventTarget { }), ); 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); @@ -756,12 +621,13 @@ class SharedStvWebSocket extends EventTarget { } // 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 should be broadcast to all chatrooms since they affect the user globally - return 'BROADCAST_TO_ALL'; + // Personal emote sets: return null (global), Zustand will propagate to all subscribers + return null; } // Reduced debug logging for performance @@ -836,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; From 2c863ca8170c2e5e0785f0e3c4afa5c748ffb91f Mon Sep 17 00:00:00 2001 From: BP602 Date: Wed, 1 Oct 2025 23:08:28 +0200 Subject: [PATCH 6/9] fix: stabilize personal emote dedupe key --- src/renderer/src/providers/ChatProvider.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index ba04889..d053f61 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -2923,8 +2923,11 @@ const useChatStore = create((set, get) => ({ // Handle personal emote set updates GLOBALLY (not per-chatroom) if (isPersonalSetUpdate) { - // Use a static flag to prevent processing the same personal set update multiple times - const updateKey = `personal_${body.id}_${Date.now()}`; + 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); From 91d091d2f33822e9f8ad3a7d3c5270b5a9dae18c Mon Sep 17 00:00:00 2001 From: BP602 Date: Thu, 2 Oct 2025 03:43:28 +0200 Subject: [PATCH 7/9] fix(cosmetics): prevent dedupe collisions for aggregated payloads Add hash-based fallback for cosmetic.create events that lack unique IDs (e.g., aggregated payloads). Previously, all such events fell back to 'unknown', causing collisions. Now hashes the payload to generate a unique dedupe key. Also adds debug logging to track dedupe behavior. --- src/renderer/src/providers/ChatProvider.jsx | 37 ++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index d053f61..e1fc459 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -33,6 +33,17 @@ const safeChatroomIdMatch = (roomId, chatroomId, context = 'unknown') => { return match; }; + +// Simple deterministic hash function for creating dedupe keys from payloads +const hashCode = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(36); // base36 for shorter string +}; import queueChannelFetch from "@utils/fetchQueue"; import ConnectionManager from "@utils/services/connectionManager"; import useCosmeticsStore from "./CosmeticsProvider"; @@ -1819,7 +1830,28 @@ const useChatStore = create((set, get) => ({ const rawId = cosmeticData?.id; const refId = cosmeticData?.ref_id; const sentinelId = "00000000000000000000000000"; - const dedupeId = rawId && rawId !== sentinelId ? rawId : (refId || rawId || body?.id || 'unknown'); + + // Extract unique ID, falling back to payload hash to prevent aggregated cosmetic collisions + let dedupeId; + if (rawId && rawId !== sentinelId) { + dedupeId = rawId; + } else if (refId) { + dedupeId = refId; + } else if (body?.id) { + dedupeId = body.id; + } else { + // No ID available - hash the payload to create unique key (prevents aggregated payload collisions) + const payloadSource = cosmeticData ?? body?.object ?? body ?? {}; + let payloadString = '{}'; + try { + payloadString = JSON.stringify(payloadSource) ?? '{}'; + } catch { + // fall through with default '{}' + } + const payloadHash = hashCode(payloadString); + dedupeId = `h${payloadHash}`; // prefix with 'h' for 'hash' + } + dedupKey = `${type}_${cosmeticKind}_${dedupeId}`; } else { // entitlement.create/delete have structure: { object: { user: { id }, ref_id, kind } } @@ -1839,6 +1871,9 @@ const useChatStore = create((set, get) => ({ return; // Skip duplicate } + // Log non-duplicate events for debugging + console.log("✅ processing", dedupKey, type); + // Update timestamp for this event recentEvents.set(dedupKey, now); From fd65c2a64d67a18b03748fed7152f5707c967ccf Mon Sep 17 00:00:00 2001 From: BP602 Date: Thu, 2 Oct 2025 03:57:17 +0200 Subject: [PATCH 8/9] feat(telemetry): trace 7TV events broadcast without chatroomId Add trace span to monitor non-cosmetic 7TV events that arrive without a chatroomId and get broadcast to all chatrooms. This helps identify potential inefficiencies and whether certain event types need different routing logic. Tracks event type, chatroom count, and body preview for analysis. --- src/renderer/src/providers/ChatProvider.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index e1fc459..e244dcb 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -1439,9 +1439,19 @@ const useChatStore = create((set, get) => ({ get().handleStvMessage(null, event.detail); } else { // Broadcast to all chatrooms if no specific chatroom (for non-cosmetic events) + // Track which event types arrive without chatroomId for monitoring + const span = window.app?.telemetry?.startSpan?.('seventv.broadcast_event_without_chatroomid'); + span?.setAttributes?.({ + 'event.type': type, + 'chatroom.count': chatrooms.size, + 'event.body_preview': body?.id || body?.object_id || 'no-id' + }); + chatrooms.forEach(chatroom => { get().handleStvMessage(chatroom.id, event.detail); }); + + span?.end?.(); } } } catch (error) { From b4e73d90e3bda2c828b376ba13137555f51abe12 Mon Sep 17 00:00:00 2001 From: BP602 Date: Thu, 2 Oct 2025 04:13:43 +0200 Subject: [PATCH 9/9] fix(telemetry): correct chatrooms scope in 7TV event handler Fix undefined variable error by accessing chatrooms from store state via get().chatrooms. Also changed .size to .length (array property) and added optional chaining for safety. --- src/renderer/src/providers/ChatProvider.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx index e244dcb..dabff20 100644 --- a/src/renderer/src/providers/ChatProvider.jsx +++ b/src/renderer/src/providers/ChatProvider.jsx @@ -1440,14 +1440,15 @@ const useChatStore = create((set, get) => ({ } else { // Broadcast to all chatrooms if no specific chatroom (for non-cosmetic events) // Track which event types arrive without chatroomId for monitoring + const chatrooms = get().chatrooms; const span = window.app?.telemetry?.startSpan?.('seventv.broadcast_event_without_chatroomid'); span?.setAttributes?.({ 'event.type': type, - 'chatroom.count': chatrooms.size, + 'chatroom.count': chatrooms?.length || 0, 'event.body_preview': body?.id || body?.object_id || 'no-id' }); - chatrooms.forEach(chatroom => { + chatrooms?.forEach?.(chatroom => { get().handleStvMessage(chatroom.id, event.detail); });