diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 25457ca9..6d177fcb 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,9 +1,65 @@ -import { useSyncExternalStore } from 'react' +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { getTelegramWebApp } from './useTelegram' type ColorScheme = 'light' | 'dark' +export type AppearancePreference = 'system' | 'dark' | 'light' + +const APPEARANCE_KEY = 'hapi-appearance' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) return + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) return + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseAppearance(raw: string | null): AppearancePreference { + if (raw === 'dark' || raw === 'light') return raw + return 'system' +} + +function getStoredAppearance(): AppearancePreference { + return parseAppearance(safeGetItem(APPEARANCE_KEY)) +} + +export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePreference; labelKey: string }> { + return [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ] +} + function getColorScheme(): ColorScheme { + const pref = getStoredAppearance() + if (pref === 'dark' || pref === 'light') return pref + + // 'system': use Telegram → system preference → light const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' @@ -68,6 +124,36 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { } } +export function useAppearance(): { appearance: AppearancePreference; setAppearance: (pref: AppearancePreference) => void } { + const [appearance, setAppearanceState] = useState(getStoredAppearance) + + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== APPEARANCE_KEY) return + setAppearanceState(parseAppearance(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setAppearance = useCallback((pref: AppearancePreference) => { + setAppearanceState(pref) + + if (pref === 'system') { + safeRemoveItem(APPEARANCE_KEY) + } else { + safeSetItem(APPEARANCE_KEY, pref) + } + + updateScheme() + }, []) + + return { appearance, setAppearance } +} + // Call this once at app startup to ensure theme is applied and listeners attached export function initializeTheme(): void { currentScheme = getColorScheme() @@ -85,5 +171,12 @@ export function initializeTheme(): void { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery.addEventListener('change', updateScheme) } + + // Cross-tab appearance sync: update theme when another tab changes localStorage + if (typeof window !== 'undefined') { + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key === APPEARANCE_KEY) updateScheme() + }) + } } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 34253918..9c67e8d2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -249,6 +249,10 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.appearance': 'Appearance', + 'settings.display.appearance.system': 'Follow System', + 'settings.display.appearance.dark': 'Dark', + 'settings.display.appearance.light': 'Light', 'settings.display.fontSize': 'Font Size', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 9af5990a..fa218ed9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -251,6 +251,10 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.appearance': '外观', + 'settings.display.appearance.system': '跟随系统', + 'settings.display.appearance.dark': '深色', + 'settings.display.appearance.light': '浅色', 'settings.display.fontSize': '字体大小', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index fe87ce41..22486831 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -22,6 +22,16 @@ vi.mock('@/hooks/useFontScale', () => ({ ], })) +// Mock useTheme hook +vi.mock('@/hooks/useTheme', () => ({ + useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), + getAppearanceOptions: () => [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ], +})) + // Mock languages vi.mock('@/lib/languages', () => ({ getElevenLabsSupportedLanguages: () => [ @@ -98,4 +108,17 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.about.appVersion') expect(calledKeys).toContain('settings.about.protocolVersion') }) + + it('renders the Appearance setting', () => { + renderWithProviders() + expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1) + }) + + it('uses correct i18n keys for Appearance setting', () => { + const spyT = renderWithSpyT() + const calledKeys = spyT.mock.calls.map((call) => call[0]) + expect(calledKeys).toContain('settings.display.appearance') + expect(calledKeys).toContain('settings.display.appearance.system') + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 0396064e..3c7be672 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -3,6 +3,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation' import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' +import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -73,12 +74,15 @@ export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const goBack = useAppGoBack() const [isOpen, setIsOpen] = useState(false) + const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) const [isFontOpen, setIsFontOpen] = useState(false) const [isVoiceOpen, setIsVoiceOpen] = useState(false) const containerRef = useRef(null) + const appearanceContainerRef = useRef(null) const fontContainerRef = useRef(null) const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() + const { appearance, setAppearance } = useAppearance() // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { @@ -86,7 +90,9 @@ export default function SettingsPage() { }) const fontScaleOptions = getFontScaleOptions() + const appearanceOptions = getAppearanceOptions() const currentLocale = locales.find((loc) => loc.value === locale) + const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) @@ -95,6 +101,11 @@ export default function SettingsPage() { setIsOpen(false) } + const handleAppearanceChange = (pref: AppearancePreference) => { + setAppearance(pref) + setIsAppearanceOpen(false) + } + const handleFontScaleChange = (newScale: FontScale) => { setFontScale(newScale) setIsFontOpen(false) @@ -112,12 +123,15 @@ export default function SettingsPage() { // Close dropdown when clicking outside useEffect(() => { - if (!isOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return const handleClickOutside = (event: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false) } + if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) { + setIsAppearanceOpen(false) + } if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) { setIsFontOpen(false) } @@ -128,15 +142,16 @@ export default function SettingsPage() { document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen]) // Close on escape key useEffect(() => { - if (!isOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsOpen(false) + setIsAppearanceOpen(false) setIsFontOpen(false) setIsVoiceOpen(false) } @@ -144,7 +159,7 @@ export default function SettingsPage() { document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen]) return (
@@ -223,6 +238,54 @@ export default function SettingsPage() {
{t('settings.display.title')}
+
+ + + {isAppearanceOpen && ( +
+ {appearanceOptions.map((opt) => { + const isSelected = appearance === opt.value + return ( + + ) + })} +
+ )} +