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