mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
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:
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -40,6 +40,7 @@ export type TelegramWebAppUser = {
|
||||
username?: string
|
||||
first_name: string
|
||||
last_name?: string
|
||||
language_code?: string
|
||||
}
|
||||
|
||||
export type TelegramWebAppInitDataUnsafe = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user