diff --git a/I18N_IMPLEMENTATION_GUIDE.md b/I18N_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..2e118bd --- /dev/null +++ b/I18N_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,232 @@ +# Guía de Implementación de i18n en KickTalk + +## ✅ Lo que ya se ha implementado: + +### 1. Configuración Base +- ✅ Instalación de dependencias: `i18next`, `react-i18next` +- ✅ Archivo de configuración i18n: `src/renderer/src/utils/i18n.js` +- ✅ Hook personalizado para cambio de idioma: `src/renderer/src/utils/useLanguage.js` +- ✅ Inicialización en `main.jsx` + +### 2. Archivos de Traducción +- ✅ `src/renderer/src/locales/en.json` (Inglés - base) +- ✅ `src/renderer/src/locales/es.json` (Español) +- ✅ `src/renderer/src/locales/pt.json` (Portugués) + +### 3. Componentes Actualizados +- ✅ `components/Dialogs/Auth.jsx` - Pantalla de autenticación +- ✅ `components/TitleBar.jsx` - Barra de título +- ✅ `pages/ChatPage.jsx` - Página principal de chat +- ✅ `components/Dialogs/User.jsx` - Diálogo de usuario +- ✅ `components/Dialogs/Settings/Sections/General.jsx` - Configuración general +- ✅ `components/Dialogs/Settings/Sections/About.jsx` - Sección Acerca de +- ✅ `components/Dialogs/Settings/SettingsMenu.jsx` - Menú de configuración +- ✅ `components/Messages/MessagesHandler.jsx` - Manejador de mensajes +- ✅ `components/Messages/RegularMessage.jsx` - Mensajes regulares +- ✅ `components/Messages/ModActionMessage.jsx` - Mensajes de moderación +- ✅ `components/Messages/EmoteUpdateMessage.jsx` - Mensajes de actualización de emotes +- ✅ `components/Chat/Input/index.jsx` - Input de chat +- ✅ `components/Dialogs/Chatters.jsx` - Diálogo de usuarios +- ✅ `components/Chat/StreamerInfo.jsx` - Información del streamer +- ✅ `pages/Loader.jsx` - Página de carga +- ✅ `components/Messages/Message.jsx` - Componente de mensajes +- ✅ `components/Navbar.jsx` - Navegación principal (NUEVO) + +### 4. Componente de Selector de Idioma +- ✅ `components/Shared/LanguageSelector.jsx` - Selector compacto con banderas +- ✅ `components/Shared/LanguageSelector.scss` - Estilos adaptados a todos los temas +- ✅ Integración con sistema de persistencia dual (localStorage + electron-store) +- ✅ Sincronización cross-window para múltiples ventanas +- ✅ Adaptación CSS a todos los temas (green, dark, blue, purple, red, light) + +### 5. Sistema de Persistencia de Idioma +- ✅ `src/renderer/src/utils/languageSync.js` - Utilidad de sincronización +- ✅ Persistencia dual: localStorage + electron-store +- ✅ Detección automática de cambios de idioma +- ✅ Sincronización entre ventanas principales y diálogos + +### 6. Traducciones de Navegación +- ✅ Pestañas "Chatroom" y "Mentions" +- ✅ Diálogo "Add Chatroom" completo +- ✅ Placeholders y botones de formularios +- ✅ Mensajes de estado ("Connecting...", etc.) +- ✅ Títulos y descripciones de configuración de idioma + +## 🔄 Componentes Pendientes de Traducir: + +### Diálogos +- `components/Dialogs/Search.jsx` +- `components/Dialogs/Settings/index.jsx` +- `components/Dialogs/Settings/Sections/Moderation.jsx` (parcial - faltan algunas claves) + +### Componentes Compartidos +- `components/Shared/Settings.jsx` +- `components/Shared/NotificationFilePicker.jsx` +- `components/Updater.jsx` + +### Componentes de Chat Restantes +- `components/Chat/Pin.jsx` + +### Mejoras Pendientes +- Formateo de fechas localizado con dayjs +- Más idiomas (francés, alemán, italiano) +- Pluralización avanzada para contadores + +## 📝 Cómo Continuar la Implementación: + +### Paso 1: Para cada componente +```jsx +// 1. Importar useTranslation +import { useTranslation } from "react-i18next"; + +// 2. Usar el hook en el componente +const MyComponent = () => { + const { t } = useTranslation(); + + // 3. Reemplazar strings hardcodeados + return {t('key.subkey')}; +}; +``` + +### Paso 2: Agregar las traducciones a los archivos JSON +```json +{ + "key": { + "subkey": "Texto en inglés" + } +} +``` + +### Paso 3: Traducir a español y portugués + +## 🎯 Strings Más Importantes para Traducir: + +### Mensajes de Error y Estado +- "Loading..." +- "Error occurred" +- "Connection failed" +- "No messages found" + +### Botones y Acciones +- "Save", "Cancel", "Apply" +- "Add", "Remove", "Edit" +- "Copy", "Delete", "Pin" + +### Configuraciones +- Títulos de secciones +- Descripciones de opciones +- Tooltips y ayuda + +### Mensajes de Chat +- Timestamps +- User actions +- Moderation messages + +## 🔧 Funcionalidades Implementadas: + +### 1. Sistema de Persistencia de Idioma Completo ✅ +```js +// Persistencia dual implementada +const saveLanguagePreference = async (lang) => { + // Guarda en localStorage para acceso inmediato + localStorage.setItem('language', lang); + // Guarda en electron-store para persistencia de app + await window.app.store.set('language', lang); +}; +``` + +### 2. Sincronización Cross-Window ✅ +```js +// Implementado en languageSync.js +const syncLanguageAcrossWindows = (language) => { + // Sincroniza cambios entre ventana principal y diálogos + window.dispatchEvent(new CustomEvent('languageChanged', { + detail: { language } + })); +}; +``` + +### 3. Sistema de Temas CSS Adaptativo ✅ +```scss +// LanguageSelector se adapta a todos los temas +.settingsSectionSubHeader { + background: var(--input-info-bar); + border-top: 3px solid var(--text-accent); // Línea de acento temática +} +``` + +### 4. Traducciones Completas por Sección ✅ +- **Navegación**: 9 claves (chatroom, mentions, formularios) +- **Autenticación**: 8 claves completas +- **Configuración**: 15+ claves (general, idioma, moderación) +- **Chat**: 25+ claves (mensajes, moderación, usuarios) +- **Estados**: Loading, errores, éxito + +## 🔧 Funcionalidades Adicionales Sugeridas: + +### 1. Formateo de Fechas Localizado (Pendiente) +```js +// Usar dayjs con locales +import 'dayjs/locale/es'; +import 'dayjs/locale/pt-br'; +``` + +### 2. Pluralización Avanzada (Pendiente) +```json +{ + "messages": { + "count_one": "{{count}} mensaje", + "count_other": "{{count}} mensajes" + } +} +``` + +### 3. Más Idiomas (Sugerido) +- Francés (fr) +- Alemán (de) +- Italiano (it) +- Japonés (ja) + +## 🚀 Estado Actual del Proyecto: + +### ✅ **COMPLETADO (95%)** +1. **Sistema base de i18n**: Configuración, hooks, persistencia +2. **17+ componentes principales**: Completamente traducidos +3. **Selector de idiomas**: Implementado con estilos adaptativos +4. **Navegación completa**: Todas las pestañas y diálogos +5. **Sistema de persistencia**: Dual storage + sincronización +6. **Adaptación CSS**: Todos los temas soportados +7. **250+ claves de traducción**: En inglés, español y portugués + +### 🔄 **PENDIENTE (5%)** +1. **3 componentes menores**: Search, Settings popup, NotificationFilePicker +2. **Formateo de fechas**: dayjs con locales +3. **Idiomas adicionales**: Francés, alemán, etc. + +## 🎯 Próximos Pasos Recomendados: + +1. **Finalizar componentes menores**: Search.jsx, Settings popup +2. **Implementar formateo de fechas**: dayjs con locales es/pt +3. **Agregar más idiomas**: Francés, Alemán como siguientes prioridades +4. **Optimización**: Lazy loading de traducciones por secciones +5. **Testing exhaustivo**: Cambios de idioma en todos los diálogos + +## 📊 Estadísticas del Proyecto: + +- **Componentes traducidos**: 17+ de 20 totales (85%) +- **Claves de traducción**: 250+ implementadas +- **Idiomas soportados**: 3 (en, es, pt) +- **Cobertura de UI**: 95% de la interfaz principal +- **Sistema de temas**: 6 temas completamente soportados +- **Persistencia**: Dual storage implementado +- **Sincronización**: Cross-window funcionando + +## 📋 Comandos Útiles: + +```bash +# Buscar strings hardcodeados +grep -r "\"[A-Z][a-zA-Z\s]*\"" src/renderer/src/components/ --include="*.jsx" + +# Verificar uso de t() function +grep -r "t(" src/renderer/src/components/ --include="*.jsx" +``` diff --git a/I18N_IMPLEMENTATION_GUIDE_EN.md b/I18N_IMPLEMENTATION_GUIDE_EN.md new file mode 100644 index 0000000..43ce2d9 --- /dev/null +++ b/I18N_IMPLEMENTATION_GUIDE_EN.md @@ -0,0 +1,307 @@ +# KickTalk i18n Implementation Guide + +## ✅ What has been implemented: + +### 1. Base Configuration +- ✅ Dependencies installation: `i18next`, `react-i18next` +- ✅ i18n configuration file: `src/renderer/src/utils/i18n.js` +- ✅ Custom hook for language switching: `src/renderer/src/utils/useLanguage.js` +- ✅ Initialization in `main.jsx` + +### 2. Translation Files +- ✅ `src/renderer/src/locales/en.json` (English - base) +- ✅ `src/renderer/src/locales/es.json` (Spanish) +- ✅ `src/renderer/src/locales/pt.json` (Portuguese) + +### 3. Updated Components +- ✅ `components/Dialogs/Auth.jsx` - Authentication screen +- ✅ `components/TitleBar.jsx` - Title bar +- ✅ `pages/ChatPage.jsx` - Main chat page +- ✅ `components/Dialogs/User.jsx` - User dialog +- ✅ `components/Dialogs/Settings/Sections/General.jsx` - General settings +- ✅ `components/Dialogs/Settings/Sections/About.jsx` - About section +- ✅ `components/Dialogs/Settings/SettingsMenu.jsx` - Settings menu +- ✅ `components/Messages/MessagesHandler.jsx` - Messages handler +- ✅ `components/Messages/RegularMessage.jsx` - Regular messages +- ✅ `components/Messages/ModActionMessage.jsx` - Moderation messages +- ✅ `components/Messages/EmoteUpdateMessage.jsx` - Emote update messages +- ✅ `components/Chat/Input/index.jsx` - Chat input +- ✅ `components/Dialogs/Chatters.jsx` - Users dialog +- ✅ `components/Chat/StreamerInfo.jsx` - Streamer information +- ✅ `pages/Loader.jsx` - Loading page +- ✅ `components/Messages/Message.jsx` - Message component +- ✅ `components/Navbar.jsx` - Main navigation (NEW) + +### 4. Language Selector Component +- ✅ `components/Shared/LanguageSelector.jsx` - Compact selector with flags +- ✅ `components/Shared/LanguageSelector.scss` - Styles adapted to all themes +- ✅ Integration with dual persistence system (localStorage + electron-store) +- ✅ Cross-window synchronization for multiple windows +- ✅ CSS adaptation to all themes (green, dark, blue, purple, red, light) + +### 5. Language Persistence System +- ✅ `src/renderer/src/utils/languageSync.js` - Synchronization utility +- ✅ Dual persistence: localStorage + electron-store +- ✅ Automatic detection of language changes +- ✅ Synchronization between main windows and dialogs + +### 6. Navigation Translations +- ✅ "Chatroom" and "Mentions" tabs +- ✅ Complete "Add Chatroom" dialog +- ✅ Form placeholders and buttons +- ✅ Status messages ("Connecting...", etc.) +- ✅ Language settings titles and descriptions + +## 🔄 Components Pending Translation: + +### Chat Components +- `components/Chat/Input/index.jsx` +- `components/Chat/StreamerInfo.jsx` +- `components/Messages/RegularMessage.jsx` +- `components/Messages/MessagesHandler.jsx` + +### Dialogs +- `components/Dialogs/Chatters.jsx` +- `components/Dialogs/Search.jsx` +- `components/Dialogs/Settings/index.jsx` +- `components/Dialogs/Settings/SettingsMenu.jsx` +- `components/Dialogs/Settings/Sections/Moderation.jsx` +- `components/Dialogs/Settings/Sections/About.jsx` + +### Shared Components +- `components/Shared/Settings.jsx` +- `components/Shared/NotificationFilePicker.jsx` +- `components/Updater.jsx` + +### Pages +- `pages/Loader.jsx` + +## 📝 How to Continue Implementation: + +### Step 1: For each component +```jsx +// 1. Import useTranslation +import { useTranslation } from "react-i18next"; + +// 2. Use the hook in the component +const MyComponent = () => { + const { t } = useTranslation(); + + // 3. Replace hardcoded strings + return {t('key.subkey')}; +}; +``` + +### Step 2: Add translations to JSON files +```json +{ + "key": { + "subkey": "Text in English" + } +} +``` + +### Step 3: Translate to Spanish and Portuguese + +## 🎯 Most Important Strings to Translate: + +### Error and Status Messages +- "Loading..." +- "Error occurred" +- "Connection failed" +- "No messages found" + +### Buttons and Actions +- "Save", "Cancel", "Apply" +- "Add", "Remove", "Edit" +- "Copy", "Delete", "Pin" + +### Settings +- Section titles +- Option descriptions +- Tooltips and help text + +### Chat Messages +- Timestamps +- User actions +- Moderation messages + +## 🔧 Suggested Additional Features: + +### 1. Automatic Language Detection +```js +// In i18n.js, add detection based on: +// - User's saved configuration +// - Browser language +// - System language +``` + +### 2. Language Persistence +```js +// Save preference in electron-store +const saveLanguagePreference = (lang) => { + window.app.store.set('language', lang); +}; +``` + +### 3. Localized Date Formatting +```js +// Use dayjs with locales +import 'dayjs/locale/es'; +import 'dayjs/locale/pt-br'; +``` + +### 4. Pluralization +```json +{ + "messages": { + "count_one": "{{count}} message", + "count_other": "{{count}} messages" + } +} +``` + +## 🚀 Recommended Next Steps: + +1. **Continue with high-priority components**: Settings, Chat Input, Messages +2. **Implement language persistence**: Save in electron-store +3. **Add more languages**: French, German, etc. +4. **Improve UX**: Smooth transitions when changing language +5. **Testing**: Test real-time language changes + +## 📋 Useful Commands: + +```bash +# Find hardcoded strings +grep -r "\"[A-Z][a-zA-Z\s]*\"" src/renderer/src/components/ --include="*.jsx" + +# Check for t() function usage +grep -r "t(" src/renderer/src/components/ --include="*.jsx" +``` + +## 🛠️ Implementation Examples: + +### Basic Component Translation +```jsx +import { useTranslation } from 'react-i18next'; + +const ChatInput = () => { + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +}; +``` + +### Settings Component with Language Selector +```jsx +import { useTranslation } from 'react-i18next'; +import LanguageSelector from '../Shared/LanguageSelector'; + +const GeneralSettings = () => { + const { t } = useTranslation(); + + return ( +
+

