+ {/* Language Selection */}
+
+
+
{t('settings.language')}
+
{t('settings.languageDescription')}
+
+
+
+
+
+
- Always on Top
+ {t('settings.general.alwaysOnTop')}
@@ -51,6 +65,36 @@ const GeneralSection = ({ settingsData, onChange }) => {
/>
+
+
+
+
Auto Update
+
+
+
+
+
+
+
+ Automatically check for and download KickTalk updates on startup
+
+
+
+
+
+ onChange("general", {
+ ...settingsData?.general,
+ autoUpdate: checked,
+ })
+ }
+ />
+
+
+
+
+
+
Compact Chatroom List
+
+
+
+
+
+
+
+
+ Display chatroom tabs in a more compact layout to save
+ space
+
+
+
+
+
+
+ onChange("general", {
+ ...settingsData?.general,
+ compactChatroomsList: checked,
+ })
+ }
+ />
+
+
{
};
const NotificationsSection = ({ settingsData, onChange }) => {
- const [notificationFiles, setNotificationFiles] = useState([]);
const [openColorPicker, setOpenColorPicker] = useState(false);
const handleColorChange = useCallback(
@@ -503,14 +580,9 @@ const NotificationsSection = ({ settingsData, onChange }) => {
const getNotificationFiles = useCallback(async () => {
const files = await window.app.notificationSounds.getAvailable();
- setNotificationFiles(files);
return files;
}, []);
- useEffect(() => {
- getNotificationFiles();
- }, [getNotificationFiles]);
-
return (
@@ -783,6 +855,47 @@ const NotificationsSection = ({ settingsData, onChange }) => {
+
+ {/* Telemetry Section */}
+
+
+
Telemetry & Analytics
+
Control data collection and usage analytics.
+
+
+
+
+
+
+
Enable Telemetry
+
+
+
+
+
+
+
+ Allow KickTalk to collect anonymous usage data to help improve the application. This includes app performance metrics, error reports, and feature usage statistics. No personal chat data is collected.
+
+
+
+
+
+ onChange("telemetry", {
+ ...settingsData?.telemetry,
+ enabled: checked,
+ })
+ }
+ />
+
+
+
+
);
};
diff --git a/src/renderer/src/components/Dialogs/Settings/Sections/Moderation.jsx b/src/renderer/src/components/Dialogs/Settings/Sections/Moderation.jsx
index 1314aa8..b51aabb 100644
--- a/src/renderer/src/components/Dialogs/Settings/Sections/Moderation.jsx
+++ b/src/renderer/src/components/Dialogs/Settings/Sections/Moderation.jsx
@@ -1,14 +1,17 @@
+import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../Shared/Tooltip";
import InfoIcon from "../../../../assets/icons/info-fill.svg?asset";
import clsx from "clsx";
import { Switch } from "../../../Shared/Switch";
const ModerationSection = ({ settingsData, onChange }) => {
+ const { t } = useTranslation();
+
return (
-
Moderation
-
Customize your moderation experience.
+
{t('settings.moderation.title')}
+
{t('settings.moderation.description')}
@@ -18,7 +21,7 @@ const ModerationSection = ({ settingsData, onChange }) => {
active: settingsData?.moderation?.quickModTools,
})}>
-
Quick Mod Tools
+
{t('settings.moderation.quickModTools')}
@@ -26,8 +29,8 @@ const ModerationSection = ({ settingsData, onChange }) => {
-
- Enable quick moderation tools in chat messages
+
+ {t('settings.moderation.quickModToolsDescription')}
diff --git a/src/renderer/src/components/Dialogs/Settings/SettingsMenu.jsx b/src/renderer/src/components/Dialogs/Settings/SettingsMenu.jsx
index 5d96ebf..fefc2a2 100644
--- a/src/renderer/src/components/Dialogs/Settings/SettingsMenu.jsx
+++ b/src/renderer/src/components/Dialogs/Settings/SettingsMenu.jsx
@@ -1,8 +1,12 @@
+import { useTranslation } from "react-i18next";
import KickTalkLogo from "../../../assets/logos/KickTalkLogo.svg?asset";
import SignOut from "../../../assets/icons/sign-out-bold.svg?asset";
import clsx from "clsx";
-const SettingsMenu = ({ activeSection, setActiveSection, onLogout }) => (
+const SettingsMenu = ({ activeSection, setActiveSection, onLogout }) => {
+ const { t } = useTranslation();
+
+ return (
@@ -12,34 +16,34 @@ const SettingsMenu = ({ activeSection, setActiveSection, onLogout }) => (
active: activeSection === "info",
})}
onClick={() => setActiveSection("info")}>
-
About KickTalk
+
{t('settings.menu.aboutKickTalk')}
-
General
+ {t('settings.menu.general')}
setActiveSection("general")}>
- General
+ {t('settings.menu.general')}
-
Chat
+ {t('settings.menu.chat')}
setActiveSection("moderation")}>
- Moderation
+ {t('settings.menu.moderation')}
{/*
(
- Sign Out
+ {t('settings.menu.signOut')}
-);
+ );
+};
export default SettingsMenu;
diff --git a/src/renderer/src/components/Dialogs/User.jsx b/src/renderer/src/components/Dialogs/User.jsx
index 6febf5c..3ddc315 100644
--- a/src/renderer/src/components/Dialogs/User.jsx
+++ b/src/renderer/src/components/Dialogs/User.jsx
@@ -1,5 +1,6 @@
import "../../assets/styles/dialogs/UserDialog.scss";
import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { userKickTalkBadges } from "../../../../../utils/kickTalkBadges";
import clsx from "clsx";
import Message from "../Messages/Message";
@@ -17,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Sha
// TODO: Add Slider/Custom Timeout to User Dialog
const User = () => {
+ const { t } = useTranslation();
const [dialogData, setDialogData] = useState(null);
const [userProfile, setUserProfile] = useState(null);
const [userLogs, setUserLogs] = useState([]);
@@ -196,7 +198,7 @@ const User = () => {
-
Following since:
+
{t('userDialog.followingSince')}:
{userProfile?.following_since
? new Date(userProfile?.following_since).toLocaleDateString(undefined, {
@@ -209,11 +211,11 @@ const User = () => {
-
Subscribed for
+
{t('userDialog.subscribedFor')}
{userProfile?.subscribed_for > 1 || userProfile?.subscribed_for < 1
- ? `${userProfile?.subscribed_for} months`
- : `${userProfile?.subscribed_for} month`}
+ ? t('userDialog.monthsPlural', { count: userProfile?.subscribed_for })
+ : t('userDialog.monthsSingular', { count: userProfile?.subscribed_for })}
.
@@ -230,9 +232,9 @@ const User = () => {
!kickUsername
}
onClick={silenceUser}>
-
{isUserSilenced ? "Unmute User" : "Mute User"}
+
{isUserSilenced ? t('userDialog.unmuteUser') : t('userDialog.muteUser')}
-
+
{
const transformedUsername = dialogData?.sender?.username.toLowerCase();
window.open(`https://kick.com/${transformedUsername}`, "_blank", "noopener,noreferrer");
}}>
- Open Channel
+ {t('userDialog.openProfile')}
@@ -259,27 +261,27 @@ const User = () => {
- Unban User
+ {t('userDialog.unban')}
handleTimeoutUser(1)}>
- 1m
+ {t('userDialog.timeout1m')}
handleTimeoutUser(5)}>
- 5m
+ {t('userDialog.timeout5m')}
handleTimeoutUser(30)}>
- 30m
+ {t('userDialog.timeout30m')}
handleTimeoutUser(60)}>
- 1h
+ {t('userDialog.timeout1h')}
handleTimeoutUser(1440)}>
- 1d
+ {t('userDialog.timeout24h')}
handleTimeoutUser(10080)}>
- 1w
+ {t('userDialog.timeout1w')}
{/*
@@ -296,7 +298,7 @@ const User = () => {
- Ban User
+ {t('userDialog.ban')}
diff --git a/src/renderer/src/components/Messages/EmoteUpdateMessage.jsx b/src/renderer/src/components/Messages/EmoteUpdateMessage.jsx
index de70a12..7374754 100644
--- a/src/renderer/src/components/Messages/EmoteUpdateMessage.jsx
+++ b/src/renderer/src/components/Messages/EmoteUpdateMessage.jsx
@@ -1,6 +1,8 @@
+import { useTranslation } from "react-i18next";
import stvLogo from "../../assets/logos/stvLogo.svg?asset";
const EmoteUpdateMessage = ({ message }) => {
+ const { t } = useTranslation();
return (
<>
{message.data.added?.length > 0 &&
@@ -9,8 +11,10 @@ const EmoteUpdateMessage = ({ message }) => {
- {message.data.setType === "personal" ? "Personal" : "Channel"}
- Added
+
+ {message.data.setType === "personal" ? t('messages.emoteUpdate.personal') : t('messages.emoteUpdate.channel')}
+
+ {t('messages.emoteUpdate.added')}
{message.data.authoredBy &&
{message.data.authoredBy?.display_name} }
@@ -19,7 +23,7 @@ const EmoteUpdateMessage = ({ message }) => {
{e.name}
- Made by: {e.owner?.display_name}
+ {t('messages.emoteUpdate.madeBy', { creator: e.owner?.display_name })}
@@ -31,8 +35,10 @@ const EmoteUpdateMessage = ({ message }) => {
- {message.data.setType === "personal" ? "Personal" : "Channel"}
- Removed
+
+ {message.data.setType === "personal" ? t('messages.emoteUpdate.personal') : t('messages.emoteUpdate.channel')}
+
+ {t('messages.emoteUpdate.removed')}
{message.data.authoredBy &&
{message.data.authoredBy?.display_name} }
@@ -41,7 +47,7 @@ const EmoteUpdateMessage = ({ message }) => {
{e.name}
- Made by: {e.owner?.display_name}
+ {t('messages.emoteUpdate.madeBy', { creator: e.owner?.display_name })}
@@ -53,8 +59,10 @@ const EmoteUpdateMessage = ({ message }) => {
- {message.data.setType === "personal" ? "Personal" : "Channel"}
- Renamed
+
+ {message.data.setType === "personal" ? t('messages.emoteUpdate.personal') : t('messages.emoteUpdate.channel')}
+
+ {t('messages.emoteUpdate.renamed')}
{message.data.authoredBy &&
{message.data.authoredBy?.display_name} }
diff --git a/src/renderer/src/components/Messages/Message.jsx b/src/renderer/src/components/Messages/Message.jsx
index a9d0eb2..6c59d65 100644
--- a/src/renderer/src/components/Messages/Message.jsx
+++ b/src/renderer/src/components/Messages/Message.jsx
@@ -1,5 +1,6 @@
import "../../assets/styles/components/Chat/Message.scss";
import { useCallback, useRef, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import ModActionMessage from "./ModActionMessage";
import RegularMessage from "./RegularMessage";
import EmoteUpdateMessage from "./EmoteUpdateMessage";
@@ -35,6 +36,7 @@ const Message = ({
chatroomName,
donators,
}) => {
+ const { t } = useTranslation();
const messageRef = useRef(null);
const getDeleteMessage = useChatStore(useShallow((state) => state.getDeleteMessage));
const [rightClickedEmote, setRightClickedEmote] = useState(null);
@@ -136,6 +138,7 @@ const Message = ({
}
};
+
const handleOpenEmoteLink = () => {
if (rightClickedEmote) {
let emoteUrl = "";
@@ -297,6 +300,8 @@ const Message = ({
message.type === "stvEmoteSetUpdate" && "emoteSetUpdate",
type === "dialog" && "dialogChatMessageItem",
shouldHighlightMessage && "highlighted",
+ message.isOptimistic && message.state === "optimistic" && "optimistic",
+ message.isOptimistic && message.state === "failed" && "failed",
)}
style={{
backgroundColor: shouldHighlightMessage ? rgbaObjectToString(settings?.notifications?.backgroundRgba) : "transparent",
@@ -328,6 +333,7 @@ const Message = ({
donatorBadges={donatorBadges}
subscriberBadges={subscriberBadges}
sevenTVEmotes={allStvEmotes}
+ sevenTVSettings={settings?.sevenTV}
userStyle={userStyle}
handleOpenUserDialog={handleOpenUserDialog}
userChatroomInfo={userChatroomInfo}
@@ -342,9 +348,9 @@ const Message = ({
{message.type === "system" && (
{message.content === "connection-pending"
- ? "Connecting to Channel..."
+ ? t('messages.connecting')
: message.content === "connection-success"
- ? "Connected to Channel"
+ ? t('messages.connected')
: message.content}
)}
diff --git a/src/renderer/src/components/Messages/MessagesHandler.jsx b/src/renderer/src/components/Messages/MessagesHandler.jsx
index 5981c3c..0b3bca4 100644
--- a/src/renderer/src/components/Messages/MessagesHandler.jsx
+++ b/src/renderer/src/components/Messages/MessagesHandler.jsx
@@ -1,5 +1,6 @@
import { memo, useMemo, useEffect, useState, useRef, useCallback } from "react";
import { Virtuoso } from "react-virtuoso";
+import { useTranslation } from "react-i18next";
import useChatStore from "../../providers/ChatProvider";
import Message from "./Message";
import MouseScroll from "../../assets/icons/mouse-scroll-fill.svg?asset";
@@ -18,6 +19,7 @@ const MessagesHandler = memo(
userId,
donators,
}) => {
+ const { t } = useTranslation();
const virtuosoRef = useRef(null);
const chatContainerRef = useRef(null);
const [silencedUserIds, setSilencedUserIds] = useState(new Set());
@@ -28,7 +30,7 @@ const MessagesHandler = memo(
if (!messages?.length) return [];
return messages.filter((message) => {
- if (message?.chatroom_id !== chatroomId) return false;
+ if (message?.chatroom_id != chatroomId) return false;
if (message?.type === "system" || message?.type === "mod_action") return true;
if (message?.type !== "reply" && message?.type !== "message") return true;
@@ -36,28 +38,6 @@ const MessagesHandler = memo(
});
}, [messages, chatroomId, silencedUserIds]);
- useEffect(() => {
- if (filteredMessages.length > 0 && !isPaused) {
- virtuosoRef.current?.scrollToIndex({
- index: filteredMessages.length - 1,
- behavior: "instant",
- align: "end",
- });
- }
- }, [chatroomId]);
-
- useEffect(() => {
- if (virtuosoRef.current && atBottom) {
- setTimeout(() => {
- virtuosoRef.current?.scrollToIndex({
- index: filteredMessages.length - 1,
- align: "start",
- behavior: "instant",
- });
- }, 0);
- }
- }, [filteredMessages, atBottom]);
-
const handleScroll = useCallback(
(e) => {
if (!e?.target) return;
@@ -79,23 +59,19 @@ const MessagesHandler = memo(
setIsPaused(newPausedState);
useChatStore.getState().handleChatroomPause(chatroomId, newPausedState);
- if (!newPausedState && filteredMessages?.length) {
- virtuosoRef.current?.scrollToIndex({
- index: filteredMessages.length - 1,
- behavior: "instant",
- align: "end",
- });
+ virtuosoRef.current?.scrollToIndex({
+ index: filteredMessages.length - 1,
+ align: "start",
+ behavior: "instant",
+ });
+
+ if (!newPausedState) {
setAtBottom(true);
}
};
const itemContent = useCallback(
(index, message) => {
- // if (!message?.id) {
- // console.warn("[MessagesHandler]: Message without ID at index:", index);
- // return null;
- // }
-
// Hide mod actions if the setting is disabled
if (message?.type === "mod_action" && !settings?.chatrooms?.showModActions) {
return false;
@@ -163,14 +139,12 @@ const MessagesHandler = memo(
itemContent={itemContent}
computeItemKey={computeItemKey}
onScroll={handleScroll}
- // followOutput={"auto"}
+ followOutput={isPaused ? false : "smooth"}
initialTopMostItemIndex={filteredMessages?.length - 1}
- // alignToBottom={true}
- // atBottomStateChange={setAtBottom}
- atBottomThreshold={100}
- overscan={20}
- increaseViewportBy={200}
- defaultItemHeight={45}
+ atBottomThreshold={6}
+ overscan={50}
+ increaseViewportBy={400}
+ defaultItemHeight={50}
style={{
height: "100%",
width: "100%",
@@ -180,8 +154,8 @@ const MessagesHandler = memo(
{!atBottom && (
- Scroll To Bottom
-
+ {t('messages.scrollToBottom')}
+
)}
@@ -189,7 +163,6 @@ const MessagesHandler = memo(
},
);
-// Add displayName for debugging
MessagesHandler.displayName = "MessagesHandler";
export default MessagesHandler;
diff --git a/src/renderer/src/components/Messages/ModActionMessage.jsx b/src/renderer/src/components/Messages/ModActionMessage.jsx
index fc41b25..fb586ec 100644
--- a/src/renderer/src/components/Messages/ModActionMessage.jsx
+++ b/src/renderer/src/components/Messages/ModActionMessage.jsx
@@ -1,9 +1,11 @@
import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
import { convertMinutesToHumanReadable } from "../../utils/ChatUtils";
import useCosmeticsStore from "../../providers/CosmeticsProvider";
import { useShallow } from "zustand/react/shallow";
const ModActionMessage = ({ message, chatroomId, allStvEmotes, subscriberBadges, chatroomName, userChatroomInfo }) => {
+ const { t } = useTranslation();
const { modAction, modActionDetails } = message;
const getUserStyle = useCosmeticsStore(useShallow((state) => state.getUserStyle));
@@ -51,14 +53,20 @@ const ModActionMessage = ({ message, chatroomId, allStvEmotes, subscriberBadges,
{isBanAction ? (
<>
handleOpenUserDialog(moderator)}>{moderator} {" "}
- {modAction === "banned" ? "permanently banned " : "timed out "}
+ {modAction === "banned"
+ ? t('messages.modAction.permanentlyBanned')
+ : t('messages.modAction.timedOut')
+ }{" "}
handleOpenUserDialog(username)}>{username} {" "}
- {modAction === "ban_temporary" && ` for ${convertMinutesToHumanReadable(duration)}`}
+ {modAction === "ban_temporary" && t('messages.modAction.forDuration', { duration: convertMinutesToHumanReadable(duration) })}
>
) : (
<>
handleOpenUserDialog(moderator)}>{moderator} {" "}
- {modAction === "unbanned" ? "unbanned" : "removed timeout on"}{" "}
+ {modAction === "unbanned"
+ ? t('messages.modAction.unbanned')
+ : t('messages.modAction.removedTimeoutOn')
+ }{" "}
handleOpenUserDialog(username)}>{username}
>
)}
diff --git a/src/renderer/src/components/Messages/RegularMessage.jsx b/src/renderer/src/components/Messages/RegularMessage.jsx
index 352b7d0..d482b50 100644
--- a/src/renderer/src/components/Messages/RegularMessage.jsx
+++ b/src/renderer/src/components/Messages/RegularMessage.jsx
@@ -1,10 +1,12 @@
import { memo, useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { MessageParser } from "../../utils/MessageParser";
import { KickBadges, KickTalkBadges, StvBadges } from "../Cosmetics/Badges";
import { getTimestampFormat } from "../../utils/ChatUtils";
import CopyIcon from "../../assets/icons/copy-simple-fill.svg?asset";
import ReplyIcon from "../../assets/icons/reply-fill.svg?asset";
import Pin from "../../assets/icons/push-pin-fill.svg?asset";
+import RetryIcon from "../../assets/icons/arrow-clockwise-fill.svg?asset";
import clsx from "clsx";
import ModActions from "./ModActions";
import useChatStore from "../../providers/ChatProvider";
@@ -26,6 +28,7 @@ const RegularMessage = memo(
isSearch = false,
settings,
}) => {
+ const { t } = useTranslation();
const getPinMessage = useChatStore((state) => state.getPinMessage);
const canModerate = useMemo(
@@ -59,6 +62,12 @@ const RegularMessage = memo(
getPinMessage(chatroomId, data);
}, [message?.id, message?.chatroom_id, message?.content, message?.sender, chatroomName, getPinMessage, chatroomId]);
+ const handleRetryMessage = useCallback(() => {
+ if (message.isOptimistic && message.state === "failed" && message.tempId) {
+ useChatStore.getState().retryFailedMessage(chatroomId, message.tempId);
+ }
+ }, [message.isOptimistic, message.state, message.tempId, chatroomId]);
+
const usernameStyle = useMemo(() => {
if (userStyle?.paint) {
return {
@@ -66,7 +75,9 @@ const RegularMessage = memo(
filter: userStyle.paint.shadows,
};
}
- return { color: message.sender.identity?.color };
+ return {
+ color: message.sender.identity?.color || 'var(--text-primary)'
+ };
}, [userStyle?.paint, message.sender.identity?.color]);
const messageContent = useMemo(
@@ -97,7 +108,9 @@ const RegularMessage = memo(
}, [canModerate, settings?.moderation?.quickModTools, message?.deleted, message?.sender?.username, chatroomName, username]);
return (
-
+
{settings?.general?.timestampFormat !== "disabled" &&
{timestamp} }
{shouldShowModActions &&
}
@@ -127,21 +140,36 @@ const RegularMessage = memo(
{messageContent}
- {canModerate && !message?.deleted && (
-
-
-
+ {message.isOptimistic && message.state === "failed" ? (
+ // Show retry and copy buttons for failed messages
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+ // Show normal action buttons for successful messages
+ <>
+ {canModerate && !message?.deleted && (
+
+
+
+ )}
+
+ {!message?.deleted && (
+
+
+
+ )}
+
+
+
+
+ >
)}
-
- {!message?.deleted && (
-
-
-
- )}
-
-
-
-
);
diff --git a/src/renderer/src/components/Messages/ReplyMessage.jsx b/src/renderer/src/components/Messages/ReplyMessage.jsx
index c02e695..2576807 100644
--- a/src/renderer/src/components/Messages/ReplyMessage.jsx
+++ b/src/renderer/src/components/Messages/ReplyMessage.jsx
@@ -8,6 +8,7 @@ import { memo, useMemo } from "react";
const ReplyMessage = ({
message,
sevenTVEmotes,
+ sevenTVSettings,
subscriberBadges,
kickTalkBadges,
donatorBadges,
@@ -45,6 +46,7 @@ const ReplyMessage = ({
type="reply"
message={message?.metadata?.original_message}
sevenTVEmotes={sevenTVEmotes}
+ sevenTVSettings={sevenTVSettings}
userChatroomInfo={userChatroomInfo}
chatroomId={chatroomId}
chatroomName={chatroomName}
diff --git a/src/renderer/src/components/Navbar.jsx b/src/renderer/src/components/Navbar.jsx
index c3b60f9..820382c 100644
--- a/src/renderer/src/components/Navbar.jsx
+++ b/src/renderer/src/components/Navbar.jsx
@@ -1,6 +1,6 @@
import "../assets/styles/components/Navbar.scss";
import clsx from "clsx";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useChatStore from "../providers/ChatProvider";
import Plus from "../assets/icons/plus-bold.svg?asset";
import X from "../assets/icons/x-bold.svg?asset";
@@ -13,13 +13,16 @@ import ChatroomTab from "./Navbar/ChatroomTab";
import MentionsTab from "./Navbar/MentionsTab";
const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
+ const { t } = useTranslation();
const { settings } = useSettings();
- const connections = useChatStore((state) => state.connections);
const addChatroom = useChatStore((state) => state.addChatroom);
const removeChatroom = useChatStore((state) => state.removeChatroom);
const renameChatroom = useChatStore((state) => state.renameChatroom);
const reorderChatrooms = useChatStore((state) => state.reorderChatrooms);
- const orderedChatrooms = useChatStore((state) => state.getOrderedChatrooms());
+ const chatrooms = useChatStore((state) => state.chatrooms);
+ const orderedChatrooms = useMemo(() => {
+ return [...chatrooms].sort((a, b) => (a.order || 0) - (b.order || 0));
+ }, [chatrooms]);
const hasMentionsTab = useChatStore((state) => state.hasMentionsTab);
const addMentionsTab = useChatStore((state) => state.addMentionsTab);
const removeMentionsTab = useChatStore((state) => state.removeMentionsTab);
@@ -67,8 +70,6 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
};
const handleRemoveChatroom = async (chatroomId) => {
- if (!connections[chatroomId]) return;
-
const currentIndex = orderedChatrooms.findIndex((chatroom) => chatroom.id === chatroomId);
await removeChatroom(chatroomId);
@@ -199,7 +200,14 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
return (
<>
-
-
Add Chatroom
-
Enter a channel name to add a new chatroom
+
{t('navbar.addChatroom')}
+
{t('navbar.addChatroomDescription')}
@@ -298,12 +306,12 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
-
Add Mentions Tab
-
Add a tab to view all your mentions & highlights in all chats in one place
+
{t('navbar.addMentionsTab')}
+
{t('navbar.addMentionsDescription')}
- Add Mentions Tab
+ {t('navbar.addMentionsTab')}
@@ -327,8 +335,8 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
}
}}
disabled={isConnecting}>
- Add
-
+ {t('common.add')}
+
)}
diff --git a/src/renderer/src/components/Navbar/ChatroomTab.jsx b/src/renderer/src/components/Navbar/ChatroomTab.jsx
index 6a8b0cb..489421f 100644
--- a/src/renderer/src/components/Navbar/ChatroomTab.jsx
+++ b/src/renderer/src/components/Navbar/ChatroomTab.jsx
@@ -1,4 +1,4 @@
-import { memo } from "react";
+import { memo, useMemo } from "react";
import { Draggable } from "@hello-pangea/dnd";
import {
ContextMenu,
@@ -9,6 +9,7 @@ import {
} from "../Shared/ContextMenu";
import clsx from "clsx";
import useChatStore from "../../providers/ChatProvider";
+import { useShallow } from "zustand/react/shallow";
import X from "../../assets/icons/x-bold.svg?asset";
const ChatroomTab = memo(
@@ -27,8 +28,11 @@ const ChatroomTab = memo(
renameInputRef,
settings,
}) => {
- const chatroomMessages = useChatStore((state) => state.messages[chatroom.id] || []);
- const unreadCount = chatroomMessages.filter((message) => !message.isRead && message.type !== "system").length;
+ const chatroomMessages = useChatStore(useShallow((state) => state.messages[chatroom.id] || []));
+
+ const unreadCount = useMemo(() => {
+ return chatroomMessages.filter((message) => !message.isRead && message.type !== "system").length;
+ }, [chatroomMessages]);
return (
diff --git a/src/renderer/src/components/Navbar/MentionsTab.jsx b/src/renderer/src/components/Navbar/MentionsTab.jsx
index 16886b5..d142817 100644
--- a/src/renderer/src/components/Navbar/MentionsTab.jsx
+++ b/src/renderer/src/components/Navbar/MentionsTab.jsx
@@ -1,31 +1,39 @@
import { memo } from "react";
import clsx from "clsx";
import X from "../../assets/icons/x-bold.svg?asset";
+import NotificationIcon from "../../assets/icons/notification-bell.svg?asset";
const MentionsTab = memo(({ currentChatroomId, onSelectChatroom, onRemoveMentionsTab }) => {
- return (
- onSelectChatroom("mentions")}
- onMouseDown={(e) => {
- if (e.button === 1) {
- onRemoveMentionsTab();
- }
- }}
- className={clsx("chatroomStreamer", currentChatroomId === "mentions" && "chatroomStreamerActive")}>
-
- Mentions
-
-
{
- e.stopPropagation();
- onRemoveMentionsTab();
+ return (
+ onSelectChatroom("mentions")}
+ onMouseDown={(e) => {
+ if (e.button === 1) {
+ onRemoveMentionsTab();
+ }
}}
+ className={clsx("chatroomStreamer", currentChatroomId === "mentions" && "chatroomStreamerActive")}>
+
+
+
Mentions
+
+
{
+ e.stopPropagation();
+ onRemoveMentionsTab();
+ }}
aria-label="Remove mentions tab">
-
-
-
- );
+
+
+
+ );
});
MentionsTab.displayName = "MentionsTab";
diff --git a/src/renderer/src/components/Shared/LanguageSelector.jsx b/src/renderer/src/components/Shared/LanguageSelector.jsx
new file mode 100644
index 0000000..2de9a75
--- /dev/null
+++ b/src/renderer/src/components/Shared/LanguageSelector.jsx
@@ -0,0 +1,75 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLanguage } from '../../utils/useLanguage';
+import { useSettings } from '../../providers/SettingsProvider';
+import clsx from 'clsx';
+import './LanguageSelector.scss';
+
+const LanguageSelector = ({ className, showFlags = true, compact = false }) => {
+ const { t } = useTranslation();
+ const { changeLanguage, getCurrentLanguage, getAvailableLanguages } = useLanguage();
+ const { updateSettings } = useSettings();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const languages = getAvailableLanguages();
+ const currentLanguage = getCurrentLanguage();
+ const currentLangData = languages.find(lang => lang.code === currentLanguage);
+
+ const handleLanguageChange = async (languageCode) => {
+ try {
+ // Change language using the hook
+ await changeLanguage(languageCode);
+
+ // Also persist in settings store
+ await updateSettings('language', languageCode);
+
+ setIsOpen(false);
+
+ console.log(`Language successfully changed to: ${languageCode}`);
+ } catch (error) {
+ console.error('Error changing language:', error);
+ }
+ };
+
+ return (
+
+
setIsOpen(!isOpen)}
+ aria-label={t('settings.language')}
+ >
+ {showFlags && currentLangData?.flag && (
+ {currentLangData.flag}
+ )}
+
+ {compact ? currentLangData?.code?.toUpperCase() : currentLangData?.name}
+
+ ▼
+
+
+ {isOpen && (
+
+ {languages.map((language) => (
+ handleLanguageChange(language.code)}
+ >
+ {showFlags && language.flag && (
+ {language.flag}
+ )}
+ {language.name}
+ {language.code === currentLanguage && (
+ ✓
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default LanguageSelector;
diff --git a/src/renderer/src/components/Shared/LanguageSelector.scss b/src/renderer/src/components/Shared/LanguageSelector.scss
new file mode 100644
index 0000000..56e04f3
--- /dev/null
+++ b/src/renderer/src/components/Shared/LanguageSelector.scss
@@ -0,0 +1,178 @@
+.language-selector {
+ position: relative;
+ display: inline-block;
+
+ .language-selector-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--bg-input);
+ border: 1px solid var(--border-primary);
+ border-radius: 6px;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 14px;
+ font-family: inherit;
+
+ &:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-hover);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--border-focus);
+ background: var(--input-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+ }
+
+ .language-flag {
+ font-size: 16px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ }
+
+ .language-name {
+ min-width: 60px;
+ text-align: left;
+ color: var(--text-primary);
+ font-weight: 500;
+ }
+
+ .dropdown-arrow {
+ font-size: 10px;
+ transition: transform 0.2s ease;
+ color: var(--text-tertiary);
+
+ &.rotated {
+ transform: rotate(180deg);
+ }
+ }
+ }
+
+ .language-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-dialog-secondary);
+ border: 1px solid var(--border-dialog);
+ border-radius: 6px;
+ box-shadow: var(--shadow-dialog);
+ z-index: 1000;
+ overflow: hidden;
+ margin-top: 4px;
+ backdrop-filter: blur(10px);
+
+ .language-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 12px;
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 14px;
+ font-family: inherit;
+
+ &:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+ }
+
+ &.active {
+ background: var(--bg-selected);
+ color: var(--text-primary);
+ font-weight: 600;
+ border-left: 3px solid var(--text-success);
+ }
+
+ .language-flag {
+ font-size: 16px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ }
+
+ .language-name {
+ flex: 1;
+ text-align: left;
+ font-weight: 500;
+ }
+
+ .check-mark {
+ color: var(--text-success);
+ font-weight: bold;
+ font-size: 12px;
+ }
+ }
+ }
+
+ &.compact {
+ .language-selector-button {
+ padding: 6px 8px;
+ min-width: auto;
+
+ .language-name {
+ min-width: 30px;
+ font-size: 12px;
+ font-weight: 600;
+ }
+ }
+
+ .language-dropdown {
+ min-width: 120px;
+ }
+ }
+
+ &.open {
+ .language-selector-button {
+ border-color: var(--border-focus);
+ background: var(--input-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+ }
+ }
+}
+
+/* Animation and smooth transitions - matching other components */
+.language-dropdown {
+ animation: fadeIn 0.2s ease-out, zoomIn 0.2s ease-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes zoomIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Focus and accessibility improvements */
+.language-selector-button:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+}
+
+.language-option:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: -2px;
+ background: var(--bg-hover);
+}
diff --git a/src/renderer/src/components/TitleBar.jsx b/src/renderer/src/components/TitleBar.jsx
index 0977a74..80adc37 100644
--- a/src/renderer/src/components/TitleBar.jsx
+++ b/src/renderer/src/components/TitleBar.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
+import { useTranslation } from "react-i18next";
import Minus from "../assets/icons/minus-bold.svg?asset";
import Square from "../assets/icons/square-bold.svg?asset";
@@ -8,11 +9,13 @@ import GearIcon from "../assets/icons/gear-fill.svg?asset";
import "../assets/styles/components/TitleBar.scss";
import clsx from "clsx";
import Updater from "./Updater";
+import useChatStore from "../providers/ChatProvider";
const TitleBar = () => {
- const [userData, setUserData] = useState(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [appInfo, setAppInfo] = useState({});
+ const currentUser = useChatStore((state) => state.currentUser);
+ const cacheCurrentUser = useChatStore((state) => state.cacheCurrentUser);
useEffect(() => {
const getAppInfo = async () => {
@@ -20,24 +23,13 @@ const TitleBar = () => {
setAppInfo(appInfo);
};
- const fetchUserData = async () => {
- try {
- const data = await window.app.kick.getSelfInfo();
- const kickId = localStorage.getItem("kickId");
-
- if (!kickId && data?.id) {
- localStorage.setItem("kickId", data.id);
- }
-
- setUserData(data);
- } catch (error) {
- console.error("[TitleBar]: Failed to fetch user data:", error);
- }
- };
-
getAppInfo();
- fetchUserData();
- }, []);
+
+ // Cache user info if not already cached
+ if (!currentUser) {
+ cacheCurrentUser();
+ }
+ }, [currentUser, cacheCurrentUser]);
const handleAuthBtn = useCallback((e) => {
const cords = [e.clientX, e.clientY];
@@ -52,39 +44,35 @@ const TitleBar = () => {
- {userData?.id ? (
+ {currentUser?.id ? (
window.app.settingsDialog.open({
- userData,
+ userData: currentUser,
})
}>
- {userData?.username || "Loading..."}
+ {currentUser?.username || "Loading..."}
-
+
) : (
- Sign In
+ {t('auth.signIn')}
window.app.settingsDialog.open({
- userData,
+ userData: currentUser,
})
}>
-
+
)}
-
- {settingsModalOpen && (
-
- )}
@@ -92,13 +80,13 @@ const TitleBar = () => {
window.app.minimize()}>
-
+
window.app.maximize()}>
-
+
window.app.close()}>
-
+
diff --git a/src/renderer/src/components/Updater.jsx b/src/renderer/src/components/Updater.jsx
index 1d237c7..1d38a10 100644
--- a/src/renderer/src/components/Updater.jsx
+++ b/src/renderer/src/components/Updater.jsx
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import clsx from "clsx";
import log from "electron-log";
import downloadIcon from "../../src/assets/icons/cloud-arrow-down-fill.svg?asset";
const Updater = () => {
+ const { t } = useTranslation();
const [updateStatus, setUpdateStatus] = useState("idle");
const [updateInfo, setUpdateInfo] = useState(null);
@@ -20,6 +22,20 @@ const Updater = () => {
};
}, []);
+ // Listen for auto-update dismiss when user disables auto-update in settings
+ useEffect(() => {
+ const handleDismiss = () => {
+ setUpdateStatus("idle");
+ setUpdateInfo(null);
+ };
+
+ const cleanup = window.app.update.onDismiss(handleDismiss);
+
+ return () => {
+ cleanup();
+ };
+ }, []);
+
const handleCheckForUpdate = async () => {
setUpdateStatus("checking");
@@ -60,11 +76,11 @@ const Updater = () => {
const getButtonConfig = () => {
switch (updateStatus) {
case "ready":
- return { text: "Update Now", action: handleInstallUpdate, disabled: false, show: true };
+ return { text: t('updater.updateNow'), action: handleInstallUpdate, disabled: false, show: true };
case "download-failed":
- return { text: "Retry Update", action: handleDownloadUpdate, disabled: false, show: true };
+ return { text: t('updater.retryUpdate'), action: handleDownloadUpdate, disabled: false, show: true };
case "error":
- return { text: "Error - Retry Update", action: handleCheckForUpdate, disabled: false, show: true };
+ return { text: t('updater.errorRetryUpdate'), action: handleCheckForUpdate, disabled: false, show: true };
default:
return { show: false };
}
diff --git a/src/renderer/src/dialogs/Auth.jsx b/src/renderer/src/dialogs/Auth.jsx
index e2a709a..fbe3a4e 100644
--- a/src/renderer/src/dialogs/Auth.jsx
+++ b/src/renderer/src/dialogs/Auth.jsx
@@ -1,5 +1,6 @@
import "../assets/styles/main.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/dialogs/Chatters.jsx b/src/renderer/src/dialogs/Chatters.jsx
index 04bacbc..37babdc 100644
--- a/src/renderer/src/dialogs/Chatters.jsx
+++ b/src/renderer/src/dialogs/Chatters.jsx
@@ -1,6 +1,7 @@
import "../assets/styles/main.scss";
import "../assets/styles/dialogs/Chatters.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/dialogs/ReplyThread.jsx b/src/renderer/src/dialogs/ReplyThread.jsx
index 4dea544..8c3df99 100644
--- a/src/renderer/src/dialogs/ReplyThread.jsx
+++ b/src/renderer/src/dialogs/ReplyThread.jsx
@@ -1,6 +1,7 @@
import "../assets/styles/main.scss";
import "../assets/styles/dialogs/ReplyThreadDialog.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/dialogs/Search.jsx b/src/renderer/src/dialogs/Search.jsx
index fa0b3ea..85310bc 100644
--- a/src/renderer/src/dialogs/Search.jsx
+++ b/src/renderer/src/dialogs/Search.jsx
@@ -1,6 +1,7 @@
import "../assets/styles/main.scss";
import "../assets/styles/dialogs/Search.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/dialogs/Settings.jsx b/src/renderer/src/dialogs/Settings.jsx
index 75ec676..1258525 100644
--- a/src/renderer/src/dialogs/Settings.jsx
+++ b/src/renderer/src/dialogs/Settings.jsx
@@ -1,6 +1,7 @@
import "../assets/styles/main.scss";
import "../assets/styles/dialogs/Chatters.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/dialogs/User.jsx b/src/renderer/src/dialogs/User.jsx
index 693a9b6..6e708c1 100644
--- a/src/renderer/src/dialogs/User.jsx
+++ b/src/renderer/src/dialogs/User.jsx
@@ -1,5 +1,6 @@
import "../assets/styles/main.scss";
import "../../../../utils/themeUtils";
+import "../utils/i18n";
import React from "react";
import ReactDOM from "react-dom/client";
diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json
new file mode 100644
index 0000000..59150eb
--- /dev/null
+++ b/src/renderer/src/locales/en.json
@@ -0,0 +1,246 @@
+{
+ "auth": {
+ "signIn": "Sign In",
+ "signInWithKick": "Sign in with your Kick account",
+ "loginWithKick": "Login with Kick",
+ "loginWithGoogle": "Login with Google",
+ "loginWithApple": "Login with Apple",
+ "continueAnonymous": "Continue anonymous",
+ "kickLoginDescription": "Use username and password for login? Continue to Kick.com",
+ "googleAppleDescription": "Already have a Kick account with Google or Apple login?",
+ "disclaimer": "We do NOT save any emails or passwords."
+ },
+ "titleBar": {
+ "loading": "Loading...",
+ "settings": "Settings",
+ "minimize": "Minimize",
+ "maximize": "Maximize",
+ "close": "Close"
+ },
+ "chat": {
+ "addChatroom": "Add a chatroom by using \"CTRL\"+\"t\" or clicking Add button",
+ "pinMessage": "Pin Message",
+ "copyMessage": "Copy Message",
+ "replyTo": "Reply to {{username}}"
+ },
+ "navbar": {
+ "chatroom": "Chatroom",
+ "mentions": "Mentions",
+ "addChatroom": "Add Chatroom",
+ "addChatroomDescription": "Enter a channel name to add a new chatroom",
+ "enterStreamerName": "Enter streamer name...",
+ "connecting": "Connecting...",
+ "addMentionsTab": "Add Mentions Tab",
+ "addMentionsDescription": "Add a tab to view all your mentions & highlights in all chats in one place",
+ "closeAddMentions": "Close Add Mentions"
+ },
+ "userDialog": {
+ "muteUser": "Mute User",
+ "unmuteUser": "Unmute User",
+ "openProfile": "Open Channel",
+ "check": "Check",
+ "unban": "Unban User",
+ "timeout1m": "1m",
+ "timeout5m": "5m",
+ "timeout10m": "10m",
+ "timeout30m": "30m",
+ "timeout1h": "1h",
+ "timeout3h": "3h",
+ "timeout6h": "6h",
+ "timeout12h": "12h",
+ "timeout24h": "1d",
+ "timeout1w": "1w",
+ "ban": "Ban User",
+ "followingSince": "Following since",
+ "subscribedFor": "Subscribed for",
+ "monthsSingular": "{{count}} month",
+ "monthsPlural": "{{count}} months"
+ },
+ "settings": {
+ "title": "Settings",
+ "language": "Language",
+ "languageDescription": "Choose your preferred language",
+ "menu": {
+ "aboutKickTalk": "About KickTalk",
+ "general": "General",
+ "chat": "Chat",
+ "moderation": "Moderation",
+ "signOut": "Sign Out"
+ },
+ "general": {
+ "title": "General",
+ "description": "Select what general app settings you want to change.",
+ "alwaysOnTop": "Always on top",
+ "alwaysOnTopDescription": "Keep the app always on top of other windows",
+ "wrapChatroomsList": "Wrap chatrooms list",
+ "wrapChatroomsListDescription": "Show chatrooms list in multiple rows when there are many tabs",
+ "showTabImages": "Show tab images",
+ "showTabImagesDescription": "Show streamer profile pictures in chatroom tabs",
+ "timestampFormat": "Timestamp format",
+ "timestampFormatDescription": "Choose how timestamps are displayed in chat messages",
+ "disabled": "Disabled"
+ },
+ "chatrooms": {
+ "title": "Chatrooms",
+ "description": "Configure chatroom-specific settings and behavior.",
+ "autoScroll": "Auto-scroll",
+ "autoScrollDescription": "Automatically scroll to the latest message",
+ "showUserBadges": "Show user badges",
+ "showUserBadgesDescription": "Display badges next to usernames in chat",
+ "showEmotes": "Show emotes",
+ "showEmotesDescription": "Display emotes and emoji in chat messages",
+ "messageBatching": "Message batching",
+ "messageBatchingDescription": "Group messages together to improve performance",
+ "batchingInterval": "Batching interval (seconds)",
+ "batchingIntervalDescription": "How often to batch messages together"
+ },
+ "notifications": {
+ "title": "Notifications",
+ "description": "Configure notification settings and sound alerts.",
+ "enabled": "Enable notifications",
+ "enabledDescription": "Show notifications for highlighted messages",
+ "sound": "Sound notifications",
+ "soundDescription": "Play sound when receiving notifications",
+ "phrases": "Highlight phrases",
+ "phrasesDescription": "Words or phrases that trigger notifications",
+ "addPhrase": "Add phrase",
+ "selectSound": "Select notification sound",
+ "uploadCustomSound": "Upload custom sound"
+ },
+ "cosmetics": {
+ "title": "Cosmetics",
+ "description": "Customize the appearance and theme of the application.",
+ "theme": "Theme",
+ "themeDescription": "Choose your preferred color scheme",
+ "customTheme": "Custom theme",
+ "customThemeDescription": "Upload or select a custom theme",
+ "chatBackground": "Chat background",
+ "chatBackgroundDescription": "Customize the chat area background",
+ "messageAnimations": "Message animations",
+ "messageAnimationsDescription": "Enable smooth animations for new messages"
+ },
+ "moderation": {
+ "title": "Moderation",
+ "description": "Configure moderation tools and filters.",
+ "quickModTools": "Quick mod tools",
+ "quickModToolsDescription": "Enable quick access to moderation tools like timeout, ban, and delete messages",
+ "autoModeration": "Auto moderation",
+ "autoModerationDescription": "Automatically moderate chat based on rules",
+ "wordFilter": "Word filter",
+ "wordFilterDescription": "Filter out inappropriate words",
+ "linkFilter": "Link filter",
+ "linkFilterDescription": "Filter messages containing links",
+ "spamFilter": "Spam filter",
+ "spamFilterDescription": "Detect and filter spam messages"
+ },
+ "about": {
+ "title": "About",
+ "description": "Meet the developers and learn more about KickTalk",
+ "meetCreators": "Meet the Creators",
+ "kickUsername": "Kick Username",
+ "role": "Role",
+ "developer": "Developer",
+ "developerDesigner": "Developer & Designer",
+ "openTwitter": "Open Twitter",
+ "openChannel": "Open Channel",
+ "aboutKickTalk": "About KickTalk",
+ "appDescription": "We created this application because we felt the current solution Kick was offering couldn't meet the needs of users who want more from their chatting experience. From multiple chatrooms to emotes and native Kick functionality all in one place.",
+ "currentVersion": "Current Version",
+ "version": "Version",
+ "electronVersion": "Electron Version",
+ "chromeVersion": "Chrome Version",
+ "nodeVersion": "Node Version",
+ "author": "Author",
+ "license": "License",
+ "repository": "Repository",
+ "support": "Support",
+ "updates": "Updates",
+ "checkForUpdates": "Check for updates",
+ "updateAvailable": "Update available",
+ "upToDate": "App is up to date"
+ }
+ },
+ "messages": {
+ "scrollToBottom": "Scroll To Bottom",
+ "pinMessage": "Pin Message",
+ "copyMessage": "Copy Message",
+ "replyTo": "Reply to {{username}}",
+ "connecting": "Connecting to Channel...",
+ "connected": "Connected to Channel",
+ "modAction": {
+ "permanentlyBanned": "permanently banned",
+ "timedOut": "timed out",
+ "unbanned": "unbanned",
+ "removedTimeoutOn": "removed timeout on",
+ "forDuration": " for {{duration}}"
+ },
+ "emoteUpdate": {
+ "personal": "Personal",
+ "channel": "Channel",
+ "added": "Added",
+ "removed": "Removed",
+ "renamed": "Renamed",
+ "madeBy": "Made by: {{creator}}"
+ }
+ },
+ "chatInput": {
+ "placeholder": "Send a message...",
+ "enterMessage": "Enter message...",
+ "replyingTo": "Replying to",
+ "subscriber": "SUB"
+ },
+ "chatters": {
+ "title": "Chatters",
+ "total": "Total",
+ "showing": "Showing",
+ "of": "of",
+ "searchPlaceholder": "Search...",
+ "noResults": "No results found",
+ "noTrackingYet": "No chatters tracked yet",
+ "trackingDescription": "As users type their username will appear here."
+ },
+ "streamerInfo": {
+ "liveFor": "Live for {{duration}} with {{viewers}} viewers",
+ "refreshEmotes": "Refresh 7TV Emotes",
+ "refreshKickEmotes": "Refresh Kick Emotes",
+ "search": "Search",
+ "openStream": "Open Stream in Browser",
+ "openPlayer": "Open Player in Browser",
+ "openModView": "Open Mod View in Browser"
+ },
+ "search": {
+ "searchingHistory": "Searching History in",
+ "messages": "Messages",
+ "placeholder": "Search messages...",
+ "noResults": "No messages found"
+ },
+ "updater": {
+ "updateNow": "Update Now",
+ "retryUpdate": "Retry Update",
+ "errorRetryUpdate": "Error - Retry Update"
+ },
+ "common": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "apply": "Apply",
+ "reset": "Reset",
+ "delete": "Delete",
+ "edit": "Edit",
+ "add": "Add",
+ "remove": "Remove",
+ "enable": "Enable",
+ "disable": "Disable",
+ "yes": "Yes",
+ "no": "No",
+ "ok": "OK",
+ "loading": "Loading...",
+ "error": "Error",
+ "success": "Success",
+ "warning": "Warning",
+ "info": "Info"
+ },
+ "loader": {
+ "createdBy": "Created by",
+ "loading": "Loading..."
+ }
+}
diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json
new file mode 100644
index 0000000..ab1ccb7
--- /dev/null
+++ b/src/renderer/src/locales/es.json
@@ -0,0 +1,246 @@
+{
+ "auth": {
+ "signIn": "Iniciar Sesión",
+ "signInWithKick": "Inicia sesión con tu cuenta de Kick",
+ "loginWithKick": "Iniciar con Kick",
+ "loginWithGoogle": "Iniciar con Google",
+ "loginWithApple": "Iniciar con Apple",
+ "continueAnonymous": "Continuar anónimo",
+ "kickLoginDescription": "¿Usar nombre de usuario y contraseña para iniciar sesión? Continúa a Kick.com",
+ "googleAppleDescription": "¿Ya tienes una cuenta de Kick con inicio de sesión de Google o Apple?",
+ "disclaimer": "NO guardamos ningún correo electrónico o contraseña."
+ },
+ "titleBar": {
+ "loading": "Cargando...",
+ "settings": "Configuración",
+ "minimize": "Minimizar",
+ "maximize": "Maximizar",
+ "close": "Cerrar"
+ },
+ "chat": {
+ "addChatroom": "Agrega una sala de chat usando \"CTRL\"+\"t\" o haciendo clic en el botón Agregar",
+ "pinMessage": "Fijar Mensaje",
+ "copyMessage": "Copiar Mensaje",
+ "replyTo": "Responder a {{username}}"
+ },
+ "navbar": {
+ "chatroom": "Sala de Chat",
+ "mentions": "Menciones",
+ "addChatroom": "Agregar Sala de Chat",
+ "addChatroomDescription": "Ingresa el nombre de un canal para agregar una nueva sala de chat",
+ "enterStreamerName": "Ingresa el nombre del streamer...",
+ "connecting": "Conectando...",
+ "addMentionsTab": "Agregar Pestaña de Menciones",
+ "addMentionsDescription": "Agrega una pestaña para ver todas tus menciones y destacados de todos los chats en un solo lugar",
+ "closeAddMentions": "Cerrar Agregar Menciones"
+ },
+ "userDialog": {
+ "muteUser": "Silenciar Usuario",
+ "unmuteUser": "Desilenciar Usuario",
+ "openProfile": "Abrir Canal",
+ "check": "Verificar",
+ "unban": "Desbanear Usuario",
+ "timeout1m": "1m",
+ "timeout5m": "5m",
+ "timeout10m": "10m",
+ "timeout30m": "30m",
+ "timeout1h": "1h",
+ "timeout3h": "3h",
+ "timeout6h": "6h",
+ "timeout12h": "12h",
+ "timeout24h": "1d",
+ "timeout1w": "1sem",
+ "ban": "Banear Usuario",
+ "followingSince": "Siguiendo desde",
+ "subscribedFor": "Suscrito por",
+ "monthsSingular": "{{count}} mes",
+ "monthsPlural": "{{count}} meses"
+ },
+ "settings": {
+ "title": "Configuración",
+ "language": "Idioma",
+ "languageDescription": "Elige tu idioma preferido",
+ "menu": {
+ "aboutKickTalk": "Acerca de KickTalk",
+ "general": "General",
+ "chat": "Chat",
+ "moderation": "Moderación",
+ "signOut": "Cerrar Sesión"
+ },
+ "general": {
+ "title": "General",
+ "description": "Selecciona qué configuraciones generales de la aplicación quieres cambiar.",
+ "alwaysOnTop": "Siempre encima",
+ "alwaysOnTopDescription": "Mantener la aplicación siempre encima de otras ventanas",
+ "wrapChatroomsList": "Envolver lista de salas",
+ "wrapChatroomsListDescription": "Mostrar la lista de salas de chat en múltiples filas cuando hay muchas pestañas",
+ "showTabImages": "Mostrar imágenes de pestañas",
+ "showTabImagesDescription": "Mostrar fotos de perfil de streamers en las pestañas de salas de chat",
+ "timestampFormat": "Formato de marca de tiempo",
+ "timestampFormatDescription": "Elige cómo se muestran las marcas de tiempo en los mensajes del chat",
+ "disabled": "Deshabilitado"
+ },
+ "chatrooms": {
+ "title": "Salas de Chat",
+ "description": "Configura ajustes específicos de salas de chat y comportamiento.",
+ "autoScroll": "Desplazamiento automático",
+ "autoScrollDescription": "Desplazarse automáticamente al último mensaje",
+ "showUserBadges": "Mostrar insignias de usuario",
+ "showUserBadgesDescription": "Mostrar insignias junto a los nombres de usuario en el chat",
+ "showEmotes": "Mostrar emotes",
+ "showEmotesDescription": "Mostrar emotes y emoji en los mensajes del chat",
+ "messageBatching": "Agrupación de mensajes",
+ "messageBatchingDescription": "Agrupar mensajes para mejorar el rendimiento",
+ "batchingInterval": "Intervalo de agrupación (segundos)",
+ "batchingIntervalDescription": "Con qué frecuencia agrupar mensajes"
+ },
+ "notifications": {
+ "title": "Notificaciones",
+ "description": "Configura ajustes de notificaciones y alertas de sonido.",
+ "enabled": "Habilitar notificaciones",
+ "enabledDescription": "Mostrar notificaciones para mensajes destacados",
+ "sound": "Notificaciones de sonido",
+ "soundDescription": "Reproducir sonido al recibir notificaciones",
+ "phrases": "Frases destacadas",
+ "phrasesDescription": "Palabras o frases que activan notificaciones",
+ "addPhrase": "Agregar frase",
+ "selectSound": "Seleccionar sonido de notificación",
+ "uploadCustomSound": "Subir sonido personalizado"
+ },
+ "cosmetics": {
+ "title": "Cosmética",
+ "description": "Personaliza la apariencia y tema de la aplicación.",
+ "theme": "Tema",
+ "themeDescription": "Elige tu esquema de colores preferido",
+ "customTheme": "Tema personalizado",
+ "customThemeDescription": "Subir o seleccionar un tema personalizado",
+ "chatBackground": "Fondo del chat",
+ "chatBackgroundDescription": "Personalizar el fondo del área de chat",
+ "messageAnimations": "Animaciones de mensajes",
+ "messageAnimationsDescription": "Habilitar animaciones suaves para nuevos mensajes"
+ },
+ "moderation": {
+ "title": "Moderación",
+ "description": "Configura herramientas de moderación y filtros.",
+ "quickModTools": "Herramientas de moderación rápidas",
+ "quickModToolsDescription": "Habilita acceso rápido a herramientas de moderación como timeout, ban y eliminar mensajes",
+ "autoModeration": "Moderación automática",
+ "autoModerationDescription": "Moderar automáticamente el chat basado en reglas",
+ "wordFilter": "Filtro de palabras",
+ "wordFilterDescription": "Filtrar palabras inapropiadas",
+ "linkFilter": "Filtro de enlaces",
+ "linkFilterDescription": "Filtrar mensajes que contengan enlaces",
+ "spamFilter": "Filtro de spam",
+ "spamFilterDescription": "Detectar y filtrar mensajes de spam"
+ },
+ "about": {
+ "title": "Acerca de",
+ "description": "Conoce a los desarrolladores y aprende más sobre KickTalk",
+ "meetCreators": "Conoce a los Creadores",
+ "kickUsername": "Usuario de Kick",
+ "role": "Rol",
+ "developer": "Desarrollador",
+ "developerDesigner": "Desarrollador y Diseñador",
+ "openTwitter": "Abrir Twitter",
+ "openChannel": "Abrir Canal",
+ "aboutKickTalk": "Acerca de KickTalk",
+ "appDescription": "Creamos esta aplicación porque sentimos que la solución actual que ofrecía Kick no podía satisfacer las necesidades de los usuarios que quieren más de su experiencia de chat. Desde múltiples salas de chat hasta emotes y funcionalidad nativa de Kick, todo en un solo lugar.",
+ "currentVersion": "Versión Actual",
+ "version": "Versión",
+ "electronVersion": "Versión de Electron",
+ "chromeVersion": "Versión de Chrome",
+ "nodeVersion": "Versión de Node",
+ "author": "Autor",
+ "license": "Licencia",
+ "repository": "Repositorio",
+ "support": "Soporte",
+ "updates": "Actualizaciones",
+ "checkForUpdates": "Buscar actualizaciones",
+ "updateAvailable": "Actualización disponible",
+ "upToDate": "La aplicación está actualizada"
+ }
+ },
+ "messages": {
+ "scrollToBottom": "Ir al Final",
+ "pinMessage": "Fijar Mensaje",
+ "copyMessage": "Copiar Mensaje",
+ "replyTo": "Responder a {{username}}",
+ "connecting": "Conectando al Canal...",
+ "connected": "Conectado al Canal",
+ "modAction": {
+ "permanentlyBanned": "baneó permanentemente a",
+ "timedOut": "puso en tiempo fuera a",
+ "unbanned": "desbaneó a",
+ "removedTimeoutOn": "removió el tiempo fuera de",
+ "forDuration": " por {{duration}}"
+ },
+ "emoteUpdate": {
+ "personal": "Personal",
+ "channel": "Canal",
+ "added": "Agregado",
+ "removed": "Eliminado",
+ "renamed": "Renombrado",
+ "madeBy": "Hecho por: {{creator}}"
+ }
+ },
+ "chatInput": {
+ "placeholder": "Envía un mensaje...",
+ "enterMessage": "Escribe un mensaje...",
+ "replyingTo": "Respondiendo a",
+ "subscriber": "SUB"
+ },
+ "chatters": {
+ "title": "Usuarios",
+ "total": "Total",
+ "showing": "Mostrando",
+ "of": "de",
+ "searchPlaceholder": "Buscar...",
+ "noResults": "No se encontraron resultados",
+ "noTrackingYet": "Aún no se han rastreado usuarios",
+ "trackingDescription": "Cuando los usuarios escriban, su nombre aparecerá aquí."
+ },
+ "streamerInfo": {
+ "liveFor": "En vivo desde hace {{duration}} con {{viewers}} espectadores",
+ "refreshEmotes": "Actualizar Emotes 7TV",
+ "refreshKickEmotes": "Actualizar Emotes Kick",
+ "search": "Buscar",
+ "openStream": "Abrir Stream en Navegador",
+ "openPlayer": "Abrir Reproductor en Navegador",
+ "openModView": "Abrir Vista de Moderador en Navegador"
+ },
+ "search": {
+ "searchingHistory": "Buscando Historial en",
+ "messages": "Mensajes",
+ "placeholder": "Buscar mensajes...",
+ "noResults": "No se encontraron mensajes"
+ },
+ "updater": {
+ "updateNow": "Actualizar Ahora",
+ "retryUpdate": "Reintentar Actualización",
+ "errorRetryUpdate": "Error - Reintentar Actualización"
+ },
+ "common": {
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "apply": "Aplicar",
+ "reset": "Restablecer",
+ "delete": "Eliminar",
+ "edit": "Editar",
+ "add": "Agregar",
+ "remove": "Quitar",
+ "enable": "Habilitar",
+ "disable": "Deshabilitar",
+ "yes": "Sí",
+ "no": "No",
+ "ok": "OK",
+ "loading": "Cargando...",
+ "error": "Error",
+ "success": "Éxito",
+ "warning": "Advertencia",
+ "info": "Información"
+ },
+ "loader": {
+ "createdBy": "Creado por",
+ "loading": "Cargando..."
+ }
+}
diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json
new file mode 100644
index 0000000..811ef61
--- /dev/null
+++ b/src/renderer/src/locales/pt.json
@@ -0,0 +1,246 @@
+{
+ "auth": {
+ "signIn": "Entrar",
+ "signInWithKick": "Entre com sua conta do Kick",
+ "loginWithKick": "Entrar com Kick",
+ "loginWithGoogle": "Entrar com Google",
+ "loginWithApple": "Entrar com Apple",
+ "continueAnonymous": "Continuar anônimo",
+ "kickLoginDescription": "Usar nome de usuário e senha para login? Continue para Kick.com",
+ "googleAppleDescription": "Já tem uma conta Kick com login do Google ou Apple?",
+ "disclaimer": "NÃO salvamos nenhum email ou senha."
+ },
+ "titleBar": {
+ "loading": "Carregando...",
+ "settings": "Configurações",
+ "minimize": "Minimizar",
+ "maximize": "Maximizar",
+ "close": "Fechar"
+ },
+ "chat": {
+ "addChatroom": "Adicione uma sala de chat usando \"CTRL\"+\"t\" ou clicando no botão Adicionar",
+ "pinMessage": "Fixar Mensagem",
+ "copyMessage": "Copiar Mensagem",
+ "replyTo": "Responder para {{username}}"
+ },
+ "navbar": {
+ "chatroom": "Sala de Chat",
+ "mentions": "Menções",
+ "addChatroom": "Adicionar Sala de Chat",
+ "addChatroomDescription": "Digite o nome de um canal para adicionar uma nova sala de chat",
+ "enterStreamerName": "Digite o nome do streamer...",
+ "connecting": "Conectando...",
+ "addMentionsTab": "Adicionar Aba de Menções",
+ "addMentionsDescription": "Adicione uma aba para ver todas as suas menções e destaques de todos os chats em um só lugar",
+ "closeAddMentions": "Fechar Adicionar Menções"
+ },
+ "userDialog": {
+ "muteUser": "Silenciar Usuário",
+ "unmuteUser": "Desilenciar Usuário",
+ "openProfile": "Abrir Canal",
+ "check": "Verificar",
+ "unban": "Desbanir Usuário",
+ "timeout1m": "1m",
+ "timeout5m": "5m",
+ "timeout10m": "10m",
+ "timeout30m": "30m",
+ "timeout1h": "1h",
+ "timeout3h": "3h",
+ "timeout6h": "6h",
+ "timeout12h": "12h",
+ "timeout24h": "1d",
+ "timeout1w": "1sem",
+ "ban": "Banir Usuário",
+ "followingSince": "Seguindo desde",
+ "subscribedFor": "Inscrito por",
+ "monthsSingular": "{{count}} mês",
+ "monthsPlural": "{{count}} meses"
+ },
+ "settings": {
+ "title": "Configurações",
+ "language": "Idioma",
+ "languageDescription": "Escolha seu idioma preferido",
+ "menu": {
+ "aboutKickTalk": "Sobre o KickTalk",
+ "general": "Geral",
+ "chat": "Chat",
+ "moderation": "Moderação",
+ "signOut": "Sair"
+ },
+ "general": {
+ "title": "Geral",
+ "description": "Selecione quais configurações gerais do aplicativo você quer alterar.",
+ "alwaysOnTop": "Sempre no topo",
+ "alwaysOnTopDescription": "Manter o aplicativo sempre no topo de outras janelas",
+ "wrapChatroomsList": "Quebrar lista de salas",
+ "wrapChatroomsListDescription": "Mostrar lista de salas de chat em múltiplas linhas quando há muitas abas",
+ "showTabImages": "Mostrar imagens das abas",
+ "showTabImagesDescription": "Mostrar fotos de perfil dos streamers nas abas das salas de chat",
+ "timestampFormat": "Formato de horário",
+ "timestampFormatDescription": "Escolha como os horários são exibidos nas mensagens do chat",
+ "disabled": "Desabilitado"
+ },
+ "chatrooms": {
+ "title": "Salas de Chat",
+ "description": "Configure configurações específicas das salas de chat e comportamento.",
+ "autoScroll": "Rolagem automática",
+ "autoScrollDescription": "Rolar automaticamente para a última mensagem",
+ "showUserBadges": "Mostrar badges de usuário",
+ "showUserBadgesDescription": "Exibir badges ao lado dos nomes de usuário no chat",
+ "showEmotes": "Mostrar emotes",
+ "showEmotesDescription": "Exibir emotes e emoji nas mensagens do chat",
+ "messageBatching": "Agrupamento de mensagens",
+ "messageBatchingDescription": "Agrupar mensagens para melhorar performance",
+ "batchingInterval": "Intervalo de agrupamento (segundos)",
+ "batchingIntervalDescription": "Com que frequência agrupar mensagens"
+ },
+ "notifications": {
+ "title": "Notificações",
+ "description": "Configure configurações de notificações e alertas sonoros.",
+ "enabled": "Habilitar notificações",
+ "enabledDescription": "Mostrar notificações para mensagens destacadas",
+ "sound": "Notificações sonoras",
+ "soundDescription": "Tocar som ao receber notificações",
+ "phrases": "Frases destacadas",
+ "phrasesDescription": "Palavras ou frases que ativam notificações",
+ "addPhrase": "Adicionar frase",
+ "selectSound": "Selecionar som de notificação",
+ "uploadCustomSound": "Enviar som personalizado"
+ },
+ "cosmetics": {
+ "title": "Cosméticos",
+ "description": "Personalize a aparência e tema da aplicação.",
+ "theme": "Tema",
+ "themeDescription": "Escolha seu esquema de cores preferido",
+ "customTheme": "Tema personalizado",
+ "customThemeDescription": "Enviar ou selecionar um tema personalizado",
+ "chatBackground": "Fundo do chat",
+ "chatBackgroundDescription": "Personalizar o fundo da área de chat",
+ "messageAnimations": "Animações de mensagens",
+ "messageAnimationsDescription": "Habilitar animações suaves para novas mensagens"
+ },
+ "moderation": {
+ "title": "Moderação",
+ "description": "Configure ferramentas de moderação e filtros.",
+ "quickModTools": "Ferramentas de moderação rápidas",
+ "quickModToolsDescription": "Habilita acesso rápido a ferramentas de moderação como timeout, ban e excluir mensagens",
+ "autoModeration": "Moderação automática",
+ "autoModerationDescription": "Moderar automaticamente o chat baseado em regras",
+ "wordFilter": "Filtro de palavras",
+ "wordFilterDescription": "Filtrar palavras inapropriadas",
+ "linkFilter": "Filtro de links",
+ "linkFilterDescription": "Filtrar mensagens contendo links",
+ "spamFilter": "Filtro de spam",
+ "spamFilterDescription": "Detectar e filtrar mensagens de spam"
+ },
+ "about": {
+ "title": "Sobre",
+ "description": "Conheça os desenvolvedores e saiba mais sobre o KickTalk",
+ "meetCreators": "Conheça os Criadores",
+ "kickUsername": "Nome de Usuário do Kick",
+ "role": "Função",
+ "developer": "Desenvolvedor",
+ "developerDesigner": "Desenvolvedor e Designer",
+ "openTwitter": "Abrir Twitter",
+ "openChannel": "Abrir Canal",
+ "aboutKickTalk": "Sobre o KickTalk",
+ "appDescription": "Criamos esta aplicação porque sentimos que a solução atual que o Kick oferecia não conseguia atender às necessidades dos usuários que querem mais da sua experiência de chat. Desde múltiplas salas de chat até emotes e funcionalidade nativa do Kick, tudo em um só lugar.",
+ "currentVersion": "Versão Atual",
+ "version": "Versão",
+ "electronVersion": "Versão do Electron",
+ "chromeVersion": "Versão do Chrome",
+ "nodeVersion": "Versão do Node",
+ "author": "Autor",
+ "license": "Licença",
+ "repository": "Repositório",
+ "support": "Suporte",
+ "updates": "Atualizações",
+ "checkForUpdates": "Verificar atualizações",
+ "updateAvailable": "Atualização disponível",
+ "upToDate": "A aplicação está atualizada"
+ }
+ },
+ "messages": {
+ "scrollToBottom": "Ir para o Final",
+ "pinMessage": "Fixar Mensagem",
+ "copyMessage": "Copiar Mensagem",
+ "replyTo": "Responder a {{username}}",
+ "connecting": "Conectando ao Canal...",
+ "connected": "Conectado ao Canal",
+ "modAction": {
+ "permanentlyBanned": "baniu permanentemente",
+ "timedOut": "deu timeout em",
+ "unbanned": "desbaniu",
+ "removedTimeoutOn": "removeu timeout de",
+ "forDuration": " por {{duration}}"
+ },
+ "emoteUpdate": {
+ "personal": "Pessoal",
+ "channel": "Canal",
+ "added": "Adicionado",
+ "removed": "Removido",
+ "renamed": "Renomeado",
+ "madeBy": "Feito por: {{creator}}"
+ }
+ },
+ "chatInput": {
+ "placeholder": "Envie uma mensagem...",
+ "enterMessage": "Digite uma mensagem...",
+ "replyingTo": "Respondendo a",
+ "subscriber": "SUB"
+ },
+ "chatters": {
+ "title": "Usuários",
+ "total": "Total",
+ "showing": "Mostrando",
+ "of": "de",
+ "searchPlaceholder": "Pesquisar...",
+ "noResults": "Nenhum resultado encontrado",
+ "noTrackingYet": "Nenhum usuário rastreado ainda",
+ "trackingDescription": "Quando os usuários digitarem, seus nomes aparecerão aqui."
+ },
+ "streamerInfo": {
+ "liveFor": "Ao vivo há {{duration}} com {{viewers}} espectadores",
+ "refreshEmotes": "Atualizar Emotes 7TV",
+ "refreshKickEmotes": "Atualizar Emotes Kick",
+ "search": "Pesquisar",
+ "openStream": "Abrir Stream no Navegador",
+ "openPlayer": "Abrir Player no Navegador",
+ "openModView": "Abrir Visualização de Moderador no Navegador"
+ },
+ "search": {
+ "searchingHistory": "Pesquisando Histórico em",
+ "messages": "Mensagens",
+ "placeholder": "Pesquisar mensagens...",
+ "noResults": "Nenhuma mensagem encontrada"
+ },
+ "updater": {
+ "updateNow": "Atualizar Agora",
+ "retryUpdate": "Tentar Atualização Novamente",
+ "errorRetryUpdate": "Erro - Tentar Atualização Novamente"
+ },
+ "common": {
+ "save": "Salvar",
+ "cancel": "Cancelar",
+ "apply": "Aplicar",
+ "reset": "Redefinir",
+ "delete": "Excluir",
+ "edit": "Editar",
+ "add": "Adicionar",
+ "remove": "Remover",
+ "enable": "Habilitar",
+ "disable": "Desabilitar",
+ "yes": "Sim",
+ "no": "Não",
+ "ok": "OK",
+ "loading": "Carregando...",
+ "error": "Erro",
+ "success": "Sucesso",
+ "warning": "Aviso",
+ "info": "Informação"
+ },
+ "loader": {
+ "createdBy": "Criado por",
+ "loading": "Carregando..."
+ }
+}
diff --git a/src/renderer/src/main.jsx b/src/renderer/src/main.jsx
index 5fd6fa6..bc7b6c6 100644
--- a/src/renderer/src/main.jsx
+++ b/src/renderer/src/main.jsx
@@ -1,7 +1,7 @@
+import "./utils/i18n";
import "./assets/styles/main.scss";
-import React from "react";
import ReactDOM from "react-dom/client";
-import App from "./App";
+import App from "./App.jsx";
ReactDOM.createRoot(document.getElementById("root")).render( );
diff --git a/src/renderer/src/pages/ChatPage.jsx b/src/renderer/src/pages/ChatPage.jsx
index 3d5b87d..3d16d7f 100644
--- a/src/renderer/src/pages/ChatPage.jsx
+++ b/src/renderer/src/pages/ChatPage.jsx
@@ -1,5 +1,6 @@
import "../assets/styles/pages/ChatPage.scss";
import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { useSettings } from "../providers/SettingsProvider";
import useChatStore from "../providers/ChatProvider";
import Chat from "../components/Chat";
@@ -7,7 +8,41 @@ import Navbar from "../components/Navbar";
import TitleBar from "../components/TitleBar";
import Mentions from "../components/Dialogs/Mentions";
+// Telemetry monitoring hook
+const useTelemetryMonitoring = () => {
+ useEffect(() => {
+ const collectMetrics = () => {
+ try {
+ // Collect DOM node count
+ const domNodeCount = document.querySelectorAll('*').length;
+ window.app?.telemetry?.recordDomNodeCount(domNodeCount);
+
+ // Collect renderer memory usage
+ if (performance.memory) {
+ const memoryData = {
+ jsHeapUsedSize: performance.memory.usedJSHeapSize,
+ jsHeapTotalSize: performance.memory.totalJSHeapSize,
+ jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
+ };
+ window.app?.telemetry?.recordRendererMemory(memoryData);
+ }
+ } catch (error) {
+ console.warn('Telemetry collection failed:', error);
+ }
+ };
+
+ // Collect metrics initially
+ collectMetrics();
+
+ // Set up periodic collection every 10 seconds for testing
+ const interval = setInterval(collectMetrics, 10000);
+
+ return () => clearInterval(interval);
+ }, []);
+};
+
const ChatPage = () => {
+ const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const setCurrentChatroom = useChatStore((state) => state.setCurrentChatroom);
@@ -15,6 +50,9 @@ const ChatPage = () => {
const kickUsername = localStorage.getItem("kickUsername");
const kickId = localStorage.getItem("kickId");
+ // Enable telemetry monitoring
+ useTelemetryMonitoring();
+
useEffect(() => {
setCurrentChatroom(activeChatroomId);
}, [activeChatroomId, setCurrentChatroom]);
@@ -41,7 +79,7 @@ const ChatPage = () => {
) : (
No Chatrooms
-
Add a chatroom by using "CTRL"+"t" or clicking Add button
+
{t('chat.addChatroom')}
)}
diff --git a/src/renderer/src/pages/Loader.jsx b/src/renderer/src/pages/Loader.jsx
index b39ee51..1e90b15 100644
--- a/src/renderer/src/pages/Loader.jsx
+++ b/src/renderer/src/pages/Loader.jsx
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import "../assets/styles/loader.css";
import Klogo from "../assets/icons/K.svg";
import clsx from "clsx";
const Loader = ({ onFinish }) => {
+ const { t } = useTranslation();
const [showText, setShowText] = useState(false);
const [hideLoader, setHideLoader] = useState(false);
const [appVersion, setAppVersion] = useState(null);
@@ -38,7 +40,7 @@ const Loader = ({ onFinish }) => {
{showText && (
- Created by DRKNESS and ftk789
+ {t('loader.createdBy')} DRKNESS and ftk789
{appVersion &&
v{appVersion}
}
diff --git a/src/renderer/src/providers/ChatProvider.jsx b/src/renderer/src/providers/ChatProvider.jsx
index 3f3cd00..0a37342 100644
--- a/src/renderer/src/providers/ChatProvider.jsx
+++ b/src/renderer/src/providers/ChatProvider.jsx
@@ -4,15 +4,29 @@ import KickPusher from "../../../../utils/services/kick/kickPusher";
import { chatroomErrorHandler } from "../utils/chatErrors";
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";
import { getKickTalkDonators } from "../../../../utils/services/kick/kickAPI";
import dayjs from "dayjs";
+// Message states for optimistic sending
+const MESSAGE_STATES = {
+ OPTIMISTIC: 'optimistic', // Sent, waiting for confirmation
+ CONFIRMED: 'confirmed', // Received back from server
+ FAILED: 'failed' // Send failed, needs retry
+};
+
let stvPresenceUpdates = new Map();
let storeStvId = null;
const PRESENCE_UPDATE_INTERVAL = 30 * 1000;
+// Global connection manager instance
+let connectionManager = null;
+let initializationInProgress = false;
+// Periodic cleanup interval for memory management
+let memoryCleanupInterval = null;
+
// Load initial state from local storage
const getInitialState = () => {
const savedChatrooms = JSON.parse(localStorage.getItem("chatrooms")) || [];
@@ -35,6 +49,7 @@ const getInitialState = () => {
mentions: {}, // Store for all Mentions
currentChatroomId: null, // Track the currently active chatroom
hasMentionsTab: savedMentionsTab, // Track if mentions tab is enabled
+ currentUser: null, // Cache current user info for optimistic messages
};
};
@@ -58,6 +73,31 @@ const useChatStore = create((set, get) => ({
}
},
+ // Get connection manager status for debugging
+ getConnectionStatus: () => {
+ if (connectionManager) {
+ return connectionManager.getConnectionStatus();
+ }
+ return {
+ manager: "not initialized",
+ individual_connections: Object.keys(get().connections).length,
+ };
+ },
+
+ // Debug function to toggle livestream status for testing
+ debugToggleStreamStatus: (chatroomId, isLive) => {
+ console.log(`[DEBUG] Toggling stream status for chatroom ${chatroomId}: ${isLive ? "LIVE" : "OFFLINE"}`);
+ const mockEvent = {
+ livestream: {
+ id: Math.random().toString(),
+ is_live: isLive,
+ session_title: "Mock Stream Title",
+ created_at: new Date().toISOString(),
+ },
+ };
+ get().handleStreamStatus(chatroomId, mockEvent, isLive);
+ },
+
// Handles Sending Presence Updates to 7TV for a chatroom
sendPresenceUpdate: (stvId, userId) => {
if (!stvId) {
@@ -83,70 +123,212 @@ const useChatStore = create((set, get) => ({
stvPresenceUpdates.set(userId, currentTime);
sendUserPresence(stvId, userId);
+
+ // Clean up old entries to prevent memory leak
+ if (stvPresenceUpdates.size > 100) {
+ const cutoffTime = currentTime - PRESENCE_UPDATE_INTERVAL * 2;
+ for (const [id, timestamp] of stvPresenceUpdates.entries()) {
+ if (timestamp < cutoffTime) {
+ stvPresenceUpdates.delete(id);
+ }
+ }
+ }
+ },
+
+ // Cache current user info for optimistic messages
+ cacheCurrentUser: async () => {
+ try {
+ const currentUser = await window.app.kick.getSelfInfo();
+ set((state) => ({ ...state, currentUser }));
+ return currentUser;
+ } catch (error) {
+ console.error("[Chat Store]: Failed to cache user info:", error);
+ return null;
+ }
+ },
+
+ // Cache current user info for optimistic messages
+ cacheCurrentUser: async () => {
+ try {
+ const currentUser = await window.app.kick.getSelfInfo();
+ set((state) => ({ ...state, currentUser }));
+ return currentUser;
+ } catch (error) {
+ console.error("[Chat Store]: Failed to cache user info:", error);
+ return null;
+ }
},
sendMessage: async (chatroomId, content) => {
+ const startTime = Date.now();
+ const chatroom = get().chatrooms.find(room => room.id === chatroomId);
+ const streamerName = chatroom?.streamerData?.user?.username || chatroom?.username || `chatroom_${chatroomId}`;
+ console.log(`[Telemetry] sendMessage - chatroomId: ${chatroomId}, streamerName: ${streamerName}`);
+
try {
const message = content.trim();
console.info("Sending message to chatroom:", chatroomId);
+ // Use cached user info for instant optimistic message, fallback to API call
+ let currentUser = get().currentUser;
+ if (!currentUser) {
+ currentUser = await get().cacheCurrentUser();
+ }
+
+ if (!currentUser) {
+ get().addMessage(chatroomId, {
+ id: crypto.randomUUID(),
+ type: "system",
+ content: "You must login to chat.",
+ timestamp: new Date().toISOString(),
+ });
+ return false;
+ }
+
+ // Create and immediately add optimistic message (should be instant now!)
+ const optimisticMessage = createOptimisticMessage(chatroomId, message, currentUser);
+ get().addMessage(chatroomId, optimisticMessage);
+
+ // Set timeout to mark message as failed if not confirmed within 30 seconds
+ const timeoutId = setTimeout(() => {
+ const messages = get().messages[chatroomId] || [];
+ const stillOptimistic = messages.find(msg =>
+ msg.tempId === optimisticMessage.tempId &&
+ msg.state === MESSAGE_STATES.OPTIMISTIC
+ );
+ if (stillOptimistic) {
+ console.warn('[Optimistic]: Message timeout, marking as failed:', optimisticMessage.tempId);
+ get().updateMessageState(chatroomId, optimisticMessage.tempId, MESSAGE_STATES.FAILED);
+ }
+ }, 30000);
+
+ // Send message to server
const response = await window.app.kick.sendMessage(chatroomId, message);
+ const apiDuration = (Date.now() - apiStartTime) / 1000;
+
+ // Record API request timing
+ try {
+ const statusCode = response?.status || response?.data?.status?.code || 200;
+ await window.app?.telemetry?.recordAPIRequest?.('kick_send_message', 'POST', statusCode, apiDuration);
+ } catch (telemetryError) {
+ console.warn('[Telemetry]: Failed to record API request:', telemetryError);
+ }
+
+ // Clear timeout if request completes (success or known failure)
+ clearTimeout(timeoutId);
if (response?.data?.status?.code === 401) {
+ // Mark optimistic message as failed and show error
+ get().updateMessageState(chatroomId, optimisticMessage.tempId, MESSAGE_STATES.FAILED);
get().addMessage(chatroomId, {
id: crypto.randomUUID(),
type: "system",
content: "You must login to chat.",
timestamp: new Date().toISOString(),
});
-
return false;
}
+ // Message sent successfully - it will be confirmed when we receive it back via WebSocket
return true;
} catch (error) {
- const errMsg = chatroomErrorHandler(error);
+ console.error('[Send Message]: Error sending message:', error);
- get().addMessage(chatroomId, {
- id: crypto.randomUUID(),
- type: "system",
- chatroom_id: chatroomId,
- content: errMsg,
- timestamp: new Date().toISOString(),
- });
+ // Find and mark the optimistic message as failed
+ const messages = get().messages[chatroomId] || [];
+ const optimisticMsg = messages.find(msg => msg.isOptimistic && msg.content === content.trim());
+ if (optimisticMsg) {
+ get().updateMessageState(chatroomId, optimisticMsg.tempId, MESSAGE_STATES.FAILED);
+ }
+
+ // No system message needed - failed state and retry button provide clear feedback
return false;
}
},
sendReply: async (chatroomId, content, metadata = {}) => {
+ const startTime = Date.now();
+ const chatroom = get().chatrooms.find(room => room.id === chatroomId);
+ const streamerName = chatroom?.streamerData?.user?.username || chatroom?.username || `chatroom_${chatroomId}`;
+ console.log(`[Telemetry] sendReply - chatroomId: ${chatroomId}, streamerName: ${streamerName}`);
+
try {
const message = content.trim();
console.info("Sending reply to chatroom:", chatroomId);
+ // Use cached user info for instant optimistic reply, fallback to API call
+ let currentUser = get().currentUser;
+ if (!currentUser) {
+ currentUser = await get().cacheCurrentUser();
+ }
+ if (!currentUser) {
+ get().addMessage(chatroomId, {
+ id: crypto.randomUUID(),
+ type: "system",
+ content: "You must login to chat.",
+ timestamp: new Date().toISOString(),
+ });
+ return false;
+ }
+
+ // Create and immediately add optimistic reply (should be instant now!)
+ const optimisticReply = createOptimisticReply(chatroomId, message, currentUser, metadata);
+ get().addMessage(chatroomId, optimisticReply);
+
+ // Set timeout to mark reply as failed if not confirmed within 30 seconds
+ const timeoutId = setTimeout(() => {
+ const messages = get().messages[chatroomId] || [];
+ const stillOptimistic = messages.find(msg =>
+ msg.tempId === optimisticReply.tempId &&
+ msg.state === MESSAGE_STATES.OPTIMISTIC
+ );
+ if (stillOptimistic) {
+ console.warn('[Optimistic]: Reply timeout, marking as failed:', optimisticReply.tempId);
+ get().updateMessageState(chatroomId, optimisticReply.tempId, MESSAGE_STATES.FAILED);
+ }
+ }, 30000);
+
+ // Send reply to server
const response = await window.app.kick.sendReply(chatroomId, message, metadata);
+ const apiDuration = (Date.now() - apiStartTime) / 1000;
+
+ // Record API request timing
+ try {
+ const statusCode = response?.status || response?.data?.status?.code || 200;
+ await window.app?.telemetry?.recordAPIRequest?.('kick_send_reply', 'POST', statusCode, apiDuration);
+ } catch (telemetryError) {
+ console.warn('[Telemetry]: Failed to record API request:', telemetryError);
+ }
+
+ // Clear timeout if request completes (success or known failure)
+ clearTimeout(timeoutId);
if (response?.data?.status?.code === 401) {
+ // Mark optimistic reply as failed and show error
+ get().updateMessageState(chatroomId, optimisticReply.tempId, MESSAGE_STATES.FAILED);
get().addMessage(chatroomId, {
id: crypto.randomUUID(),
type: "system",
content: "You must login to chat.",
timestamp: new Date().toISOString(),
});
-
return false;
}
+ // Reply sent successfully - it will be confirmed when we receive it back via WebSocket
return true;
} catch (error) {
- const errMsg = chatroomErrorHandler(error);
+ console.error('[Send Reply]: Error sending reply:', error);
- get().addMessage(chatroomId, {
- id: crypto.randomUUID(),
- type: "system",
- content: errMsg,
- timestamp: new Date().toISOString(),
- });
+ // Find and mark the optimistic reply as failed
+ const messages = get().messages[chatroomId] || [];
+ const optimisticMsg = messages.find(msg => msg.isOptimistic && msg.content === content.trim() && msg.type === "reply");
+ if (optimisticMsg) {
+ get().updateMessageState(chatroomId, optimisticMsg.tempId, MESSAGE_STATES.FAILED);
+ }
+
+ // No system message needed - failed state and retry button provide clear feedback
return false;
}
@@ -241,7 +423,7 @@ const useChatStore = create((set, get) => ({
connectToChatroom: async (chatroom) => {
if (!chatroom?.id) return;
- const pusher = new KickPusher(chatroom.id, chatroom.streamerData.id);
+ const pusher = new KickPusher(chatroom.id, chatroom.streamerData.id, chatroom.streamerData?.user?.username);
// Connection Events
pusher.addEventListener("connection", (event) => {
@@ -415,6 +597,11 @@ const useChatStore = create((set, get) => ({
// connect to Pusher after getting initial data
pusher.connect();
+ // Pre-cache current user info for instant optimistic messaging
+ if (!get().currentUser) {
+ get().cacheCurrentUser().catch(console.error);
+ }
+
if (pusher.chat.OPEN) {
const channel7TVEmotes = await window.app.stv.getChannelEmotes(chatroom.streamerData.user_id);
@@ -618,9 +805,159 @@ const useChatStore = create((set, get) => ({
}
},
- initializeConnections: () => {
- // Fetch donators list once on initialization
- get().fetchDonators();
+ initializeConnections: async () => {
+ // Prevent multiple simultaneous initializations
+ if (initializationInProgress) {
+ console.log("[ChatProvider] Initialization already in progress, skipping...");
+ return;
+ }
+
+ initializationInProgress = true;
+ console.log("[ChatProvider] Starting OPTIMIZED connection initialization...");
+
+ try {
+ // Fetch donators list once on initialization
+ get().fetchDonators();
+
+ const chatrooms = get().chatrooms;
+ if (!chatrooms?.length) {
+ console.log("[ChatProvider] No chatrooms to initialize");
+ return;
+ }
+
+ // Cleanup existing connection manager if it exists
+ if (connectionManager) {
+ connectionManager.cleanup();
+ }
+
+ // Create new connection manager
+ connectionManager = new ConnectionManager();
+
+ // Set up event handlers for the shared connections
+ const eventHandlers = {
+ // KickPusher event handlers
+ onKickMessage: (event) => {
+ try {
+ const { chatroomId } = event.detail;
+ // console.log(`[ChatProvider] Received kick message for chatroom ${chatroomId}:`, event.detail);
+ if (chatroomId) {
+ get().handleKickMessage(chatroomId, event.detail);
+ }
+ } catch (error) {
+ console.error("[ChatProvider] Error handling kick message:", error);
+ }
+ },
+ onKickChannel: (event) => {
+ try {
+ const { chatroomId } = event.detail;
+ if (chatroomId) {
+ get().handleKickChannel(chatroomId, event.detail);
+ }
+ } catch (error) {
+ console.error("[ChatProvider] Error handling kick channel event:", error);
+ }
+ },
+ onKickConnection: (event) => {
+ try {
+ get().handleKickConnection(event.detail);
+ } catch (error) {
+ console.error("[ChatProvider] Error handling kick connection:", error);
+ }
+ },
+ onKickSubscriptionSuccess: (event) => {
+ try {
+ const { chatroomId } = event.detail;
+ if (chatroomId) {
+ console.log(`[ChatProvider] Subscription successful for chatroom: ${chatroomId}`);
+ // Use setTimeout to prevent immediate state update loops
+ setTimeout(() => {
+ get().addMessage(chatroomId, {
+ id: crypto.randomUUID(),
+ type: "system",
+ content: "connection-success",
+ chatroomNumber: chatroomId,
+ timestamp: new Date().toISOString(),
+ });
+ }, 0);
+ }
+ } catch (error) {
+ console.error("[ChatProvider] Error handling kick subscription success:", error);
+ }
+ },
+ // 7TV event handlers
+ onStvMessage: (event) => {
+ try {
+ const { chatroomId } = 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);
+ });
+ }
+ } catch (error) {
+ console.error("[ChatProvider] Error handling 7TV message:", error);
+ }
+ },
+ onStvOpen: (event) => {
+ try {
+ const { chatroomId } = event.detail;
+ if (chatroomId) {
+ console.log(`[ChatProvider] 7TV WebSocket connected for chatroom: ${chatroomId}`);
+ } else {
+ console.log("[ChatProvider] 7TV WebSocket connected for all chatrooms");
+ }
+ } catch (error) {
+ console.error("[ChatProvider] Error handling 7TV open:", error);
+ }
+ },
+ onStvConnection: () => {
+ try {
+ console.log("[ChatProvider] 7TV shared connection established");
+ } catch (error) {
+ console.error("[ChatProvider] Error handling 7TV connection:", error);
+ }
+ },
+ };
+
+ try {
+ console.log(`[ChatProvider] Initializing ${chatrooms.length} chatrooms with optimized connections...`);
+
+ // Prepare store callbacks to avoid circular imports
+ const storeCallbacks = {
+ handlePinnedMessageCreated: get().handlePinnedMessageCreated,
+ handlePinnedMessageDeleted: get().handlePinnedMessageDeleted,
+ addInitialChatroomMessages: get().addInitialChatroomMessages,
+ handleStreamStatus: get().handleStreamStatus,
+ };
+
+ // Initialize connections with the new manager
+ await connectionManager.initializeConnections(chatrooms, eventHandlers, storeCallbacks);
+
+ console.log("[ChatProvider] ✅ Optimized connection initialization completed!");
+ console.log("[ChatProvider] 📊 Connection status:", connectionManager.getConnectionStatus());
+
+ // 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`);
+ } catch (error) {
+ console.error("[ChatProvider] ❌ Error during optimized initialization:", error);
+ // Fallback to individual connections if shared connections fail
+ console.log("[ChatProvider] 🔄 Falling back to individual connections...");
+ get().initializeIndividualConnections();
+ }
+ } finally {
+ initializationInProgress = false;
+ }
+ },
+
+ // Fallback method for individual connections (existing behavior)
+ initializeIndividualConnections: () => {
+ console.log("[ChatProvider] Initializing individual connections (fallback)...");
get()?.chatrooms?.forEach((chatroom) => {
if (!get().connections[chatroom.id]) {
@@ -633,6 +970,189 @@ const useChatStore = create((set, get) => ({
});
},
+ // Shared connection event handlers
+ handleKickMessage: async (chatroomId, eventDetail) => {
+ // console.log(`[ChatProvider] Processing kick message for chatroom ${chatroomId}:`, eventDetail);
+ const parsedEvent = JSON.parse(eventDetail.data);
+
+ switch (eventDetail.event) {
+ case "App\\Events\\ChatMessageEvent":
+ // Add user to chatters list if they're not already in there
+ get().addChatter(chatroomId, parsedEvent?.sender);
+
+ // Get batching settings
+ const settings = await window.app.store.get("chatrooms");
+ const batchingSettings = {
+ enabled: settings?.batching ?? false,
+ interval: settings?.batchingInterval ?? 0,
+ };
+
+ if (!batchingSettings.enabled || batchingSettings.interval === 0) {
+ // No batching - add message immediately
+ const messageWithTimestamp = {
+ ...parsedEvent,
+ timestamp: new Date().toISOString(),
+ };
+ // console.log(`[ChatProvider] Adding message to chatroom ${chatroomId}:`, messageWithTimestamp);
+ get().addMessage(chatroomId, messageWithTimestamp);
+
+ // Verify the message was added
+ const currentMessages = get().messages[chatroomId] || [];
+ // console.log(`[ChatProvider] Chatroom ${chatroomId} now has ${currentMessages.length} messages`);
+
+ if (parsedEvent?.type === "reply") {
+ window.app.replyLogs.add({
+ chatroomId: chatroomId,
+ userId: parsedEvent.sender.id,
+ message: messageWithTimestamp,
+ });
+ } else {
+ window.app.logs.add({
+ chatroomId: chatroomId,
+ userId: parsedEvent.sender.id,
+ message: messageWithTimestamp,
+ });
+ }
+ } else {
+ // Use batching system (existing logic)
+ if (!window.__chatMessageBatch) {
+ window.__chatMessageBatch = {};
+ }
+
+ if (!window.__chatMessageBatch[chatroomId]) {
+ window.__chatMessageBatch[chatroomId] = {
+ queue: [],
+ timer: null,
+ };
+ }
+
+ window.__chatMessageBatch[chatroomId].queue.push({
+ ...parsedEvent,
+ timestamp: new Date().toISOString(),
+ });
+
+ const flushBatch = () => {
+ try {
+ const batch = window.__chatMessageBatch[chatroomId]?.queue;
+ if (batch && batch.length > 0) {
+ batch.forEach((msg) => {
+ get().addMessage(chatroomId, msg);
+
+ if (msg?.type === "reply") {
+ window.app.replyLogs.add({
+ chatroomId: chatroomId,
+ userId: msg.sender.id,
+ message: msg,
+ });
+ } else {
+ window.app.logs.add({
+ chatroomId: chatroomId,
+ userId: msg.sender.id,
+ message: msg,
+ });
+ }
+ });
+ window.__chatMessageBatch[chatroomId].queue = [];
+ }
+ } catch (error) {
+ console.error("[Batching] Error flushing batch:", error);
+ }
+ };
+
+ if (!window.__chatMessageBatch[chatroomId].timer) {
+ window.__chatMessageBatch[chatroomId].timer = setTimeout(() => {
+ flushBatch();
+ window.__chatMessageBatch[chatroomId].timer = null;
+ }, batchingSettings.interval);
+ }
+ }
+ break;
+
+ case "App\\Events\\MessageDeletedEvent":
+ get().handleMessageDelete(chatroomId, parsedEvent.message.id);
+ break;
+
+ case "App\\Events\\UserBannedEvent":
+ get().handleUserBanned(chatroomId, parsedEvent.user, parsedEvent.banned_by, parsedEvent.permanent);
+ break;
+
+ case "App\\Events\\UserUnbannedEvent":
+ get().handleUserUnbanned(chatroomId, parsedEvent.user, parsedEvent.unbanned_by);
+ break;
+ }
+ },
+
+ handleKickChannel: (chatroomId, eventDetail) => {
+ const parsedEvent = JSON.parse(eventDetail.data);
+
+ switch (eventDetail.event) {
+ case "App\\Events\\LivestreamUpdated":
+ get().handleStreamStatus(chatroomId, parsedEvent, true);
+ break;
+ case "App\\Events\\ChatroomUpdatedEvent":
+ get().handleChatroomUpdated(chatroomId, parsedEvent);
+ break;
+ case "App\\Events\\StreamerIsLive":
+ console.log("Streamer is live", parsedEvent);
+ get().handleStreamStatus(chatroomId, parsedEvent, true);
+ break;
+ case "App\\Events\\StopStreamBroadcast":
+ console.log("Streamer is offline", parsedEvent);
+ get().handleStreamStatus(chatroomId, parsedEvent, false);
+ break;
+ case "App\\Events\\PinnedMessageCreatedEvent":
+ get().handlePinnedMessageCreated(chatroomId, parsedEvent);
+ break;
+ case "App\\Events\\PinnedMessageDeletedEvent":
+ get().handlePinnedMessageDeleted(chatroomId);
+ break;
+ case "App\\Events\\PollUpdateEvent":
+ console.log("Poll update event:", parsedEvent);
+ get().handlePollUpdate(chatroomId, parsedEvent?.poll);
+ break;
+ case "App\\Events\\PollDeleteEvent":
+ get().handlePollDelete(chatroomId);
+ break;
+ }
+ },
+
+ handleKickConnection: (eventDetail) => {
+ const { chatrooms } = eventDetail;
+ if (chatrooms) {
+ chatrooms.forEach((chatroomId) => {
+ get().addMessage(chatroomId, {
+ id: crypto.randomUUID(),
+ type: "system",
+ content: eventDetail.content,
+ chatroomNumber: chatroomId,
+ timestamp: new Date().toISOString(),
+ });
+ });
+ }
+ },
+
+ handleStvMessage: (chatroomId, eventDetail) => {
+ const { type, body } = eventDetail;
+
+ switch (type) {
+ case "connection_established":
+ break;
+ case "emote_set.update":
+ get().handleEmoteSetUpdate(chatroomId, 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;
+ }
+ },
+
// [Notification Sounds & Mentions]
handleNotification: async (chatroomId, message) => {
try {
@@ -642,7 +1162,6 @@ const useChatStore = create((set, get) => ({
const notificationSettings = await window.app.store.get("notifications");
if (!notificationSettings?.enabled || !notificationSettings?.sound || !notificationSettings?.phrases?.length) return;
- const slug = localStorage.getItem("kickUsername");
const userId = localStorage.getItem("kickId");
// Skip own messages
@@ -702,12 +1221,55 @@ const useChatStore = create((set, get) => ({
isRead: isRead,
};
+ // Check if this is a confirmation of an optimistic message (regular or reply)
+ if (!newMessage.isOptimistic && (newMessage.type === "message" || newMessage.type === "reply")) {
+ const optimisticIndex = messages.findIndex(msg =>
+ msg.isOptimistic &&
+ msg.content === newMessage.content &&
+ msg.sender?.id === newMessage.sender?.id &&
+ msg.type === newMessage.type &&
+ msg.state === MESSAGE_STATES.OPTIMISTIC
+ );
+
+ if (optimisticIndex !== -1) {
+ // Replace optimistic message with confirmed message
+ const updatedMessages = [...messages];
+ updatedMessages[optimisticIndex] = {
+ ...newMessage,
+ state: MESSAGE_STATES.CONFIRMED,
+ isOptimistic: false
+ };
+
+ return {
+ ...state,
+ messages: {
+ ...state.messages,
+ [chatroomId]: updatedMessages,
+ },
+ };
+ }
+ }
+
if (messages.some((msg) => msg.id === newMessage.id)) {
+ console.log(`[addMessage] Duplicate message ${newMessage.id}, skipping`);
return state;
}
let updatedMessages = message?.is_old ? [newMessage, ...messages] : [...messages, newMessage];
+ // Sort messages by timestamp to handle edge cases where messages arrive out of order
+ // Only sort if we have a mix of optimistic and confirmed messages to avoid unnecessary work
+ const hasOptimistic = updatedMessages.some(msg => msg.isOptimistic);
+ const hasConfirmed = updatedMessages.some(msg => !msg.isOptimistic);
+
+ if (hasOptimistic && hasConfirmed) {
+ updatedMessages.sort((a, b) => {
+ const timeA = new Date(a.created_at || a.timestamp).getTime();
+ const timeB = new Date(b.created_at || b.timestamp).getTime();
+ return timeA - timeB;
+ });
+ }
+
// Keep a fixed window of messages based on pause state
if (state.isChatroomPaused?.[chatroomId] && updatedMessages.length > 600) {
updatedMessages = updatedMessages.slice(-300);
@@ -732,14 +1294,34 @@ const useChatStore = create((set, get) => ({
const chatters = state.chatters[chatroomId] || [];
// Check if chatter already exists
- if (chatters?.some((c) => c.id === chatter.id)) {
- return state;
+ const existingChatterIndex = chatters.findIndex((c) => c.id === chatter.id);
+ if (existingChatterIndex !== -1) {
+ // Update existing chatter's timestamp to mark as recently active
+ const updatedChatters = [...chatters];
+ updatedChatters[existingChatterIndex] = {
+ ...updatedChatters[existingChatterIndex],
+ lastSeen: Date.now(),
+ };
+ return {
+ chatters: {
+ ...state.chatters,
+ [chatroomId]: updatedChatters,
+ },
+ };
}
+ // Add timestamp to new chatter
+ const chatterWithTimestamp = {
+ ...chatter,
+ lastSeen: Date.now(),
+ };
+
+ let updatedChatters = [...chatters, chatterWithTimestamp]?.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
+
return {
chatters: {
...state.chatters,
- [chatroomId]: [...(state.chatters[chatroomId] || []), chatter],
+ [chatroomId]: updatedChatters,
},
};
});
@@ -749,15 +1331,19 @@ const useChatStore = create((set, get) => ({
try {
const savedChatrooms = JSON.parse(localStorage.getItem("chatrooms")) || [];
- if (
- savedChatrooms.some(
- (chatroom) =>
- chatroom.username.toLowerCase() === username.toLowerCase() ||
- chatroom.username.toLowerCase() === username.replaceAll("-", "_"),
- ) ||
- savedChatrooms.length >= 5
- ) {
- return;
+ // Check for duplicate chatroom
+ const isDuplicate = savedChatrooms.some(
+ (chatroom) =>
+ chatroom.username.toLowerCase() === username.toLowerCase() ||
+ chatroom.username.toLowerCase() === username.replaceAll("-", "_"),
+ );
+
+ if (isDuplicate) {
+ return { error: "DUPLICATE", message: `Chatroom "${username}" is already added` };
+ }
+
+ if (savedChatrooms.length >= 5) {
+ return { error: "LIMIT_REACHED", message: "Maximum of 5 chatrooms allowed" };
}
const response = await queueChannelFetch(username);
@@ -792,9 +1378,89 @@ const useChatStore = create((set, get) => ({
}
},
+ // Update message state (optimistic -> confirmed/failed)
+ updateMessageState: (chatroomId, tempId, newState) => {
+ set((state) => {
+ const messages = state.messages[chatroomId] || [];
+ const updatedMessages = messages.map(msg =>
+ msg.tempId === tempId
+ ? { ...msg, state: newState }
+ : msg
+ );
+
+ return {
+ ...state,
+ messages: {
+ ...state.messages,
+ [chatroomId]: updatedMessages
+ }
+ };
+ });
+ },
+
+ // Remove optimistic message and replace with confirmed message
+ confirmMessage: (chatroomId, tempId, confirmedMessage) => {
+ set((state) => {
+ const messages = state.messages[chatroomId] || [];
+ const updatedMessages = messages.map(msg =>
+ msg.tempId === tempId
+ ? { ...confirmedMessage, state: MESSAGE_STATES.CONFIRMED, isOptimistic: false }
+ : msg
+ );
+
+ return {
+ ...state,
+ messages: {
+ ...state.messages,
+ [chatroomId]: updatedMessages
+ }
+ };
+ });
+ },
+
+ // Remove failed optimistic messages
+ removeOptimisticMessage: (chatroomId, tempId) => {
+ set((state) => {
+ const messages = state.messages[chatroomId] || [];
+ const updatedMessages = messages.filter(msg => msg.tempId !== tempId);
+
+ return {
+ ...state,
+ messages: {
+ ...state.messages,
+ [chatroomId]: updatedMessages
+ }
+ };
+ });
+ },
+
+ // Retry failed optimistic message
+ retryFailedMessage: async (chatroomId, tempId) => {
+ const messages = get().messages[chatroomId] || [];
+ const failedMessage = messages.find(msg => msg.tempId === tempId && msg.state === MESSAGE_STATES.FAILED);
+
+ if (!failedMessage) return false;
+
+ // Remove the failed message
+ get().removeOptimisticMessage(chatroomId, tempId);
+
+ // Resend based on message type
+ if (failedMessage.type === "reply") {
+ return await get().sendReply(chatroomId, failedMessage.content, failedMessage.metadata);
+ } else {
+ return await get().sendMessage(chatroomId, failedMessage.content);
+ }
+ },
+
removeChatroom: (chatroomId) => {
console.log(`[ChatProvider]: Removing chatroom ${chatroomId}`);
+ // Use connection manager for shared connections
+ if (connectionManager) {
+ connectionManager.removeChatroom(chatroomId);
+ }
+
+ // Clean up any individual connections in state (works for both pooled and individual modes)
const { connections } = get();
const connection = connections[chatroomId];
const stvSocket = connection?.stvSocket;
@@ -840,11 +1506,6 @@ const useChatStore = create((set, get) => ({
localStorage.setItem("chatrooms", JSON.stringify(savedChatrooms.filter((room) => room.id !== chatroomId)));
},
- // Ordered Chatrooms
- getOrderedChatrooms: () => {
- return get().chatrooms.sort((a, b) => (a.order || 0) - (b.order || 0));
- },
-
updateChatroomOrder: (chatroomId, newOrder) => {
set((state) => ({
chatrooms: state.chatrooms.map((room) => (room.id === chatroomId ? { ...room, order: newOrder } : room)),
@@ -1279,8 +1940,8 @@ const useChatStore = create((set, get) => ({
});
}
- personalEmotes.sort((a, b) => a.name.localeCompare(b.name));
- emotes.sort((a, b) => a.name.localeCompare(b.name));
+ personalEmotes = [...personalEmotes].sort((a, b) => a.name.localeCompare(b.name));
+ emotes = [...emotes].sort((a, b) => a.name.localeCompare(b.name));
// Send emote update data to frontend for custom handling
if (addedEmotes.length > 0 || removedEmotes.length > 0 || updatedEmotes.length > 0) {
@@ -1511,12 +2172,21 @@ const useChatStore = create((set, get) => ({
isRead: false,
};
- set((state) => ({
- mentions: {
- ...state.mentions,
- [chatroomId]: [...(state.mentions[chatroomId] || []), mention],
- },
- }));
+ set((state) => {
+ let updatedMentions = [...(state.mentions[chatroomId] || []), mention];
+
+ // Limit mentions to prevent memory leak (keep most recent 200)
+ if (updatedMentions.length > 200) {
+ updatedMentions = updatedMentions.slice(-200);
+ }
+
+ return {
+ mentions: {
+ ...state.mentions,
+ [chatroomId]: updatedMentions,
+ },
+ };
+ });
console.log(`[Mentions]: Added ${type} mention for chatroom ${chatroomId}:`, mention);
},
@@ -1531,7 +2201,7 @@ const useChatStore = create((set, get) => ({
});
// Sort by timestamp, newest first
- return allMentions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
+ return [...allMentions].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
},
// Get mentions for a specific chatroom
@@ -1650,6 +2320,31 @@ const useChatStore = create((set, get) => ({
set({ hasMentionsTab: false });
localStorage.setItem("hasMentionsTab", "false");
},
+
+ // Draft message management
+ saveDraftMessage: (chatroomId, content) => {
+ set((state) => {
+ const newDraftMessages = new Map(state.draftMessages);
+ if (content.trim()) {
+ newDraftMessages.set(chatroomId, content);
+ } else {
+ newDraftMessages.delete(chatroomId);
+ }
+ return { draftMessages: newDraftMessages };
+ });
+ },
+
+ getDraftMessage: (chatroomId) => {
+ return get().draftMessages.get(chatroomId) || '';
+ },
+
+ clearDraftMessage: (chatroomId) => {
+ set((state) => {
+ const newDraftMessages = new Map(state.draftMessages);
+ newDraftMessages.delete(chatroomId);
+ return { draftMessages: newDraftMessages };
+ });
+ },
}));
if (window.location.pathname === "/" || window.location.pathname.endsWith("index.html")) {
@@ -1725,6 +2420,17 @@ if (window.location.pathname === "/" || window.location.pathname.endsWith("index
initializeDonationBadges();
+ // Initialize periodic cleanup interval for memory management
+ if (!memoryCleanupInterval) {
+ memoryCleanupInterval = setInterval(
+ () => {
+ useChatStore.getState().performPeriodicCleanup();
+ },
+ 10 * 60 * 1000,
+ ); // Run cleanup every 10 minutes
+ console.log("[ChatProvider] Initialized periodic memory cleanup");
+ }
+
// Cleanup when window is about to unload
window.addEventListener("beforeunload", () => {
useChatStore.getState().cleanupBatching();
@@ -1736,9 +2442,32 @@ if (window.location.pathname === "/" || window.location.pathname.endsWith("index
if (donationBadgesInterval) {
clearInterval(donationBadgesInterval);
}
+
+ if (memoryCleanupInterval) {
+ clearInterval(memoryCleanupInterval);
+ }
});
}
+// Expose debug functions globally in development
+if (process.env.NODE_ENV === "development") {
+ window.debugKickTalk = {
+ toggleStreamStatus: (chatroomId, isLive) => {
+ useChatStore.getState().debugToggleStreamStatus(chatroomId, isLive);
+ },
+ getChatrooms: () => {
+ return useChatStore.getState().chatrooms.map((room) => ({
+ id: room.id,
+ username: room.username,
+ isLive: room.isStreamerLive,
+ }));
+ },
+ getConnectionStatus: () => {
+ return useChatStore.getState().getConnectionStatus();
+ },
+ };
+}
+
// Cleanup component to handle unmounting
export const ChatProviderCleanup = () => {
useEffect(() => {
diff --git a/src/renderer/src/providers/SettingsProvider.jsx b/src/renderer/src/providers/SettingsProvider.jsx
index 5841fbb..981eb0a 100644
--- a/src/renderer/src/providers/SettingsProvider.jsx
+++ b/src/renderer/src/providers/SettingsProvider.jsx
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect } from "react";
import { applyTheme } from "../../../../utils/themeUtils";
+import i18n from "../utils/i18n";
const SettingsContext = createContext({});
@@ -7,6 +8,11 @@ const SettingsProvider = ({ children }) => {
const [settings, setSettings] = useState({});
const handleThemeChange = async (newTheme) => {
+ if (!window.app?.store) {
+ console.warn("[SettingsProvider]: window.app.store not available for theme change");
+ return;
+ }
+
const themeData = { current: newTheme };
setSettings((prev) => ({ ...prev, customTheme: themeData }));
applyTheme(themeData);
@@ -16,6 +22,13 @@ const SettingsProvider = ({ children }) => {
useEffect(() => {
async function loadSettings() {
try {
+ // Wait for window.app to be available
+ if (!window.app?.store) {
+ console.warn("[SettingsProvider]: window.app.store not available yet, retrying...");
+ setTimeout(loadSettings, 100);
+ return;
+ }
+
const settings = await window.app.store.get();
setSettings(settings);
@@ -23,6 +36,11 @@ const SettingsProvider = ({ children }) => {
if (settings?.customTheme?.current) {
applyTheme(settings.customTheme);
}
+
+ // Apply language if stored
+ if (settings?.language && settings.language !== i18n.language) {
+ await i18n.changeLanguage(settings.language);
+ }
} catch (error) {
console.error("[SettingsProvider]: Error loading settings:", error);
}
@@ -30,40 +48,67 @@ const SettingsProvider = ({ children }) => {
loadSettings();
- const cleanup = window.app.store.onUpdate((data) => {
- setSettings((prev) => {
- const newSettings = { ...prev };
-
- Object.entries(data).forEach(([key, value]) => {
- if (typeof value === "object" && value !== null) {
- newSettings[key] = {
- ...newSettings[key],
- ...value,
- };
- } else {
- newSettings[key] = value;
- }
+ // Setup store update listener with safety check
+ let cleanup;
+ const setupListener = () => {
+ if (window.app?.store?.onUpdate) {
+ cleanup = window.app.store.onUpdate((data) => {
+ setSettings((prev) => {
+ const newSettings = { ...prev };
+
+ Object.entries(data).forEach(([key, value]) => {
+ if (typeof value === "object" && value !== null) {
+ newSettings[key] = {
+ ...newSettings[key],
+ ...value,
+ };
+ } else {
+ newSettings[key] = value;
+ }
+ });
+
+ if (data.customTheme?.current) {
+ applyTheme(data.customTheme);
+ }
+
+ // Apply language if changed
+ if (data.language && data.language !== i18n.language) {
+ i18n.changeLanguage(data.language);
+ }
+
+ return newSettings;
+ });
});
+ } else {
+ setTimeout(setupListener, 100);
+ }
+ };
- if (data.customTheme?.current) {
- applyTheme(data.customTheme);
- }
-
- return newSettings;
- });
- });
+ setupListener();
- return () => cleanup();
+ return () => {
+ if (cleanup) cleanup();
+ };
}, []);
const updateSettings = async (key, value) => {
try {
+ if (!window.app?.store) {
+ console.warn("[SettingsProvider]: window.app.store not available for settings update");
+ return;
+ }
+
setSettings((prev) => ({ ...prev, [key]: value }));
await window.app.store.set(key, value);
if (key === "customTheme" && value?.current) {
applyTheme(value);
}
+
+ // Handle language changes
+ if (key === "language" && value !== i18n.language) {
+ await i18n.changeLanguage(value);
+ }
} catch (error) {
console.error(`Error updating setting ${key}:`, error);
}
diff --git a/src/renderer/src/utils/MessageParser.jsx b/src/renderer/src/utils/MessageParser.jsx
index 9108d59..e7b0a8e 100644
--- a/src/renderer/src/utils/MessageParser.jsx
+++ b/src/renderer/src/utils/MessageParser.jsx
@@ -34,13 +34,9 @@ const rules = [
{
// Kick Emote Rule
regexPattern: kickEmoteRegex,
- component: ({ match, index, type }) => {
+ component: ({ match, index }) => {
const { id, name } = match.groups;
- if (type === "reply") {
- return name;
- }
-
return (
{
+ try {
+ return localStorage.getItem('kicktalk-language') || 'en';
+ } catch (error) {
+ console.warn('Could not access localStorage:', error);
+ return 'en';
+ }
+};
+
+i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: getStoredLanguage(), // Use stored language
+ fallbackLng: 'en',
+
+ interpolation: {
+ escapeValue: false // React already does escaping
+ },
+
+ supportedLngs: ['en', 'es', 'pt'],
+
+ react: {
+ useSuspense: false
+ }
+ });
+
+// Listen for language changes and persist them
+i18n.on('languageChanged', (lng) => {
+ try {
+ localStorage.setItem('kicktalk-language', lng);
+ // Also save to app store if available
+ if (window.app?.store) {
+ window.app.store.set('language', lng);
+ }
+ } catch (error) {
+ console.warn('Could not save language preference:', error);
+ }
+});
+
+export default i18n;
diff --git a/src/renderer/src/utils/languageSync.js b/src/renderer/src/utils/languageSync.js
new file mode 100644
index 0000000..036fc49
--- /dev/null
+++ b/src/renderer/src/utils/languageSync.js
@@ -0,0 +1,67 @@
+/**
+ * Language synchronization utility
+ * Ensures all windows/dialogs stay in sync when language changes
+ */
+
+import i18n from './i18n';
+
+class LanguageSync {
+ constructor() {
+ this.listeners = new Set();
+ this.init();
+ }
+
+ init() {
+ // Listen for storage changes (from other windows)
+ window.addEventListener('storage', (e) => {
+ if (e.key === 'kicktalk-language' && e.newValue !== i18n.language) {
+ i18n.changeLanguage(e.newValue);
+ }
+ });
+
+ // Listen for i18n language changes
+ i18n.on('languageChanged', (lng) => {
+ this.notifyListeners(lng);
+ });
+ }
+
+ addListener(callback) {
+ this.listeners.add(callback);
+ return () => this.listeners.delete(callback);
+ }
+
+ notifyListeners(language) {
+ this.listeners.forEach(callback => {
+ try {
+ callback(language);
+ } catch (error) {
+ console.error('Language sync listener error:', error);
+ }
+ });
+ }
+
+ getCurrentLanguage() {
+ return i18n.language || 'en';
+ }
+
+ async changeLanguage(language) {
+ try {
+ await i18n.changeLanguage(language);
+
+ // Notify main process if available
+ if (window.app?.onLanguageChange) {
+ window.app.onLanguageChange(language);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error changing language:', error);
+ return false;
+ }
+ }
+}
+
+// Create singleton instance
+const languageSync = new LanguageSync();
+
+export default languageSync;
diff --git a/src/renderer/src/utils/useLanguage.js b/src/renderer/src/utils/useLanguage.js
new file mode 100644
index 0000000..e7137d5
--- /dev/null
+++ b/src/renderer/src/utils/useLanguage.js
@@ -0,0 +1,44 @@
+import { useTranslation } from 'react-i18next';
+import { useCallback, useEffect, useState } from 'react';
+import languageSync from './languageSync';
+
+export const useLanguage = () => {
+ const { i18n } = useTranslation();
+ const [currentLanguage, setCurrentLanguage] = useState(languageSync.getCurrentLanguage());
+
+ useEffect(() => {
+ // Listen for language changes from sync utility
+ const unsubscribe = languageSync.addListener((language) => {
+ setCurrentLanguage(language);
+ });
+
+ return unsubscribe;
+ }, []);
+
+ const changeLanguage = useCallback(async (language) => {
+ const success = await languageSync.changeLanguage(language);
+ if (success) {
+ setCurrentLanguage(language);
+ }
+ return success;
+ }, []);
+
+ const getCurrentLanguage = useCallback(() => {
+ return currentLanguage;
+ }, [currentLanguage]);
+
+ const getAvailableLanguages = () => {
+ return [
+ { code: 'en', name: 'English', flag: '🇺🇸' },
+ { code: 'es', name: 'Español', flag: '🇪🇸' },
+ { code: 'pt', name: 'Português', flag: '🇧🇷' }
+ ];
+ };
+
+ return {
+ changeLanguage,
+ getCurrentLanguage,
+ getAvailableLanguages,
+ currentLanguage
+ };
+};
diff --git a/src/telemetry/index.js b/src/telemetry/index.js
new file mode 100644
index 0000000..e42b56c
--- /dev/null
+++ b/src/telemetry/index.js
@@ -0,0 +1,256 @@
+// Main telemetry module for KickTalk
+let initializeTelemetry, shutdown, MetricsHelper, TracingHelper, SpanStatusCode;
+
+try {
+ console.log('[Telemetry]: Loading telemetry modules...');
+ const instrumentation = require('./instrumentation');
+ const metrics = require('./metrics');
+ const tracing = require('./tracing');
+
+ initializeTelemetry = instrumentation.initializeTelemetry;
+ shutdown = instrumentation.shutdown;
+ MetricsHelper = metrics.MetricsHelper;
+ TracingHelper = tracing.TracingHelper;
+ SpanStatusCode = tracing.SpanStatusCode;
+
+ console.log('[Telemetry]: All modules loaded successfully');
+} catch (error) {
+ console.error('[Telemetry]: Failed to load telemetry modules:', error.message);
+ console.error('[Telemetry]: Full error:', error);
+
+ // Provide fallback implementations
+ initializeTelemetry = () => false;
+ shutdown = () => Promise.resolve();
+ MetricsHelper = {
+ startTimer: () => Date.now(),
+ endTimer: () => 0,
+ incrementWebSocketConnections: () => {},
+ decrementWebSocketConnections: () => {},
+ recordConnectionError: () => {},
+ recordReconnection: () => {},
+ recordMessageReceived: () => {},
+ recordMessageSent: () => {},
+ recordMessageSendDuration: () => {},
+ recordError: () => {},
+ recordRendererMemory: () => {},
+ recordDomNodeCount: () => {},
+ incrementOpenWindows: () => {},
+ decrementOpenWindows: () => {}
+ };
+ TracingHelper = {
+ addEvent: () => {},
+ setAttributes: () => {},
+ traceWebSocketConnection: (id, streamerId, callback) => callback(),
+ traceMessageFlow: (id, content, callback) => callback(),
+ traceKickAPICall: (endpoint, method, callback) => callback()
+ };
+ SpanStatusCode = { OK: 1, ERROR: 2 };
+}
+
+let telemetryInitialized = false;
+
+// Initialize telemetry system
+const initTelemetry = () => {
+ if (telemetryInitialized) {
+ console.log('[Telemetry]: Already initialized');
+ return true;
+ }
+
+ try {
+ const success = initializeTelemetry();
+ if (success) {
+ telemetryInitialized = true;
+ console.log('[Telemetry]: KickTalk telemetry initialized successfully');
+
+ // Prometheus metrics server is now integrated into the MeterProvider
+ console.log('[Telemetry]: Prometheus metrics available at http://localhost:9464/metrics');
+
+ // Record application start
+ KickTalkMetrics.recordApplicationStart();
+ TracingHelper.addEvent('application.start', {
+ 'app.version': require('../../package.json').version,
+ 'node.version': process.version,
+ 'electron.version': process.versions.electron
+ });
+ }
+ return success;
+ } catch (error) {
+ console.error('[Telemetry]: Failed to initialize:', error);
+ return false;
+ }
+};
+
+// Graceful shutdown
+const shutdownTelemetry = async () => {
+ if (!telemetryInitialized) return;
+
+ try {
+ // Metrics server shutdown is handled by the MeterProvider
+ await shutdown();
+ telemetryInitialized = false;
+ console.log('[Telemetry]: Shutdown complete');
+ } catch (error) {
+ console.error('[Telemetry]: Error during shutdown:', error);
+ }
+};
+
+// Check if telemetry is enabled (controlled by user settings)
+// This function will be overridden by the main process with actual settings
+let isTelemetryEnabled = () => {
+ // Default to false for privacy - main process will override this
+ return false;
+};
+
+// Extended metrics helper with application-specific methods
+const KickTalkMetrics = {
+ ...MetricsHelper,
+
+ // Application lifecycle
+ recordApplicationStart() {
+ TracingHelper.addEvent('application.lifecycle', {
+ 'lifecycle.event': 'start',
+ 'app.startup_time': Date.now()
+ });
+ },
+
+ recordApplicationShutdown() {
+ TracingHelper.addEvent('application.lifecycle', {
+ 'lifecycle.event': 'shutdown',
+ 'app.shutdown_time': Date.now()
+ });
+ },
+
+ // Chatroom operations
+ recordChatroomJoin(chatroomId, streamerId) {
+ this.incrementWebSocketConnections(chatroomId, streamerId);
+ TracingHelper.addEvent('chatroom.join', {
+ 'chatroom.id': chatroomId,
+ 'streamer.id': streamerId
+ });
+ },
+
+ recordChatroomLeave(chatroomId, streamerId) {
+ this.decrementWebSocketConnections(chatroomId, streamerId);
+ TracingHelper.addEvent('chatroom.leave', {
+ 'chatroom.id': chatroomId,
+ 'streamer.id': streamerId
+ });
+ },
+
+ // Error tracking
+ recordError(error, context = {}) {
+ const errorAttributes = {
+ 'error.name': error.name,
+ 'error.message': error.message,
+ 'error.stack': error.stack?.substring(0, 1000), // Limit stack trace size
+ ...context
+ };
+
+ TracingHelper.addEvent('error.occurred', errorAttributes);
+
+ // Categorize error types
+ const errorType = error.name || 'UnknownError';
+ if (errorType.includes('Network') || errorType.includes('Connection')) {
+ this.recordConnectionError(errorType, context.chatroomId);
+ }
+ }
+};
+
+// Extended tracing helper with application-specific methods
+const KickTalkTracing = {
+ ...TracingHelper,
+
+ // Trace complete message flow
+ traceMessageFlow(chatroomId, messageContent, callback) {
+ return this.traceMessageSend(chatroomId, messageContent, (span) => {
+ // Add message flow specific attributes
+ span.setAttributes({
+ 'message.flow': 'user_to_chat',
+ 'message.chatroom': chatroomId
+ });
+
+ return callback(span);
+ });
+ },
+
+ // Trace API calls with KickTalk specific context
+ traceKickAPICall(endpoint, method, callback) {
+ return this.traceAPIRequest(endpoint, method, (span) => {
+ span.setAttributes({
+ 'api.provider': 'kick.com',
+ 'api.client': 'kicktalk'
+ });
+
+ return callback(span);
+ });
+ },
+
+ // Trace emote loading operations
+ traceEmoteLoad(emoteProvider, emoteId, callback) {
+ return this.startActiveSpan('emote.load', (span) => {
+ span.setAttributes({
+ 'emote.provider': emoteProvider,
+ 'emote.id': emoteId,
+ 'emote.operation': 'load'
+ });
+
+ try {
+ const result = callback(span);
+
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(res => {
+ span.setAttributes({
+ 'emote.load_success': true,
+ 'emote.cache_hit': res.fromCache || false
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return res;
+ })
+ .catch(error => {
+ span.setAttributes({
+ 'emote.load_success': false,
+ 'emote.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ });
+ } else {
+ span.setAttributes({
+ 'emote.load_success': true
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ span.setAttributes({
+ 'emote.load_success': false,
+ 'emote.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ }
+ });
+ }
+};
+
+module.exports = {
+ initTelemetry,
+ shutdownTelemetry,
+ isTelemetryEnabled,
+ isInitialized: () => telemetryInitialized,
+ metrics: KickTalkMetrics,
+ tracing: KickTalkTracing
+};
\ No newline at end of file
diff --git a/src/telemetry/instrumentation.js b/src/telemetry/instrumentation.js
new file mode 100644
index 0000000..d852558
--- /dev/null
+++ b/src/telemetry/instrumentation.js
@@ -0,0 +1,163 @@
+// OpenTelemetry tracing and metrics for KickTalk (Electron-compatible)
+// Based on SigNoz Electron sample: https://github.com/SigNoz/ElectronJS-otel-sample-app
+
+let tracer = null;
+let provider = null;
+let metricsProvider = null;
+
+try {
+ // Try to import from different packages - some versions have different locations
+ let BasicTracerProvider, SimpleSpanProcessor;
+
+ try {
+ ({ BasicTracerProvider } = require('@opentelemetry/sdk-trace-base'));
+ ({ SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'));
+ } catch (sdkError) {
+ console.log('[OTEL]: Trying alternative SDK imports...');
+ ({ BasicTracerProvider } = require('@opentelemetry/sdk-trace-node'));
+ ({ SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-node'));
+ }
+
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
+ const { trace } = require('@opentelemetry/api');
+ const pkg = require('../../package.json');
+
+ const isDev = process.env.NODE_ENV === 'development';
+
+ // Create a tracer provider without resource for Electron compatibility
+ provider = new BasicTracerProvider();
+
+ // Configure the OTLP exporter
+ const exporter = new OTLPTraceExporter({
+ url: 'http://localhost:4318/v1/traces',
+ headers: {
+ 'X-Custom-Header': 'kicktalk-telemetry'
+ }
+ });
+
+ // Add a simple span processor - check if method exists
+ if (typeof provider.addSpanProcessor === 'function') {
+ provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
+ console.log('[OTEL]: addSpanProcessor method available, using standard approach');
+ } else {
+ console.log('[OTEL]: addSpanProcessor method not available, trying alternative');
+ }
+
+ // Register the provider (only once)
+ if (typeof provider.register === 'function') {
+ provider.register();
+ console.log('[OTEL]: Provider registered successfully');
+ } else {
+ console.log('[OTEL]: Provider register method not available');
+ }
+
+ // Get a tracer
+ tracer = trace.getTracer('kicktalk', pkg.version);
+
+ console.log('[OTEL]: Manual instrumentation tracer initialized');
+
+ // Initialize metrics provider
+ try {
+ const { MeterProvider } = require('@opentelemetry/sdk-metrics');
+ const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
+ const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
+ const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
+ const { metrics } = require('@opentelemetry/api');
+ const http = require('http');
+
+ // Create Prometheus exporter
+ const prometheusExporter = new PrometheusExporter({
+ port: 9464,
+ endpoint: '/metrics',
+ }, () => {
+ console.log('[OTEL]: Prometheus metrics server started on http://localhost:9464/metrics');
+ });
+
+ // Create readers array
+ const readers = [
+ // OTLP exporter for external systems
+ new PeriodicExportingMetricReader({
+ exporter: new OTLPMetricExporter({
+ url: 'http://localhost:4318/v1/metrics',
+ headers: {
+ 'X-Custom-Header': 'kicktalk-telemetry'
+ }
+ }),
+ exportIntervalMillis: 10000, // Export every 10 seconds
+ }),
+ // Prometheus exporter for Grafana
+ prometheusExporter
+ ];
+
+ // Create metrics provider
+ metricsProvider = new MeterProvider({
+ readers: readers,
+ });
+
+ // Register the metrics provider
+ metrics.setGlobalMeterProvider(metricsProvider);
+ console.log('[OTEL]: Metrics provider initialized successfully with Prometheus and OTLP exporters');
+ } catch (metricsError) {
+ console.warn('[OTEL]: Failed to initialize metrics provider:', metricsError.message);
+ }
+} catch (error) {
+ console.error('[OTEL]: Failed to initialize tracer:', error.message);
+ // Create a no-op tracer
+ tracer = {
+ startSpan: (name) => ({
+ setAttributes: () => {},
+ addEvent: () => {},
+ recordException: () => {},
+ setStatus: () => {},
+ end: () => {}
+ })
+ };
+}
+
+// Graceful shutdown
+const shutdown = async () => {
+ const shutdownPromises = [];
+
+ if (provider) {
+ shutdownPromises.push(provider.shutdown());
+ }
+
+ if (metricsProvider) {
+ shutdownPromises.push(metricsProvider.shutdown());
+ }
+
+ if (shutdownPromises.length === 0) return;
+
+ try {
+ console.log('[OTEL]: Shutting down telemetry...');
+ await Promise.all(shutdownPromises);
+ console.log('[OTEL]: Telemetry shut down successfully');
+ } catch (error) {
+ console.error('[OTEL]: Error shutting down telemetry:', error);
+ }
+};
+
+// Initialize telemetry (already done above, just return status)
+const initializeTelemetry = () => {
+ const isInitialized = tracer !== null && provider !== null;
+
+ if (isInitialized) {
+ // Register shutdown handlers
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+ process.on('exit', shutdown);
+
+ console.log('[OTEL]: Telemetry ready for manual instrumentation');
+ return true;
+ }
+
+ console.log('[OTEL]: Using no-op tracer (telemetry disabled)');
+ return false;
+};
+
+module.exports = {
+ tracer,
+ provider,
+ initializeTelemetry,
+ shutdown
+};
\ No newline at end of file
diff --git a/src/telemetry/metrics.js b/src/telemetry/metrics.js
new file mode 100644
index 0000000..c4468c5
--- /dev/null
+++ b/src/telemetry/metrics.js
@@ -0,0 +1,333 @@
+// KickTalk metrics implementation
+const { metrics } = require('@opentelemetry/api');
+
+// Get the meter for KickTalk
+const meter = metrics.getMeter('kicktalk', require('../../package.json').version);
+
+// Connection Metrics - Track active connections in a Map for accurate counting
+const activeConnections = new Map();
+
+const websocketConnections = meter.createObservableGauge('kicktalk_websocket_connections_active', {
+ description: 'Number of active WebSocket connections',
+ unit: '1'
+});
+
+const websocketReconnections = meter.createCounter('kicktalk_websocket_reconnections_total', {
+ description: 'Total number of WebSocket reconnection attempts',
+ unit: '1'
+});
+
+const connectionErrors = meter.createCounter('kicktalk_connection_errors_total', {
+ description: 'Total number of connection errors',
+ unit: '1'
+});
+
+// Message Metrics
+const messagesSent = meter.createCounter('kicktalk_messages_sent_total', {
+ description: 'Total number of messages sent by user',
+ unit: '1'
+});
+
+const messagesReceived = meter.createCounter('kicktalk_messages_received_total', {
+ description: 'Total number of messages received from chat',
+ unit: '1'
+});
+
+const messageSendDuration = meter.createHistogram('kicktalk_message_send_duration_seconds', {
+ description: 'Time taken to send a message',
+ unit: 's',
+ boundaries: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
+});
+
+// API Metrics
+const apiRequestDuration = meter.createHistogram('kicktalk_api_request_duration_seconds', {
+ description: 'Time taken for API requests',
+ unit: 's',
+ boundaries: [0.1, 0.5, 1, 2, 5, 10, 30]
+});
+
+const apiRequests = meter.createCounter('kicktalk_api_requests_total', {
+ description: 'Total number of API requests',
+ unit: '1'
+});
+
+// Resource Metrics (using observableGauges for real-time values)
+const memoryUsage = meter.createObservableGauge('kicktalk_memory_usage_bytes', {
+ description: 'Application memory usage in bytes',
+ unit: 'By'
+});
+
+const cpuUsage = meter.createObservableGauge('kicktalk_cpu_usage_percent', {
+ description: 'CPU usage percentage',
+ unit: '%'
+});
+
+const openHandles = meter.createObservableGauge('kicktalk_open_handles_total', {
+ description: 'Number of open file/socket handles',
+ unit: '1'
+});
+
+const rendererMemoryUsage = meter.createObservableGauge('kicktalk_renderer_memory_usage_bytes', {
+ description: 'Renderer process memory usage in bytes',
+ unit: 'By'
+});
+
+const domNodeCount = meter.createObservableGauge('kicktalk_dom_node_count', {
+ description: 'Number of DOM nodes in the renderer process',
+ unit: '1'
+});
+
+// Storage for current values
+let currentRendererMemory = {
+ jsHeapUsedSize: 0,
+ jsHeapTotalSize: 0
+};
+let currentDomNodeCount = 0;
+
+const openWindows = meter.createUpDownCounter('kicktalk_open_windows', {
+ description: 'Number of open windows',
+ unit: '1'
+});
+
+const upStatus = meter.createObservableGauge('kicktalk_up', {
+ description: 'Application status (1=up, 0=down)',
+ unit: '1'
+});
+
+const gcDuration = meter.createHistogram('kicktalk_gc_duration_seconds', {
+ description: 'Garbage collection duration',
+ unit: 's'
+});
+
+// Callback for resource metrics
+memoryUsage.addCallback((observableResult) => {
+ const memUsage = process.memoryUsage();
+ observableResult.observe(memUsage.heapUsed, {
+ type: 'heap_used'
+ });
+ observableResult.observe(memUsage.heapTotal, {
+ type: 'heap_total'
+ });
+ observableResult.observe(memUsage.rss, {
+ type: 'rss'
+ });
+ observableResult.observe(memUsage.external, {
+ type: 'external'
+ });
+});
+
+cpuUsage.addCallback((observableResult) => {
+ const cpuUsageValue = process.cpuUsage();
+ const totalUsage = (cpuUsageValue.user + cpuUsageValue.system) / 1000000; // Convert to seconds
+ observableResult.observe(totalUsage, {
+ type: 'total'
+ });
+});
+
+// Handle count approximation using process._getActiveHandles (Node.js specific)
+openHandles.addCallback((observableResult) => {
+ try {
+ // This is a Node.js internal API, use with caution
+ const handles = process._getActiveHandles ? process._getActiveHandles().length : 0;
+ const requests = process._getActiveRequests ? process._getActiveRequests().length : 0;
+
+ observableResult.observe(handles + requests, {
+ type: 'total'
+ });
+ } catch (error) {
+ // Fallback if internal APIs are not available
+ observableResult.observe(0);
+ }
+});
+
+// Application uptime status
+upStatus.addCallback((observableResult) => {
+ // Application is up if this callback is running
+ observableResult.observe(1);
+});
+
+// Renderer memory usage callback
+rendererMemoryUsage.addCallback((observableResult) => {
+ observableResult.observe(currentRendererMemory.jsHeapUsedSize, { type: 'js_heap_used' });
+ observableResult.observe(currentRendererMemory.jsHeapTotalSize, { type: 'js_heap_total' });
+});
+
+// DOM node count callback
+domNodeCount.addCallback((observableResult) => {
+ observableResult.observe(currentDomNodeCount);
+});
+
+// Active WebSocket connections callback
+websocketConnections.addCallback((observableResult) => {
+ // Group connections by unique attribute sets and count them
+ const connectionCounts = new Map();
+
+ for (const [connectionKey, attributes] of activeConnections) {
+ const key = JSON.stringify(attributes);
+ connectionCounts.set(key, (connectionCounts.get(key) || 0) + 1);
+ }
+
+ for (const [attributesJson, count] of connectionCounts) {
+ const attributes = JSON.parse(attributesJson);
+ observableResult.observe(count, attributes);
+ }
+});
+
+// GC monitoring setup
+try {
+ const v8 = require('v8');
+ const performanceObserver = require('perf_hooks').PerformanceObserver;
+
+ // Monitor GC events using Performance Observer
+ const gcObserver = new performanceObserver((list) => {
+ const entries = list.getEntries();
+ entries.forEach((entry) => {
+ if (entry.entryType === 'gc') {
+ gcDuration.record(entry.duration / 1000, {
+ kind: entry.detail?.kind || 'unknown'
+ });
+ }
+ });
+ });
+
+ gcObserver.observe({ entryTypes: ['gc'] });
+} catch (error) {
+ // GC monitoring not available, continue without it
+ console.warn('GC monitoring unavailable:', error.message);
+}
+
+// Metrics helper functions
+const MetricsHelper = {
+ // Connection metrics
+ incrementWebSocketConnections(chatroomId, streamerId, streamerName = null) {
+ const attributes = {
+ chatroom_id: chatroomId,
+ streamer_id: streamerId
+ };
+ if (streamerName) attributes.streamer_name = streamerName;
+
+ const connectionKey = `${chatroomId}_${streamerId}`;
+ activeConnections.set(connectionKey, attributes);
+ console.log(`[Metrics] WebSocket INCREMENT for ${streamerName || 'unknown'} (${chatroomId}) - Active: ${activeConnections.size}`);
+ },
+
+ decrementWebSocketConnections(chatroomId, streamerId, streamerName = null) {
+ const connectionKey = `${chatroomId}_${streamerId}`;
+ const removed = activeConnections.delete(connectionKey);
+ console.log(`[Metrics] WebSocket DECREMENT for ${streamerName || 'unknown'} (${chatroomId}) - Removed: ${removed} - Active: ${activeConnections.size}`);
+ },
+
+ recordReconnection(chatroomId, reason = 'unknown') {
+ websocketReconnections.add(1, {
+ chatroom_id: chatroomId,
+ reason
+ });
+ },
+
+ recordConnectionError(errorType, chatroomId = null) {
+ const attributes = { error_type: errorType };
+ if (chatroomId) attributes.chatroom_id = chatroomId;
+
+ connectionErrors.add(1, attributes);
+ },
+
+ // Message metrics
+ recordMessageSent(chatroomId, messageType = 'regular', streamerName = null) {
+ const attributes = {
+ chatroom_id: chatroomId,
+ message_type: messageType
+ };
+ if (streamerName) attributes.streamer_name = streamerName;
+
+ messagesSent.add(1, attributes);
+ },
+
+ recordMessageReceived(chatroomId, messageType = 'regular', senderId = null, streamerName = null) {
+ const attributes = {
+ chatroom_id: chatroomId,
+ message_type: messageType
+ };
+ if (senderId) attributes.sender_id = senderId;
+ if (streamerName) attributes.streamer_name = streamerName;
+
+ messagesReceived.add(1, attributes);
+ },
+
+ recordMessageSendDuration(duration, chatroomId, success = true) {
+ messageSendDuration.record(duration, {
+ chatroom_id: chatroomId,
+ success: success.toString()
+ });
+ },
+
+ // API metrics
+ recordAPIRequest(endpoint, method, statusCode, duration) {
+ apiRequests.add(1, {
+ endpoint,
+ method,
+ status_code: statusCode.toString()
+ });
+
+ apiRequestDuration.record(duration, {
+ endpoint,
+ method,
+ status_code: statusCode.toString()
+ });
+ },
+
+ // Utility function to time operations
+ startTimer() {
+ return process.hrtime.bigint();
+ },
+
+ endTimer(startTime) {
+ const endTime = process.hrtime.bigint();
+ return Number(endTime - startTime) / 1e9; // Convert nanoseconds to seconds
+ },
+
+ recordGCDuration(duration, kind) {
+ gcDuration.record(duration, {
+ kind
+ });
+ },
+
+ recordRendererMemory(memory) {
+ currentRendererMemory.jsHeapUsedSize = memory.jsHeapUsedSize || 0;
+ currentRendererMemory.jsHeapTotalSize = memory.jsHeapTotalSize || 0;
+ },
+
+ recordDomNodeCount(count) {
+ currentDomNodeCount = count || 0;
+ },
+
+ incrementOpenWindows() {
+ openWindows.add(1);
+ },
+
+ decrementOpenWindows() {
+ openWindows.add(-1);
+ }
+};
+
+module.exports = {
+ meter,
+ metrics: {
+ websocketConnections,
+ websocketReconnections,
+ connectionErrors,
+ messagesSent,
+ messagesReceived,
+ messageSendDuration,
+ apiRequestDuration,
+ apiRequests,
+ memoryUsage,
+ cpuUsage,
+ openHandles,
+ gcDuration,
+ rendererMemoryUsage,
+ domNodeCount,
+ openWindows,
+ upStatus
+ },
+ MetricsHelper
+};
\ No newline at end of file
diff --git a/src/telemetry/prometheus-server.js b/src/telemetry/prometheus-server.js
new file mode 100644
index 0000000..2fb79d4
--- /dev/null
+++ b/src/telemetry/prometheus-server.js
@@ -0,0 +1,131 @@
+// Prometheus metrics HTTP server for KickTalk
+const http = require('http');
+
+let metricsServer = null;
+let isServerRunning = false;
+
+// Start Prometheus metrics server
+const startMetricsServer = (port = 9464) => {
+ if (isServerRunning) {
+ console.log('[Metrics]: Server already running');
+ return;
+ }
+
+ try {
+ // Try to use PrometheusRegistry from OpenTelemetry
+ let PrometheusRegistry;
+ try {
+ const { PrometheusRegistry: PR } = require('@opentelemetry/exporter-prometheus');
+ PrometheusRegistry = PR;
+ } catch (error) {
+ console.warn('[Metrics]: @opentelemetry/exporter-prometheus not available, using fallback');
+ PrometheusRegistry = null;
+ }
+
+ if (PrometheusRegistry) {
+ // Create the registry with proper configuration
+ const registry = new PrometheusRegistry({
+ port: port,
+ endpoint: '/metrics',
+ });
+
+ // Start the registry (this creates the HTTP server internally)
+ registry.startServer().then(() => {
+ isServerRunning = true;
+ console.log(`[Metrics]: Prometheus server started on http://localhost:${port}/metrics`);
+ }).catch((error) => {
+ console.error('[Metrics]: Failed to start Prometheus server:', error.message);
+ // Fall through to fallback implementation
+ throw error;
+ });
+
+ return true;
+ } else {
+ throw new Error('PrometheusRegistry not available');
+ }
+ } catch (error) {
+ console.error('[Metrics]: Error setting up Prometheus server:', error.message);
+
+ // Fallback: create a simple HTTP server that returns basic metrics
+ try {
+ const { metrics } = require('@opentelemetry/api');
+
+ metricsServer = http.createServer((req, res) => {
+ if (req.url === '/metrics' && req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'
+ });
+
+ // Basic health metric
+ const uptime = process.uptime();
+ const memUsage = process.memoryUsage();
+
+ let output = '';
+ output += '# HELP kicktalk_up Application is running\n';
+ output += '# TYPE kicktalk_up gauge\n';
+ output += 'kicktalk_up 1\n';
+
+ output += '# HELP kicktalk_uptime_seconds Application uptime in seconds\n';
+ output += '# TYPE kicktalk_uptime_seconds counter\n';
+ output += `kicktalk_uptime_seconds ${uptime}\n`;
+
+ output += '# HELP kicktalk_memory_heap_used_bytes Memory heap used in bytes\n';
+ output += '# TYPE kicktalk_memory_heap_used_bytes gauge\n';
+ output += `kicktalk_memory_heap_used_bytes ${memUsage.heapUsed}\n`;
+
+ output += '# HELP kicktalk_memory_heap_total_bytes Memory heap total in bytes\n';
+ output += '# TYPE kicktalk_memory_heap_total_bytes gauge\n';
+ output += `kicktalk_memory_heap_total_bytes ${memUsage.heapTotal}\n`;
+
+ res.end(output);
+ } else {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('404 Not Found - Try /metrics\n');
+ }
+ });
+
+ metricsServer.listen(port, '0.0.0.0', () => {
+ isServerRunning = true;
+ console.log(`[Metrics]: Fallback metrics server started on http://0.0.0.0:${port}/metrics`);
+ });
+
+ metricsServer.on('error', (error) => {
+ console.error('[Metrics]: Metrics server error:', error.message);
+ isServerRunning = false;
+ });
+
+ return true;
+ } catch (fallbackError) {
+ console.error('[Metrics]: Failed to create fallback metrics server:', fallbackError.message);
+ return false;
+ }
+ }
+};
+
+// Stop Prometheus metrics server
+const stopMetricsServer = () => {
+ if (!isServerRunning) {
+ return;
+ }
+
+ try {
+ if (metricsServer) {
+ metricsServer.close(() => {
+ console.log('[Metrics]: Metrics server stopped');
+ isServerRunning = false;
+ metricsServer = null;
+ });
+ } else {
+ console.log('[Metrics]: Metrics server stopped');
+ isServerRunning = false;
+ }
+ } catch (error) {
+ console.error('[Metrics]: Error stopping metrics server:', error.message);
+ }
+};
+
+module.exports = {
+ startMetricsServer,
+ stopMetricsServer,
+ isRunning: () => isServerRunning
+};
\ No newline at end of file
diff --git a/src/telemetry/tracing.js b/src/telemetry/tracing.js
new file mode 100644
index 0000000..13aa896
--- /dev/null
+++ b/src/telemetry/tracing.js
@@ -0,0 +1,343 @@
+// KickTalk distributed tracing implementation - Manual instrumentation
+const { tracer } = require('./instrumentation');
+
+// Import OpenTelemetry API with fallbacks
+let trace, context, SpanStatusCode, SpanKind;
+try {
+ ({ trace, context, SpanStatusCode, SpanKind } = require('@opentelemetry/api'));
+} catch (error) {
+ // Fallback for when API is not available
+ SpanStatusCode = { OK: 1, ERROR: 2 };
+ SpanKind = { INTERNAL: 0, CLIENT: 3, PRODUCER: 5 };
+ trace = { getActiveSpan: () => null };
+ context = {};
+}
+
+// Tracing helper functions
+const TracingHelper = {
+ // Start a new span with common KickTalk attributes
+ startSpan(name, options = {}) {
+ const span = tracer.startSpan(name, {
+ kind: options.kind || SpanKind.INTERNAL,
+ attributes: {
+ 'service.name': 'kicktalk',
+ 'service.version': require('../../package.json').version,
+ ...options.attributes
+ }
+ });
+
+ return span;
+ },
+
+ // Start a span with automatic context propagation
+ startActiveSpan(name, callback, options = {}) {
+ // Use manual span management since Electron doesn't support auto-context
+ const span = this.startSpan(name, options);
+ try {
+ const result = callback(span);
+ if (result && typeof result.then === 'function') {
+ return result.finally(() => span.end());
+ } else {
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ span.recordException(error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
+ span.end();
+ throw error;
+ }
+ },
+
+ // WebSocket connection tracing
+ traceWebSocketConnection(chatroomId, streamerId, callback) {
+ return this.startActiveSpan('websocket.connect', (span) => {
+ span.setAttributes({
+ 'websocket.chatroom_id': chatroomId,
+ 'websocket.streamer_id': streamerId,
+ 'websocket.operation': 'connect'
+ });
+
+ try {
+ const result = callback(span);
+
+ // Handle both sync and async results
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(res => {
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return res;
+ })
+ .catch(error => {
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ });
+ } else {
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ }
+ }, {
+ kind: SpanKind.CLIENT,
+ attributes: {
+ 'network.protocol.name': 'websocket'
+ }
+ });
+ },
+
+ // Message sending tracing
+ traceMessageSend(chatroomId, messageContent, callback) {
+ return this.startActiveSpan('message.send', (span) => {
+ span.setAttributes({
+ 'message.chatroom_id': chatroomId,
+ 'message.length': messageContent.length,
+ 'message.type': 'user_message',
+ 'messaging.operation': 'send'
+ });
+
+ // Don't include actual message content for privacy
+ const startTime = Date.now();
+
+ try {
+ const result = callback(span);
+
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(res => {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'message.send_duration_ms': duration,
+ 'message.success': true
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return res;
+ })
+ .catch(error => {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'message.send_duration_ms': duration,
+ 'message.success': false,
+ 'message.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ });
+ } else {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'message.send_duration_ms': duration,
+ 'message.success': true
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'message.send_duration_ms': duration,
+ 'message.success': false,
+ 'message.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ }
+ }, {
+ kind: SpanKind.PRODUCER
+ });
+ },
+
+ // API request tracing
+ traceAPIRequest(endpoint, method, callback) {
+ return this.startActiveSpan('api.request', (span) => {
+ span.setAttributes({
+ 'http.method': method,
+ 'http.url': endpoint,
+ 'http.request.method': method,
+ 'url.full': endpoint
+ });
+
+ const startTime = Date.now();
+
+ try {
+ const result = callback(span);
+
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(res => {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'http.response.status_code': res.status || 200,
+ 'http.request.duration_ms': duration
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return res;
+ })
+ .catch(error => {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'http.response.status_code': error.status || error.response?.status || 500,
+ 'http.request.duration_ms': duration,
+ 'http.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ });
+ } else {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'http.response.status_code': 200,
+ 'http.request.duration_ms': duration
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ span.setAttributes({
+ 'http.response.status_code': error.status || error.response?.status || 500,
+ 'http.request.duration_ms': duration,
+ 'http.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ }
+ }, {
+ kind: SpanKind.CLIENT
+ });
+ },
+
+ // User action tracing (e.g., joining chatroom)
+ traceUserAction(action, chatroomId, callback) {
+ return this.startActiveSpan(`user.${action}`, (span) => {
+ span.setAttributes({
+ 'user.action': action,
+ 'user.chatroom_id': chatroomId,
+ 'user.operation': action
+ });
+
+ try {
+ const result = callback(span);
+
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(res => {
+ span.setAttributes({
+ 'user.action_success': true
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return res;
+ })
+ .catch(error => {
+ span.setAttributes({
+ 'user.action_success': false,
+ 'user.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ });
+ } else {
+ span.setAttributes({
+ 'user.action_success': true
+ });
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return result;
+ }
+ } catch (error) {
+ span.setAttributes({
+ 'user.action_success': false,
+ 'user.error': error.name
+ });
+ span.recordException(error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: error.message
+ });
+ span.end();
+ throw error;
+ }
+ }, {
+ kind: SpanKind.INTERNAL
+ });
+ },
+
+ // Get current trace context for correlation
+ getCurrentTraceId() {
+ const activeSpan = trace.getActiveSpan();
+ if (activeSpan) {
+ const spanContext = activeSpan.spanContext();
+ return spanContext.traceId;
+ }
+ return null;
+ },
+
+ // Add event to current span
+ addEvent(name, attributes = {}) {
+ const activeSpan = trace.getActiveSpan();
+ if (activeSpan) {
+ activeSpan.addEvent(name, attributes);
+ }
+ },
+
+ // Set attribute on current span
+ setAttributes(attributes) {
+ const activeSpan = trace.getActiveSpan();
+ if (activeSpan) {
+ activeSpan.setAttributes(attributes);
+ }
+ }
+};
+
+module.exports = {
+ tracer,
+ TracingHelper,
+ trace,
+ context,
+ SpanStatusCode,
+ SpanKind
+};
\ No newline at end of file
diff --git a/utils/config.js b/utils/config.js
index de93e8c..cd8feb4 100644
--- a/utils/config.js
+++ b/utils/config.js
@@ -20,6 +20,10 @@ const schema = {
type: "boolean",
default: false,
},
+ compactChatroomsList: {
+ type: "boolean",
+ default: false,
+ },
showTabImages: {
type: "boolean",
default: true,
@@ -34,6 +38,7 @@ const schema = {
alwaysOnTop: false,
dialogAlwaysOnTop: false,
wrapChatroomsList: false,
+ compactChatroomsList: false,
showTabImages: true,
timestampFormat: "disabled",
},
diff --git a/utils/services/connectionManager.js b/utils/services/connectionManager.js
new file mode 100644
index 0000000..3930320
--- /dev/null
+++ b/utils/services/connectionManager.js
@@ -0,0 +1,341 @@
+import SharedKickPusher from "./kick/sharedKickPusher.js";
+import SharedStvWebSocket from "./seventv/sharedStvWebSocket.js";
+
+class ConnectionManager {
+ constructor() {
+ this.kickPusher = new SharedKickPusher();
+ this.stvWebSocket = new SharedStvWebSocket();
+ this.initializationInProgress = false;
+ this.emoteCache = new Map(); // Cache for global/common emotes
+ this.globalStvEmotesCache = null; // Cache for global 7TV emotes
+
+ // Callbacks to avoid circular imports
+ this.storeCallbacks = null;
+
+ // Connection configuration
+ this.config = {
+ staggerDelay: 200, // ms between batches
+ batchSize: 3, // chatrooms per batch
+ maxConcurrentEmoteFetches: 5,
+ };
+ }
+
+ async initializeConnections(chatrooms, eventHandlers = {}, storeCallbacks = {}) {
+ if (this.initializationInProgress) {
+ console.log("[ConnectionManager] Initialization already in progress");
+ return;
+ }
+
+ this.initializationInProgress = true;
+ this.storeCallbacks = storeCallbacks;
+ console.log(`[ConnectionManager] Starting optimized initialization for ${chatrooms.length} chatrooms`);
+
+ try {
+ // Set up event handlers
+ this.setupEventHandlers(eventHandlers);
+
+ // Start shared connections
+ await this.startSharedConnections();
+
+ // Initialize chatrooms in staggered batches
+ await this.initializeChatroomsInBatches(chatrooms);
+
+ // Batch fetch emotes
+ await this.batchFetchEmotes(chatrooms);
+
+ console.log("[ConnectionManager] Initialization completed successfully");
+ } catch (error) {
+ console.error("[ConnectionManager] Error during initialization:", error);
+ throw error;
+ } finally {
+ this.initializationInProgress = false;
+ }
+ }
+
+ setupEventHandlers(handlers) {
+ // Set up KickPusher event handlers
+ if (handlers.onKickMessage) {
+ this.kickPusher.addEventListener("message", handlers.onKickMessage);
+ }
+ if (handlers.onKickChannel) {
+ this.kickPusher.addEventListener("channel", handlers.onKickChannel);
+ }
+ if (handlers.onKickConnection) {
+ this.kickPusher.addEventListener("connection", handlers.onKickConnection);
+ }
+ if (handlers.onKickSubscriptionSuccess) {
+ this.kickPusher.addEventListener("subscription_success", handlers.onKickSubscriptionSuccess);
+ }
+
+ // Set up 7TV event handlers
+ if (handlers.onStvMessage) {
+ this.stvWebSocket.addEventListener("message", handlers.onStvMessage);
+ }
+ if (handlers.onStvOpen) {
+ this.stvWebSocket.addEventListener("open", handlers.onStvOpen);
+ }
+ if (handlers.onStvConnection) {
+ this.stvWebSocket.addEventListener("connection", handlers.onStvConnection);
+ }
+ }
+
+ async startSharedConnections() {
+ console.log("[ConnectionManager] Starting shared connections...");
+
+ // Start both connections in parallel
+ const kickPromise = new Promise((resolve) => {
+ const onConnection = (event) => {
+ if (event.detail.content === "connection-success") {
+ this.kickPusher.removeEventListener("connection", onConnection);
+ resolve();
+ }
+ };
+ this.kickPusher.addEventListener("connection", onConnection);
+ this.kickPusher.connect();
+ });
+
+ const stvPromise = new Promise((resolve) => {
+ const onConnection = (event) => {
+ if (event.detail.content === "connection-success") {
+ this.stvWebSocket.removeEventListener("connection", onConnection);
+ resolve();
+ }
+ };
+ this.stvWebSocket.addEventListener("connection", onConnection);
+ this.stvWebSocket.connect();
+ });
+
+ // Wait for both connections with timeout
+ await Promise.race([
+ Promise.all([kickPromise, stvPromise]),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Connection timeout")), 10000)),
+ ]);
+
+ console.log("[ConnectionManager] Shared connections established");
+ }
+
+ async initializeChatroomsInBatches(chatrooms) {
+ console.log(`[ConnectionManager] Initializing ${chatrooms.length} chatrooms in batches of ${this.config.batchSize}`);
+
+ // Sort chatrooms by priority (you can customize this logic)
+ const prioritizedChatrooms = this.prioritizeChatrooms(chatrooms);
+
+ // Split into batches
+ const batches = this.chunkArray(prioritizedChatrooms, this.config.batchSize);
+
+ for (let i = 0; i < batches.length; i++) {
+ const batch = batches[i];
+ console.log(`[ConnectionManager] Processing batch ${i + 1}/${batches.length} (${batch.length} chatrooms)`);
+
+ // Process batch in parallel
+ const batchPromises = batch.map((chatroom) => this.addChatroom(chatroom));
+ await Promise.allSettled(batchPromises);
+
+ // Add delay between batches (except for the last one)
+ if (i < batches.length - 1) {
+ await this.delay(this.config.staggerDelay);
+ }
+ }
+
+ console.log("[ConnectionManager] All chatrooms initialized");
+ }
+
+ async addChatroom(chatroom) {
+ try {
+ // Add to KickPusher
+ this.kickPusher.addChatroom(chatroom.id, chatroom.streamerData.id, chatroom);
+
+ // Add to 7TV WebSocket
+ const stvId = chatroom.streamerData?.user_id || "0";
+ const stvEmoteSetId = chatroom.channel7TVEmotes?.[0]?.id || "0";
+
+ this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId);
+
+ // Fetch initial messages for this chatroom
+ await this.fetchInitialMessages(chatroom);
+
+ // Fetch initial chatroom info (including livestream status)
+ await this.fetchInitialChatroomInfo(chatroom);
+
+ console.log(`[ConnectionManager] Added chatroom ${chatroom.id} (${chatroom.streamerData?.user?.username})`);
+ } catch (error) {
+ console.error(`[ConnectionManager] Error adding chatroom ${chatroom.id}:`, error);
+ }
+ }
+
+ async removeChatroom(chatroomId) {
+ this.kickPusher.removeChatroom(chatroomId);
+ this.stvWebSocket.removeChatroom(chatroomId);
+ console.log(`[ConnectionManager] Removed chatroom ${chatroomId}`);
+ }
+
+ async batchFetchEmotes(chatrooms) {
+ console.log("[ConnectionManager] Starting batch emote fetching...");
+
+ // Fetch global 7TV emotes first (cached)
+ await this.fetchGlobalStvEmotes();
+
+ // Batch fetch channel-specific emotes
+ const emoteFetchPromises = chatrooms.map((chatroom) => this.fetchChatroomEmotes(chatroom));
+
+ // Process in batches to avoid overwhelming the APIs
+ const emoteBatches = this.chunkArray(emoteFetchPromises, this.config.maxConcurrentEmoteFetches);
+
+ for (const batch of emoteBatches) {
+ await Promise.allSettled(batch);
+ await this.delay(100); // Small delay between batches
+ }
+
+ console.log("[ConnectionManager] Batch emote fetching completed");
+ }
+
+ async fetchGlobalStvEmotes() {
+ if (this.globalStvEmotesCache) {
+ console.log("[ConnectionManager] Using cached global 7TV emotes");
+ return this.globalStvEmotesCache;
+ }
+
+ 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...");
+ // const globalEmotes = await window.app.seventv.getGlobalEmotes();
+ // this.globalStvEmotesCache = globalEmotes;
+ console.log("[ConnectionManager] Global 7TV emotes cached");
+ } catch (error) {
+ console.error("[ConnectionManager] Error fetching global 7TV emotes:", error);
+ }
+ }
+
+ async fetchChatroomEmotes(chatroom) {
+ const cacheKey = `${chatroom.streamerData?.slug}`;
+
+ if (this.emoteCache.has(cacheKey)) {
+ console.log(`[ConnectionManager] Using cached emotes for ${chatroom.streamerData?.user?.username}`);
+ return this.emoteCache.get(cacheKey);
+ }
+
+ try {
+ console.log(`[ConnectionManager] Fetching emotes for ${chatroom.streamerData?.user?.username}`);
+
+ // Fetch Kick emotes
+ const kickEmotes = await window.app.kick.getEmotes(chatroom.streamerData?.slug);
+
+ // Cache the result
+ this.emoteCache.set(cacheKey, kickEmotes);
+
+ return kickEmotes;
+ } catch (error) {
+ console.error(`[ConnectionManager] Error fetching emotes for ${chatroom.streamerData?.user?.username}:`, error);
+ return null;
+ }
+ }
+
+ prioritizeChatrooms(chatrooms) {
+ // Sort chatrooms by priority - you can customize this logic
+ return chatrooms.sort((a, b) => {
+ // Prioritize live streamers
+ if (a.isStreamerLive && !b.isStreamerLive) return -1;
+ if (!a.isStreamerLive && b.isStreamerLive) return 1;
+
+ // Then by last activity or other criteria
+ return 0;
+ });
+ }
+
+ chunkArray(array, size) {
+ const chunks = [];
+ for (let i = 0; i < array.length; i += size) {
+ chunks.push(array.slice(i, i + size));
+ }
+ return chunks;
+ }
+
+ delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ // Status methods
+ getConnectionStatus() {
+ return {
+ kick: {
+ state: this.kickPusher.getConnectionState(),
+ chatrooms: this.kickPusher.getChatroomCount(),
+ channels: this.kickPusher.getSubscribedChannelCount(),
+ },
+ stv: {
+ state: this.stvWebSocket.getConnectionState(),
+ chatrooms: this.stvWebSocket.getChatroomCount(),
+ events: this.stvWebSocket.getSubscribedEventCount(),
+ },
+ emoteCache: {
+ size: this.emoteCache.size,
+ globalCached: !!this.globalStvEmotesCache,
+ },
+ };
+ }
+
+ // Fetch initial messages for a chatroom
+ async fetchInitialMessages(chatroom) {
+ try {
+ const response = await window.app.kick.getInitialChatroomMessages(chatroom.streamerData.id);
+
+ if (!response?.data?.data) {
+ console.log(`[ConnectionManager] No initial messages data for chatroom ${chatroom.id}`);
+ return;
+ }
+
+ const data = response.data.data;
+
+ // Use callbacks to avoid circular imports
+ if (this.storeCallbacks) {
+ // Handle initial pinned message
+ if (data?.pinned_message) {
+ this.storeCallbacks.handlePinnedMessageCreated?.(chatroom.id, data.pinned_message);
+ } else {
+ this.storeCallbacks.handlePinnedMessageDeleted?.(chatroom.id);
+ }
+
+ // Add initial messages to the chatroom
+ if (data?.messages) {
+ this.storeCallbacks.addInitialChatroomMessages?.(chatroom.id, data.messages.reverse());
+ console.log(`[ConnectionManager] Loaded ${data.messages.length} initial messages for chatroom ${chatroom.id}`);
+ }
+ }
+ } catch (error) {
+ console.error(`[ConnectionManager] Error fetching initial messages for chatroom ${chatroom.id}:`, error);
+ }
+ }
+
+ // Fetch initial chatroom info (including livestream status)
+ async fetchInitialChatroomInfo(chatroom) {
+ try {
+ const response = await window.app.kick.getChannelChatroomInfo(chatroom.streamerData.slug);
+ console.log(response);
+
+ if (!response?.data) {
+ return;
+ }
+
+ // Use callbacks to avoid circular imports
+ if (this.storeCallbacks) {
+ const isLive = response.data?.livestream?.is_live || false;
+ this.storeCallbacks.handleStreamStatus?.(chatroom.id, response.data, isLive);
+ }
+ } catch (error) {
+ console.error(`[ConnectionManager] Error fetching initial chatroom info for chatroom ${chatroom.id}:`, error);
+ }
+ }
+
+ // Cleanup method
+ cleanup() {
+ console.log("[ConnectionManager] Cleaning up connections...");
+ this.kickPusher.close();
+ this.stvWebSocket.close();
+ this.emoteCache.clear();
+ this.globalStvEmotesCache = null;
+ this.initializationInProgress = false;
+ }
+}
+
+export default ConnectionManager;
diff --git a/utils/services/kick/kickPusher.js b/utils/services/kick/kickPusher.js
index 3dcf798..5443522 100644
--- a/utils/services/kick/kickPusher.js
+++ b/utils/services/kick/kickPusher.js
@@ -1,10 +1,11 @@
class KickPusher extends EventTarget {
- constructor(chatroomNumber, streamerId) {
+ constructor(chatroomNumber, streamerId, streamerName = null) {
super();
this.reconnectDelay = 5000;
this.chat = null;
this.chatroomNumber = chatroomNumber;
this.streamerId = streamerId;
+ this.streamerName = streamerName;
this.shouldReconnect = true;
this.socketId = null;
}
@@ -31,6 +32,15 @@ class KickPusher extends EventTarget {
this.chat.addEventListener("open", async () => {
console.log(`Connected to Kick.com Streamer Chat: ${this.chatroomNumber}`);
+
+ // Record WebSocket connection
+ try {
+ const streamerName = this.streamerName || `chatroom_${this.chatroomNumber}`;
+ console.log(`[Telemetry] WebSocket connected - chatroomId: ${this.chatroomNumber}, streamerId: ${this.streamerId}, streamerName: ${streamerName}`);
+ await window.app?.telemetry?.recordWebSocketConnection?.(this.chatroomNumber, this.streamerId, true, streamerName);
+ } catch (error) {
+ console.warn('[Telemetry]: Failed to record WebSocket connection:', error);
+ }
setTimeout(() => {
if (this.chat && this.chat.readyState === WebSocket.OPEN) {
@@ -58,17 +68,41 @@ class KickPusher extends EventTarget {
this.chat.addEventListener("error", (error) => {
console.log(`Error occurred: ${error.message}`);
+
+ // Record connection error
+ try {
+ window.app?.telemetry?.recordConnectionError?.(this.chatroomNumber, error.message || 'unknown');
+ } catch (telemetryError) {
+ console.warn('[Telemetry]: Failed to record connection error:', telemetryError);
+ }
+
this.dispatchEvent(new CustomEvent("error", { detail: error }));
});
this.chat.addEventListener("close", () => {
console.log(`Connection closed for chatroom: ${this.chatroomNumber}`);
+
+ // Record WebSocket disconnection
+ try {
+ const streamerName = this.streamerName || `chatroom_${this.chatroomNumber}`;
+ window.app?.telemetry?.recordWebSocketConnection?.(this.chatroomNumber, this.streamerId, false, streamerName);
+ } catch (error) {
+ console.warn('[Telemetry]: Failed to record WebSocket disconnection:', error);
+ }
this.dispatchEvent(new Event("close"));
if (this.shouldReconnect) {
setTimeout(() => {
console.log(`Attempting to reconnect to chatroom: ${this.chatroomNumber}...`);
+
+ // Record reconnection attempt
+ try {
+ window.app?.telemetry?.recordReconnection?.(this.chatroomNumber, 'websocket_close');
+ } catch (error) {
+ console.warn('[Telemetry]: Failed to record reconnection:', error);
+ }
+
this.connect();
}, this.reconnectDelay);
} else {
@@ -180,6 +214,19 @@ class KickPusher extends EventTarget {
jsonData.event === `App\\Events\\UserBannedEvent` ||
jsonData.event === `App\\Events\\UserUnbannedEvent`
) {
+ // Record received message for ChatMessageEvent
+ if (jsonData.event === `App\\Events\\ChatMessageEvent`) {
+ try {
+ const messageData = JSON.parse(jsonData.data);
+ const messageType = messageData.type || 'regular';
+ const senderId = messageData.sender?.id;
+ const streamerName = this.streamerName || `chatroom_${this.chatroomNumber}`;
+ await window.app?.telemetry?.recordMessageReceived?.(this.chatroomNumber, messageType, senderId, streamerName);
+ } catch (error) {
+ console.warn('[Telemetry]: Failed to record received message:', error);
+ }
+ }
+
this.dispatchEvent(new CustomEvent("message", { detail: jsonData }));
}
diff --git a/utils/services/kick/sharedKickPusher.js b/utils/services/kick/sharedKickPusher.js
new file mode 100644
index 0000000..da0ccd4
--- /dev/null
+++ b/utils/services/kick/sharedKickPusher.js
@@ -0,0 +1,376 @@
+class SharedKickPusher extends EventTarget {
+ constructor() {
+ super();
+ this.reconnectDelay = 5000;
+ this.chat = null;
+ this.shouldReconnect = true;
+ this.socketId = null;
+ this.chatrooms = new Map(); // Map of chatroomId -> chatroom info
+ this.subscribedChannels = new Set(); // Track subscribed channels
+ this.userEventsSubscribed = false; // Track if user events are subscribed
+ this.connectionState = 'disconnected'; // disconnected, connecting, connected
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = 10;
+ }
+
+ addChatroom(chatroomId, streamerId, chatroomData) {
+ this.chatrooms.set(chatroomId, {
+ chatroomId,
+ streamerId,
+ chatroomData,
+ channels: [
+ `channel_${streamerId}`,
+ `channel.${streamerId}`,
+ `chatrooms.${chatroomId}`,
+ `chatrooms.${chatroomId}.v2`,
+ `chatroom_${chatroomId}`,
+ ],
+ });
+
+ // If we're already connected, subscribe to this chatroom's channels
+ if (this.connectionState === 'connected') {
+ this.subscribeToChatroomChannels(chatroomId);
+ }
+ }
+
+ removeChatroom(chatroomId) {
+ const chatroom = this.chatrooms.get(chatroomId);
+ if (chatroom && this.connectionState === 'connected') {
+ this.unsubscribeFromChatroomChannels(chatroomId);
+ }
+ this.chatrooms.delete(chatroomId);
+
+ // If no more chatrooms, close the connection
+ if (this.chatrooms.size === 0) {
+ this.close();
+ }
+ }
+
+ connect() {
+ if (!this.shouldReconnect) {
+ console.log("[SharedKickPusher] Not connecting. Disabled reconnect.");
+ return;
+ }
+
+ if (this.connectionState === 'connecting' || this.connectionState === 'connected') {
+ console.log("[SharedKickPusher] Already connecting/connected");
+ return;
+ }
+
+ this.connectionState = 'connecting';
+ console.log(`[SharedKickPusher] Connecting to Kick WebSocket for ${this.chatrooms.size} chatrooms`);
+
+ this.chat = new WebSocket(
+ "wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=8.4.0-rc2&flash=false",
+ );
+
+ this.dispatchEvent(
+ new CustomEvent("connection", {
+ detail: {
+ type: "system",
+ content: "connection-pending",
+ chatrooms: Array.from(this.chatrooms.keys()),
+ },
+ }),
+ );
+
+ this.chat.addEventListener("open", () => {
+ console.log("[SharedKickPusher] Connected to Kick WebSocket");
+ this.reconnectAttempts = 0;
+
+ // Wait for connection_established event before subscribing
+ });
+
+ this.chat.addEventListener("error", (error) => {
+ console.log(`[SharedKickPusher] Error occurred: ${error.message}`);
+ this.connectionState = 'disconnected';
+ this.dispatchEvent(new CustomEvent("error", { detail: error }));
+ });
+
+ this.chat.addEventListener("close", () => {
+ console.log("[SharedKickPusher] Connection closed");
+ this.connectionState = 'disconnected';
+ this.socketId = null;
+ this.userEventsSubscribed = false;
+ this.subscribedChannels.clear();
+
+ this.dispatchEvent(new Event("close"));
+
+ if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
+ this.reconnectAttempts++;
+ setTimeout(() => {
+ console.log(`[SharedKickPusher] Attempting to reconnect (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
+ this.connect();
+ }, this.reconnectDelay * this.reconnectAttempts);
+ } else {
+ console.log("[SharedKickPusher] Not reconnecting - connection was closed intentionally or max attempts reached");
+ }
+ });
+
+ this.chat.addEventListener("message", async (event) => {
+ try {
+ const dataString = event.data;
+ const jsonData = JSON.parse(dataString);
+
+ // Handle connection established
+ if (jsonData.event === "pusher:connection_established") {
+ this.connectionState = 'connected';
+ this.socketId = JSON.parse(jsonData.data).socket_id;
+ console.log(`[SharedKickPusher] Connection established: socket ID - ${this.socketId}`);
+
+ // Subscribe to all chatroom channels
+ await this.subscribeToAllChannels();
+
+ this.dispatchEvent(
+ new CustomEvent("connection", {
+ detail: {
+ type: "system",
+ content: "connection-success",
+ chatrooms: Array.from(this.chatrooms.keys()),
+ },
+ }),
+ );
+ }
+
+ // Handle subscription success
+ if (jsonData.event === "pusher_internal:subscription_succeeded") {
+ const chatroomId = this.extractChatroomIdFromChannel(jsonData.channel);
+ if (chatroomId) {
+ console.log(`[SharedKickPusher] Subscription successful for chatroom: ${chatroomId}`);
+ this.dispatchEvent(
+ new CustomEvent("subscription_success", {
+ detail: {
+ chatroomId,
+ channel: jsonData.channel,
+ },
+ }),
+ );
+ }
+ }
+
+ // Handle chat messages and events
+ if (
+ jsonData.event === `App\\Events\\ChatMessageEvent` ||
+ jsonData.event === `App\\Events\\MessageDeletedEvent` ||
+ jsonData.event === `App\\Events\\UserBannedEvent` ||
+ jsonData.event === `App\\Events\\UserUnbannedEvent`
+ ) {
+ const chatroomId = this.extractChatroomIdFromChannel(jsonData.channel);
+ if (chatroomId) {
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: {
+ chatroomId,
+ event: jsonData.event,
+ data: jsonData.data,
+ channel: jsonData.channel,
+ },
+ }),
+ );
+ }
+ }
+
+ // Handle channel events
+ if (
+ jsonData.event === `App\\Events\\LivestreamUpdated` ||
+ jsonData.event === `App\\Events\\StreamerIsLive` ||
+ jsonData.event === `App\\Events\\StopStreamBroadcast` ||
+ jsonData.event === `App\\Events\\PinnedMessageCreatedEvent` ||
+ jsonData.event === `App\\Events\\PinnedMessageDeletedEvent` ||
+ jsonData.event === `App\\Events\\ChatroomUpdatedEvent` ||
+ jsonData.event === `App\\Events\\PollUpdateEvent` ||
+ jsonData.event === `App\\Events\\PollDeleteEvent`
+ ) {
+ const chatroomId = this.extractChatroomIdFromChannel(jsonData.channel);
+ if (chatroomId) {
+ this.dispatchEvent(
+ new CustomEvent("channel", {
+ detail: {
+ chatroomId,
+ event: jsonData.event,
+ data: jsonData.data,
+ channel: jsonData.channel,
+ },
+ }),
+ );
+ }
+ }
+ } catch (error) {
+ console.log(`[SharedKickPusher] Error in message processing: ${error.message}`);
+ this.dispatchEvent(new CustomEvent("error", { detail: error }));
+ }
+ });
+ }
+
+ async subscribeToAllChannels() {
+ if (!this.chat || this.chat.readyState !== WebSocket.OPEN) {
+ console.log("[SharedKickPusher] Cannot subscribe - WebSocket not open");
+ return;
+ }
+
+ // Subscribe to user events (only once)
+ await this.subscribeToUserEvents();
+
+ // Subscribe to all chatroom channels
+ for (const [chatroomId] of this.chatrooms) {
+ await this.subscribeToChatroomChannels(chatroomId);
+ }
+ }
+
+ async subscribeToUserEvents() {
+ if (this.userEventsSubscribed) return;
+
+ const user_id = localStorage.getItem("kickId");
+ if (!user_id) {
+ console.log("[SharedKickPusher] No user ID found, skipping private event subscriptions");
+ return;
+ }
+
+ const userEvents = [`private-userfeed.${user_id}`, `private-channelpoints-${user_id}`];
+ console.log("[SharedKickPusher] Subscribing to user events:", userEvents);
+
+ for (const event of userEvents) {
+ try {
+ console.log("[SharedKickPusher] Subscribing to private event:", event);
+ const AuthToken = await window.app.kick.getKickAuthForEvents(event, this.socketId);
+
+ if (AuthToken.auth) {
+ this.chat.send(
+ JSON.stringify({
+ event: "pusher:subscribe",
+ data: { auth: AuthToken.auth, channel: event },
+ }),
+ );
+ this.subscribedChannels.add(event);
+ console.log("[SharedKickPusher] Subscribed to event:", event);
+ }
+ } catch (error) {
+ console.error("[SharedKickPusher] Error subscribing to event:", error);
+ }
+ }
+
+ this.userEventsSubscribed = true;
+ }
+
+ async subscribeToChatroomChannels(chatroomId) {
+ const chatroom = this.chatrooms.get(chatroomId);
+ if (!chatroom) {
+ console.log(`[SharedKickPusher] Chatroom ${chatroomId} not found`);
+ return;
+ }
+
+ // Subscribe to basic chatroom channels
+ for (const channel of chatroom.channels) {
+ if (!this.subscribedChannels.has(channel)) {
+ this.chat.send(
+ JSON.stringify({
+ event: "pusher:subscribe",
+ data: { auth: "", channel },
+ }),
+ );
+ this.subscribedChannels.add(channel);
+ }
+ }
+
+ console.log(`[SharedKickPusher] Subscribed to channels for chatroom: ${chatroomId}`);
+
+ // Subscribe to livestream event if streamer is live
+ if (chatroom.chatroomData?.streamerData?.livestream !== null) {
+ const livestreamId = chatroom.chatroomData.streamerData.livestream.id;
+ const liveEventToSubscribe = `private-livestream.${livestreamId}`;
+
+ try {
+ console.log(`[SharedKickPusher] Subscribing to livestream event for chatroom ${chatroomId}:`, liveEventToSubscribe);
+
+ const AuthToken = await window.app.kick.getKickAuthForEvents(liveEventToSubscribe, this.socketId);
+
+ if (AuthToken.auth && !this.subscribedChannels.has(liveEventToSubscribe)) {
+ this.chat.send(
+ JSON.stringify({
+ event: "pusher:subscribe",
+ data: { auth: AuthToken.auth, channel: liveEventToSubscribe },
+ }),
+ );
+ this.subscribedChannels.add(liveEventToSubscribe);
+ console.log("[SharedKickPusher] Subscribed to livestream event:", liveEventToSubscribe);
+ }
+ } catch (error) {
+ console.error("[SharedKickPusher] Error subscribing to livestream event:", error);
+ }
+ } else {
+ console.log(`[SharedKickPusher] Chatroom ${chatroomId} is not live, skipping livestream subscription`);
+ }
+ }
+
+ unsubscribeFromChatroomChannels(chatroomId) {
+ const chatroom = this.chatrooms.get(chatroomId);
+ if (!chatroom) return;
+
+ for (const channel of chatroom.channels) {
+ if (this.subscribedChannels.has(channel)) {
+ this.chat.send(
+ JSON.stringify({
+ event: "pusher:unsubscribe",
+ data: { channel },
+ }),
+ );
+ this.subscribedChannels.delete(channel);
+ }
+ }
+
+ console.log(`[SharedKickPusher] Unsubscribed from channels for chatroom: ${chatroomId}`);
+ }
+
+ extractChatroomIdFromChannel(channel) {
+ // Extract chatroom ID from channel names like "chatrooms.12345.v2"
+ const match = channel.match(/chatrooms\.(\d+)(?:\.v2)?$/);
+ return match ? match[1] : null;
+ }
+
+ close() {
+ console.log("[SharedKickPusher] Closing shared connection");
+ this.shouldReconnect = false;
+ this.connectionState = 'disconnected';
+
+ if (this.chat && this.chat.readyState === WebSocket.OPEN) {
+ try {
+ // Unsubscribe from all channels
+ for (const channel of this.subscribedChannels) {
+ this.chat.send(
+ JSON.stringify({
+ event: "pusher:unsubscribe",
+ data: { channel },
+ }),
+ );
+ }
+
+ this.subscribedChannels.clear();
+ this.chat.close();
+ this.chat = null;
+ this.socketId = null;
+ this.userEventsSubscribed = false;
+
+ console.log("[SharedKickPusher] WebSocket connection closed");
+ } catch (error) {
+ console.error("[SharedKickPusher] Error during closing of connection:", error);
+ }
+ }
+ }
+
+ // Get connection status
+ getConnectionState() {
+ return this.connectionState;
+ }
+
+ // Get number of subscribed channels
+ getSubscribedChannelCount() {
+ return this.subscribedChannels.size;
+ }
+
+ // Get number of chatrooms
+ getChatroomCount() {
+ return this.chatrooms.size;
+ }
+}
+
+export default SharedKickPusher;
\ No newline at end of file
diff --git a/utils/services/seventv/sharedStvWebSocket.js b/utils/services/seventv/sharedStvWebSocket.js
new file mode 100644
index 0000000..e304b09
--- /dev/null
+++ b/utils/services/seventv/sharedStvWebSocket.js
@@ -0,0 +1,584 @@
+// 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: [],
+};
+
+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("[Shared7TV] Didn't process cosmetics:", body);
+ }
+};
+
+class SharedStvWebSocket extends EventTarget {
+ constructor() {
+ super();
+ this.startDelay = 1000;
+ this.maxRetrySteps = 5;
+ this.reconnectAttempts = 0;
+ this.chat = null;
+ this.shouldReconnect = true;
+ this.connectionState = 'disconnected'; // disconnected, connecting, connected
+ this.chatrooms = new Map(); // Map of chatroomId -> channel data
+ this.subscribedEvents = new Set(); // Track subscribed events
+ this.userEventSubscribed = false; // Track global user events
+ }
+
+ addChatroom(chatroomId, channelKickID, stvId = "0", stvEmoteSetId = "0") {
+ this.chatrooms.set(chatroomId, {
+ channelKickID: String(channelKickID),
+ stvId,
+ stvEmoteSetId,
+ });
+
+ // If we're already connected, subscribe to this chatroom's events
+ if (this.connectionState === 'connected') {
+ this.subscribeToChatroomEvents(chatroomId);
+ }
+ }
+
+ removeChatroom(chatroomId) {
+ const chatroomData = this.chatrooms.get(chatroomId);
+ if (chatroomData && this.connectionState === 'connected') {
+ this.unsubscribeFromChatroomEvents(chatroomId);
+ }
+ this.chatrooms.delete(chatroomId);
+
+ // If no more chatrooms, close the connection
+ if (this.chatrooms.size === 0) {
+ this.close();
+ }
+ }
+
+ connect() {
+ if (!this.shouldReconnect) {
+ console.log(`[Shared7TV]: Not connecting to WebSocket - reconnect disabled`);
+ return;
+ }
+
+ if (this.connectionState === 'connecting' || this.connectionState === 'connected') {
+ console.log("[Shared7TV]: Already connecting/connected");
+ return;
+ }
+
+ this.connectionState = 'connecting';
+ console.log(`[Shared7TV]: Connecting to WebSocket for ${this.chatrooms.size} chatrooms (attempt ${this.reconnectAttempts + 1})`);
+
+ this.chat = new WebSocket("wss://events.7tv.io/v3?app=kicktalk&version=420.69");
+
+ this.chat.onerror = (event) => {
+ console.log(`[Shared7TV]: WebSocket error:`, event);
+ this.connectionState = 'disconnected';
+ this.handleConnectionError();
+ };
+
+ this.chat.onclose = (event) => {
+ console.log(`[Shared7TV]: WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`);
+ this.connectionState = 'disconnected';
+ this.subscribedEvents.clear();
+ this.userEventSubscribed = false;
+ this.handleReconnection();
+ };
+
+ this.chat.onopen = async () => {
+ console.log(`[Shared7TV]: Connection opened successfully`);
+ this.connectionState = 'connected';
+ this.reconnectAttempts = 0;
+
+ await this.delay(1000);
+
+ // Subscribe to events for all chatrooms
+ await this.subscribeToAllEvents();
+
+ // Setup message handler
+ this.setupMessageHandler();
+
+ // Dispatch connection event
+ this.dispatchEvent(
+ new CustomEvent("connection", {
+ detail: {
+ type: "system",
+ content: "connection-success",
+ chatrooms: Array.from(this.chatrooms.keys()),
+ },
+ }),
+ );
+ };
+ }
+
+ handleConnectionError() {
+ this.reconnectAttempts++;
+ console.log(`[Shared7TV]: Connection error. Attempt ${this.reconnectAttempts}`);
+ }
+
+ handleReconnection() {
+ if (!this.shouldReconnect) {
+ console.log(`[Shared7TV]: Reconnection disabled`);
+ 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(`[Shared7TV]: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
+
+ setTimeout(() => {
+ this.connect();
+ }, delay);
+ }
+
+ async subscribeToAllEvents() {
+ // Subscribe to user events (only once for all chatrooms)
+ await this.subscribeToUserEvents();
+
+ // Subscribe to events for each chatroom
+ for (const [chatroomId] of this.chatrooms) {
+ await this.subscribeToChatroomEvents(chatroomId);
+ }
+ }
+
+ async subscribeToChatroomEvents(chatroomId) {
+ const chatroomData = this.chatrooms.get(chatroomId);
+ if (!chatroomData) {
+ console.log(`[Shared7TV]: Chatroom ${chatroomId} not found`);
+ return;
+ }
+
+ const { channelKickID, stvId, stvEmoteSetId } = chatroomData;
+
+ // Subscribe to cosmetic events
+ if (channelKickID !== "0") {
+ await this.subscribeToCosmeticEvents(chatroomId, channelKickID);
+ await this.subscribeToEntitlementEvents(chatroomId, channelKickID);
+
+ // Only subscribe to emote set events if we have a valid emote set ID
+ if (stvEmoteSetId !== "0") {
+ await this.subscribeToEmoteSetEvents(chatroomId, stvEmoteSetId);
+ }
+ }
+ }
+
+ unsubscribeFromChatroomEvents(chatroomId) {
+ // Note: 7TV doesn't have explicit unsubscribe, so we just remove tracking
+ // The events will be filtered out in the message handler
+ console.log(`[Shared7TV]: Unsubscribing events for chatroom ${chatroomId}`);
+ }
+
+ /**
+ * Subscribe to user events (global, only once)
+ */
+ async subscribeToUserEvents() {
+ if (this.userEventSubscribed || !this.chat || this.chat.readyState !== WebSocket.OPEN) {
+ return;
+ }
+
+ // Find any chatroom with a valid stvId
+ const chatroomWithStvId = Array.from(this.chatrooms.values()).find(data => data.stvId !== "0");
+ if (!chatroomWithStvId) {
+ console.log(`[Shared7TV]: No valid stvId found for user events`);
+ return;
+ }
+
+ const eventKey = `user.*:${chatroomWithStvId.stvId}`;
+ if (this.subscribedEvents.has(eventKey)) {
+ return;
+ }
+
+ const subscribeUserMessage = {
+ op: 35,
+ t: Date.now(),
+ d: {
+ type: "user.*",
+ condition: { object_id: chatroomWithStvId.stvId },
+ },
+ };
+
+ this.chat.send(JSON.stringify(subscribeUserMessage));
+ this.subscribedEvents.add(eventKey);
+ this.userEventSubscribed = true;
+ console.log(`[Shared7TV]: Subscribed to user.* events`);
+ }
+
+ /**
+ * Subscribe to cosmetic events for a specific chatroom
+ */
+ async subscribeToCosmeticEvents(chatroomId, channelKickID) {
+ if (!this.chat || this.chat.readyState !== WebSocket.OPEN) {
+ console.log(`[Shared7TV]: Cannot subscribe to cosmetic events - WebSocket not ready`);
+ return;
+ }
+
+ const eventKey = `cosmetic.*:${channelKickID}`;
+ if (this.subscribedEvents.has(eventKey)) {
+ return;
+ }
+
+ const subscribeAllCosmetics = {
+ op: 35,
+ t: Date.now(),
+ d: {
+ type: "cosmetic.*",
+ condition: { platform: "KICK", ctx: "channel", id: channelKickID },
+ },
+ };
+
+ this.chat.send(JSON.stringify(subscribeAllCosmetics));
+ this.subscribedEvents.add(eventKey);
+ console.log(`[Shared7TV]: Subscribed to cosmetic.* events for chatroom ${chatroomId}`);
+ }
+
+ /**
+ * Subscribe to entitlement events for a specific chatroom
+ */
+ async subscribeToEntitlementEvents(chatroomId, channelKickID) {
+ if (!this.chat || this.chat.readyState !== WebSocket.OPEN) {
+ console.log(`[Shared7TV]: Cannot subscribe to entitlement events - WebSocket not ready`);
+ return;
+ }
+
+ const eventKey = `entitlement.*:${channelKickID}`;
+ if (this.subscribedEvents.has(eventKey)) {
+ return;
+ }
+
+ const subscribeAllEntitlements = {
+ op: 35,
+ t: Date.now(),
+ d: {
+ type: "entitlement.*",
+ condition: { platform: "KICK", ctx: "channel", id: channelKickID },
+ },
+ };
+
+ this.chat.send(JSON.stringify(subscribeAllEntitlements));
+ this.subscribedEvents.add(eventKey);
+ console.log(`[Shared7TV]: Subscribed to entitlement.* events for chatroom ${chatroomId}`);
+
+ this.dispatchEvent(
+ new CustomEvent("open", {
+ detail: {
+ body: "SUBSCRIBED",
+ type: "entitlement.*",
+ chatroomId,
+ },
+ }),
+ );
+ }
+
+ /**
+ * Subscribe to emote set events for a specific chatroom
+ */
+ async subscribeToEmoteSetEvents(chatroomId, stvEmoteSetId) {
+ if (!this.chat || this.chat.readyState !== WebSocket.OPEN) {
+ console.log(`[Shared7TV]: Cannot subscribe to emote set events - WebSocket not ready`);
+ return;
+ }
+
+ const eventKey = `emote_set.*:${stvEmoteSetId}`;
+ if (this.subscribedEvents.has(eventKey)) {
+ return;
+ }
+
+ const subscribeAllEmoteSets = {
+ op: 35,
+ t: Date.now(),
+ d: {
+ type: "emote_set.*",
+ condition: { object_id: stvEmoteSetId },
+ },
+ };
+
+ this.chat.send(JSON.stringify(subscribeAllEmoteSets));
+ this.subscribedEvents.add(eventKey);
+ console.log(`[Shared7TV]: Subscribed to emote_set.* events for chatroom ${chatroomId}`);
+ }
+
+ setupMessageHandler() {
+ this.chat.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+
+ if (!msg?.d?.body) return;
+
+ const { body, type } = msg.d;
+
+ // Find which chatroom this event belongs to
+ const chatroomId = this.findChatroomForEvent(body, type);
+
+ switch (type) {
+ case "user.update":
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: {
+ body,
+ type: "user.update",
+ chatroomId,
+ },
+ }),
+ );
+ break;
+
+ case "emote_set.update":
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: {
+ body,
+ type: "emote_set.update",
+ chatroomId,
+ },
+ }),
+ );
+ break;
+
+ case "cosmetic.create":
+ updateCosmetics(body);
+
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: {
+ body: cosmetics,
+ type: "cosmetic.create",
+ chatroomId,
+ },
+ }),
+ );
+ break;
+
+ case "entitlement.create":
+ if (body.kind === 10) {
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: {
+ body,
+ type: "entitlement.create",
+ chatroomId,
+ },
+ }),
+ );
+ }
+ break;
+ }
+ } catch (error) {
+ console.log("[Shared7TV] Error parsing message:", error);
+ }
+ };
+ }
+
+ findChatroomForEvent(body, type) {
+ // Try to identify which chatroom this event belongs to
+ // This is a best-effort approach since 7TV events don't always include channel context
+
+ // 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?.object_id) {
+ for (const [chatroomId, data] of this.chatrooms) {
+ if (data.stvEmoteSetId === body.object_id) {
+ return chatroomId;
+ }
+ }
+ }
+
+ // For cosmetic and entitlement events, they should include channel context
+ // but if not, we'll broadcast to all chatrooms
+ return null;
+ }
+
+ delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ close() {
+ console.log(`[Shared7TV]: Closing shared connection`);
+ this.shouldReconnect = false;
+ this.connectionState = 'disconnected';
+
+ if (this.chat) {
+ try {
+ if (this.chat.readyState === WebSocket.OPEN || this.chat.readyState === WebSocket.CONNECTING) {
+ console.log(`[Shared7TV]: WebSocket state: ${this.chat.readyState}, closing...`);
+ this.chat.close();
+ }
+ this.chat = null;
+ this.subscribedEvents.clear();
+ this.userEventSubscribed = false;
+ console.log(`[Shared7TV]: Shared connection closed`);
+ } catch (error) {
+ console.error(`[Shared7TV]: Error during closing of connection:`, error);
+ }
+ } else {
+ console.log(`[Shared7TV]: No active connection to close`);
+ }
+ }
+
+ // Get connection status
+ getConnectionState() {
+ return this.connectionState;
+ }
+
+ // Get number of subscribed events
+ getSubscribedEventCount() {
+ return this.subscribedEvents.size;
+ }
+
+ // Get number of chatrooms
+ getChatroomCount() {
+ return this.chatrooms.size;
+ }
+}
+
+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