Skip to content

Commit efc9004

Browse files
mrudybtwmrudy
andauthored
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>
1 parent 0ef5f97 commit efc9004

9 files changed

Lines changed: 1537 additions & 9 deletions

File tree

‎web/src/chat/presentation.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri
4545

4646
export function formatOutlineTimestamp(
4747
date: Date,
48-
locale: 'en' | 'zh-CN',
48+
locale: 'en' | 'zh-CN' | 'ru',
4949
now: Date = new Date()
5050
): string {
5151
const sameDay = date.getFullYear() === now.getFullYear()
@@ -67,6 +67,11 @@ export function formatOutlineTimestamp(
6767
return `${dateLabel} ${time}`
6868
}
6969

70+
if (locale === 'ru') {
71+
const dateLabel = sameYear ? `${day}.${month}` : `${day}.${month}.${year}`
72+
return `${dateLabel} ${time}`
73+
}
74+
7075
const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}`
7176
return `${dateLabel} ${time}`
7277
}

‎web/src/components/LanguageSwitcher.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation'
44
const locales: { value: Locale; label: string }[] = [
55
{ value: 'en', label: 'English' },
66
{ value: 'zh-CN', label: '简体中文' },
7+
{ value: 'ru', label: 'Русский' },
78
]
89

910
function LanguageIcon() {

‎web/src/components/settings/SettingsNav.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
3030
const { composerEnterBehavior } = useComposerEnterBehavior()
3131

3232
const summaries: Record<string, string> = {
33-
general: locale === 'zh-CN' ? '简体中文' : 'English',
33+
general: locale === 'zh-CN' ? '简体中文' : locale === 'ru' ? 'Русский' : 'English',
3434
display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`,
3535
chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`),
3636
voice: t('settings.hub.voice.summary'),

‎web/src/hooks/useTelegram.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type TelegramWebAppUser = {
4040
username?: string
4141
first_name: string
4242
last_name?: string
43+
language_code?: string
4344
}
4445

4546
export type TelegramWebAppInitDataUnsafe = {

‎web/src/lib/i18n-context.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import {
3+
applyLateTelegramLocale,
4+
detectInitialLocale,
5+
normalizeLocaleTag,
6+
resolveLateTelegramLocale
7+
} from './i18n-context'
8+
9+
function setTelegramLanguage(code: string | undefined) {
10+
if (code === undefined) {
11+
delete (window as unknown as { Telegram?: unknown }).Telegram
12+
return
13+
}
14+
;(window as unknown as { Telegram?: unknown }).Telegram = {
15+
WebApp: { initDataUnsafe: { user: { language_code: code } } }
16+
}
17+
}
18+
19+
beforeEach(() => {
20+
localStorage.clear()
21+
setTelegramLanguage(undefined)
22+
})
23+
24+
afterEach(() => {
25+
localStorage.clear()
26+
setTelegramLanguage(undefined)
27+
vi.restoreAllMocks()
28+
})
29+
30+
describe('normalizeLocaleTag', () => {
31+
it('maps Russian tags', () => {
32+
expect(normalizeLocaleTag('ru')).toBe('ru')
33+
expect(normalizeLocaleTag('ru-RU')).toBe('ru')
34+
expect(normalizeLocaleTag('RU')).toBe('ru')
35+
expect(normalizeLocaleTag('ru_RU')).toBe('ru')
36+
})
37+
38+
it('maps Chinese tags to the simplified locale we ship', () => {
39+
expect(normalizeLocaleTag('zh')).toBe('zh-CN')
40+
expect(normalizeLocaleTag('zh-Hans')).toBe('zh-CN')
41+
expect(normalizeLocaleTag('zh-CN')).toBe('zh-CN')
42+
expect(normalizeLocaleTag('zh_CN')).toBe('zh-CN')
43+
})
44+
45+
it('maps English tags', () => {
46+
expect(normalizeLocaleTag('en')).toBe('en')
47+
expect(normalizeLocaleTag('en-US')).toBe('en')
48+
})
49+
50+
it('rejects unsupported or empty tags', () => {
51+
expect(normalizeLocaleTag('de')).toBeNull()
52+
expect(normalizeLocaleTag('')).toBeNull()
53+
expect(normalizeLocaleTag(null)).toBeNull()
54+
expect(normalizeLocaleTag(undefined)).toBeNull()
55+
})
56+
})
57+
58+
describe('detectInitialLocale', () => {
59+
it('prefers an explicit stored choice over Telegram', () => {
60+
localStorage.setItem('hapi-lang', 'en')
61+
setTelegramLanguage('ru')
62+
63+
expect(detectInitialLocale()).toBe('en')
64+
})
65+
66+
it('uses the Telegram Mini App language when nothing is stored', () => {
67+
setTelegramLanguage('ru')
68+
69+
expect(detectInitialLocale()).toBe('ru')
70+
})
71+
72+
it('normalizes the Telegram language tag', () => {
73+
setTelegramLanguage('zh-hans')
74+
75+
expect(detectInitialLocale()).toBe('zh-CN')
76+
})
77+
78+
it('falls back to the browser language', () => {
79+
vi.spyOn(navigator, 'language', 'get').mockReturnValue('ru-RU')
80+
81+
expect(detectInitialLocale()).toBe('ru')
82+
})
83+
84+
it('falls back to English for unsupported languages', () => {
85+
setTelegramLanguage('de')
86+
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE')
87+
88+
expect(detectInitialLocale()).toBe('en')
89+
})
90+
})
91+
92+
describe('resolveLateTelegramLocale', () => {
93+
it('picks up the language when the SDK appears after the timeout', async () => {
94+
vi.useFakeTimers()
95+
setTelegramLanguage(undefined)
96+
97+
const pending = resolveLateTelegramLocale(500, 5)
98+
await vi.advanceTimersByTimeAsync(1500)
99+
setTelegramLanguage('ru')
100+
await vi.advanceTimersByTimeAsync(500)
101+
102+
await expect(pending).resolves.toBe('ru')
103+
vi.useRealTimers()
104+
})
105+
106+
it('resolves null when the SDK never loads', async () => {
107+
vi.useFakeTimers()
108+
setTelegramLanguage(undefined)
109+
110+
const pending = resolveLateTelegramLocale(500, 2)
111+
await vi.advanceTimersByTimeAsync(1500)
112+
113+
await expect(pending).resolves.toBeNull()
114+
vi.useRealTimers()
115+
})
116+
})
117+
118+
describe('applyLateTelegramLocale', () => {
119+
it('applies the detected locale when nothing is stored', () => {
120+
const apply = vi.fn()
121+
122+
expect(applyLateTelegramLocale('ru', apply)).toBe(true)
123+
expect(apply).toHaveBeenCalledWith('ru')
124+
})
125+
126+
it('does not override a manual choice made while the SDK loaded', () => {
127+
localStorage.setItem('hapi-lang', 'en')
128+
const apply = vi.fn()
129+
130+
expect(applyLateTelegramLocale('ru', apply)).toBe(false)
131+
expect(apply).not.toHaveBeenCalled()
132+
})
133+
134+
it('ignores a null detection', () => {
135+
const apply = vi.fn()
136+
137+
expect(applyLateTelegramLocale(null, apply)).toBe(false)
138+
expect(apply).not.toHaveBeenCalled()
139+
})
140+
})

‎web/src/lib/i18n-context.tsx‎

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'
2-
import { en, zhCN } from './locales'
2+
import { getTelegramWebApp, isTelegramEnvironment } from '@/hooks/useTelegram'
3+
import { en, zhCN, ru } from './locales'
34

4-
export type Locale = 'en' | 'zh-CN'
5+
export type Locale = 'en' | 'zh-CN' | 'ru'
56

67
export type Translations = Record<string, string>
78

@@ -13,7 +14,54 @@ export type I18nContextValue = {
1314

1415
export const I18nContext = createContext<I18nContextValue | null>(null)
1516

16-
const locales: Record<Locale, Translations> = { en, 'zh-CN': zhCN }
17+
const locales: Record<Locale, Translations> = { en, 'zh-CN': zhCN, ru }
18+
19+
/**
20+
* Map a BCP-47-ish language tag (`ru`, `ru-RU`, `zh-Hans`, `en-US`) onto a
21+
* supported locale. Returns null for anything we do not ship.
22+
*/
23+
export function normalizeLocaleTag(tag: string | null | undefined): Locale | null {
24+
if (!tag) return null
25+
const lower = tag.trim().toLowerCase().replace(/_/g, '-')
26+
if (!lower) return null
27+
if (lower === 'ru' || lower.startsWith('ru-')) return 'ru'
28+
if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN'
29+
if (lower === 'en' || lower.startsWith('en-')) return 'en'
30+
return null
31+
}
32+
33+
function readStoredLocale(): Locale | null {
34+
try {
35+
return normalizeLocaleTag(localStorage.getItem('hapi-lang'))
36+
} catch {
37+
return null
38+
}
39+
}
40+
41+
/**
42+
* Pick the locale for a fresh session: an explicit user choice wins, then the
43+
* Telegram Mini App user's language, then the browser language, then English.
44+
* Telegram runs the app in its own web view, so `localStorage` from a normal
45+
* browser visit is not available and the language must be detected here.
46+
*/
47+
export function detectInitialLocale(): Locale {
48+
const stored = readStoredLocale()
49+
if (stored) return stored
50+
51+
if (typeof window !== 'undefined') {
52+
const telegramLocale = normalizeLocaleTag(
53+
getTelegramWebApp()?.initDataUnsafe?.user?.language_code
54+
)
55+
if (telegramLocale) return telegramLocale
56+
}
57+
58+
if (typeof navigator !== 'undefined') {
59+
const browserLocale = normalizeLocaleTag(navigator.language)
60+
if (browserLocale) return browserLocale
61+
}
62+
63+
return 'en'
64+
}
1765

1866
function interpolate(str: string, params?: Record<string, string | number>): string {
1967
if (!params) return str
@@ -23,11 +71,40 @@ function interpolate(str: string, params?: Record<string, string | number>): str
2371
})
2472
}
2573

74+
/**
75+
* Telegram loads its SDK asynchronously and `loadTelegramSdk` gives up after a
76+
* timeout, so the user's `language_code` can appear after the first render.
77+
* Poll briefly for the WebApp and resolve the language once it shows up.
78+
*/
79+
export async function resolveLateTelegramLocale(
80+
intervalMs = 500,
81+
maxAttempts = 20
82+
): Promise<Locale | null> {
83+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
84+
const telegramLocale = normalizeLocaleTag(
85+
getTelegramWebApp()?.initDataUnsafe?.user?.language_code
86+
)
87+
if (telegramLocale) return telegramLocale
88+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
89+
}
90+
return null
91+
}
92+
93+
/**
94+
* Apply a late-detected Telegram locale unless the user has stored an explicit
95+
* choice in the meantime. Returns true when the locale was applied.
96+
*/
97+
export function applyLateTelegramLocale(
98+
detected: Locale | null,
99+
apply: (locale: Locale) => void
100+
): boolean {
101+
if (!detected || readStoredLocale()) return false
102+
apply(detected)
103+
return true
104+
}
105+
26106
export function I18nProvider({ children }: { children: ReactNode }) {
27-
const [locale, setLocaleState] = useState<Locale>(() => {
28-
const saved = localStorage.getItem('hapi-lang')
29-
return (saved === 'en' || saved === 'zh-CN') ? saved : 'en'
30-
})
107+
const [locale, setLocaleState] = useState<Locale>(() => detectInitialLocale())
31108

32109
const setLocale = useCallback((newLocale: Locale) => {
33110
setLocaleState(newLocale)
@@ -46,6 +123,24 @@ export function I18nProvider({ children }: { children: ReactNode }) {
46123
document.documentElement.lang = locale
47124
}, [locale])
48125

126+
// The Telegram SDK can finish loading after `detectInitialLocale` ran, so
127+
// pick up the Telegram language then — unless the user chose one explicitly.
128+
useEffect(() => {
129+
if (typeof window === 'undefined') return
130+
if (readStoredLocale()) return
131+
if (!isTelegramEnvironment()) return
132+
if (normalizeLocaleTag(getTelegramWebApp()?.initDataUnsafe?.user?.language_code)) return
133+
134+
let cancelled = false
135+
void resolveLateTelegramLocale().then((detected) => {
136+
if (cancelled) return
137+
applyLateTelegramLocale(detected, setLocaleState)
138+
})
139+
return () => {
140+
cancelled = true
141+
}
142+
}, [])
143+
49144
return (
50145
<I18nContext.Provider value={{ t, locale, setLocale }}>
51146
{children}

‎web/src/lib/locales/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as en } from './en'
22
export { default as zhCN } from './zh-CN'
3+
export { default as ru } from './ru'

0 commit comments

Comments
 (0)