+ {/* Language Selection */}
+
+
+
{t('settings.language')}
+
{t('settings.languageDescription')}
+
+
+
+
+
+
- Always on Top
+ {t('settings.general.alwaysOnTop')}
@@ -111,6 +125,40 @@ const GeneralSection = ({ settingsData, onChange }) => {
/>
+
+
+
+
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(
@@ -533,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 (
@@ -813,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 2f39f84..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",
@@ -343,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 8ae0d92..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());
@@ -152,8 +154,8 @@ const MessagesHandler = memo(
{!atBottom && (
- Scroll To Bottom
-
+ {t('messages.scrollToBottom')}
+
)}
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/Navbar.jsx b/src/renderer/src/components/Navbar.jsx
index 179098f..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,12 +13,16 @@ import ChatroomTab from "./Navbar/ChatroomTab";
import MentionsTab from "./Navbar/MentionsTab";
const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
+ const { t } = useTranslation();
const { settings } = useSettings();
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);
@@ -196,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')}
@@ -295,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')}
@@ -324,8 +335,8 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
}
}}
disabled={isConnecting}>
- Add
-
+ {t('common.add')}
+
)}
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 3592c36..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);
@@ -74,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 dc42b04..0a37342 100644
--- a/src/renderer/src/providers/ChatProvider.jsx
+++ b/src/renderer/src/providers/ChatProvider.jsx
@@ -10,6 +10,13 @@ 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;
@@ -42,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
};
};
@@ -127,68 +135,200 @@ const useChatStore = create((set, get) => ({
}
},
+ // 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;
}
@@ -283,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) => {
@@ -457,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);
@@ -1076,6 +1221,35 @@ 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;
@@ -1083,6 +1257,19 @@ const useChatStore = create((set, get) => ({
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);
@@ -1191,6 +1378,80 @@ 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}`);
@@ -1245,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)),
@@ -1684,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) {
@@ -1945,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
@@ -2064,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")) {
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/i18n.js b/src/renderer/src/utils/i18n.js
new file mode 100644
index 0000000..3df0453
--- /dev/null
+++ b/src/renderer/src/utils/i18n.js
@@ -0,0 +1,62 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+// Import translation files
+import enTranslations from '../locales/en.json';
+import esTranslations from '../locales/es.json';
+import ptTranslations from '../locales/pt.json';
+
+const resources = {
+ en: {
+ translation: enTranslations
+ },
+ es: {
+ translation: esTranslations
+ },
+ pt: {
+ translation: ptTranslations
+ }
+};
+
+// Get stored language or default to 'en'
+const getStoredLanguage = () => {
+ 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/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 }));
}