diff --git a/src/hooks/useTelegramSDK.ts b/src/hooks/useTelegramSDK.ts index cd5bed1..b2bc9f4 100644 --- a/src/hooks/useTelegramSDK.ts +++ b/src/hooks/useTelegramSDK.ts @@ -14,6 +14,7 @@ import { expandViewport, retrieveLaunchParams, retrieveRawInitData, + themeParamsState, } from '@telegram-apps/sdk-react'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; @@ -66,6 +67,46 @@ export function getTelegramInitData(): string | null { } } +function isDarkHexColor(hex: string): boolean { + const m = hex.replace('#', ''); + const full = m.length === 3 ? m.replace(/(.)/g, '$1$1') : m; + if (full.length !== 6) return false; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + // Perceived sRGB luminance; below 0.5 reads as a dark surface. + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5; +} + +/** + * The Telegram client's effective color scheme ('light' | 'dark'), derived from + * the theme background color. Returns null outside Telegram or before theme params load. + */ +export function getTelegramColorScheme(): 'light' | 'dark' | null { + if (!detectTelegram()) return null; + try { + const bg = themeParamsState()?.bgColor; + return bg ? (isDarkHexColor(bg) ? 'dark' : 'light') : null; + } catch { + return null; + } +} + +/** + * The user's Telegram client language as a 2-letter code (e.g. 'en'), or null + * outside Telegram / when unavailable. + */ +export function getTelegramLanguageCode(): string | null { + if (!detectTelegram()) return null; + try { + const user = retrieveLaunchParams().tgWebAppData?.user as { languageCode?: string } | undefined; + const code = user?.languageCode; + return code ? code.split('-')[0].toLowerCase() : null; + } catch { + return null; + } +} + export type TelegramPlatform = | 'android' | 'ios' diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 45d20c7..44faf27 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'; import { themeColorsApi } from '../api/themeColors'; import { STORAGE_KEYS } from '../config/constants'; +import { getTelegramColorScheme } from './useTelegramSDK'; type Theme = 'dark' | 'light'; @@ -74,6 +75,13 @@ export function useTheme() { if (stored && !enabled[stored]) { return enabled.dark ? 'dark' : 'light'; } + // No stored preference: follow the Telegram client's color scheme in a Mini App. + if (!stored) { + const tgScheme = getTelegramColorScheme(); + if (tgScheme && enabled[tgScheme]) { + return tgScheme; + } + } // Check system preference if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) { return 'light'; diff --git a/src/i18n.ts b/src/i18n.ts index 25ab022..78a8a22 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,6 +1,7 @@ import i18n, { type ResourceLanguage } from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; +import { getTelegramLanguageCode } from './hooks/useTelegramSDK'; const localeLoaders: Record Promise<{ default: ResourceLanguage }>> = { ru: () => import('./locales/ru.json'), @@ -11,6 +12,7 @@ const localeLoaders: Record Promise<{ default: ResourceLanguage }> const SUPPORTED_LANGS = Object.keys(localeLoaders); const FALLBACK_LNG = 'ru'; +const LANGUAGE_STORAGE_KEY = 'cabinet_language'; const loadedLanguages = new Set(); @@ -61,4 +63,21 @@ i18n.on('languageChanged', (lng: string) => { loadLanguage(code); }); +/** + * On first run inside Telegram (no explicit stored choice), adopt the user's + * Telegram client language. Must be called after the Telegram SDK is initialised + * (e.g. from main.tsx), since launch params are unavailable before init(). + */ +export function applyTelegramLanguage(): void { + try { + if (localStorage.getItem(LANGUAGE_STORAGE_KEY)) return; // explicit choice wins + } catch { + return; + } + const code = getTelegramLanguageCode(); + if (code && SUPPORTED_LANGS.includes(code) && i18n.language?.split('-')[0] !== code) { + i18n.changeLanguage(code); + } +} + export default i18n; diff --git a/src/main.tsx b/src/main.tsx index 2b9b765..2885897 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -24,7 +24,7 @@ import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; import { initLogoPreload } from './api/branding'; import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK'; -import './i18n'; +import { applyTelegramLanguage } from './i18n'; import './styles/globals.css'; // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). @@ -55,6 +55,9 @@ if (isTelegramEnv && !alreadyInitialized) { clearStaleSessionIfNeeded(retrieveRawInitData() || null); + // Adopt the user's Telegram client language on first run (no explicit choice yet). + applyTelegramLanguage(); + // Each mount in its own try/catch so one failure doesn't block others. // mountMiniApp() internally mounts themeParams in SDK v3, // so we don't call mountThemeParams() separately to avoid ConcurrentCallError. diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index edfe8c0..4463a50 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { AdminBackButton } from '../components/admin/AdminBackButton'; +import { useFocusTrap } from '../hooks/useFocusTrap'; import { banSystemApi, type BanSystemStatus, @@ -198,6 +199,9 @@ export default function AdminBanSystem() { const [stats, setStats] = useState(null); const [users, setUsers] = useState(null); const [selectedUser, setSelectedUser] = useState(null); + const userDetailRef = useFocusTrap(selectedUser !== null, { + onEscape: () => setSelectedUser(null), + }); const [punishments, setPunishments] = useState(null); const [nodes, setNodes] = useState(null); const [agents, setAgents] = useState(null); @@ -1498,15 +1502,21 @@ export default function AdminBanSystem() { onClick={() => setSelectedUser(null)} >
e.stopPropagation()} >
-

+

{t('banSystem.userDetail.title')}