feat(web): add Russian locale and detect initial language (#1958)

* feat(web): add Russian locale

- Add web/src/lib/locales/ru.ts
- Register 'ru' in the locale registry, i18n context, language switcher and settings
- Format outline timestamps with the day.month convention for 'ru'

* feat(web): detect initial locale from Telegram and browser

The Telegram Mini App runs in its own web view, so a language chosen in
the browser does not carry over and the app always opened in English.
Pick the first session locale from an explicit stored choice, then the
Telegram user's language_code, then navigator.language, then English.

* feat(web): translate the newly localized strings

Russian values for the keys added by the hardcoded-string fix (see the
separate web i18n PR). Keeping them on this branch so the locale PR is
complete when both land.

* feat(web): add Russian for the newly localized strings

* fix(web): recheck the Telegram language after a slow SDK load

* fix(web): keep a manual language choice over late Telegram detection

---------

Co-authored-by: mrudy <mrudy@local>
This commit is contained in:
Matvey Rudoy
2026-10-05 17:13:53 +08:00
committed by GitHub
co-authored by mrudy
parent 0ef5f976c0
commit efc9004fda
9 changed files with 1537 additions and 9 deletions
+6 -1
View File
@@ -45,7 +45,7 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri
export function formatOutlineTimestamp(
date: Date,
locale: 'en' | 'zh-CN',
locale: 'en' | 'zh-CN' | 'ru',
now: Date = new Date()
): string {
const sameDay = date.getFullYear() === now.getFullYear()
@@ -67,6 +67,11 @@ export function formatOutlineTimestamp(
return `${dateLabel} ${time}`
}
if (locale === 'ru') {
const dateLabel = sameYear ? `${day}.${month}` : `${day}.${month}.${year}`
return `${dateLabel} ${time}`
}
const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}`
return `${dateLabel} ${time}`
}
+1
View File
@@ -4,6 +4,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation'
const locales: { value: Locale; label: string }[] = [
{ value: 'en', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'ru', label: 'Русский' },
]
function LanguageIcon() {
+1 -1
View File
@@ -30,7 +30,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
const { composerEnterBehavior } = useComposerEnterBehavior()
const summaries: Record<string, string> = {
general: locale === 'zh-CN' ? '简体中文' : 'English',
general: locale === 'zh-CN' ? '简体中文' : locale === 'ru' ? 'Русский' : 'English',
display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`,
chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`),
voice: t('settings.hub.voice.summary'),
+1
View File
@@ -40,6 +40,7 @@ export type TelegramWebAppUser = {
username?: string
first_name: string
last_name?: string
language_code?: string
}
export type TelegramWebAppInitDataUnsafe = {
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyLateTelegramLocale,
detectInitialLocale,
normalizeLocaleTag,
resolveLateTelegramLocale
} from './i18n-context'
function setTelegramLanguage(code: string | undefined) {
if (code === undefined) {
delete (window as unknown as { Telegram?: unknown }).Telegram
return
}
;(window as unknown as { Telegram?: unknown }).Telegram = {
WebApp: { initDataUnsafe: { user: { language_code: code } } }
}
}
beforeEach(() => {
localStorage.clear()
setTelegramLanguage(undefined)
})
afterEach(() => {
localStorage.clear()
setTelegramLanguage(undefined)
vi.restoreAllMocks()
})
describe('normalizeLocaleTag', () => {
it('maps Russian tags', () => {
expect(normalizeLocaleTag('ru')).toBe('ru')
expect(normalizeLocaleTag('ru-RU')).toBe('ru')
expect(normalizeLocaleTag('RU')).toBe('ru')
expect(normalizeLocaleTag('ru_RU')).toBe('ru')
})
it('maps Chinese tags to the simplified locale we ship', () => {
expect(normalizeLocaleTag('zh')).toBe('zh-CN')
expect(normalizeLocaleTag('zh-Hans')).toBe('zh-CN')
expect(normalizeLocaleTag('zh-CN')).toBe('zh-CN')
expect(normalizeLocaleTag('zh_CN')).toBe('zh-CN')
})
it('maps English tags', () => {
expect(normalizeLocaleTag('en')).toBe('en')
expect(normalizeLocaleTag('en-US')).toBe('en')
})
it('rejects unsupported or empty tags', () => {
expect(normalizeLocaleTag('de')).toBeNull()
expect(normalizeLocaleTag('')).toBeNull()
expect(normalizeLocaleTag(null)).toBeNull()
expect(normalizeLocaleTag(undefined)).toBeNull()
})
})
describe('detectInitialLocale', () => {
it('prefers an explicit stored choice over Telegram', () => {
localStorage.setItem('hapi-lang', 'en')
setTelegramLanguage('ru')
expect(detectInitialLocale()).toBe('en')
})
it('uses the Telegram Mini App language when nothing is stored', () => {
setTelegramLanguage('ru')
expect(detectInitialLocale()).toBe('ru')
})
it('normalizes the Telegram language tag', () => {
setTelegramLanguage('zh-hans')
expect(detectInitialLocale()).toBe('zh-CN')
})
it('falls back to the browser language', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('ru-RU')
expect(detectInitialLocale()).toBe('ru')
})
it('falls back to English for unsupported languages', () => {
setTelegramLanguage('de')
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE')
expect(detectInitialLocale()).toBe('en')
})
})
describe('resolveLateTelegramLocale', () => {
it('picks up the language when the SDK appears after the timeout', async () => {
vi.useFakeTimers()
setTelegramLanguage(undefined)
const pending = resolveLateTelegramLocale(500, 5)
await vi.advanceTimersByTimeAsync(1500)
setTelegramLanguage('ru')
await vi.advanceTimersByTimeAsync(500)
await expect(pending).resolves.toBe('ru')
vi.useRealTimers()
})
it('resolves null when the SDK never loads', async () => {
vi.useFakeTimers()
setTelegramLanguage(undefined)
const pending = resolveLateTelegramLocale(500, 2)
await vi.advanceTimersByTimeAsync(1500)
await expect(pending).resolves.toBeNull()
vi.useRealTimers()
})
})
describe('applyLateTelegramLocale', () => {
it('applies the detected locale when nothing is stored', () => {
const apply = vi.fn()
expect(applyLateTelegramLocale('ru', apply)).toBe(true)
expect(apply).toHaveBeenCalledWith('ru')
})
it('does not override a manual choice made while the SDK loaded', () => {
localStorage.setItem('hapi-lang', 'en')
const apply = vi.fn()
expect(applyLateTelegramLocale('ru', apply)).toBe(false)
expect(apply).not.toHaveBeenCalled()
})
it('ignores a null detection', () => {
const apply = vi.fn()
expect(applyLateTelegramLocale(null, apply)).toBe(false)
expect(apply).not.toHaveBeenCalled()
})
})
+102 -7
View File
@@ -1,7 +1,8 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { en, zhCN } from './locales'
import { getTelegramWebApp, isTelegramEnvironment } from '@/hooks/useTelegram'
import { en, zhCN, ru } from './locales'
export type Locale = 'en' | 'zh-CN'
export type Locale = 'en' | 'zh-CN' | 'ru'
export type Translations = Record<string, string>
@@ -13,7 +14,54 @@ export type I18nContextValue = {
export const I18nContext = createContext<I18nContextValue | null>(null)
const locales: Record<Locale, Translations> = { en, 'zh-CN': zhCN }
const locales: Record<Locale, Translations> = { en, 'zh-CN': zhCN, ru }
/**
* Map a BCP-47-ish language tag (`ru`, `ru-RU`, `zh-Hans`, `en-US`) onto a
* supported locale. Returns null for anything we do not ship.
*/
export function normalizeLocaleTag(tag: string | null | undefined): Locale | null {
if (!tag) return null
const lower = tag.trim().toLowerCase().replace(/_/g, '-')
if (!lower) return null
if (lower === 'ru' || lower.startsWith('ru-')) return 'ru'
if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN'
if (lower === 'en' || lower.startsWith('en-')) return 'en'
return null
}
function readStoredLocale(): Locale | null {
try {
return normalizeLocaleTag(localStorage.getItem('hapi-lang'))
} catch {
return null
}
}
/**
* Pick the locale for a fresh session: an explicit user choice wins, then the
* Telegram Mini App user's language, then the browser language, then English.
* Telegram runs the app in its own web view, so `localStorage` from a normal
* browser visit is not available and the language must be detected here.
*/
export function detectInitialLocale(): Locale {
const stored = readStoredLocale()
if (stored) return stored
if (typeof window !== 'undefined') {
const telegramLocale = normalizeLocaleTag(
getTelegramWebApp()?.initDataUnsafe?.user?.language_code
)
if (telegramLocale) return telegramLocale
}
if (typeof navigator !== 'undefined') {
const browserLocale = normalizeLocaleTag(navigator.language)
if (browserLocale) return browserLocale
}
return 'en'
}
function interpolate(str: string, params?: Record<string, string | number>): string {
if (!params) return str
@@ -23,11 +71,40 @@ function interpolate(str: string, params?: Record<string, string | number>): str
})
}
/**
* Telegram loads its SDK asynchronously and `loadTelegramSdk` gives up after a
* timeout, so the user's `language_code` can appear after the first render.
* Poll briefly for the WebApp and resolve the language once it shows up.
*/
export async function resolveLateTelegramLocale(
intervalMs = 500,
maxAttempts = 20
): Promise<Locale | null> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const telegramLocale = normalizeLocaleTag(
getTelegramWebApp()?.initDataUnsafe?.user?.language_code
)
if (telegramLocale) return telegramLocale
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
return null
}
/**
* Apply a late-detected Telegram locale unless the user has stored an explicit
* choice in the meantime. Returns true when the locale was applied.
*/
export function applyLateTelegramLocale(
detected: Locale | null,
apply: (locale: Locale) => void
): boolean {
if (!detected || readStoredLocale()) return false
apply(detected)
return true
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => {
const saved = localStorage.getItem('hapi-lang')
return (saved === 'en' || saved === 'zh-CN') ? saved : 'en'
})
const [locale, setLocaleState] = useState<Locale>(() => detectInitialLocale())
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale)
@@ -46,6 +123,24 @@ export function I18nProvider({ children }: { children: ReactNode }) {
document.documentElement.lang = locale
}, [locale])
// The Telegram SDK can finish loading after `detectInitialLocale` ran, so
// pick up the Telegram language then — unless the user chose one explicitly.
useEffect(() => {
if (typeof window === 'undefined') return
if (readStoredLocale()) return
if (!isTelegramEnvironment()) return
if (normalizeLocaleTag(getTelegramWebApp()?.initDataUnsafe?.user?.language_code)) return
let cancelled = false
void resolveLateTelegramLocale().then((detected) => {
if (cancelled) return
applyLateTelegramLocale(detected, setLocaleState)
})
return () => {
cancelled = true
}
}, [])
return (
<I18nContext.Provider value={{ t, locale, setLocale }}>
{children}
+1
View File
@@ -1,2 +1,3 @@
export { default as en } from './en'
export { default as zhCN } from './zh-CN'
export { default as ru } from './ru'
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@ import { queryKeys } from '@/lib/query-keys'
const locales: ReadonlyArray<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'ru', label: 'Русский' },
]
function getNamespace(token: string | null): string | null {