{t('settings.general.title')}

+
+ + +
+
+ ); +}; +``` + +### Using Interpolation +```jsx +const UserProfile = ({ username, messageCount }) => { + const { t } = useTranslation(); + + return ( +
+

{t('user.profile.title', { username })}

+

{t('user.messages.count', { count: messageCount })}

+
+ ); +}; +``` + +## 🎨 Translation Key Structure: + +```json +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "loading": "Loading..." + }, + "auth": { + "signIn": "Sign In", + "loginWith": "Login with {{provider}}" + }, + "chat": { + "input": { + "placeholder": "Type a message...", + "send": "Send" + }, + "actions": { + "pin": "Pin Message", + "copy": "Copy Message" + } + }, + "settings": { + "title": "Settings", + "language": "Language", + "general": { + "title": "General", + "alwaysOnTop": "Always on Top" + } + } +} +``` + +## ⚡ Performance Tips: + +1. **Use namespaces** for large translation files +2. **Lazy load** translations for better performance +3. **Cache translations** in production +4. **Use translation keys** that are descriptive but concise + +## 🔍 Testing Strategy: + +1. **Component testing**: Ensure all text renders correctly in each language +2. **Layout testing**: Check that UI doesn't break with longer translations +3. **Functionality testing**: Verify language switching works seamlessly +4. **Accessibility testing**: Ensure screen readers work with translated content + +## 📦 File Structure: +``` +src/renderer/src/ +├── locales/ +│ ├── en.json +│ ├── es.json +│ └── pt.json +├── utils/ +│ ├── i18n.js +│ └── useLanguage.js +└── components/ + └── Shared/ + ├── LanguageSelector.jsx + └── LanguageSelector.scss +``` + +## 🏁 Conclusion: + +The KickTalk i18n system is **95% complete** with a robust, scalable architecture that supports: +- ✅ 3 languages with 250+ translation keys +- ✅ Complete persistence and synchronization system +- ✅ Adaptive CSS themes integration +- ✅ 17+ fully translated components +- ✅ Cross-window language consistency + +The remaining 5% consists mainly of minor components and enhancements that don't affect the core user experience. diff --git a/docker-compose.otel.yml b/docker-compose.otel.yml new file mode 100644 index 0000000..645e910 --- /dev/null +++ b/docker-compose.otel.yml @@ -0,0 +1,79 @@ +services: + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: kicktalk-otel-collector + command: ["--config=/etc/otel-collector-config.yml"] + volumes: + - ./otel/collector-config.yml:/etc/otel-collector-config.yml:Z + - ./otel/logs:/var/log/otel:Z + user: "0:0" # Run as root to avoid permission issues + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + - "8888:8888" # Prometheus metrics + - "8889:8889" # Prometheus exporter metrics + - "13133:13133" # Health check endpoint + depends_on: + - jaeger + - prometheus + + # Jaeger for distributed tracing + jaeger: + image: jaegertracing/all-in-one:latest + container_name: kicktalk-jaeger + ports: + - "16686:16686" # Jaeger UI + - "14250:14250" # Jaeger gRPC + environment: + - COLLECTOR_OTLP_ENABLED=true + - LOG_LEVEL=debug + + # Prometheus for metrics storage + prometheus: + image: prom/prometheus:latest + container_name: kicktalk-prometheus + ports: + - "9090:9090" + volumes: + - ./otel/prometheus.yml:/etc/prometheus/prometheus.yml:Z + - prometheus_data:/prometheus + user: "nobody" # Use nobody user for security + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + + # Grafana for visualization + grafana: + image: grafana/grafana:latest + container_name: kicktalk-grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + - ./otel/grafana/provisioning:/etc/grafana/provisioning:Z + - ./otel/grafana/dashboards:/var/lib/grafana/dashboards:Z + + # Redis for caching telemetry data (optional) + redis: + image: redis:alpine + container_name: kicktalk-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + prometheus_data: + grafana_data: + redis_data: + +networks: + default: + name: kicktalk-otel \ No newline at end of file diff --git a/electron-builder.yml b/electron-builder.yml index 0d70565..67fcf3c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -22,11 +22,13 @@ nsis: oneClick: true allowToChangeInstallationDirectory: false mac: + icon: resources/icons/mac/KickTalk_v1.png entitlementsInherit: build/entitlements.mac.plist notarize: false dmg: artifactName: ${name}-${version}.${ext} linux: + icon: resources/icons/linux/KickTalk_v1.png target: - AppImage - snap diff --git a/electron.vite.config.mjs b/electron.vite.config.mjs index 7ec2b43..224e028 100644 --- a/electron.vite.config.mjs +++ b/electron.vite.config.mjs @@ -1,10 +1,44 @@ import { resolve } from "path"; import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import react from "@vitejs/plugin-react"; +import { copyFileSync, mkdirSync, existsSync } from "fs"; +import { join, dirname } from "path"; + +// Custom plugin to copy telemetry files +const copyTelemetryPlugin = () => ({ + name: 'copy-telemetry', + writeBundle() { + const srcTelemetry = resolve('src/telemetry'); + const outTelemetry = resolve('out/telemetry'); + + // Create telemetry directory in output + if (!existsSync(outTelemetry)) { + mkdirSync(outTelemetry, { recursive: true }); + } + + // Copy telemetry files + const files = ['index.js', 'metrics.js', 'tracing.js', 'instrumentation.js', 'prometheus-server.js']; + files.forEach(file => { + const src = join(srcTelemetry, file); + const dest = join(outTelemetry, file); + if (existsSync(src)) { + try { + copyFileSync(src, dest); + console.log(`[Telemetry]: Copied ${file} to build output`); + } catch (error) { + console.warn(`[Telemetry]: Failed to copy ${file}:`, error.message); + } + } + }); + } +}); export default defineConfig({ main: { - plugins: [externalizeDepsPlugin({ exclude: ["electron-store", "electron-util"] })], + plugins: [ + externalizeDepsPlugin({ exclude: ["electron-store", "electron-util"] }), + copyTelemetryPlugin() + ], }, preload: { plugins: [externalizeDepsPlugin({ exclude: ["electron-store", "electron-util"] })], diff --git a/otel/.gitignore b/otel/.gitignore new file mode 100644 index 0000000..d287c03 --- /dev/null +++ b/otel/.gitignore @@ -0,0 +1,19 @@ +# OTEL logs and temporary data +logs/ +*.log +*.log.* + +# Grafana runtime data (keep provisioning configs) +grafana/data/ +grafana/runtime/ + +# Prometheus data +prometheus/data/ + +# Temporary collector files +collector-temp/ +*.tmp + +# Docker volumes (if using local bind mounts) +data/ +storage/ \ No newline at end of file diff --git a/otel/README.md b/otel/README.md new file mode 100644 index 0000000..6fa79d4 --- /dev/null +++ b/otel/README.md @@ -0,0 +1,159 @@ +# KickTalk OpenTelemetry Setup + +This directory contains the OpenTelemetry observability stack for KickTalk application monitoring, including distributed tracing, metrics collection, and log aggregation. + +## Architecture + +- **OpenTelemetry Collector**: Receives, processes, and exports telemetry data +- **Jaeger**: Distributed tracing backend and UI +- **Prometheus**: Metrics storage and querying +- **Grafana**: Visualization and dashboards +- **Redis**: Optional caching for telemetry data + +## Quick Start + +1. **Start the observability stack:** + ```bash + docker-compose -f docker-compose.otel.yml up -d + ``` + +2. **Access the services:** + - **Grafana Dashboard**: http://localhost:3000 (admin/admin) + - **Jaeger UI**: http://localhost:16686 + - **Prometheus**: http://localhost:9090 + - **OTEL Collector Health**: http://localhost:13133 + +3. **Configure KickTalk** to send telemetry to: + - **OTLP gRPC**: `http://localhost:4317` + - **OTLP HTTP**: `http://localhost:4318` + +## Configuration + +### OTEL Collector (`collector-config.yml`) + +The collector is configured to: +- **Receive** telemetry via OTLP (gRPC/HTTP) +- **Process** data with batching, memory limiting, and attribute filtering +- **Export** traces to Jaeger, metrics to Prometheus, and logs to files + +Key features: +- **Privacy-focused**: Automatically filters sensitive data (tokens, auth info) +- **Resource attribution**: Adds service.name, version, environment tags +- **Performance optimized**: Batching and memory limits configured + +### Prometheus (`prometheus.yml`) + +Scrapes metrics from: +- OTEL Collector internal metrics +- KickTalk application metrics (port 9464) +- Jaeger metrics for tracing health + +### Grafana Dashboards + +Pre-configured dashboards for: +- **KickTalk Overview**: Application health, connections, message throughput +- **Memory & Performance**: Resource usage, API response times +- **Connection Health**: WebSocket stability, reconnection rates + +## Application Integration + +To integrate KickTalk with this observability stack, the application needs to: + +1. **Install OTEL SDK** packages for Node.js/Electron +2. **Configure exporters** to send data to `localhost:4317` +3. **Implement metrics** for key application events +4. **Add tracing** to critical code paths + +## Metrics to Implement + +### Connection Metrics +- `kicktalk_websocket_connections_active` - Active WebSocket connections +- `kicktalk_websocket_reconnections_total` - Connection reconnection events +- `kicktalk_connection_errors_total` - Connection failure events + +### Message Metrics +- `kicktalk_messages_sent_total` - Messages sent by user +- `kicktalk_messages_received_total` - Messages received from chat +- `kicktalk_message_send_duration_seconds` - Message send latency + +### Resource Metrics +- `kicktalk_memory_usage_bytes` - Application memory consumption +- `kicktalk_cpu_usage_percent` - CPU utilization +- `kicktalk_open_handles_total` - File/socket handles + +### API Metrics +- `kicktalk_api_request_duration_seconds` - API response times +- `kicktalk_api_requests_total` - API request counts by endpoint/status + +## Traces to Implement + +### User Actions +- Message sending flow (input → validation → API → confirmation) +- Chatroom joining/leaving +- Settings changes + +### System Operations +- WebSocket connection establishment +- API calls (Kick, 7TV) +- Emote loading and caching + +### Error Scenarios +- Connection failures and recovery +- API timeouts and retries +- Memory leak detection points + +## Privacy & Security + +The collector configuration includes privacy protections: +- **Automatic filtering** of authentication tokens +- **Local-only operation** by default +- **Configurable data retention** periods +- **No PII collection** in standard metrics + +## Development Usage + +### View Real-time Metrics +```bash +# Watch collector logs +docker-compose -f docker-compose.otel.yml logs -f otel-collector + +# Query Prometheus directly +curl http://localhost:9090/api/v1/query?query=up + +# Check collector health +curl http://localhost:13133 +``` + +### Custom Dashboards + +Add custom dashboard JSON files to `otel/grafana/dashboards/` and they'll be automatically loaded into Grafana. + +### Testing Telemetry + +Send test traces/metrics to the collector: +```bash +# Test OTLP HTTP endpoint +curl -X POST http://localhost:4318/v1/traces \ + -H "Content-Type: application/json" \ + -d '{"resourceSpans":[...]}' +``` + +## Production Considerations + +For production deployment: +- Use external Prometheus/Jaeger instances +- Configure authentication for Grafana +- Set up alerting rules in Prometheus +- Implement log rotation and retention policies +- Consider using OTEL Collector in agent/gateway mode + +## Stopping the Stack + +```bash +docker-compose -f docker-compose.otel.yml down +``` + +To remove all data: +```bash +docker-compose -f docker-compose.otel.yml down -v +``` \ No newline at end of file diff --git a/otel/collector-config.yml b/otel/collector-config.yml new file mode 100644 index 0000000..6c6ddc4 --- /dev/null +++ b/otel/collector-config.yml @@ -0,0 +1,122 @@ +receivers: + # OTLP receiver for traces, metrics, and logs + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + cors: + allowed_origins: + - "http://localhost:*" + - "http://127.0.0.1:*" + - "http://0.0.0.0:*" + + # Prometheus receiver for scraping metrics + prometheus: + config: + scrape_configs: + - job_name: 'otel-collector' + scrape_interval: 10s + static_configs: + - targets: ['localhost:8888'] + - job_name: 'kicktalk-app' + scrape_interval: 15s + static_configs: + - targets: ['host.docker.internal:9464'] # KickTalk metrics endpoint + +processors: + # Batch processor for better performance + batch: + timeout: 1s + send_batch_size: 1024 + + # Memory limiter to prevent OOM + memory_limiter: + limit_mib: 256 + spike_limit_mib: 64 + check_interval: 5s + + # Add resource attributes + resource: + attributes: + - key: service.name + value: "kicktalk" + action: upsert + - key: service.version + from_attribute: service.version + action: insert + - key: deployment.environment + value: "development" + action: upsert + + # Filter sensitive data + attributes: + actions: + - key: user.token + action: delete + - key: auth.token + action: delete + - key: kick.session + action: delete + +exporters: + # OTLP exporter for traces (to Jaeger) + otlp/jaeger: + endpoint: jaeger:4317 + tls: + insecure: true + + # Prometheus exporter for metrics + prometheus: + endpoint: "0.0.0.0:8889" + metric_expiration: 180m + enable_open_metrics: true + + # File exporter for logs and debugging + file: + path: /var/log/otel/telemetry.log + rotation: + max_megabytes: 100 + max_days: 3 + + # Debug exporter for debugging (console output) + debug: + verbosity: normal + sampling_initial: 5 + sampling_thereafter: 200 + + # OTLP HTTP exporter (for external systems) + otlphttp: + endpoint: "http://localhost:4318" + headers: + "X-Custom-Header": "kicktalk-telemetry" + +service: + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, resource, attributes, batch] + exporters: [otlp/jaeger, debug] + + metrics: + receivers: [otlp, prometheus] + processors: [memory_limiter, resource, batch] + exporters: [prometheus, debug] + + logs: + receivers: [otlp] + processors: [memory_limiter, resource, batch] + exporters: [file, debug] + + extensions: [health_check, pprof] + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + + pprof: + endpoint: 0.0.0.0:1777 + + zpages: + endpoint: 0.0.0.0:55679 \ No newline at end of file diff --git a/otel/grafana/dashboards/kicktalk-overview.json b/otel/grafana/dashboards/kicktalk-overview.json new file mode 100644 index 0000000..a0764f1 --- /dev/null +++ b/otel/grafana/dashboards/kicktalk-overview.json @@ -0,0 +1,623 @@ +{ + "id": null, + "title": "KickTalk Application Overview", + "tags": ["kicktalk", "overview"], + "style": "dark", + "timezone": "browser", + "refresh": "5s", + "schemaVersion": 27, + "version": 3, + "time": { + "from": "now-15m", + "to": "now" + }, + "panels": [ + { + "id": 1, + "title": "Application Status", + "type": "stat", + "gridPos": {"h": 6, "w": 6, "x": 0, "y": 0}, + "targets": [ + { + "expr": "kicktalk_up", + "refId": "A", + "legendFormat": "App Status" + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + {"options": {"0": {"text": "Down"}}, "type": "value"}, + {"options": {"1": {"text": "Up"}}, "type": "value"} + ], + "color": { + "mode": "thresholds" + }, + "thresholds": { + "steps": [ + {"color": "red", "value": null}, + {"color": "green", "value": 1} + ] + } + } + } + }, + { + "id": 2, + "title": "Total Messages Sent", + "type": "stat", + "gridPos": {"h": 6, "w": 6, "x": 6, "y": 0}, + "targets": [ + { + "expr": "sum(kicktalk_messages_sent_total)", + "refId": "A", + "legendFormat": "Messages Sent" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + } + } + }, + { + "id": 3, + "title": "Total Messages Received", + "type": "stat", + "gridPos": {"h": 6, "w": 6, "x": 12, "y": 0}, + "targets": [ + { + "expr": "sum(kicktalk_messages_received_total)", + "refId": "A", + "legendFormat": "Messages Received" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + } + } + }, + { + "id": 4, + "title": "Active Connections", + "type": "stat", + "gridPos": {"h": 6, "w": 6, "x": 18, "y": 0}, + "targets": [ + { + "expr": "sum(kicktalk_websocket_connections_active)", + "refId": "A", + "legendFormat": "Active Connections" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + } + } + }, + { + "id": 5, + "title": "Message Throughput", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 6}, + "targets": [ + { + "expr": "topk(10, sum by(streamer_name)(rate(kicktalk_messages_received_total{streamer_name!=\"\"}[1m])))", + "refId": "A", + "legendFormat": "{{streamer_name}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 50, + "gradientMode": "none", + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "unit": "short" + } + } + }, + { + "id": 6, + "title": "Memory Usage", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 6}, + "targets": [ + { + "expr": "kicktalk_memory_usage_bytes{type=\"heap_used\"}", + "refId": "A", + "legendFormat": "Heap Used" + }, + { + "expr": "kicktalk_memory_usage_bytes{type=\"heap_total\"}", + "refId": "B", + "legendFormat": "Heap Total" + }, + { + "expr": "kicktalk_memory_usage_bytes{type=\"rss\"}", + "refId": "C", + "legendFormat": "RSS Memory" + }, + { + "expr": "kicktalk_memory_usage_bytes{type=\"external\"}", + "refId": "D", + "legendFormat": "External Memory" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "bytes" + } + } + }, + { + "id": 7, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 14}, + "targets": [ + { + "expr": "kicktalk_cpu_usage_percent", + "refId": "A", + "legendFormat": "CPU Usage (%)" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "opacity" + }, + "unit": "percent", + "max": 100 + } + } + }, + { + "id": 8, + "title": "Uptime", + "type": "stat", + "gridPos": {"h": 4, "w": 8, "x": 8, "y": 14}, + "targets": [ + { + "expr": "kicktalk_uptime_seconds", + "refId": "A", + "legendFormat": "Uptime" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "palette-classic" + } + } + } + }, + { + "id": 9, + "title": "Connection Health", + "type": "stat", + "gridPos": {"h": 4, "w": 8, "x": 16, "y": 14}, + "targets": [ + { + "expr": "sum(increase(kicktalk_connection_errors_total[5m]))", + "refId": "A", + "legendFormat": "Total Errors (5m)" + }, + { + "expr": "sum(increase(kicktalk_websocket_reconnections_total[5m]))", + "refId": "B", + "legendFormat": "Total Reconnections (5m)" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 1}, + {"color": "red", "value": 5} + ] + }, + "unit": "short" + } + } + }, + { + "id": 10, + "title": "Open Windows & Handles", + "type": "stat", + "gridPos": {"h": 4, "w": 8, "x": 8, "y": 18}, + "targets": [ + { + "expr": "kicktalk_open_windows", + "refId": "A", + "legendFormat": "Open Windows" + }, + { + "expr": "kicktalk_open_handles_total_ratio", + "refId": "B", + "legendFormat": "Open Handles" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "list", + "orientation": "horizontal" + }, + "unit": "short" + } + } + }, + { + "id": 11, + "title": "Message Send Latency", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 22}, + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(kicktalk_message_send_duration_seconds_bucket[5m])) or vector(0)", + "refId": "A", + "legendFormat": "Message Send p50" + }, + { + "expr": "histogram_quantile(0.95, rate(kicktalk_message_send_duration_seconds_bucket[5m])) or vector(0)", + "refId": "B", + "legendFormat": "Message Send p95" + }, + { + "expr": "rate(kicktalk_message_send_duration_seconds_count[5m])", + "refId": "C", + "legendFormat": "Send Rate/min" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "s" + } + } + }, + { + "id": 12, + "title": "API Request Performance", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 22}, + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(kicktalk_api_request_duration_seconds_bucket[5m]))", + "refId": "A", + "legendFormat": "API p50" + }, + { + "expr": "histogram_quantile(0.95, rate(kicktalk_api_request_duration_seconds_bucket[5m]))", + "refId": "B", + "legendFormat": "API p95" + }, + { + "expr": "histogram_quantile(0.99, rate(kicktalk_api_request_duration_seconds_bucket[5m]))", + "refId": "C", + "legendFormat": "API p99" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "s" + } + } + }, + { + "id": 13, + "title": "API Request Rate", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 30}, + "targets": [ + { + "expr": "rate(kicktalk_api_requests_total[1m])", + "refId": "A", + "legendFormat": "API Requests/min - {{endpoint}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "reqps" + } + } + }, + { + "id": 14, + "title": "WebSocket Connections by Streamer", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 30}, + "targets": [ + { + "expr": "kicktalk_websocket_connections_active{streamer_name!=\"\"}", + "refId": "A", + "legendFormat": "{{streamer_name}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 50, + "gradientMode": "none", + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "unit": "short" + } + } + }, + { + "id": 15, + "title": "Garbage Collection Performance", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 38}, + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(kicktalk_gc_duration_seconds_bucket[5m])) or vector(0)", + "refId": "A", + "legendFormat": "GC p95 - {{kind}}" + }, + { + "expr": "rate(kicktalk_gc_duration_seconds_count[5m]) or vector(0)", + "refId": "B", + "legendFormat": "GC Frequency - {{kind}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "s" + } + } + }, + { + "id": 16, + "title": "DOM Node Count", + "type": "stat", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 38}, + "targets": [ + { + "expr": "kicktalk_dom_node_count", + "refId": "A", + "legendFormat": "DOM Nodes" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 5000}, + {"color": "red", "value": 10000} + ] + }, + "unit": "short" + } + } + }, + { + "id": 17, + "title": "Error Rates", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 38}, + "targets": [ + { + "expr": "rate(kicktalk_connection_errors_total[5m]) * 100", + "refId": "A", + "legendFormat": "Connection Error Rate % - {{error_type}}" + }, + { + "expr": "rate(kicktalk_websocket_reconnections_total[5m]) * 100", + "refId": "B", + "legendFormat": "Reconnection Rate % - {{reason}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none" + }, + "unit": "percent" + } + } + }, + { + "id": 18, + "title": "Memory Efficiency", + "type": "stat", + "gridPos": {"h": 4, "w": 8, "x": 0, "y": 44}, + "targets": [ + { + "expr": "((kicktalk_memory_usage_bytes{type=\"heap_used\"} / kicktalk_memory_usage_bytes{type=\"heap_total\"}) * 100) or on() vector(0)", + "refId": "A", + "legendFormat": "Heap Usage %" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 90} + ] + }, + "unit": "percent", + "max": 100 + } + } + }, + { + "id": 19, + "title": "Handle Efficiency", + "type": "timeseries", + "gridPos": {"h": 4, "w": 8, "x": 8, "y": 44}, + "targets": [ + { + "expr": "kicktalk_open_handles_total{type=\"total\"} or on() vector(0)", + "refId": "A", + "legendFormat": "Open Handles" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "opacity" + }, + "unit": "short" + } + } + }, + { + "id": 20, + "title": "Message Success Rate", + "type": "stat", + "gridPos": {"h": 4, "w": 8, "x": 16, "y": 44}, + "targets": [ + { + "expr": "((sum(rate(kicktalk_messages_sent_total[5m])) / (sum(rate(kicktalk_messages_sent_total[5m])) + sum(rate(kicktalk_connection_errors_total{error_type=\"message_send_failed\"}[5m])))) * 100) or on() vector(100)", + "refId": "A", + "legendFormat": "Message Success %" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "steps": [ + {"color": "red", "value": null}, + {"color": "yellow", "value": 95}, + {"color": "green", "value": 99} + ] + }, + "unit": "percent", + "max": 100 + } + } + } + ] +} \ No newline at end of file diff --git a/otel/grafana/provisioning/dashboards/dashboards.yml b/otel/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..5439ca1 --- /dev/null +++ b/otel/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,24 @@ +apiVersion: 1 + +providers: + # KickTalk dashboards + - name: 'kicktalk-dashboards' + orgId: 1 + folder: 'KickTalk' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + + # OTEL Collector dashboards + - name: 'otel-dashboards' + orgId: 1 + folder: 'OpenTelemetry' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards/otel \ No newline at end of file diff --git a/otel/grafana/provisioning/datasources/datasources.yml b/otel/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..f1e45a1 --- /dev/null +++ b/otel/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,35 @@ +apiVersion: 1 + +datasources: + # Prometheus datasource for metrics + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + timeInterval: "5s" + queryTimeout: "60s" + + # Jaeger datasource for traces + - name: Jaeger + type: jaeger + access: proxy + url: http://jaeger:16686 + editable: true + jsonData: + nodeGraph: + enabled: true + search: + hide: false + spanBar: + type: "Tag" + tag: "http.method" + + # Loki datasource for logs (if added later) + # - name: Loki + # type: loki + # access: proxy + # url: http://loki:3100 + # editable: true \ No newline at end of file diff --git a/otel/prometheus.yml b/otel/prometheus.yml new file mode 100644 index 0000000..1bd5d43 --- /dev/null +++ b/otel/prometheus.yml @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + monitor: 'kicktalk-monitor' + +rule_files: + # Add alerting rules here if needed + # - "rules/*.yml" + +scrape_configs: + # Scrape Prometheus itself + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Scrape OTEL Collector metrics + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8888', 'otel-collector:8889'] + scrape_interval: 10s + metrics_path: /metrics + + # Scrape KickTalk application metrics (when implemented) + - job_name: 'kicktalk-app' + static_configs: + - targets: ['192.168.1.50:9464'] + scrape_interval: 15s + metrics_path: /metrics + scheme: http + + # Scrape Jaeger metrics + - job_name: 'jaeger' + static_configs: + - targets: ['jaeger:14269'] + scrape_interval: 30s + + # Redis metrics (if redis exporter is added) + # - job_name: 'redis' + # static_configs: + # - targets: ['redis:6379'] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d2eb222..95aef79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "electron-util": "^0.18.1", "emoji-picker-react": "^4.12.2", "i": "^0.3.7", + "i18next": "^25.3.2", "install": "^0.13.0", "lexical": "^0.30.0", "npm": "^11.4.0", @@ -35,6 +36,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-i18next": "^15.6.1", "react-router-dom": "^7.4.0", "react-virtuoso": "^4.12.7", "tldts": "^7.0.7", @@ -309,13 +311,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -6737,6 +6736,15 @@ "dev": true, "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -6800,6 +6808,37 @@ "node": ">=0.4" } }, + "node_modules/i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -12676,7 +12715,7 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12956,6 +12995,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index 3a5d31a..771ff5b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "electron-util": "^0.18.1", "emoji-picker-react": "^4.12.2", "i": "^0.3.7", + "i18next": "^25.3.2", "install": "^0.13.0", "lexical": "^0.30.0", "lodash": "^4.17.21", @@ -73,6 +74,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-i18next": "^15.6.1", "react-router-dom": "^7.4.0", "react-virtuoso": "^4.12.7", "tldts": "^7.0.7", diff --git a/resources/icons/linux/KickTalk_v1.png b/resources/icons/linux/KickTalk_v1.png new file mode 100644 index 0000000..da25af1 Binary files /dev/null and b/resources/icons/linux/KickTalk_v1.png differ diff --git a/resources/icons/mac/KickTalk_v1.png b/resources/icons/mac/KickTalk_v1.png new file mode 100644 index 0000000..da25af1 Binary files /dev/null and b/resources/icons/mac/KickTalk_v1.png differ diff --git a/scripts/block-websockets-firewalld.sh b/scripts/block-websockets-firewalld.sh new file mode 100755 index 0000000..021bde8 --- /dev/null +++ b/scripts/block-websockets-firewalld.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +# WebSocket IP range blocking script using the native 'nft' tool. +# This script dynamically fetches the latest AWS (for Pusher) and Cloudflare (for 7TV) +# IP ranges for both IPv4 and IPv6 and creates nftables rules to block them. +# +# REQUIRES: curl, jq +# Usage: ./scripts/block-websockets-firewalld.sh {start|stop|status} + +set -e + +# Name for our dedicated nftables objects +TABLE_NAME="kicktalk_blocker" +CHAIN_NAME_V4="output_block_v4" +CHAIN_NAME_V6="output_block_v6" + +# URLs for IP ranges +AWS_URL="https://ip-ranges.amazonaws.com/ip-ranges.json" +CLOUDFLARE_V4_URL="https://www.cloudflare.com/ips-v4" +CLOUDFLARE_V6_URL="https://www.cloudflare.com/ips-v6" + +# Function to check if our dedicated table exists +table_exists() { + sudo nft list tables | grep -q "table inet $TABLE_NAME" +} + +# --- Status --- +show_status() { + echo "WebSocket Domain Block Status (native nftables):" + echo "================================================" + + if ! table_exists; then + echo "✓ Status: No active blocking table found. Network is clear." + else + echo "✓ Table 'inet $TABLE_NAME' exists." + echo "" + echo "--- IPv4 Rules ---" + sudo nft list chain inet "$TABLE_NAME" "$CHAIN_NAME_V4" + echo "" + echo "--- IPv6 Rules ---" + sudo nft list chain inet "$TABLE_NAME" "$CHAIN_NAME_V6" + fi + + # Check for lingering rich rules from old script versions + if sudo firewall-cmd --list-rich-rules 2>/dev/null | grep -q "."; then + echo "" + echo "⚠️ Warning: Found lingering rich rules from old script attempts." + echo " Run './scripts/block-websockets-firewalld.sh stop' to clean them up." + fi +} + +# --- Start --- +block_ranges() { + if ! command -v jq &> /dev/null; then + echo "Error: 'jq' command not found. Please install it (e.g., sudo dnf install jq)." + exit 1 + fi + + if table_exists; then + echo "Blocking table already exists. Use 'status' to check or 'stop' to clear." + return 0 + fi + + echo "Fetching and parsing IP ranges..." + # AWS services we care about for Pusher + local aws_services=("AMAZON_CONNECT" "API_GATEWAY" "EC2") + local aws_jq_filter + # Create a JSON array of service names to pass safely to jq + local services_json_array + services_json_array=$(printf '"%s",' "${aws_services[@]}" | sed 's/,$//' | sed 's/^/[/' | sed 's/$/]/') + + # Use --argjson to pass the array and 'IN' to check for membership + local aws_ipv4 + aws_ipv4=$(curl -s "$AWS_URL" | jq -r --argjson services "$services_json_array" '.prefixes[] | select(.service | IN($services[])) | .ip_prefix') + local aws_ipv6 + aws_ipv6=$(curl -s "$AWS_URL" | jq -r --argjson services "$services_json_array" '.ipv6_prefixes[] | select(.service | IN($services[])) | .ipv6_prefix') + + local cloudflare_ipv4 + cloudflare_ipv4=$(curl -s "$CLOUDFLARE_V4_URL") + local cloudflare_ipv6 + cloudflare_ipv6=$(curl -s "$CLOUDFLARE_V6_URL") + + # Combine all ranges into a valid, comma-separated set for nftables + local full_ipv4_set + full_ipv4_set="{ $(echo "$aws_ipv4"$'\n'"$cloudflare_ipv4" | grep -v '^$' | paste -sd, -) }" + local full_ipv6_set + full_ipv6_set="{ $(echo "$aws_ipv6"$'\n'"$cloudflare_ipv6" | grep -v '^$' | paste -sd, -) }" + + echo "Blocking WebSocket domains using native nft..." + sudo nft add table inet "$TABLE_NAME" + + # Create and populate IPv4 chain + echo " Creating IPv4 chain and rules..." + sudo nft add chain inet "$TABLE_NAME" "$CHAIN_NAME_V4" '{ type filter hook output priority filter; }' + sudo nft add rule inet "$TABLE_NAME" "$CHAIN_NAME_V4" ip daddr "$full_ipv4_set" tcp dport 443 drop + + # Create and populate IPv6 chain + echo " Creating IPv6 chain and rules..." + sudo nft add chain inet "$TABLE_NAME" "$CHAIN_NAME_V6" '{ type filter hook output priority filter; }' + sudo nft add rule inet "$TABLE_NAME" "$CHAIN_NAME_V6" ip6 daddr "$full_ipv6_set" tcp dport 443 drop + + echo "✗ WebSocket domains blocked for IPv4 and IPv6." +} + +# --- Stop --- +unblock_ranges() { + echo "Unblocking all WebSocket domains and cleaning up..." + + if table_exists; then + echo " Deleting table: inet $TABLE_NAME" + sudo nft delete table inet "$TABLE_NAME" + else + echo " No active nft blocking table found." + fi + + echo " Cleaning up any lingering rich rules from old scripts..." + # This command is noisy on error, so we redirect stderr + local old_rules + old_rules=$(sudo firewall-cmd --list-rich-rules 2>/dev/null) + if [ -n "$old_rules" ]; then + while IFS= read -r rule; do + echo " Removing old rule: $rule" + sudo firewall-cmd --remove-rich-rule="$rule" 2>/dev/null || true + done <<< "$old_rules" + else + echo " No lingering rich rules found." + fi + + echo "✓ All WebSocket blocking rules should now be removed." +} + + +# --- Main Script --- +if ! command -v nft &> /dev/null; then + echo "Error: 'nft' command not found. Please install nftables." + exit 1 +fi + +case "$1" in + start) + block_ranges + ;; + stop) + unblock_ranges + ;; + status) + show_status + ;; + *) + echo "Usage: $0 {start|stop|status}" + echo "" + echo " start - Fetches IPs and creates nftables rules to block traffic." + echo " stop - Deletes the dedicated table and cleans up old rich rules." + echo " status - Shows the status of the dedicated nftables rules." + echo "" + exit 1 + ;; +esac \ No newline at end of file diff --git a/scripts/block-websockets-hosts.sh b/scripts/block-websockets-hosts.sh new file mode 100755 index 0000000..fca81b2 --- /dev/null +++ b/scripts/block-websockets-hosts.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# WebSocket domain blocking script for testing connection recovery +# Usage: ./scripts/block-websockets.sh {start|stop|status} + +DOMAINS=("ws-us2.pusher.com" "events.7tv.io") +HOSTS_FILE="/etc/hosts" + +check_blocked() { + local blocked_count=0 + for domain in "${DOMAINS[@]}"; do + if grep -q "127.0.0.1 $domain" "$HOSTS_FILE" 2>/dev/null; then + ((blocked_count++)) + fi + done + echo $blocked_count +} + +show_status() { + local blocked_count=$(check_blocked) + local total_domains=${#DOMAINS[@]} + + echo "WebSocket Domain Block Status:" + echo "==============================" + + for domain in "${DOMAINS[@]}"; do + if grep -q "127.0.0.1 $domain" "$HOSTS_FILE" 2>/dev/null; then + echo " ✗ $domain - BLOCKED" + else + echo " ✓ $domain - ALLOWED" + fi + done + + echo "" + if [ $blocked_count -eq $total_domains ]; then + echo "Status: ALL domains blocked ($blocked_count/$total_domains)" + elif [ $blocked_count -eq 0 ]; then + echo "Status: ALL domains allowed ($blocked_count/$total_domains)" + else + echo "Status: PARTIAL block ($blocked_count/$total_domains)" + fi +} + +block_domains() { + local blocked_count=$(check_blocked) + local total_domains=${#DOMAINS[@]} + + if [ $blocked_count -eq $total_domains ]; then + echo "All WebSocket domains are already blocked." + return 0 + fi + + echo "Blocking WebSocket domains..." + echo "Note: This requires sudo permissions to modify /etc/hosts" + + # Create temp file with new entries + local temp_file=$(mktemp) + for domain in "${DOMAINS[@]}"; do + if ! grep -q "127.0.0.1 $domain" "$HOSTS_FILE" 2>/dev/null; then + echo " Blocking $domain" + echo "127.0.0.1 $domain" >> "$temp_file" + else + echo " $domain already blocked" + fi + done + + # Append to hosts file if we have entries to add + if [ -s "$temp_file" ]; then + sudo bash -c "cat '$temp_file' >> '$HOSTS_FILE'" + rm "$temp_file" + echo "✗ WebSocket domains blocked. Connections should fail now." + else + rm "$temp_file" + echo "All domains were already blocked." + fi +} + +unblock_domains() { + local blocked_count=$(check_blocked) + + if [ $blocked_count -eq 0 ]; then + echo "All WebSocket domains are already unblocked." + return 0 + fi + + echo "Unblocking WebSocket domains..." + + for domain in "${DOMAINS[@]}"; do + if grep -q "127.0.0.1 $domain" "$HOSTS_FILE" 2>/dev/null; then + echo " Unblocking $domain" + sudo sed -i "/127.0.0.1 $domain/d" "$HOSTS_FILE" + else + echo " $domain already unblocked" + fi + done + + echo "✓ WebSocket domains unblocked. Connections should work now." +} + +case "$1" in + start) + block_domains + ;; + stop) + unblock_domains + ;; + status) + show_status + ;; + *) + echo "Usage: $0 {start|stop|status}" + echo "" + echo "Commands:" + echo " start - Block WebSocket domains (simulate network failure)" + echo " stop - Unblock WebSocket domains (restore connections)" + echo " status - Show current blocking status" + echo "" + echo "Domains managed:" + for domain in "${DOMAINS[@]}"; do + echo " - $domain" + done + exit 1 + ;; +esac \ No newline at end of file diff --git a/scripts/otel-stack.sh b/scripts/otel-stack.sh new file mode 100755 index 0000000..fa68245 --- /dev/null +++ b/scripts/otel-stack.sh @@ -0,0 +1,250 @@ +#!/bin/bash + +# KickTalk OpenTelemetry Stack Management Script (Podman Compatible) + +set -e + +# Detect container runtime (podman preferred, docker fallback) +if command -v podman-compose &> /dev/null; then + COMPOSE_CMD="podman-compose" + CONTAINER_CMD="podman" +elif command -v podman &> /dev/null && podman compose version &> /dev/null; then + COMPOSE_CMD="podman compose" + CONTAINER_CMD="podman" +elif command -v docker &> /dev/null; then + COMPOSE_CMD="docker compose" + CONTAINER_CMD="docker" +else + echo "Error: No suitable container runtime found." + echo "Please install either:" + echo " - podman-compose: pip install podman-compose" + echo " - Docker with compose plugin" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +COMPOSE_FILE="$PROJECT_ROOT/docker-compose.otel.yml" + +# Check if podman socket is needed and available +check_podman_socket() { + if [[ "$CONTAINER_CMD" == "podman" ]] && [[ "$COMPOSE_CMD" == "podman compose" ]]; then + if ! systemctl --user is-active podman.socket &> /dev/null; then + echo -e "${YELLOW}Podman socket not running. Starting it...${NC}" + systemctl --user start podman.socket + sleep 2 + fi + + # Set the Docker host for podman compose to use podman socket + export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock" + fi +} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_usage() { + echo "KickTalk OpenTelemetry Stack Management (Podman/Docker Compatible)" + echo "Using: $COMPOSE_CMD" + echo + echo "Usage: $0 [COMMAND]" + echo + echo "Commands:" + echo " start Start the observability stack" + echo " stop Stop the observability stack" + echo " restart Restart the observability stack" + echo " status Show status of all services" + echo " logs Show logs from all services" + echo " clean Stop and remove all containers and volumes" + echo " urls Display service URLs" + echo " test Test the stack connectivity" + echo +} + +start_stack() { + echo -e "${GREEN}Starting KickTalk OpenTelemetry stack...${NC}" + + if [ ! -f "$COMPOSE_FILE" ]; then + echo -e "${RED}Error: docker-compose.otel.yml not found at $COMPOSE_FILE${NC}" + exit 1 + fi + + check_podman_socket + $COMPOSE_CMD -f "$COMPOSE_FILE" up -d + + echo -e "${GREEN}✓ Stack started successfully!${NC}" + echo + show_urls +} + +stop_stack() { + echo -e "${YELLOW}Stopping KickTalk OpenTelemetry stack...${NC}" + check_podman_socket + $COMPOSE_CMD -f "$COMPOSE_FILE" down + echo -e "${GREEN}✓ Stack stopped successfully!${NC}" +} + +restart_stack() { + echo -e "${YELLOW}Restarting KickTalk OpenTelemetry stack...${NC}" + check_podman_socket + $COMPOSE_CMD -f "$COMPOSE_FILE" down + $COMPOSE_CMD -f "$COMPOSE_FILE" up -d + echo -e "${GREEN}✓ Stack restarted successfully!${NC}" + echo + show_urls +} + +show_status() { + echo -e "${BLUE}KickTalk OpenTelemetry Stack Status:${NC}" + echo + if [[ "$CONTAINER_CMD" == "podman" ]]; then + # Use native podman commands for better compatibility + echo "Containers (filtering by kicktalk prefix):" + podman ps --filter name=kicktalk --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + echo + echo "All containers:" + podman ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + else + $COMPOSE_CMD -f "$COMPOSE_FILE" ps + fi +} + +show_logs() { + echo -e "${BLUE}Following logs from all services (Ctrl+C to exit):${NC}" + echo + $COMPOSE_CMD -f "$COMPOSE_FILE" logs -f +} + +clean_stack() { + echo -e "${RED}Warning: This will remove all containers and data volumes!${NC}" + read -p "Are you sure? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}Cleaning up KickTalk OpenTelemetry stack...${NC}" + $COMPOSE_CMD -f "$COMPOSE_FILE" down -v --remove-orphans + echo -e "${GREEN}✓ Stack cleaned up successfully!${NC}" + else + echo "Cancelled." + fi +} + +show_urls() { + echo -e "${BLUE}Service URLs:${NC}" + echo " 📊 Grafana Dashboard: http://localhost:3000 (admin/admin)" + echo " 🔍 Jaeger Tracing UI: http://localhost:16686" + echo " 📈 Prometheus: http://localhost:9090" + echo " 🔧 OTEL Collector: http://localhost:13133 (health)" + echo + echo -e "${BLUE}Application Integration:${NC}" + echo " 📡 OTLP gRPC Endpoint: localhost:4317" + echo " 📡 OTLP HTTP Endpoint: localhost:4318" + echo +} + +test_connectivity() { + echo -e "${BLUE}Testing KickTalk OpenTelemetry stack connectivity...${NC}" + echo + + # Test OTEL Collector health with detailed response + local otel_response=$(curl -s http://localhost:13133 2>/dev/null) + if [ $? -eq 0 ] && [[ "$otel_response" == *"status"* ]]; then + echo -e "✅ OTEL Collector: ${GREEN}Healthy${NC} - $otel_response" + else + echo -e "❌ OTEL Collector: ${RED}Unhealthy or not responding${NC}" + fi + + # Test Grafana with login page detection + local grafana_response=$(curl -s http://localhost:3000/login 2>/dev/null) + if [ $? -eq 0 ] && [[ "$grafana_response" == *"login"* ]]; then + echo -e "✅ Grafana: ${GREEN}Login page accessible${NC} - Ready at http://localhost:3000" + else + echo -e "❌ Grafana: ${RED}Not responding${NC}" + fi + + # Test Jaeger UI with title detection + local jaeger_response=$(curl -s http://localhost:16686 2>/dev/null) + if [ $? -eq 0 ] && [[ "$jaeger_response" == *"Jaeger UI"* ]]; then + echo -e "✅ Jaeger: ${GREEN}UI accessible${NC} - Ready at http://localhost:16686" + else + echo -e "❌ Jaeger: ${RED}Not responding${NC}" + fi + + # Test Prometheus with redirect detection + local prometheus_response=$(curl -s http://localhost:9090 2>/dev/null) + if [ $? -eq 0 ] && ([[ "$prometheus_response" == *"Found"* ]] || [[ "$prometheus_response" == *"Prometheus"* ]]); then + echo -e "✅ Prometheus: ${GREEN}Web UI accessible${NC} - Ready at http://localhost:9090" + else + echo -e "❌ Prometheus: ${RED}Not responding${NC}" + fi + + # Test Redis connection + if nc -z localhost 6379 2>/dev/null; then + echo -e "✅ Redis: ${GREEN}Port open${NC} - Available at localhost:6379" + else + echo -e "❌ Redis: ${RED}Port closed${NC}" + fi + + # Test OTLP endpoints + if nc -z localhost 4317 2>/dev/null; then + echo -e "✅ OTLP gRPC: ${GREEN}Port open${NC} - Ready for telemetry at localhost:4317" + else + echo -e "❌ OTLP gRPC: ${RED}Port closed${NC}" + fi + + if nc -z localhost 4318 2>/dev/null; then + echo -e "✅ OTLP HTTP: ${GREEN}Port open${NC} - Ready for telemetry at localhost:4318" + else + echo -e "❌ OTLP HTTP: ${RED}Port closed${NC}" + fi + + # Test OTEL Collector metrics endpoint + local metrics_response=$(curl -s http://localhost:8889/metrics 2>/dev/null) + if [ $? -eq 0 ] && ([[ "$metrics_response" == *"promhttp"* ]] || [[ "$metrics_response" == *"TYPE"* ]]); then + local metric_count=$(echo "$metrics_response" | grep -c "^# TYPE") + echo -e "✅ OTEL Metrics: ${GREEN}Collector metrics available${NC} - $metric_count metrics at http://localhost:8889/metrics" + else + echo -e "❌ OTEL Metrics: ${RED}Metrics endpoint not responding${NC}" + fi + + echo + echo -e "${BLUE}Summary:${NC}" + echo " 📊 All services tested with actual HTTP requests" + echo " 🔍 Response content validated (not just connection checks)" + echo " 📈 Telemetry endpoints verified and ready for data" +} + +# Main script logic +case "${1:-}" in + start) + start_stack + ;; + stop) + stop_stack + ;; + restart) + restart_stack + ;; + status) + show_status + ;; + logs) + show_logs + ;; + clean) + clean_stack + ;; + urls) + show_urls + ;; + test) + test_connectivity + ;; + *) + print_usage + exit 1 + ;; +esac \ No newline at end of file diff --git a/src/main/index.js b/src/main/index.js index 39a56ea..8543c7a 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -8,6 +8,39 @@ import fs from "fs"; import dotenv from "dotenv"; dotenv.config(); +// Initialize telemetry early if enabled +let initTelemetry = null; +let shutdownTelemetry = null; +let isTelemetryEnabled = () => false; // Default fallback + +// Function to check telemetry settings from main process +const checkTelemetrySettings = () => { + // Check user settings + try { + // Use the same store approach as elsewhere in the codebase + const settings = store.get('telemetry', { enabled: false }); + return settings.enabled === true; + } catch (error) { + console.warn('[Telemetry]: Could not access settings store:', error.message); + return false; + } +}; + +try { + const telemetryModule = require("../telemetry/index.js"); + initTelemetry = telemetryModule.initTelemetry; + shutdownTelemetry = telemetryModule.shutdownTelemetry; + + // Override the telemetry enabled check with our main process version + isTelemetryEnabled = checkTelemetrySettings; + + if (isTelemetryEnabled()) { + initTelemetry(); + } +} catch (error) { + console.warn('[Telemetry]: Failed to load telemetry module:', error.message); +} + const isDev = process.env.NODE_ENV === "development"; const iconPath = process.platform === "win32" ? join(__dirname, "../../resources/icons/win/KickTalk_v1.ico") @@ -77,6 +110,9 @@ let userDialog = null; let authDialog = null; let chattersDialog = null; let settingsDialog = null; + +// Track all windows for telemetry +const allWindows = new Set(); let searchDialog = null; let replyThreadDialog = null; let availableNotificationSounds = []; @@ -434,7 +470,7 @@ const setAlwaysOnTop = (window) => { }; const createWindow = () => { - mainWindow = new BrowserWindow({ + const windowOptions = { width: store.get("lastMainWindowState.width"), height: store.get("lastMainWindowState.height"), x: store.get("lastMainWindowState.x"), @@ -446,7 +482,7 @@ const createWindow = () => { autoHideMenuBar: true, titleBarStyle: "hidden", roundedCorners: true, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { devTools: true, nodeIntegration: false, @@ -454,8 +490,18 @@ const createWindow = () => { preload: join(__dirname, "../preload/index.js"), sandbox: false, backgroundThrottling: false, + webSecurity: !isDev, + allowRunningInsecureContent: false, + experimentalFeatures: false, + enableRemoteModule: false, + ...(process.platform === 'darwin' && { + hardwareAcceleration: false, + offscreen: false + }) }, - }); + }; + + mainWindow = new BrowserWindow(windowOptions); mainWindow.setThumbarButtons([ { @@ -467,10 +513,24 @@ const createWindow = () => { ]); setAlwaysOnTop(mainWindow); + metrics.incrementOpenWindows(); mainWindow.once("ready-to-show", async () => { mainWindow.show(); setAlwaysOnTop(mainWindow); + allWindows.add(mainWindow); + + // Suppress GPU/EGL console warnings + if (process.platform === 'darwin') { + mainWindow.webContents.on('console-message', (event, level, message) => { + if (message.includes('EGL Driver message') || + message.includes('eglQueryDeviceAttribEXT') || + message.includes('Bad attribute') || + message.includes('GL_INVALID_OPERATION')) { + event.preventDefault(); + } + }); + } if (isDev) { mainWindow.webContents.openDevTools({ mode: "detach" }); @@ -483,6 +543,8 @@ const createWindow = () => { mainWindow.on("close", () => { store.set("lastMainWindowState", { ...mainWindow.getNormalBounds() }); + allWindows.delete(mainWindow); + metrics.decrementOpenWindows(); }); mainWindow.webContents.setWindowOpenHandler((details) => { @@ -527,7 +589,7 @@ const loginToKick = async (method) => { autoHideMenuBar: true, parent: authDialog, roundedCorners: true, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { autoplayPolicy: "user-gesture-required", nodeIntegration: false, @@ -535,6 +597,8 @@ const loginToKick = async (method) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(loginDialog); switch (method) { case "kick": @@ -598,6 +662,8 @@ const loginToKick = async (method) => { loginDialog.on("closed", () => { clearInterval(interval); resolve(false); + allWindows.delete(loginDialog); + metrics.decrementOpenWindows(); }); }); }; @@ -754,6 +820,8 @@ ipcMain.handle("userDialog:open", (e, { data }) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(userDialog); // Load the same URL as main window but with dialog hash if (isDev && process.env["ELECTRON_RENDERER_URL"]) { @@ -785,7 +853,9 @@ ipcMain.handle("userDialog:open", (e, { data }) => { userDialog.on("closed", () => { setAlwaysOnTop(mainWindow); dialogInfo = null; + allWindows.delete(userDialog); userDialog = null; + metrics.decrementOpenWindows(); }); }); @@ -829,7 +899,7 @@ ipcMain.handle("authDialog:open", (e) => { transparent: true, roundedCorners: true, parent: mainWindow, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { devtools: true, nodeIntegration: false, @@ -838,6 +908,8 @@ ipcMain.handle("authDialog:open", (e) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(authDialog); // Load the same URL as main window but with dialog hash if (isDev && process.env["ELECTRON_RENDERER_URL"]) { @@ -855,6 +927,8 @@ ipcMain.handle("authDialog:open", (e) => { authDialog.on("closed", () => { authDialog = null; + allWindows.delete(authDialog); + metrics.decrementOpenWindows(); }); }); @@ -914,11 +988,91 @@ ipcMain.handle("get-app-info", () => { }; }); +// Telemetry handlers +ipcMain.handle("telemetry:recordMessageSent", (e, { chatroomId, messageType = 'regular', duration = null, success = true, streamerName = null }) => { + if (isTelemetryEnabled()) { + metrics.recordMessageSent(chatroomId, messageType, streamerName); + if (duration !== null) { + metrics.recordMessageSendDuration(duration, chatroomId, success); + } + } +}); + +ipcMain.handle("telemetry:recordError", (e, { error, context = {} }) => { + if (isTelemetryEnabled()) { + const errorObj = new Error(error.message || error); + errorObj.name = error.name || 'RendererError'; + errorObj.stack = error.stack; + metrics.recordError(errorObj, context); + } +}); + +ipcMain.handle("telemetry:recordRendererMemory", (e, memory) => { + if (isTelemetryEnabled()) { + metrics.recordRendererMemory(memory); + } +}); + +ipcMain.handle("telemetry:recordDomNodeCount", (e, count) => { + if (isTelemetryEnabled()) { + metrics.recordDomNodeCount(count); + } +}); + +ipcMain.handle("telemetry:recordWebSocketConnection", (e, { chatroomId, streamerId, connected, streamerName }) => { + if (isTelemetryEnabled()) { + if (connected) { + metrics.incrementWebSocketConnections(chatroomId, streamerId, streamerName); + } else { + metrics.decrementWebSocketConnections(chatroomId, streamerId, streamerName); + } + } +}); + +ipcMain.handle("telemetry:recordConnectionError", (e, { chatroomId, errorType }) => { + if (isTelemetryEnabled()) { + metrics.recordConnectionError(errorType, chatroomId); + } +}); + +ipcMain.handle("telemetry:recordMessageReceived", (e, { chatroomId, messageType, senderId, streamerName }) => { + if (isTelemetryEnabled()) { + metrics.recordMessageReceived(chatroomId, messageType, senderId, streamerName); + } +}); + +ipcMain.handle("telemetry:recordReconnection", (e, { chatroomId, reason }) => { + if (isTelemetryEnabled()) { + metrics.recordReconnection(chatroomId, reason); + } +}); + +ipcMain.handle("telemetry:recordAPIRequest", (e, { endpoint, method, statusCode, duration }) => { + if (isTelemetryEnabled()) { + metrics.recordAPIRequest(endpoint, method, statusCode, duration); + } +}); + // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. -app.on("window-all-closed", () => { +app.on("window-all-closed", async () => { if (process.platform !== "darwin") { + // Shutdown telemetry before quitting + if (isTelemetryEnabled()) { + if (allWindows.size > 0) { + const openWindowTitles = Array.from(allWindows).map(win => win.getTitle()); + console.error(`[ProcessExit] Closing with ${allWindows.size} windows still open: ${openWindowTitles.join(", ")}`); + metrics.recordError(new Error("Lingering windows on exit"), { openWindows: openWindowTitles }); + } + if (shutdownTelemetry) { + try { + await shutdownTelemetry(); + } catch (error) { + console.warn('[Telemetry]: Failed to shutdown telemetry:', error.message); + } + } + } app.quit(); } }); @@ -950,7 +1104,7 @@ ipcMain.handle("chattersDialog:open", (e, { data }) => { transparent: true, roundedCorners: true, parent: mainWindow, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { devtools: true, nodeIntegration: false, @@ -959,6 +1113,8 @@ ipcMain.handle("chattersDialog:open", (e, { data }) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(chattersDialog); if (isDev && process.env["ELECTRON_RENDERER_URL"]) { chattersDialog.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}/chatters.html`); @@ -978,6 +1134,8 @@ ipcMain.handle("chattersDialog:open", (e, { data }) => { chattersDialog.on("closed", () => { chattersDialog = null; + allWindows.delete(chattersDialog); + metrics.decrementOpenWindows(); }); }); @@ -1018,7 +1176,7 @@ ipcMain.handle("searchDialog:open", (e, { data }) => { transparent: true, roundedCorners: true, parent: mainWindow, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { devtools: true, nodeIntegration: false, @@ -1027,6 +1185,8 @@ ipcMain.handle("searchDialog:open", (e, { data }) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(searchDialog); if (isDev && process.env["ELECTRON_RENDERER_URL"]) { searchDialog.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}/search.html`); @@ -1051,6 +1211,8 @@ ipcMain.handle("searchDialog:open", (e, { data }) => { searchDialog.on("closed", () => { searchDialog = null; + allWindows.delete(searchDialog); + metrics.decrementOpenWindows(); }); }); @@ -1096,7 +1258,7 @@ ipcMain.handle("settingsDialog:open", async (e, { data }) => { backgroundColor: "#020a05", roundedCorners: true, parent: mainWindow, - icon: iconPath, + ...(iconPath && { icon: iconPath }), webPreferences: { devtools: true, nodeIntegration: false, @@ -1105,6 +1267,8 @@ ipcMain.handle("settingsDialog:open", async (e, { data }) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(settingsDialog); if (isDev && process.env["ELECTRON_RENDERER_URL"]) { settingsDialog.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}/settings.html`); @@ -1129,6 +1293,8 @@ ipcMain.handle("settingsDialog:open", async (e, { data }) => { settingsDialog.on("closed", () => { settingsDialog = null; + allWindows.delete(settingsDialog); + metrics.decrementOpenWindows(); }); }); @@ -1183,6 +1349,8 @@ ipcMain.handle("replyThreadDialog:open", (e, { data }) => { sandbox: false, }, }); + metrics.incrementOpenWindows(); + allWindows.add(replyThreadDialog); if (isDev && process.env["ELECTRON_RENDERER_URL"]) { replyThreadDialog.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}/replyThread.html`); @@ -1204,6 +1372,8 @@ ipcMain.handle("replyThreadDialog:open", (e, { data }) => { replyThreadDialog.on("closed", () => { replyThreadDialog = null; + allWindows.delete(replyThreadDialog); + metrics.decrementOpenWindows(); }); }); @@ -1218,3 +1388,14 @@ ipcMain.handle("replyThreadDialog:close", () => { replyThreadDialog = null; } }); + +// Global error handlers +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Promise Rejection at:', promise, 'reason:', reason); + // Don't crash the app, just log the error +}); + +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + // Don't crash the app, just log the error +}); diff --git a/src/preload/index.js b/src/preload/index.js index b2f308f..0a20efa 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -396,8 +396,30 @@ if (process.contextIsolated) { clearTokens: () => tokenManager.clearTokens(), getToken: () => tokenManager.getToken(), }, + + // Telemetry utilities + telemetry: { + recordMessageSent: (chatroomId, messageType, duration, success, streamerName) => + ipcRenderer.invoke("telemetry:recordMessageSent", { chatroomId, messageType, duration, success, streamerName }), + recordError: (error, context) => + ipcRenderer.invoke("telemetry:recordError", { error, context }), + recordRendererMemory: (memory) => + ipcRenderer.invoke("telemetry:recordRendererMemory", memory), + recordDomNodeCount: (count) => + ipcRenderer.invoke("telemetry:recordDomNodeCount", count), + recordWebSocketConnection: (chatroomId, streamerId, connected, streamerName) => + ipcRenderer.invoke("telemetry:recordWebSocketConnection", { chatroomId, streamerId, connected, streamerName }), + recordConnectionError: (chatroomId, errorType) => + ipcRenderer.invoke("telemetry:recordConnectionError", { chatroomId, errorType }), + recordMessageReceived: (chatroomId, messageType, senderId, streamerName) => + ipcRenderer.invoke("telemetry:recordMessageReceived", { chatroomId, messageType, senderId, streamerName }), + recordReconnection: (chatroomId, reason) => + ipcRenderer.invoke("telemetry:recordReconnection", { chatroomId, reason }), + recordAPIRequest: (endpoint, method, statusCode, duration) => + ipcRenderer.invoke("telemetry:recordAPIRequest", { endpoint, method, statusCode, duration }), + }, }); - } catch (error) { + } catch (error) { console.error("Failed to expose APIs:", error); } } else { diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index 3dde7d7..2a4b3d1 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -1,11 +1,30 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import ChatPage from "./pages/ChatPage"; import SettingsProvider from "./providers/SettingsProvider"; import ErrorBoundary from "./components/ErrorBoundary"; import Loader from "./pages/Loader"; const App = () => { + const { i18n } = useTranslation(); + const [currentLanguage, setCurrentLanguage] = useState(i18n.language); + + useEffect(() => { + const handleLanguageChange = (lng) => { + setCurrentLanguage(lng); + // Force a re-render of the entire app + console.log('App re-rendering due to language change:', lng); + }; + + i18n.on('languageChanged', handleLanguageChange); + + return () => { + i18n.off('languageChanged', handleLanguageChange); + }; + }, [i18n]); + return ( - + diff --git a/src/renderer/src/assets/icons/arrow-clockwise-fill.svg b/src/renderer/src/assets/icons/arrow-clockwise-fill.svg new file mode 100644 index 0000000..b6ee782 --- /dev/null +++ b/src/renderer/src/assets/icons/arrow-clockwise-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/assets/styles/components/Chat/Message.scss b/src/renderer/src/assets/styles/components/Chat/Message.scss index 8e4b50b..4b34c7a 100644 --- a/src/renderer/src/assets/styles/components/Chat/Message.scss +++ b/src/renderer/src/assets/styles/components/Chat/Message.scss @@ -82,6 +82,58 @@ } } + // Optimistic message states + &.optimistic { + // Use visual indicators instead of opacity to preserve readability + border-left: 3px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.03) !important; + + // Add a subtle loading animation + position: relative; + + &::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent); + animation: optimisticPulse 2s ease-in-out infinite; + } + } + + // Loading animation for optimistic messages + @keyframes optimisticPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } + } + + &.failed { + opacity: 0.7; + cursor: pointer; + transition: all 0.2s ease; + border-left: 3px solid #ff4444; + background: rgba(255, 68, 68, 0.1) !important; + + // Add subtle red tint to text instead of aggressive filter + .chatMessageContent, + .chatMessageUsername, + .chatMessageTimestamp { + color: #ff6b6b !important; + } + + &:hover { + opacity: 0.9; + background: rgba(255, 68, 68, 0.15) !important; + } + + // Show retry button in action area for failed messages + .chatMessageActions { + opacity: 1; // Always show for failed messages + } + } + &.dialogChatMessageItem { padding: 4px 16px; } diff --git a/src/renderer/src/assets/styles/components/Navbar.scss b/src/renderer/src/assets/styles/components/Navbar.scss index b97ccb0..968d45d 100644 --- a/src/renderer/src/assets/styles/components/Navbar.scss +++ b/src/renderer/src/assets/styles/components/Navbar.scss @@ -71,6 +71,88 @@ } } } + + &.compactChatroomList { + .chatroomStreamer { + min-width: 40px; + width: 40px; + padding: 0 8px; + justify-content: center; + position: relative; + + .streamerInfo { + > span:first-of-type { + display: none; + } + + .profileImage { + margin-right: 0; + width: 24px; + height: 24px; + border-radius: 4px; + transition: border 0.2s ease-in-out; + } + + .unreadCountIndicator { + position: absolute; + top: 6px; + right: 6px; + margin-left: 0; + } + } + + .closeChatroom { + display: none; + } + + // Live indicator for compact mode + &.chatroomStreamerLive { + border-color: rgba(255, 35, 35, 0.3); + + &.chatroomStreamerActive { + background: rgba(255, 84, 84, 0.2); + } + } + } + + .chatroomsSeparator { + display: none; + } + + &.wrapChatroomList { + .chatroomsList { + .navbarAddChatroomContainer { + .navbarAddChatroomButton { + height: 40px; + width: 40px; + padding: 0; + + span { + display: none; + } + } + } + } + } + } +} + +// Give mentions tab its own opacity behavior like other UI icons +.chatroomStreamer:has(.profileImage[alt="Mentions"]) { + opacity: 1; + + .streamerInfo .profileImage[alt="Mentions"] { + opacity: 0.5; + transition: opacity 0.2s ease-in-out; + } + + &:hover .streamerInfo .profileImage[alt="Mentions"] { + opacity: 0.8; + } + + &.chatroomStreamerActive .streamerInfo .profileImage[alt="Mentions"] { + opacity: 0.8; + } } .chatroomStreamer { diff --git a/src/renderer/src/assets/styles/dialogs/Settings.scss b/src/renderer/src/assets/styles/dialogs/Settings.scss index 9bb7c15..9311265 100644 --- a/src/renderer/src/assets/styles/dialogs/Settings.scss +++ b/src/renderer/src/assets/styles/dialogs/Settings.scss @@ -454,6 +454,38 @@ .settingsItem { width: 100%; + .settingsSectionSubHeader { + padding: 12px 16px 8px 16px; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--input-info-bar); + border: 1px solid var(--border-primary); + border-top: 3px solid var(--text-accent); + border-radius: 6px 6px 0 0; + + h5 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + p { + font-size: 12px; + color: var(--text-tertiary); + margin: 0; + } + } + + .settingsItemContent { + padding: 12px 16px; + background: var(--input-bg); + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0 0 6px 6px; + } + &.extended { display: flex; flex-direction: column; diff --git a/src/renderer/src/components/Chat/Input/index.jsx b/src/renderer/src/components/Chat/Input/index.jsx index 3bdebb2..0604c21 100644 --- a/src/renderer/src/components/Chat/Input/index.jsx +++ b/src/renderer/src/components/Chat/Input/index.jsx @@ -18,8 +18,10 @@ import { KEY_SPACE_COMMAND, COMMAND_PRIORITY_CRITICAL, $getNodeByKey, + $createParagraphNode, } from "lexical"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; @@ -765,6 +767,46 @@ const EmoteTransformer = ({ chatroomId }) => { }, [editor, kickEmotes]); }; +const DraftManager = ({ chatroomId }) => { + const [editor] = useLexicalComposerContext(); + const saveDraftMessage = useChatStore((state) => state.saveDraftMessage); + const getDraftMessage = useChatStore((state) => state.getDraftMessage); + const clearDraftMessage = useChatStore((state) => state.clearDraftMessage); + + // Save draft on editor content changes + useEffect(() => { + if (!editor) return; + + const unregister = editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const content = $rootTextContent(); + saveDraftMessage(chatroomId, content); + }); + }); + + return unregister; + }, [editor, chatroomId, saveDraftMessage]); + + // Restore draft when chatroom changes + useEffect(() => { + if (!editor) return; + + const draft = getDraftMessage(chatroomId); + if (draft) { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const textNode = $createTextNode(draft); + const paragraph = $createParagraphNode(); + paragraph.append(textNode); + root.append(paragraph); + }); + } + }, [editor, chatroomId, getDraftMessage]); + + return null; +}; + const EmoteHandler = ({ chatroomId, userChatroomInfo }) => { const [editor] = useLexicalComposerContext(); @@ -797,13 +839,15 @@ const initialConfig = { }; const ReplyHandler = ({ chatroomId, replyInputData, setReplyInputData }) => { + const { t } = useTranslation(); + return ( <> {replyInputData && (
- Replying to @{replyInputData?.sender?.username} + {t('chatInput.replyingTo')} @{replyInputData?.sender?.username}
} + aria-placeholder={t('chatInput.enterMessage')} + placeholder={
{t('chatInput.placeholder')}
} spellCheck={false} />
@@ -963,6 +1013,7 @@ const ChatInput = memo( setReplyInputData={setReplyInputData} /> + diff --git a/src/renderer/src/components/Chat/StreamerInfo.jsx b/src/renderer/src/components/Chat/StreamerInfo.jsx index 89c5360..06270a1 100644 --- a/src/renderer/src/components/Chat/StreamerInfo.jsx +++ b/src/renderer/src/components/Chat/StreamerInfo.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, memo, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useShallow } from "zustand/shallow"; import clsx from "clsx"; import useChatStore from "../../providers/ChatProvider"; @@ -19,6 +20,7 @@ import { const StreamerInfo = memo( ({ streamerData, isStreamerLive, chatroomId, userChatroomInfo, settings, updateSettings, handleSearch }) => { + const { t } = useTranslation(); const [showPinnedMessage, setShowPinnedMessage] = useState(true); // const [showPollMessage, setShowPollMessage] = useState(false); const [showStreamerCard, setShowStreamerCard] = useState(false); @@ -109,8 +111,10 @@ const StreamerInfo = memo(
{streamerData?.livestream?.session_title}

- Live for {convertDateToHumanReadable(streamerData?.livestream?.created_at)} with{" "} - {streamerData?.livestream?.viewer_count?.toLocaleString() || 0} viewers + {t('streamerInfo.liveFor', { + duration: convertDateToHumanReadable(streamerData?.livestream?.created_at), + viewers: streamerData?.livestream?.viewer_count?.toLocaleString() || 0 + })}

@@ -150,19 +154,19 @@ const StreamerInfo = memo( - Refresh 7TV Emotes - Refresh Kick Emotes - Search + {t('streamerInfo.refreshEmotes')} + {t('streamerInfo.refreshKickEmotes')} + {t('streamerInfo.search')} window.open(`https://kick.com/${streamerData?.slug}`, "_blank")}> - Open Stream in Browser + {t('streamerInfo.openStream')} window.open(`https://player.kick.com/${streamerData?.slug}`, "_blank")}> - Open Player in Browser + {t('streamerInfo.openPlayer')} {canModerate && ( window.open(`https://kick.com/${streamerData?.slug}/moderator`, "_blank")}> - Open Mod View in Browser + {t('streamerInfo.openModView')} )} diff --git a/src/renderer/src/components/Dialogs/Auth.jsx b/src/renderer/src/components/Dialogs/Auth.jsx index 33e6d93..0c66fa5 100644 --- a/src/renderer/src/components/Dialogs/Auth.jsx +++ b/src/renderer/src/components/Dialogs/Auth.jsx @@ -1,10 +1,13 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import "../../assets/styles/dialogs/AuthDialog.scss"; import GoogleIcon from "../../assets/logos/googleLogo.svg?asset"; import AppleIcon from "../../assets/logos/appleLogo.svg?asset"; import KickIconIcon from "../../assets/logos/kickLogoIcon.svg?asset"; import GhostIcon from "../../assets/icons/ghost-fill.svg?asset"; + const Auth = () => { + const { t } = useTranslation(); const handleAuthLogin = (type) => { switch (type) { case "kick": @@ -26,36 +29,36 @@ const Auth = () => { return (
- Sign in with your
Kick account + {t('auth.signInWithKick')}
-

Use username and password for login? Continue to Kick.com

+

{t('auth.kickLoginDescription')}

-

Already have a Kick account with Google or Apple login?

+

{t('auth.googleAppleDescription')}

- Disclaimer: We do NOT save any emails or passwords. + Disclaimer: {t('auth.disclaimer')}

); diff --git a/src/renderer/src/components/Dialogs/Chatters.jsx b/src/renderer/src/components/Dialogs/Chatters.jsx index 2067a6e..b110261 100644 --- a/src/renderer/src/components/Dialogs/Chatters.jsx +++ b/src/renderer/src/components/Dialogs/Chatters.jsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Virtuoso } from "react-virtuoso"; import X from "../../assets/icons/x-bold.svg"; import { useDebounceValue } from "../../utils/hooks"; import { KickBadges } from "../Cosmetics/Badges"; const Chatters = () => { + const { t } = useTranslation(); const [chattersData, setChattersData] = useState(null); const [debouncedValue, setDebouncedValue] = useDebounceValue("", 200); @@ -81,16 +83,16 @@ const Chatters = () => {

- Chatters: {chattersData?.streamerData?.user?.username || ""} + {t('chatters.title')}: {chattersData?.streamerData?.user?.username || ""}

{debouncedValue ? ( <> - Showing: {filteredChatters.length} of {chattersData?.chatters?.length || 0} + {t('chatters.showing')}: {filteredChatters.length} {t('chatters.of')} {chattersData?.chatters?.length || 0} ) : ( <> - Total: {chattersData?.chatters?.length || 0} + {t('chatters.total')}: {chattersData?.chatters?.length || 0} )}

@@ -102,14 +104,14 @@ const Chatters = () => {

- setDebouncedValue(e.target.value.trim())} /> + setDebouncedValue(e.target.value.trim())} />
{chattersData?.chatters?.length ? (
{!filteredChatters?.length && debouncedValue ? (
- No results found + {t('chatters.noResults')}
) : ( {
) : (
-

No chatters tracked yet

- As users type their username will appear here. +

{t('chatters.noTrackingYet')}

+ {t('chatters.trackingDescription')}
)} diff --git a/src/renderer/src/components/Dialogs/Search.jsx b/src/renderer/src/components/Dialogs/Search.jsx index 3c34a4f..5b03d13 100644 --- a/src/renderer/src/components/Dialogs/Search.jsx +++ b/src/renderer/src/components/Dialogs/Search.jsx @@ -1,11 +1,13 @@ import "../../assets/styles/components/Chat/Message.scss"; import { useCallback, useEffect, useState, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Virtuoso } from "react-virtuoso"; import { useDebounceValue } from "../../utils/hooks"; import X from "../../assets/icons/x-bold.svg"; import RegularMessage from "../Messages/RegularMessage"; const Search = () => { + const { t } = useTranslation(); const [searchData, setSearchData] = useState(null); const [messages, setMessages] = useState([]); const [debouncedValue, setDebouncedValue] = useDebounceValue("", 200); @@ -139,20 +141,20 @@ const Search = () => { {debouncedValue ? (

- Searching History in {searchData?.chatroomName} + {t('search.searchingHistory')} {searchData?.chatroomName}

- Messages: {filteredMessages.length} of{" "} + {t('search.messages')}: {filteredMessages.length} {t('chatters.of')}{" "} {messages?.filter((m) => m.type === "message")?.length || 0}

) : (

- Searching History in {searchData?.chatroomName} + {t('search.searchingHistory')} {searchData?.chatroomName}

- Messages: {messages?.filter((m) => m.type === "message")?.length || 0} + {t('search.messages')}: {messages?.filter((m) => m.type === "message")?.length || 0}

)} @@ -164,7 +166,7 @@ const Search = () => {
setDebouncedValue(e.target.value.trim())} ref={inputRef} /> @@ -173,7 +175,7 @@ const Search = () => {
{!filteredMessages?.length && debouncedValue ? (
- No messages found + {t('search.noResults')}
) : ( { + const { t } = useTranslation(); + return (
-

About KickTalk

-

A chat client for Kick.com.

+

{t('settings.about.title')}

+

{t('settings.about.description')}

-
Meet the Creators
+
{t('settings.about.meetCreators')}
@@ -22,23 +25,23 @@ const AboutSection = ({ appInfo }) => { dark Profile Pic
-

Kick Username:

+

{t('settings.about.kickUsername')}:

DRKNESS_x
-

Role:

-
Developer & Designer
+

{t('settings.about.role')}:

+
{t('settings.about.developerDesigner')}
@@ -49,23 +52,23 @@ const AboutSection = ({ appInfo }) => { ftk789 Profile Pic
-

Kick Username:

+

{t('settings.about.kickUsername')}:

ftk789
-

Role:

-
Developer
+

{t('settings.about.role')}:

+
{t('settings.about.developer')}
@@ -76,14 +79,12 @@ const AboutSection = ({ appInfo }) => {
-
About KickTalk
+
{t('settings.about.aboutKickTalk')}

- 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. + {t('settings.about.appDescription')}

@@ -92,7 +93,7 @@ const AboutSection = ({ appInfo }) => {
-
Current Version:
+
{t('settings.about.currentVersion')}:

{appInfo?.appVersion}

{/* */} diff --git a/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx b/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx index c8c171b..3b7cac6 100644 --- a/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx +++ b/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Switch } from "../../../Shared/Switch"; import { Slider } from "../../../Shared/Slider"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../Shared/Tooltip"; @@ -9,25 +10,38 @@ import ColorPicker from "../../../Shared/ColorPicker"; import folderOpenIcon from "../../../../assets/icons/folder-open-fill.svg?asset"; import playIcon from "../../../../assets/icons/play-fill.svg?asset"; import NotificationFilePicker from "../../../Shared/NotificationFilePicker"; +import LanguageSelector from "../../../Shared/LanguageSelector"; import clsx from "clsx"; const GeneralSection = ({ settingsData, onChange }) => { + const { t } = useTranslation(); return (
-

General

-

Select what general app settings you want to change.

+

{t('settings.general.title')}

+

{t('settings.general.description')}

+ {/* Language Selection */} +
+
+
{t('settings.language')}
+

{t('settings.languageDescription')}

+
+
+ +
+
+
- Always on Top + {t('settings.general.alwaysOnTop')}
+
+
+
+ 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')} - Quick Mod Tools -

Enable quick moderation tools in chat messages

+ {t('settings.moderation.quickModTools')} +

{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')} KickTalk Logo
-
General
+
{t('settings.menu.general')}
-
Chat
+
{t('settings.menu.chat')}
{/*
-); + ); +}; 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')}
- Check + {t('userDialog.check')}
@@ -259,27 +261,27 @@ const User = () => { -

Unban User

+

{t('userDialog.unban')}

{/*
@@ -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 }) => {
7TV Logo
- {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}
{e.name} - Made by: {e.owner?.display_name} + {t('messages.emoteUpdate.madeBy', { creator: e.owner?.display_name })}
@@ -31,8 +35,10 @@ const EmoteUpdateMessage = ({ message }) => {
7TV Logo
- {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}
{e.name} - Made by: {e.owner?.display_name} + {t('messages.emoteUpdate.madeBy', { creator: e.owner?.display_name })}
@@ -53,8 +59,10 @@ const EmoteUpdateMessage = ({ message }) => {
7TV Logo
- {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 - Scroll To Bottom + {t('messages.scrollToBottom')} + {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 ? ( <> {" "} - {modAction === "banned" ? "permanently banned " : "timed out "} + {modAction === "banned" + ? t('messages.modAction.permanentlyBanned') + : t('messages.modAction.timedOut') + }{" "} {" "} - {modAction === "ban_temporary" && ` for ${convertMinutesToHumanReadable(duration)}`} + {modAction === "ban_temporary" && t('messages.modAction.forDuration', { duration: convertMinutesToHumanReadable(duration) })} ) : ( <> {" "} - {modAction === "unbanned" ? "unbanned" : "removed timeout on"}{" "} + {modAction === "unbanned" + ? t('messages.modAction.unbanned') + : t('messages.modAction.removedTimeoutOn') + }{" "} )} 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 ( <> -
+
{(provided) => ( @@ -259,35 +270,35 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => {
-
-

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')}

@@ -324,8 +335,8 @@ const Navbar = ({ currentChatroomId, kickId, onSelectChatroom }) => { } }} disabled={isConnecting}> - Add - Add chatroom + {t('common.add')} + {t('navbar.addChatroom')}
)} 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 -
- -
- ); + 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 ( +
+ + + {isOpen && ( +
+ {languages.map((language) => ( + + ))} +
+ )} +
+ ); +}; + +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 ? ( ) : (
)} - - {settingsModalOpen && ( - - )}
@@ -92,13 +80,13 @@ const TitleBar = () => {
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 })); }