mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Add onboarding language selection (#31784)
* Add onboarding language selection and system locale detection - Detect and synchronize native/browser language preferences safely - Load catalogs before rendering onboarding and support recovery - Let users choose a supported language or request another language * Add onboarding language experiment cohorts and detection recovery * Stabilize onboarding language detection tests * Test persisted onboarding language cohort behavior
This commit is contained in:
@@ -75,5 +75,12 @@
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Switch language",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Why: main-i18n avoids bundling locale catalogs on the cold-start path.
|
||||
// English comes from translateMain() fallbacks; non-English catalogs load
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../shared/ui-language'
|
||||
import {
|
||||
ensureMainI18n,
|
||||
mainI18n,
|
||||
setMainPluginLanguagePacks,
|
||||
setMainUiLanguage,
|
||||
translateMain
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact'
|
||||
|
||||
describe('main-i18n lazy locale loading', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
beforeEach(async () => {
|
||||
await ensureMainI18n()
|
||||
setMainPluginLanguagePacks([])
|
||||
@@ -75,6 +77,25 @@ describe('main-i18n lazy locale loading', () => {
|
||||
expect(translateMain('menu.file', 'File')).toBe('File')
|
||||
})
|
||||
|
||||
it('keeps a newer explicit language when system fallback catalog loading finishes late', async () => {
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
expect(getSystemLanguageDetection(['fr-FR'])?.locale).toBe('fr')
|
||||
let finishLoad: () => void = () => {}
|
||||
const load = vi.spyOn(mainI18n, 'loadLanguages').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const fallback = setMainUiLanguage('system')
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledWith('fr'))
|
||||
await setMainUiLanguage('en')
|
||||
finishLoad()
|
||||
await fallback
|
||||
expect(mainI18n.language).toBe('en')
|
||||
expect(translateMain('menu.file', 'File')).toBe('File')
|
||||
})
|
||||
|
||||
it('loads a contributed catalog for native menus and dialogs', async () => {
|
||||
const id = 'plugin:orca-samples.portuguese/pt-BR' as const
|
||||
setMainPluginLanguagePacks([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { getSystemLanguageDetection } from './system-language'
|
||||
import i18next, {
|
||||
type BackendModule,
|
||||
type i18n as I18nInstance,
|
||||
@@ -14,6 +14,7 @@ import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin
|
||||
export const mainI18n: I18nInstance = i18next.createInstance()
|
||||
|
||||
let initialized = false
|
||||
let languageGeneration = 0
|
||||
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
|
||||
const registeredPluginLanguages = new Set<string>()
|
||||
|
||||
@@ -51,11 +52,7 @@ const lazyLocaleBackend: BackendModule = {
|
||||
}
|
||||
|
||||
export function getMainSystemLocale(): string {
|
||||
try {
|
||||
return app.getLocale()
|
||||
} catch {
|
||||
return DEFAULT_UI_LOCALE
|
||||
}
|
||||
return getSystemLanguageDetection()?.locale ?? DEFAULT_UI_LOCALE
|
||||
}
|
||||
|
||||
export async function ensureMainI18n(): Promise<I18nInstance> {
|
||||
@@ -83,6 +80,7 @@ export async function ensureMainI18n(): Promise<I18nInstance> {
|
||||
}
|
||||
|
||||
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
|
||||
const request = ++languageGeneration
|
||||
await ensureMainI18n()
|
||||
const selectedLocale = resolveUiLocale(
|
||||
language,
|
||||
@@ -92,9 +90,10 @@ export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
|
||||
pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ??
|
||||
(selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale)
|
||||
if (mainI18n.language !== locale) {
|
||||
// changeLanguage triggers the lazy backend load for non-English locales and
|
||||
// resolves once the catalog is in memory, so callers that await this have
|
||||
// the translations ready before they render menus/dialogs.
|
||||
await mainI18n.loadLanguages(locale)
|
||||
if (request !== languageGeneration) {
|
||||
return mainI18n.language
|
||||
}
|
||||
await mainI18n.changeLanguage(locale)
|
||||
}
|
||||
return locale
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getPreferredSystemLanguages, isReady } = vi.hoisted(() => ({
|
||||
getPreferredSystemLanguages: vi.fn(),
|
||||
isReady: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: { getPreferredSystemLanguages, isReady } }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
isReady.mockReturnValue(true)
|
||||
getPreferredSystemLanguages.mockReturnValue(['fr-FR', 'en-US'])
|
||||
})
|
||||
|
||||
describe('native language snapshot', () => {
|
||||
it('reads preferences once after app readiness and shares the ordered result', async () => {
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
isReady.mockReturnValue(false)
|
||||
expect(getSystemLanguageDetection()).toBeNull()
|
||||
expect(getPreferredSystemLanguages).not.toHaveBeenCalled()
|
||||
isReady.mockReturnValue(true)
|
||||
expect(getSystemLanguageDetection()).toMatchObject({
|
||||
detectedTag: 'fr-FR',
|
||||
locale: 'fr',
|
||||
source: 'native'
|
||||
})
|
||||
getPreferredSystemLanguages.mockReturnValue(['en-US'])
|
||||
expect(getSystemLanguageDetection()?.locale).toBe('fr')
|
||||
expect(getPreferredSystemLanguages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a C-only native result unknown so Chromium cannot override it', async () => {
|
||||
getPreferredSystemLanguages.mockReturnValue(['C.UTF-8', 'POSIX'])
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
expect(getSystemLanguageDetection()).toMatchObject({ status: 'unknown', source: 'native' })
|
||||
})
|
||||
|
||||
it.each(['empty', 'failed'])(
|
||||
'permits uncertain browser fallback for %s native lookup',
|
||||
async (kind) => {
|
||||
getPreferredSystemLanguages.mockImplementation(() => {
|
||||
if (kind === 'failed') {
|
||||
throw new Error('Unavailable')
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
expect(getSystemLanguageDetection()).toBeNull()
|
||||
const fallback = getSystemLanguageDetection(['fr-FR'])
|
||||
expect(fallback).toMatchObject({ locale: 'fr', source: 'browser-fallback' })
|
||||
expect(getSystemLanguageDetection(['ja-JP'])).toBe(fallback)
|
||||
const { getMainSystemLocale, setMainUiLanguage, mainI18n } = await import('./main-i18n')
|
||||
expect(getMainSystemLocale()).toBe('fr')
|
||||
await setMainUiLanguage('system')
|
||||
expect(mainI18n.language).toBe('fr')
|
||||
await setMainUiLanguage('en')
|
||||
expect(mainI18n.language).toBe('en')
|
||||
}
|
||||
)
|
||||
|
||||
it('never replaces a successful native snapshot with browser fallback hints', async () => {
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
const native = getSystemLanguageDetection()
|
||||
expect(getSystemLanguageDetection(['ja-JP'])).toBe(native)
|
||||
expect(native).toMatchObject({ locale: 'fr', source: 'native' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ platform: 'Windows', preferences: ['ja-JP', 'en-US'], locale: 'ja', status: 'supported' },
|
||||
{ platform: 'macOS', preferences: ['fr-US', 'en-US'], locale: 'fr', status: 'supported' },
|
||||
{
|
||||
platform: 'Linux',
|
||||
preferences: ['zh_CN.UTF-8', 'zh_CN', 'zh', 'C'],
|
||||
locale: 'zh',
|
||||
status: 'supported'
|
||||
},
|
||||
{ platform: 'Linux C-only', preferences: ['C.UTF-8', 'C'], locale: 'en', status: 'unknown' },
|
||||
{ platform: 'unsupported', preferences: ['de-DE', 'fr-FR'], locale: 'en', status: 'unsupported' }
|
||||
])('handles $platform preference lists', async ({ preferences, locale, status }) => {
|
||||
getPreferredSystemLanguages.mockReturnValue(preferences)
|
||||
const { getSystemLanguageDetection } = await import('./system-language')
|
||||
expect(getSystemLanguageDetection()).toMatchObject({ locale, status })
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { app } from 'electron'
|
||||
import { detectUiLanguage, type UiLanguageDetection } from '../../shared/ui-locale'
|
||||
|
||||
let snapshot: UiLanguageDetection | null | undefined
|
||||
|
||||
export function getSystemLanguageDetection(
|
||||
browserPreferences?: readonly string[]
|
||||
): UiLanguageDetection | null {
|
||||
if (snapshot === undefined) {
|
||||
try {
|
||||
if (!app.isReady()) {
|
||||
return null
|
||||
}
|
||||
const preferences = app.getPreferredSystemLanguages()
|
||||
snapshot = preferences.length ? detectUiLanguage(preferences, 'native') : null
|
||||
} catch {
|
||||
snapshot = null
|
||||
}
|
||||
}
|
||||
if (snapshot === null && browserPreferences) {
|
||||
snapshot = detectUiLanguage(browserPreferences, 'browser-fallback')
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AppRelaunch from '../app-relaunch'
|
||||
import { detectUiLanguage } from '../../shared/ui-locale'
|
||||
|
||||
const {
|
||||
handlers,
|
||||
@@ -9,6 +10,10 @@ const {
|
||||
appRelaunchMock,
|
||||
spawnMock,
|
||||
destroySystemTrayMock,
|
||||
rebuildSystemTrayMenuMock,
|
||||
rebuildAppMenuMock,
|
||||
getSystemLanguageDetectionMock,
|
||||
setMainUiLanguageMock,
|
||||
relaunchAppMock,
|
||||
showOpenDialogMock,
|
||||
trustFloatingWorkspaceDirectoryMock,
|
||||
@@ -22,6 +27,10 @@ const {
|
||||
appRelaunchMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
destroySystemTrayMock: vi.fn(),
|
||||
rebuildSystemTrayMenuMock: vi.fn(),
|
||||
rebuildAppMenuMock: vi.fn(),
|
||||
getSystemLanguageDetectionMock: vi.fn(),
|
||||
setMainUiLanguageMock: vi.fn(),
|
||||
relaunchAppMock: vi.fn(),
|
||||
showOpenDialogMock: vi.fn(),
|
||||
trustFloatingWorkspaceDirectoryMock: vi.fn(),
|
||||
@@ -97,9 +106,16 @@ vi.mock('@electron-toolkit/utils', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../tray/system-tray', () => ({
|
||||
destroySystemTray: destroySystemTrayMock
|
||||
destroySystemTray: destroySystemTrayMock,
|
||||
rebuildSystemTrayMenu: rebuildSystemTrayMenuMock
|
||||
}))
|
||||
|
||||
vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: rebuildAppMenuMock }))
|
||||
vi.mock('../i18n/system-language', () => ({
|
||||
getSystemLanguageDetection: getSystemLanguageDetectionMock
|
||||
}))
|
||||
vi.mock('../i18n/main-i18n', () => ({ setMainUiLanguage: setMainUiLanguageMock }))
|
||||
|
||||
vi.mock('../app-relaunch', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof AppRelaunch>()),
|
||||
relaunchApp: relaunchAppMock
|
||||
@@ -158,6 +174,10 @@ describe('registerAppHandlers', () => {
|
||||
appRelaunchMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
destroySystemTrayMock.mockReset()
|
||||
rebuildSystemTrayMenuMock.mockReset()
|
||||
rebuildAppMenuMock.mockReset()
|
||||
getSystemLanguageDetectionMock.mockReset()
|
||||
setMainUiLanguageMock.mockReset()
|
||||
relaunchAppMock.mockReset()
|
||||
relaunchAppMock.mockImplementation(() => appRelaunchMock())
|
||||
prepareWindowsAdministratorRestartMock.mockReset()
|
||||
@@ -191,6 +211,63 @@ describe('registerAppHandlers', () => {
|
||||
expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns fallback detection before catalog loading and then refreshes native menus', async () => {
|
||||
const detection = detectUiLanguage(['fr-FR'], 'browser-fallback')
|
||||
getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection)
|
||||
let finishLoad: (locale: string) => void = () => {}
|
||||
setMainUiLanguageMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const store = { getSettings: () => ({ uiLanguage: 'system' }) }
|
||||
// SAFETY: the language handler only reads the uiLanguage setting.
|
||||
registerAppHandlers(store as never)
|
||||
|
||||
expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection)
|
||||
expect(getSystemLanguageDetectionMock).toHaveBeenLastCalledWith(['fr-FR'])
|
||||
expect(rebuildAppMenuMock).not.toHaveBeenCalled()
|
||||
expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled()
|
||||
finishLoad('fr')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rebuildAppMenuMock).toHaveBeenCalledOnce()
|
||||
expect(rebuildSystemTrayMenuMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an explicit language while accepting browser fallback detection', () => {
|
||||
const detection = detectUiLanguage(['fr-FR'], 'browser-fallback')
|
||||
getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection)
|
||||
const store = { getSettings: () => ({ uiLanguage: 'en' }) }
|
||||
// SAFETY: the language handler only reads the uiLanguage setting.
|
||||
registerAppHandlers(store as never)
|
||||
|
||||
expect(handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection)
|
||||
expect(setMainUiLanguageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves detection if the asynchronous native menu refresh fails', async () => {
|
||||
const detection = detectUiLanguage(['fr-FR'], 'browser-fallback')
|
||||
getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection)
|
||||
const failure = new Error('Catalog unavailable')
|
||||
setMainUiLanguageMock.mockRejectedValue(failure)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = { getSettings: () => ({ uiLanguage: 'system' }) }
|
||||
// SAFETY: the language handler only reads the uiLanguage setting.
|
||||
registerAppHandlers(store as never)
|
||||
|
||||
try {
|
||||
expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(
|
||||
detection
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(warn).toHaveBeenCalledWith('[app] failed to refresh system language menus:', failure)
|
||||
expect(rebuildAppMenuMock).not.toHaveBeenCalled()
|
||||
expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('checkpoints before scheduling the native administrator restart and quits normally', async () => {
|
||||
const plan = { execPath: 'C:\\Orca\\resources\\bin\\orca.exe', args: ['restart'] }
|
||||
prepareWindowsAdministratorRestartMock.mockReturnValue(plan)
|
||||
|
||||
+21
-1
@@ -1,3 +1,6 @@
|
||||
import { getSystemLanguageDetection } from '../i18n/system-language'
|
||||
import { setMainUiLanguage } from '../i18n/main-i18n'
|
||||
import { rebuildAppMenu } from '../menu/register-app-menu'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -15,7 +18,7 @@ import { isPwshAvailableAsync } from '../pwsh'
|
||||
import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl'
|
||||
import { isGitBashAvailable } from '../git-bash'
|
||||
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
|
||||
import { destroySystemTray } from '../tray/system-tray'
|
||||
import { destroySystemTray, rebuildSystemTrayMenu } from '../tray/system-tray'
|
||||
import {
|
||||
ensureDefaultFloatingWorkspacePath,
|
||||
trustFloatingWorkspaceDirectory,
|
||||
@@ -125,6 +128,23 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
|
||||
)
|
||||
registerMacKeyboardLayoutChangeNotifications()
|
||||
ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed)
|
||||
ipcMain.handle('app:getSystemLanguageDetection', (_event, preferences: unknown) => {
|
||||
const previous = getSystemLanguageDetection()
|
||||
const detection = getSystemLanguageDetection(
|
||||
Array.isArray(preferences) && preferences.every((tag) => typeof tag === 'string')
|
||||
? preferences
|
||||
: undefined
|
||||
)
|
||||
if (detection !== previous && store.getSettings().uiLanguage === 'system') {
|
||||
void setMainUiLanguage('system')
|
||||
.then(() => {
|
||||
rebuildAppMenu()
|
||||
rebuildSystemTrayMenu()
|
||||
})
|
||||
.catch((error) => console.warn('[app] failed to refresh system language menus:', error))
|
||||
}
|
||||
return detection
|
||||
})
|
||||
ipcMain.handle('app:getWindowsAdministratorStatus', getWindowsAdministratorStatus)
|
||||
|
||||
ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl())
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
closeTestStores,
|
||||
createStore,
|
||||
readDataFile,
|
||||
testState,
|
||||
writeDataFile
|
||||
} from './persistence-test-harness'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as crypto from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('node:crypto', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof crypto>()),
|
||||
randomInt: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-onboarding-language-cohort-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
vi.resetAllMocks()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('persisted onboarding language cohorts', () => {
|
||||
it.each([
|
||||
{ draw: 0, cohort: 'control', language: 'en' },
|
||||
{ draw: 1, cohort: 'treatment', language: 'system' }
|
||||
])('retains $cohort through settings changes and restart', async ({ draw, cohort, language }) => {
|
||||
vi.mocked(crypto.randomInt).mockImplementation(() => draw)
|
||||
const store = createStore()
|
||||
const telemetry = store.getSettings().telemetry
|
||||
if (!telemetry) {
|
||||
throw new Error('Fresh profile has no telemetry settings')
|
||||
}
|
||||
expect(telemetry.onboardingLanguageCohort).toBe(cohort)
|
||||
expect(store.getSettings().uiLanguage).toBe(language)
|
||||
store.flush()
|
||||
expect(readDataFile()).toHaveProperty('settings.telemetry.onboardingLanguageCohort', cohort)
|
||||
|
||||
store.updateSettings({ uiLanguage: 'fr' })
|
||||
store.updateSettings({ telemetry: { ...telemetry, optedIn: false } })
|
||||
store.flush()
|
||||
await closeTestStores()
|
||||
|
||||
const reopened = createStore()
|
||||
expect(reopened.getSettings().telemetry).toEqual({ ...telemetry, optedIn: false })
|
||||
expect(reopened.getSettings().uiLanguage).toBe('fr')
|
||||
expect(crypto.randomInt).toHaveBeenCalledExactlyOnceWith(2)
|
||||
})
|
||||
|
||||
it('keeps a previously migrated profile excluded through restart', async () => {
|
||||
writeDataFile({
|
||||
settings: {
|
||||
uiLanguage: 'ja',
|
||||
telemetry: {
|
||||
optedIn: false,
|
||||
installId: 'existing-profile',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createStore()
|
||||
store.flush()
|
||||
await closeTestStores()
|
||||
const reopened = createStore()
|
||||
expect(reopened.getSettings().telemetry?.onboardingLanguageCohort).toBeUndefined()
|
||||
expect(reopened.getSettings().telemetry?.optedIn).toBe(false)
|
||||
expect(reopened.getSettings().uiLanguage).toBe('ja')
|
||||
expect(crypto.randomInt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as crypto from 'node:crypto'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations'
|
||||
|
||||
vi.mock('node:crypto', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof crypto>()),
|
||||
randomInt: vi.fn()
|
||||
}))
|
||||
|
||||
afterEach(() => vi.resetAllMocks())
|
||||
|
||||
describe('onboarding language experiment', () => {
|
||||
it.each([
|
||||
{ draw: 0, cohort: 'control', language: 'en' },
|
||||
{ draw: 1, cohort: 'treatment', language: 'system' }
|
||||
])(
|
||||
'assigns and freezes the $cohort experience for a new install',
|
||||
({ draw, cohort, language }) => {
|
||||
vi.mocked(crypto.randomInt).mockImplementation(() => draw)
|
||||
const runtime = { loadNeedsSave: false }
|
||||
const migration = new LoadedCohortMigrationOperations(runtime)
|
||||
const fresh = migration.migrateTelemetry(getDefaultPersistedState('/test'), false)
|
||||
expect(fresh.settings.telemetry?.onboardingLanguageCohort).toBe(cohort)
|
||||
expect(fresh.settings.uiLanguage).toBe(language)
|
||||
expect(runtime.loadNeedsSave).toBe(true)
|
||||
const reopened = migration.migrateTelemetry(JSON.parse(JSON.stringify(fresh)), true)
|
||||
expect(reopened).toEqual(fresh)
|
||||
expect(crypto.randomInt).toHaveBeenCalledExactlyOnceWith(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('excludes existing profiles and preserves their language', () => {
|
||||
const migration = new LoadedCohortMigrationOperations({ loadNeedsSave: false })
|
||||
const profile = getDefaultPersistedState('/test')
|
||||
profile.settings.uiLanguage = 'fr'
|
||||
const upgraded = migration.migrateTelemetry(profile, true)
|
||||
expect(upgraded.settings.telemetry?.onboardingLanguageCohort).toBeUndefined()
|
||||
expect(upgraded.settings.uiLanguage).toBe('fr')
|
||||
expect(crypto.randomInt).not.toHaveBeenCalled()
|
||||
expect(migration.migrateTelemetry(upgraded, true)).toEqual(upgraded)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
createNewProfileNativeChatUpgradeTipAudience,
|
||||
@@ -86,12 +86,20 @@ export class LoadedCohortMigrationOperations {
|
||||
typeof existing?.existedBeforeTelemetryRelease === 'boolean'
|
||||
? existing.existedBeforeTelemetryRelease
|
||||
: fileExistedOnLoad
|
||||
const onboardingLanguageCohort = !fileExistedOnLoad
|
||||
? randomInt(2) === 0
|
||||
? 'control'
|
||||
: 'treatment'
|
||||
: undefined
|
||||
this.runtime.loadNeedsSave = true
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
...(onboardingLanguageCohort === 'control' ? { uiLanguage: 'en' as const } : {}),
|
||||
telemetry: {
|
||||
...existing,
|
||||
...(onboardingLanguageCohort ? { onboardingLanguageCohort } : {}),
|
||||
existedBeforeTelemetryRelease: resolvedExistedBefore,
|
||||
// Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false.
|
||||
optedIn:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initTelemetry, track } from './client'
|
||||
import { cleanupTelemetryClientTest, setupTelemetryClientTest } from './client-test-harness'
|
||||
|
||||
const { capture } = vi.hoisted(() => {
|
||||
vi.stubGlobal('ORCA_BUILD_IDENTITY', 'stable')
|
||||
vi.stubGlobal('ORCA_POSTHOG_WRITE_KEY', 'test-key')
|
||||
return { capture: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('posthog-node', () => ({
|
||||
PostHog: class {
|
||||
capture = capture
|
||||
}
|
||||
}))
|
||||
|
||||
afterEach(() => capture.mockClear())
|
||||
|
||||
describe('onboarding language cohort on analytics', () => {
|
||||
it.each(['control', 'treatment', undefined] as const)(
|
||||
'attaches %s to completion and activation events without changing consent',
|
||||
(cohort) => {
|
||||
const state = setupTelemetryClientTest({
|
||||
optedIn: true,
|
||||
installId: 'test-install',
|
||||
existedBeforeTelemetryRelease: false,
|
||||
...(cohort ? { onboardingLanguageCohort: cohort } : {})
|
||||
})
|
||||
try {
|
||||
initTelemetry(state.store)
|
||||
track('onboarding_completed', { path: 'open_folder', total_duration_ms: 1000 })
|
||||
track('activation_checklist_item_completed', {
|
||||
item: 'ranFirstAgent',
|
||||
time_since_completed_ms: 2000
|
||||
})
|
||||
expect(capture).toHaveBeenCalledTimes(2)
|
||||
for (const [event] of capture.mock.calls) {
|
||||
expect(event.properties.onboarding_language_v1).toBe(cohort)
|
||||
}
|
||||
if (state.settings.telemetry) {
|
||||
state.settings.telemetry.optedIn = false
|
||||
}
|
||||
track('app_opened', {})
|
||||
expect(capture).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
cleanupTelemetryClientTest(state.envStash)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -91,6 +91,11 @@ export function initTelemetry(store: Store): void {
|
||||
BUILD_IDENTITY as 'stable' | 'rc'
|
||||
)
|
||||
|
||||
const languageCohort = settings.telemetry?.onboardingLanguageCohort
|
||||
if (languageCohort === 'control' || languageCohort === 'treatment') {
|
||||
commonProps.onboarding_language_v1 = languageCohort
|
||||
}
|
||||
|
||||
// Fail-closed: a bad `install_id` (e.g. empty from a migration bug) would collapse all events into one distinct_id.
|
||||
// Validated once here (not per `track()`): `commonProps` is a session-lifetime singleton that can't drift.
|
||||
const parsedCommon = commonPropsSchema.safeParse(commonProps)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UiLanguageDetection } from '../../shared/ui-locale'
|
||||
import type { AppIdentity } from '../../shared/app-identity'
|
||||
import type { E2EConfig } from '../../shared/e2e-config'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -14,6 +15,9 @@ import type { KeyboardLayoutSnapshot } from '../../shared/keyboard-layout-snapsh
|
||||
import type { KeyboardLayoutChangeEvent } from '../../shared/keyboard-layout-events'
|
||||
|
||||
export type AppApi = {
|
||||
getSystemLanguageDetection?: (
|
||||
browserPreferences?: readonly string[]
|
||||
) => Promise<UiLanguageDetection | null>
|
||||
/** A local profile storage operation is delayed and holds up subsequent saves. */
|
||||
isProfileStateSaveDelayed: () => Promise<boolean>
|
||||
onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void) => () => void
|
||||
|
||||
@@ -13,6 +13,8 @@ import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../prelo
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const appApi = {
|
||||
getSystemLanguageDetection: (browserPreferences?: readonly string[]) =>
|
||||
ipcRenderer.invoke('app:getSystemLanguageDetection', browserPreferences),
|
||||
isProfileStateSaveDelayed: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('app:isProfileStateSaveDelayed'),
|
||||
onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void): (() => void) => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { ModalBoundary, OverlayBoundary } from './app-root-surface-boundaries'
|
||||
import { OnboardingLanguageLoading } from '../components/onboarding/OnboardingLanguageLoading'
|
||||
import { useLanguageReadiness } from '../i18n/language-readiness'
|
||||
import { NotificationCardStack } from '../components/NotificationCardStack'
|
||||
import { Suspense } from 'react'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
@@ -93,30 +96,6 @@ const PetOverlay = lazy(() => import('../components/pet/PetOverlay'))
|
||||
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
|
||||
const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow'))
|
||||
|
||||
type BoundaryProps = {
|
||||
boundaryId: string
|
||||
resetKey?: string | number | boolean | null
|
||||
title?: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean {
|
||||
if (status.state === 'idle') {
|
||||
return false
|
||||
@@ -136,6 +115,7 @@ export function AppRootSurfaces(props: {
|
||||
onboardingGate: OnboardingGate
|
||||
}): React.JSX.Element {
|
||||
const { floatingWorkspace, onboardingGate } = props
|
||||
const languageReady = useLanguageReadiness((state) => state.ready)
|
||||
const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts()
|
||||
usePreloadWorktreeJumpPalette()
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
@@ -382,10 +362,14 @@ export function AppRootSurfaces(props: {
|
||||
'Retry onboarding or close it and continue in the app.'
|
||||
)}
|
||||
>
|
||||
<OnboardingFlow
|
||||
onboarding={onboardingGate.onboarding}
|
||||
onOnboardingChange={onboardingGate.setOnboarding}
|
||||
/>
|
||||
{languageReady ? (
|
||||
<OnboardingFlow
|
||||
onboarding={onboardingGate.onboarding}
|
||||
onOnboardingChange={onboardingGate.setOnboarding}
|
||||
/>
|
||||
) : (
|
||||
<OnboardingLanguageLoading />
|
||||
)}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
|
||||
|
||||
type BoundaryProps = {
|
||||
boundaryId: string
|
||||
resetKey?: string | number | boolean | null
|
||||
title?: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OnboardingLanguageSwitcher } from './OnboardingLanguageSwitcher'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
@@ -138,6 +139,7 @@ export default function OnboardingFlow({
|
||||
(currentStep.id === 'notifications'
|
||||
? translate('components.onboarding.flow.actions.addFirstProject', 'Add your first project')
|
||||
: translate('components.onboarding.flow.actions.continue', 'Continue'))
|
||||
const [languagePickerOpen, setLanguagePickerOpen] = useState(false)
|
||||
const [skipConfirmOpen, setSkipConfirmOpen] = useState(false)
|
||||
const skipConfirmAdvancedViaRef = useRef<'button' | 'keyboard'>('button')
|
||||
const { next: flowNext, dismissOnboarding: flowDismissOnboarding } = flow
|
||||
@@ -146,13 +148,13 @@ export default function OnboardingFlow({
|
||||
(advancedVia: 'button' | 'keyboard') => {
|
||||
// Why: click-off / Escape dismissal stays available on every step,
|
||||
// including the final notifications step, so the modal never feels stuck.
|
||||
if (busyLabel || skipConfirmOpen) {
|
||||
if (busyLabel || skipConfirmOpen || languagePickerOpen) {
|
||||
return
|
||||
}
|
||||
skipConfirmAdvancedViaRef.current = advancedVia
|
||||
setSkipConfirmOpen(true)
|
||||
},
|
||||
[busyLabel, skipConfirmOpen]
|
||||
[busyLabel, skipConfirmOpen, languagePickerOpen]
|
||||
)
|
||||
|
||||
const confirmSkipOnboarding = useCallback(() => {
|
||||
@@ -165,6 +167,9 @@ export default function OnboardingFlow({
|
||||
// re-bind on every render of the parent (flow object identity changes).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (languagePickerOpen || skipConfirmOpen) {
|
||||
return
|
||||
}
|
||||
// Why: don't hijack Enter / Cmd+Enter while the user is typing into the
|
||||
// clone-URL input or any other editable field on a step.
|
||||
if (isEditableTarget(event.target)) {
|
||||
@@ -180,11 +185,11 @@ export default function OnboardingFlow({
|
||||
}
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [flowNext])
|
||||
}, [flowNext, languagePickerOpen, skipConfirmOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape' || skipConfirmOpen) {
|
||||
if (event.key !== 'Escape' || skipConfirmOpen || languagePickerOpen) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -192,7 +197,7 @@ export default function OnboardingFlow({
|
||||
}
|
||||
addAppKeyListener('keydown', onKeyDown)
|
||||
return () => removeAppKeyListener('keydown', onKeyDown)
|
||||
}, [requestSkipConfirmation, skipConfirmOpen])
|
||||
}, [requestSkipConfirmation, skipConfirmOpen, languagePickerOpen])
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
|
||||
@@ -226,16 +231,22 @@ export default function OnboardingFlow({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 flex-col px-6 pb-6 pt-8 sm:px-8 sm:pb-8 sm:pt-9">
|
||||
<div className="flex items-center gap-3 text-base font-semibold tracking-tight">
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-7 w-auto shrink-0 invert dark:invert-0"
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-base font-semibold tracking-tight">
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-7 w-auto shrink-0 invert dark:invert-0"
|
||||
/>
|
||||
<span>
|
||||
{translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingLanguageSwitcher
|
||||
open={languagePickerOpen}
|
||||
onOpenChange={setLanguagePickerOpen}
|
||||
/>
|
||||
<span>
|
||||
{translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center gap-2 transition-[margin-top] duration-[760ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { OnboardingLanguageLoading } from './OnboardingLanguageLoading'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('releases settings recovery while remote owner hydration is still pending', async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
markRuntimeEnvironmentCompatible('language-retry-owner')
|
||||
let finishOwnerRead: (value: unknown) => void = () => {}
|
||||
const ownerRead = new Promise((resolve) => {
|
||||
finishOwnerRead = resolve
|
||||
})
|
||||
const call = vi.fn().mockReturnValue(ownerRead)
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeRuntimeEnvironmentId: 'language-retry-owner'
|
||||
}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
settings: { get: vi.fn().mockResolvedValue(settings) },
|
||||
runtimeEnvironments: { call, list: vi.fn().mockResolvedValue([]) }
|
||||
}
|
||||
})
|
||||
render(<OnboardingLanguageLoading />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading settings')
|
||||
try {
|
||||
await waitFor(() => expect(call).toHaveBeenCalled())
|
||||
expect(useAppStore.getState().settings).toBe(settings)
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading language')
|
||||
} finally {
|
||||
finishOwnerRead({
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: { runtimeId: 'language-retry-runtime' }
|
||||
})
|
||||
await useAppStore.getState().awaitOwnerWorktreeVisibilityDefaultsHydration()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
|
||||
export function OnboardingLanguageLoading(): React.JSX.Element {
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const fetchSettings = useAppStore((state) => state.fetchSettings)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background text-foreground"
|
||||
aria-busy={settings !== null || retrying}
|
||||
>
|
||||
{settings === null && !retrying ? (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm">
|
||||
{translate('onboarding.language.settingsError', 'Could not load your settings.')}
|
||||
</p>
|
||||
<Button
|
||||
disabled={retrying}
|
||||
onClick={() => {
|
||||
setRetrying(true)
|
||||
void fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true }).finally(() =>
|
||||
setRetrying(false)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{translate('onboarding.language.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoadingSpinner className="size-4" />
|
||||
{settings === null
|
||||
? translate('onboarding.language.loadingSettings', 'Loading settings…')
|
||||
: translate('onboarding.language.loading', 'Loading language…')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, useState } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { detectUiLanguage, setRendererLanguageDetection } from '../../../../shared/ui-locale'
|
||||
import { useAppStore } from '@/store'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import * as rendererI18n from '@/i18n/i18n'
|
||||
import { useLanguageReadiness } from '@/i18n/language-readiness'
|
||||
import { I18nProvider } from '@/i18n/I18nProvider'
|
||||
import * as languageReadiness from '@/i18n/language-readiness'
|
||||
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
|
||||
import { OnboardingLanguageSwitcher } from './OnboardingLanguageSwitcher'
|
||||
|
||||
const { submit, telemetryTrack } = vi.hoisted(() => ({ submit: vi.fn(), telemetryTrack: vi.fn() }))
|
||||
|
||||
beforeEach(async () => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
useAppStore.setState({ settings: getDefaultSettings('/tmp') })
|
||||
useLanguageReadiness.setState({ ...useLanguageReadiness.getInitialState(), ready: true })
|
||||
usePluginLanguagePackStore.setState({ entries: [], loaded: true })
|
||||
vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockResolvedValue(
|
||||
detectUiLanguage(['de-DE'], 'native')
|
||||
)
|
||||
setRendererLanguageDetection(detectUiLanguage(['de-DE'], 'native'))
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { feedback: { submit }, telemetryTrack }
|
||||
})
|
||||
submit.mockReset()
|
||||
telemetryTrack.mockReset().mockResolvedValue(undefined)
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function openRequest(): Promise<void> {
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Other language…' }))
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: ' Deutsch ' } })
|
||||
}
|
||||
|
||||
describe('onboarding language request', () => {
|
||||
it.each(['control', 'treatment'] as const)('gates the picker for the %s cohort', (cohort) => {
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...getDefaultSettings('/tmp'),
|
||||
telemetry: {
|
||||
optedIn: true,
|
||||
installId: 'test-install',
|
||||
existedBeforeTelemetryRelease: false,
|
||||
onboardingLanguageCohort: cohort
|
||||
}
|
||||
}
|
||||
})
|
||||
render(<OnboardingLanguageSwitcher open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: 'Switch language' }) !== null).toBe(
|
||||
cohort === 'treatment'
|
||||
)
|
||||
})
|
||||
|
||||
it('acknowledges only delivered feedback, disables duplicate sends and preserves language', async () => {
|
||||
let finish: (value: { ok: true }) => void = () => {}
|
||||
submit.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
await openRequest()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request language' }))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Request language' }).hasAttribute('disabled')).toBe(
|
||||
true
|
||||
)
|
||||
expect(submit).toHaveBeenCalledWith({
|
||||
feedback: 'Language request: Deutsch',
|
||||
submitAnonymously: true,
|
||||
githubLogin: null,
|
||||
githubEmail: null
|
||||
})
|
||||
await act(async () => finish({ ok: true }))
|
||||
expect(screen.getByRole('status').textContent).toContain('recorded')
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('system')
|
||||
expect(telemetryTrack).toHaveBeenCalledWith(
|
||||
'onboarding_language_requested',
|
||||
expect.not.objectContaining({ requested_language: expect.anything() })
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'deutsch' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request language' }))
|
||||
expect(submit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('status').textContent).toContain('recorded')
|
||||
})
|
||||
|
||||
it('retains a failed draft and retries without claiming success', async () => {
|
||||
submit
|
||||
.mockResolvedValueOnce({ ok: false, status: null, error: 'Offline' })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
await openRequest()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request language' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Could not record'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(telemetryTrack).not.toHaveBeenCalledWith(
|
||||
'onboarding_language_requested',
|
||||
expect.anything()
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByRole('status').textContent).toContain('recorded'))
|
||||
expect(submit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('allows language selection after leaving a pending request and still prevents duplicate requests', async () => {
|
||||
let finishRequest: (value: { ok: true }) => void = () => {}
|
||||
submit.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRequest = resolve
|
||||
})
|
||||
)
|
||||
let settings = getDefaultSettings('/tmp')
|
||||
const save = vi.fn(async (updates: Partial<GlobalSettings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
return settings
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { feedback: { submit }, telemetryTrack, settings: { set: save } }
|
||||
})
|
||||
await openRequest()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request language' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Languages' }))
|
||||
|
||||
expect(screen.getByRole('radiogroup').getAttribute('aria-disabled')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Français' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('fr')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Other language…' }).hasAttribute('disabled')).toBe(
|
||||
false
|
||||
)
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Other language…' }))
|
||||
const form = screen.getByRole('textbox').closest('form')
|
||||
if (!form) {
|
||||
throw new Error('Request form missing')
|
||||
}
|
||||
fireEvent.submit(form)
|
||||
expect(submit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
await act(async () => finishRequest({ ok: true }))
|
||||
expect(screen.getByRole('status').textContent).toContain('recorded')
|
||||
})
|
||||
|
||||
it('rejects empty and oversized input before transport', async () => {
|
||||
await openRequest()
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: ' ' } })
|
||||
const form = screen.getByRole('textbox').closest('form')
|
||||
if (!form) {
|
||||
throw new Error('Request form missing')
|
||||
}
|
||||
fireEvent.submit(form)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x'.repeat(81) } })
|
||||
fireEvent.submit(form)
|
||||
expect(submit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('freezes switcher eligibility after selecting English', async () => {
|
||||
const save = vi.fn(async () => {
|
||||
const settings = useAppStore.getState().settings
|
||||
if (settings) {
|
||||
useAppStore.setState({ settings: { ...settings, uiLanguage: 'en' } })
|
||||
}
|
||||
})
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'en' }))
|
||||
expect(screen.getByRole('button', { name: 'Switch language' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lets an open picker close after late native English detection removes its eligibility', async () => {
|
||||
useLanguageReadiness.setState({ detection: detectUiLanguage(['en-US'], 'browser-fallback') })
|
||||
const onOpenChange = vi.fn()
|
||||
function Picker(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<OnboardingLanguageSwitcher
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
onOpenChange(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
render(<Picker />)
|
||||
await user.click(screen.getByRole('button', { name: 'Switch language' }))
|
||||
await act(async () => {
|
||||
useLanguageReadiness.setState({ detection: detectUiLanguage(['en-US'], 'native') })
|
||||
})
|
||||
|
||||
expect(screen.getByRole('radiogroup')).toBeTruthy()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
expect(screen.queryByRole('radiogroup')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Switch language' })).toBeNull()
|
||||
})
|
||||
it('keeps the previous selection and offers retry when persistence fails', async () => {
|
||||
const save = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Disk unavailable'))
|
||||
.mockImplementationOnce(async () => {
|
||||
const settings = useAppStore.getState().settings
|
||||
if (settings) {
|
||||
useAppStore.setState({ settings: { ...settings, uiLanguage: 'fr' } })
|
||||
}
|
||||
})
|
||||
const onOpenChange = vi.fn()
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
render(
|
||||
<I18nProvider>
|
||||
<OnboardingLanguageSwitcher open onOpenChange={onOpenChange} />
|
||||
</I18nProvider>
|
||||
)
|
||||
await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(false))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Français' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'Could not save your language preference'
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('system')
|
||||
expect(screen.getByRole('radio', { name: 'English' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
expect(telemetryTrack).not.toHaveBeenCalledWith(
|
||||
'onboarding_language_changed',
|
||||
expect.anything()
|
||||
)
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(i18n.language).toBe('fr'))
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(save).toHaveBeenNthCalledWith(2, { uiLanguage: 'fr' })
|
||||
expect(i18n.language).toBe('fr')
|
||||
expect(telemetryTrack).toHaveBeenCalledWith(
|
||||
'onboarding_language_changed',
|
||||
expect.objectContaining({ selected_language: 'fr', effective_language: 'fr' })
|
||||
)
|
||||
})
|
||||
|
||||
it('guards rapid choices while saving, preserves keyboard focus, and closes on Escape', async () => {
|
||||
let finishSave: () => void = () => {}
|
||||
const save = vi.fn<ReturnType<typeof useAppStore.getState>['updateSettingsOrThrow']>(
|
||||
async (change) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishSave = resolve
|
||||
})
|
||||
const settings = useAppStore.getState().settings
|
||||
if (settings && (change.uiLanguage === 'zh' || change.uiLanguage === 'ko')) {
|
||||
useAppStore.setState({ settings: { ...settings, uiLanguage: change.uiLanguage } })
|
||||
}
|
||||
}
|
||||
)
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
function Picker(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <OnboardingLanguageSwitcher open={open} onOpenChange={setOpen} />
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
render(<Picker />)
|
||||
const trigger = screen.getByRole('button', { name: 'Switch language' })
|
||||
await user.click(trigger)
|
||||
const english = screen.getByRole('radio', { name: 'English' })
|
||||
await waitFor(() => expect(document.activeElement).toBe(english))
|
||||
|
||||
for (const name of ['中文(简体)', '한국어']) {
|
||||
await user.keyboard('{ArrowDown>}')
|
||||
const radio = screen.getByRole('radio', { name })
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(name === '中文(简体)' ? 1 : 2))
|
||||
await user.keyboard('{/ArrowDown}')
|
||||
expect(document.activeElement).toBe(radio)
|
||||
expect(radio.hasAttribute('disabled')).toBe(false)
|
||||
expect(screen.getByRole('radiogroup').getAttribute('aria-disabled')).toBe('true')
|
||||
expect(screen.getByRole('status').textContent).toContain('Saving language')
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(document.activeElement).toBe(radio)
|
||||
expect(save).toHaveBeenCalledTimes(name === '中文(简体)' ? 1 : 2)
|
||||
await act(async () => finishSave())
|
||||
await waitFor(() => expect(radio.getAttribute('data-state')).toBe('checked'))
|
||||
expect(radio.hasAttribute('disabled')).toBe(false)
|
||||
expect(document.activeElement).toBe(radio)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
}
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('radiogroup')).toBeNull())
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('ko')
|
||||
})
|
||||
|
||||
it('uses detection only as a hint and saves only explicit built-in selections', async () => {
|
||||
setRendererLanguageDetection(detectUiLanguage(['fr-FR'], 'native'))
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(7)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('system')
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'en' }))
|
||||
})
|
||||
|
||||
it('keeps unsupported-system copy accurate after a manual non-English choice', async () => {
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } })
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
expect(
|
||||
screen.getByText('System language: de-DE. This language is not supported yet.')
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText(/Orca uses English/)).toBeNull()
|
||||
expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the newer authoritative setting when a delayed picker save resolves', async () => {
|
||||
const settings = { ...getDefaultSettings('/tmp'), uiLanguage: 'en' as const }
|
||||
const newerSettings = { ...settings, uiLanguage: 'ja' as const }
|
||||
useAppStore.setState({ settings })
|
||||
let finishSave: (value: typeof newerSettings) => void = () => {}
|
||||
const save = vi.fn(
|
||||
() =>
|
||||
new Promise<typeof newerSettings>((resolve) => {
|
||||
finishSave = resolve
|
||||
})
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { feedback: { submit }, telemetryTrack, settings: { set: save } }
|
||||
})
|
||||
render(
|
||||
<I18nProvider>
|
||||
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
|
||||
</I18nProvider>
|
||||
)
|
||||
await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(false))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Français' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'fr' }))
|
||||
await act(async () => useAppStore.setState({ settings: newerSettings }))
|
||||
await waitFor(() => expect(i18n.language).toBe('ja'))
|
||||
|
||||
await act(async () => finishSave(newerSettings))
|
||||
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('ja')
|
||||
expect(i18n.language).toBe('ja')
|
||||
expect(screen.getByRole('radio', { name: '日本語' }).getAttribute('data-state')).toBe('checked')
|
||||
expect(telemetryTrack).not.toHaveBeenCalledWith(
|
||||
'onboarding_language_changed',
|
||||
expect.objectContaining({ selected_language: 'fr' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['fr', 'system'] as const)(
|
||||
'retries the saved %s preference without writing settings',
|
||||
async (uiLanguage) => {
|
||||
setRendererLanguageDetection(detectUiLanguage(['fr-FR'], 'native'))
|
||||
const settings = { ...getDefaultSettings('/tmp'), uiLanguage }
|
||||
useAppStore.setState({ settings })
|
||||
let finishLoad: (locale: string) => void = () => {}
|
||||
vi.spyOn(rendererI18n, 'loadRendererUiLanguage')
|
||||
.mockRejectedValueOnce(new Error('Offline'))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const save = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
feedback: { submit },
|
||||
telemetryTrack,
|
||||
settings: { set: save }
|
||||
}
|
||||
})
|
||||
render(
|
||||
<I18nProvider>
|
||||
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
|
||||
</I18nProvider>
|
||||
)
|
||||
await waitFor(() => expect(useLanguageReadiness.getState().error).toBe(true))
|
||||
expect(i18n.language).toBe('en')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
|
||||
await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(true))
|
||||
expect(screen.getByRole('button', { name: 'Retry' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading language')
|
||||
expect(screen.getByRole('radio', { name: 'Français' })).toBeTruthy()
|
||||
expect(useLanguageReadiness.getState().ready).toBe(true)
|
||||
await act(async () => finishLoad('fr'))
|
||||
await waitFor(() => expect(i18n.language).toBe('fr'))
|
||||
expect(useLanguageReadiness.getState().error).toBe(false)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe(uiLanguage)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(telemetryTrack).not.toHaveBeenCalledWith(
|
||||
'onboarding_language_changed',
|
||||
expect.anything()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the saved preference when an explicit selection cannot load its catalog', async () => {
|
||||
const save = vi.fn()
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockRejectedValue(new Error('Offline'))
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Français' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain('Could not load this language')
|
||||
)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('system')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('radio', { name: 'English' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits unavailable language requests when using the actual web adapter', async () => {
|
||||
const { installWebPreloadApi } = await import('@/web/web-preload-api')
|
||||
const originalPath = window.location.href
|
||||
window.history.replaceState(null, '', '/web-index.html')
|
||||
try {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: window.api
|
||||
})
|
||||
installWebPreloadApi()
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(7)
|
||||
expect(screen.queryByRole('button', { name: 'Other language…' })).toBeNull()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
} finally {
|
||||
window.history.replaceState(null, '', originalPath)
|
||||
Reflect.deleteProperty(window, '__ORCA_WEB_CLIENT__')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not offer or retry a saved plugin language', async () => {
|
||||
useAppStore.setState({
|
||||
settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'plugin:example.missing/de' }
|
||||
})
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
useAppStore.setState({ updateSettingsOrThrow: save })
|
||||
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(7)
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { ArrowLeft, ChevronDown, Globe } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { i18n, translate, loadRendererUiLanguage } from '@/i18n/i18n'
|
||||
import { getUiLanguageNativeLabel } from '@/i18n/supported-languages'
|
||||
import { useLanguageReadiness } from '@/i18n/language-readiness'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
SUPPORTED_UI_LOCALES,
|
||||
type SupportedUiLocale,
|
||||
getRendererLanguageDetection,
|
||||
shouldShowLanguageSwitcher
|
||||
} from '../../../../shared/ui-locale'
|
||||
|
||||
export function OnboardingLanguageSwitcher({
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}): React.JSX.Element | null {
|
||||
const cohort = useAppStore((state) => state.settings?.telemetry?.onboardingLanguageCohort)
|
||||
const language = useAppStore((state) => state.settings?.uiLanguage ?? 'system')
|
||||
const updateSettings = useAppStore((state) => state.updateSettingsOrThrow)
|
||||
const catalogError = useLanguageReadiness((state) => state.error)
|
||||
const catalogLoading = useLanguageReadiness((state) => state.loading)
|
||||
const detection =
|
||||
useLanguageReadiness((state) => state.detection) ?? getRendererLanguageDetection()
|
||||
const [recoveryVisible] = useState(catalogError)
|
||||
const [requestView, setRequestView] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [requestState, setRequestState] = useState<'idle' | 'pending' | 'error' | 'success'>('idle')
|
||||
const [selectionPhase, setSelectionPhase] = useState<'load' | 'save' | null>(null)
|
||||
const [selectionFailure, setSelectionFailure] = useState<{
|
||||
language: SupportedUiLocale
|
||||
stage: 'load' | 'save'
|
||||
} | null>(null)
|
||||
const selectionInFlight = useRef(false)
|
||||
const requestInFlight = useRef(false)
|
||||
const successfulRequests = useRef(new Set<string>())
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const busy = selectionPhase !== null || catalogLoading
|
||||
const canRequestLanguage = !isWebClientLocation()
|
||||
const metadata = {
|
||||
system_language: detection.detectedTag,
|
||||
system_language_supported: detection.status === 'supported',
|
||||
detection_source: detection.source,
|
||||
effective_language: i18n.language
|
||||
}
|
||||
const setOpen = (next: boolean): void => {
|
||||
if (next) {
|
||||
setRequestView(false)
|
||||
track('onboarding_language_picker_opened', metadata)
|
||||
}
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
const chooseLanguage = async (next: SupportedUiLocale): Promise<void> => {
|
||||
if (selectionInFlight.current || catalogLoading) {
|
||||
return
|
||||
}
|
||||
selectionInFlight.current = true
|
||||
setSelectionPhase('load')
|
||||
setSelectionFailure(null)
|
||||
let stage: 'load' | 'save' = 'load'
|
||||
try {
|
||||
const locale = await loadRendererUiLanguage(next)
|
||||
stage = 'save'
|
||||
setSelectionPhase('save')
|
||||
await updateSettings({ uiLanguage: next })
|
||||
if (useAppStore.getState().settings?.uiLanguage !== next) {
|
||||
return
|
||||
}
|
||||
if (catalogError && language === next) {
|
||||
useLanguageReadiness.getState().retryLanguageLoad()
|
||||
}
|
||||
track('onboarding_language_changed', {
|
||||
...metadata,
|
||||
selected_language: next,
|
||||
effective_language: locale
|
||||
})
|
||||
} catch {
|
||||
setSelectionFailure({ language: next, stage })
|
||||
} finally {
|
||||
selectionInFlight.current = false
|
||||
setSelectionPhase(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitRequest = async (): Promise<void> => {
|
||||
const request = draft.trim()
|
||||
if (!canRequestLanguage || !request || request.length > 80 || requestInFlight.current) {
|
||||
return
|
||||
}
|
||||
const normalized = request.normalize('NFKC').toLowerCase()
|
||||
if (successfulRequests.current.has(normalized)) {
|
||||
setRequestState('success')
|
||||
return
|
||||
}
|
||||
requestInFlight.current = true
|
||||
setRequestState('pending')
|
||||
try {
|
||||
const result = await window.api.feedback.submit({
|
||||
feedback: `Language request: ${request}`,
|
||||
submitAnonymously: true,
|
||||
githubLogin: null,
|
||||
githubEmail: null
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
successfulRequests.current.add(normalized)
|
||||
setRequestState('success')
|
||||
track('onboarding_language_requested', metadata)
|
||||
} catch {
|
||||
setRequestState('error')
|
||||
} finally {
|
||||
requestInFlight.current = false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
cohort === 'control' ||
|
||||
(!open && !shouldShowLanguageSwitcher(detection) && !catalogError && !recoveryVisible)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" lang="en">
|
||||
<Globe />
|
||||
<span>Switch language</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
align="end"
|
||||
aria-label={translate('onboarding.language.title', 'Display language')}
|
||||
className="z-[110] w-80 max-w-[calc(100vw-2rem)]"
|
||||
wheelScroll
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const content = contentRef.current
|
||||
const selected =
|
||||
content?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]') ??
|
||||
content?.querySelector<HTMLElement>('[role="radio"]')
|
||||
selected?.focus()
|
||||
}}
|
||||
>
|
||||
<div className="scrollbar-sleek max-h-[var(--radix-popover-content-available-height)] overflow-y-auto p-4">
|
||||
{requestView ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitRequest()
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRequestView(false)
|
||||
requestAnimationFrame(() =>
|
||||
contentRef.current
|
||||
?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]')
|
||||
?.focus()
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ArrowLeft />
|
||||
{translate('onboarding.language.back', 'Languages')}
|
||||
</Button>
|
||||
<Label htmlFor="onboarding-language-request">
|
||||
{translate(
|
||||
'onboarding.language.requestPrompt',
|
||||
'Which language would you like Orca to support?'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="onboarding-language-request"
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
value={draft}
|
||||
disabled={busy || requestState === 'pending'}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value)
|
||||
setRequestState('idle')
|
||||
}}
|
||||
/>
|
||||
{requestState === 'success' ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'onboarding.language.requestSuccess',
|
||||
'Language request recorded. You can continue onboarding.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{requestState === 'error' && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{translate(
|
||||
'onboarding.language.requestError',
|
||||
'Could not record your request. Try again.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={
|
||||
!draft.trim() || draft.trim().length > 80 || requestState === 'pending'
|
||||
}
|
||||
>
|
||||
{requestState === 'error'
|
||||
? translate('onboarding.language.retry', 'Retry')
|
||||
: translate('onboarding.language.requestAction', 'Request language')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{translate('onboarding.language.title', 'Display language')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{detection.status === 'supported'
|
||||
? translate('onboarding.language.device', 'System language: {{language}}', {
|
||||
language: getUiLanguageNativeLabel(detection.locale)
|
||||
})
|
||||
: detection.status === 'unsupported'
|
||||
? translate(
|
||||
'onboarding.language.unsupported',
|
||||
'System language: {{language}}. This language is not supported yet.',
|
||||
{ language: detection.detectedTag }
|
||||
)
|
||||
: translate(
|
||||
'onboarding.language.unknown',
|
||||
'Your system language could not be detected. Choose a language below.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={language === 'system' ? detection.locale : language}
|
||||
aria-busy={busy}
|
||||
aria-disabled={busy}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (
|
||||
busy &&
|
||||
[
|
||||
'ArrowDown',
|
||||
'ArrowUp',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
' ',
|
||||
'Enter'
|
||||
].includes(event.key)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
const locale = SUPPORTED_UI_LOCALES.find((entry) => entry === value)
|
||||
if (locale) {
|
||||
void chooseLanguage(locale)
|
||||
}
|
||||
}}
|
||||
aria-label={translate('onboarding.language.title', 'Display language')}
|
||||
>
|
||||
{SUPPORTED_UI_LOCALES.map((locale) => (
|
||||
<div key={locale} className="rounded-md px-2 py-2 hover:bg-accent">
|
||||
<Label className="cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value={locale}
|
||||
onClick={() => {
|
||||
if (language === 'system' && locale === detection.locale) {
|
||||
void chooseLanguage(locale)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{getUiLanguageNativeLabel(locale)}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<p role="status" className="h-4 text-xs text-muted-foreground">
|
||||
{selectionPhase === 'save'
|
||||
? translate('onboarding.language.saving', 'Saving language…')
|
||||
: busy
|
||||
? translate('onboarding.language.loading', 'Loading language…')
|
||||
: null}
|
||||
</p>
|
||||
{(selectionFailure !== null || catalogError) && (
|
||||
<div role="alert" className="space-y-2">
|
||||
<p className="text-xs text-destructive">
|
||||
{selectionFailure?.stage === 'save'
|
||||
? translate(
|
||||
'onboarding.language.saveError',
|
||||
'Could not save your language preference. Try again.'
|
||||
)
|
||||
: translate(
|
||||
'onboarding.language.loadError',
|
||||
'Could not load this language. Try again.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (selectionFailure) {
|
||||
void chooseLanguage(selectionFailure.language)
|
||||
} else {
|
||||
useLanguageReadiness.getState().retryLanguageLoad()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate('onboarding.language.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{canRequestLanguage && (
|
||||
<div className="border-t border-border pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
disabled={busy}
|
||||
onClick={() => setRequestView(true)}
|
||||
>
|
||||
{translate('onboarding.language.other', 'Other language…')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import * as RadioGroupPrimitive from 'radix-ui/radio-group'
|
||||
import { Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
'size-4 shrink-0 rounded-full border border-input text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="size-2 fill-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -6,8 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { UI_LANGUAGE_ENGLISH, UI_LANGUAGE_SPANISH } from '../../../shared/ui-language'
|
||||
import {
|
||||
getBrowserLanguageDetection,
|
||||
setRendererLanguageDetection
|
||||
} from '../../../shared/ui-locale'
|
||||
import { useAppStore } from '@/store'
|
||||
import { i18n } from './i18n'
|
||||
import * as rendererI18n from './i18n'
|
||||
import { useLanguageReadiness } from './language-readiness'
|
||||
import * as languageReadiness from './language-readiness'
|
||||
import { usePluginLanguagePackStore } from '../store/plugin-language-packs'
|
||||
import { I18nProvider } from './I18nProvider'
|
||||
|
||||
// Why: settings arrive async over IPC after first render. The provider used to
|
||||
@@ -24,6 +32,7 @@ const roots: Root[] = []
|
||||
const ORIGINAL_SYSTEM_LOCALE = navigator.language
|
||||
function stubSystemLocale(tag: string): void {
|
||||
Object.defineProperty(navigator, 'language', { value: tag, configurable: true })
|
||||
Object.defineProperty(navigator, 'languages', { value: [tag], configurable: true })
|
||||
}
|
||||
|
||||
async function renderProvider(): Promise<void> {
|
||||
@@ -37,6 +46,13 @@ async function renderProvider(): Promise<void> {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useLanguageReadiness.setState(useLanguageReadiness.getInitialState(), true)
|
||||
vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation(async () => {
|
||||
const detection = getBrowserLanguageDetection()
|
||||
setRendererLanguageDetection(detection)
|
||||
useLanguageReadiness.setState({ detection })
|
||||
return detection
|
||||
})
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
@@ -53,6 +69,67 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('I18nProvider startup language', () => {
|
||||
it.each(['loaded', 'failed'] as const)(
|
||||
'starts an explicit catalog before detection finishes and waits for detection when %s',
|
||||
async (outcome) => {
|
||||
const detection = getBrowserLanguageDetection()
|
||||
let finishDetection: () => void = () => {}
|
||||
vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishDetection = () => resolve(detection)
|
||||
})
|
||||
)
|
||||
const load = vi.spyOn(rendererI18n, 'loadRendererUiLanguage')
|
||||
if (outcome === 'loaded') {
|
||||
load.mockResolvedValue('fr')
|
||||
} else {
|
||||
load.mockRejectedValue(new Error('Catalog offline'))
|
||||
}
|
||||
const changeLanguage = vi.spyOn(i18n, 'changeLanguage')
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } })
|
||||
|
||||
await renderProvider()
|
||||
|
||||
expect(load).toHaveBeenCalledWith('fr')
|
||||
expect(changeLanguage).not.toHaveBeenCalled()
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({ ready: false, loading: true })
|
||||
|
||||
await act(async () => finishDetection())
|
||||
|
||||
expect(changeLanguage).toHaveBeenCalledWith(outcome === 'loaded' ? 'fr' : 'en')
|
||||
await vi.waitFor(() =>
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({
|
||||
ready: true,
|
||||
loading: false,
|
||||
error: outcome === 'failed'
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for detection before resolving the system language catalog', async () => {
|
||||
const detection = getBrowserLanguageDetection()
|
||||
let finishDetection: () => void = () => {}
|
||||
vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishDetection = () => resolve(detection)
|
||||
})
|
||||
)
|
||||
const load = vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockResolvedValue('en')
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'system' } })
|
||||
|
||||
await renderProvider()
|
||||
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => finishDetection())
|
||||
|
||||
expect(load).toHaveBeenCalledWith('system')
|
||||
expect(useLanguageReadiness.getState().ready).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply any language while settings are still loading', async () => {
|
||||
const changeLanguage = vi.spyOn(i18n, 'changeLanguage')
|
||||
|
||||
@@ -71,7 +148,7 @@ describe('I18nProvider startup language', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(changeLanguage).toHaveBeenCalledWith('es')
|
||||
await vi.waitFor(() => expect(changeLanguage).toHaveBeenCalledWith('es'))
|
||||
})
|
||||
|
||||
it('applies persisted English even if i18n reports it as already active', async () => {
|
||||
@@ -108,6 +185,7 @@ describe('I18nProvider startup language', () => {
|
||||
})
|
||||
|
||||
// English wins and the Spanish OS locale is never requested.
|
||||
expect(useLanguageReadiness.getState().detection?.locale).toBe('es')
|
||||
expect(changeLanguage).toHaveBeenCalledWith('en')
|
||||
expect(changeLanguage).not.toHaveBeenCalledWith('es')
|
||||
})
|
||||
@@ -127,6 +205,64 @@ describe('I18nProvider startup language', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(changeLanguage).toHaveBeenLastCalledWith('es')
|
||||
await vi.waitFor(() => expect(changeLanguage).toHaveBeenLastCalledWith('es'))
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply a delayed catalog after a newer saved preference', async () => {
|
||||
let finishSpanish: (locale: string) => void = () => {}
|
||||
const load = vi
|
||||
.spyOn(rendererI18n, 'loadRendererUiLanguage')
|
||||
.mockImplementation(async (language) => {
|
||||
if (language === 'es') {
|
||||
return new Promise<string>((resolve) => {
|
||||
finishSpanish = resolve
|
||||
})
|
||||
}
|
||||
return 'en'
|
||||
})
|
||||
const changeLanguage = vi.spyOn(i18n, 'changeLanguage')
|
||||
await renderProvider()
|
||||
await act(async () => {
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'es' } })
|
||||
})
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledWith('es'))
|
||||
await act(async () => {
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } })
|
||||
})
|
||||
await vi.waitFor(() => expect(changeLanguage).toHaveBeenCalledWith('en'))
|
||||
await act(async () => finishSpanish('es'))
|
||||
expect(changeLanguage).not.toHaveBeenCalledWith('es')
|
||||
expect(i18n.language).toBe('en')
|
||||
})
|
||||
|
||||
it('settles catalog failure to English with recovery while preserving the saved language', async () => {
|
||||
vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockRejectedValue(new Error('Catalog offline'))
|
||||
await renderProvider()
|
||||
await act(async () => {
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } })
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: true })
|
||||
)
|
||||
expect(i18n.language).toBe('en')
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('fr')
|
||||
})
|
||||
|
||||
it('continues in English while plugin discovery is pending without changing the saved preference', async () => {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { plugins: { listLanguagePacks: () => new Promise(() => {}) } }
|
||||
})
|
||||
usePluginLanguagePackStore.setState({ entries: [], loaded: false })
|
||||
useAppStore.setState({
|
||||
settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'plugin:example.missing/de' }
|
||||
})
|
||||
await renderProvider()
|
||||
await vi.waitFor(() =>
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: false })
|
||||
)
|
||||
expect(i18n.language).toBe('en')
|
||||
expect(usePluginLanguagePackStore.getState().loaded).toBe(false)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('plugin:example.missing/de')
|
||||
})
|
||||
|
||||
@@ -1,42 +1,61 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
|
||||
import { useAppStore } from '../store'
|
||||
import { i18n, setRendererPluginLanguagePacks } from './i18n'
|
||||
import { resolveUiLocale } from './supported-languages'
|
||||
import { isPluginUiLanguage } from '../../../shared/ui-language'
|
||||
import { i18n, setRendererPluginLanguagePacks, loadRendererUiLanguage } from './i18n'
|
||||
import { usePluginLanguagePacks } from '../store/plugin-language-packs'
|
||||
import { initializeLanguageDetection, useLanguageReadiness } from './language-readiness'
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
// Why: settings arrive async over IPC; until they load we must not apply any
|
||||
// language. Falling back to 'system' here used to kick off an OS-locale
|
||||
// changeLanguage that raced with (and could permanently override) the
|
||||
// persisted preference applied moments later.
|
||||
const uiLanguage = useAppStore((state) => state.settings?.uiLanguage ?? null)
|
||||
const pluginLanguagePacks = usePluginLanguagePacks()
|
||||
const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage)
|
||||
const locale =
|
||||
uiLanguage === null
|
||||
? null
|
||||
: (selectedPluginLanguage?.resourceLanguage ??
|
||||
(isPluginUiLanguage(uiLanguage) ? 'en' : resolveUiLocale(uiLanguage)))
|
||||
const requestedLocale = useRef<string | null>(null)
|
||||
const packs = usePluginLanguagePacks()
|
||||
const retryCount = useLanguageReadiness((state) => state.retryCount)
|
||||
const systemLocale = useLanguageReadiness((state) =>
|
||||
uiLanguage === 'system' ? state.detection?.locale : null
|
||||
)
|
||||
const generation = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
setRendererPluginLanguagePacks(pluginLanguagePacks)
|
||||
requestedLocale.current = null
|
||||
}, [pluginLanguagePacks])
|
||||
setRendererPluginLanguagePacks(packs)
|
||||
}, [packs])
|
||||
|
||||
useEffect(() => {
|
||||
// Why: track the last *requested* locale instead of checking i18n.language —
|
||||
// an in-flight lazy catalog load leaves i18n.language stale, which made the
|
||||
// guard skip corrections back to the persisted language.
|
||||
if (locale === null || requestedLocale.current === locale) {
|
||||
if (uiLanguage === null) {
|
||||
return
|
||||
}
|
||||
requestedLocale.current = locale
|
||||
void i18n.changeLanguage(locale)
|
||||
}, [locale, pluginLanguagePacks])
|
||||
const request = ++generation.current
|
||||
let cancelled = false
|
||||
useLanguageReadiness.setState({ loading: true })
|
||||
void (async () => {
|
||||
const detection = initializeLanguageDetection()
|
||||
try {
|
||||
if (uiLanguage === 'system') {
|
||||
await detection
|
||||
if (cancelled || request !== generation.current) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const [, locale] = await Promise.all([detection, loadRendererUiLanguage(uiLanguage)])
|
||||
if (cancelled || request !== generation.current) {
|
||||
return
|
||||
}
|
||||
await i18n.changeLanguage(locale)
|
||||
if (!cancelled && request === generation.current) {
|
||||
useLanguageReadiness.setState({ ready: true, error: false, loading: false })
|
||||
}
|
||||
} catch {
|
||||
await detection
|
||||
if (!cancelled && request === generation.current) {
|
||||
await i18n.changeLanguage('en')
|
||||
if (!cancelled && request === generation.current) {
|
||||
useLanguageReadiness.setState({ ready: true, error: true, loading: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [uiLanguage, packs, retryCount, systemLocale])
|
||||
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
|
||||
import type { SupportedUiLocale } from '../../../shared/ui-locale'
|
||||
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
|
||||
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
|
||||
export const i18n: I18nInstance = i18next.createInstance()
|
||||
|
||||
@@ -93,16 +94,27 @@ export function translate(key: string, fallback: string, options?: TOptions): st
|
||||
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
|
||||
}
|
||||
|
||||
export async function setRendererUiLanguage(language: UiLanguage): Promise<void> {
|
||||
export async function loadRendererUiLanguage(language: UiLanguage): Promise<string> {
|
||||
const resolved = resolveUiLocale(language)
|
||||
const resourceLanguage = resolveRendererResourceLanguage(resolved)
|
||||
const locale =
|
||||
isPluginUiLanguage(language) && resourceLanguage === resolved
|
||||
? DEFAULT_LOCALE
|
||||
: resourceLanguage
|
||||
const loader = Object.entries(NON_DEFAULT_LOCALE_LOADERS).find(([name]) => name === locale)?.[1]
|
||||
if (loader && !i18n.hasResourceBundle(locale, 'translation')) {
|
||||
const catalog = await withTimeout(loader(), 5000, null)
|
||||
if (catalog === null) {
|
||||
throw new Error('Language catalog unavailable')
|
||||
}
|
||||
i18n.addResourceBundle(locale, 'translation', catalog.default, true, true)
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
export async function setRendererUiLanguage(language: UiLanguage): Promise<void> {
|
||||
const locale = await loadRendererUiLanguage(language)
|
||||
if (i18n.language !== locale) {
|
||||
// changeLanguage triggers the lazy backend load for non-English locales and
|
||||
// resolves once the catalog is in memory.
|
||||
await i18n.changeLanguage(locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from 'react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
|
||||
import { OnboardingLanguageLoading } from '@/components/onboarding/OnboardingLanguageLoading'
|
||||
import { I18nProvider } from './I18nProvider'
|
||||
import { i18n } from './i18n'
|
||||
import { useLanguageReadiness } from './language-readiness'
|
||||
|
||||
const { catalog } = vi.hoisted(() => {
|
||||
let finish: (value: { default: Record<string, unknown> }) => void = () => {}
|
||||
const promise = new Promise<{ default: Record<string, unknown> }>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { catalog: { promise, finish } }
|
||||
})
|
||||
vi.mock('./locales/fr.json', () => catalog.promise)
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('releases onboarding in English when a catalog stalls, ignores its late result, and can retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: {} })
|
||||
usePluginLanguagePackStore.setState({ entries: [], loaded: true })
|
||||
useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } })
|
||||
useLanguageReadiness.setState({ ready: false, error: false })
|
||||
function OnboardingGate(): React.JSX.Element {
|
||||
const ready = useLanguageReadiness((state) => state.ready)
|
||||
return ready ? <p>Onboarding available</p> : <OnboardingLanguageLoading />
|
||||
}
|
||||
render(
|
||||
<I18nProvider>
|
||||
<OnboardingGate />
|
||||
</I18nProvider>
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading language')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Onboarding available')).toBeTruthy()
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: true })
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('fr')
|
||||
expect(i18n.language).toBe('en')
|
||||
await act(async () => {
|
||||
catalog.finish({ default: { menu: { file: 'Fichier' } } })
|
||||
await import('./locales/fr.json')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(i18n.language).toBe('en')
|
||||
expect(useLanguageReadiness.getState().error).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
useLanguageReadiness.getState().retryLanguageLoad()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(i18n.language).toBe('fr'))
|
||||
})
|
||||
|
||||
expect(i18n.language).toBe('fr')
|
||||
expect(useLanguageReadiness.getState().error).toBe(false)
|
||||
expect(i18n.t('menu.file')).toBe('Fichier')
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { UiLanguageDetection } from '../../../shared/ui-locale'
|
||||
|
||||
async function loadLanguageRuntime() {
|
||||
const [constants, locale, store, packs, readiness, provider, rendererI18n, onboarding] =
|
||||
await Promise.all([
|
||||
import('../../../shared/constants'),
|
||||
import('../../../shared/ui-locale'),
|
||||
import('@/store'),
|
||||
import('@/store/plugin-language-packs'),
|
||||
import('./language-readiness'),
|
||||
import('./I18nProvider'),
|
||||
import('./i18n'),
|
||||
import('@/components/onboarding/OnboardingLanguageSwitcher')
|
||||
])
|
||||
return {
|
||||
getDefaultSettings: constants.getDefaultSettings,
|
||||
detectUiLanguage: locale.detectUiLanguage,
|
||||
useAppStore: store.useAppStore,
|
||||
usePluginLanguagePackStore: packs.usePluginLanguagePackStore,
|
||||
useLanguageReadiness: readiness.useLanguageReadiness,
|
||||
I18nProvider: provider.I18nProvider,
|
||||
i18n: rendererI18n.i18n,
|
||||
loadRendererUiLanguage: rendererI18n.loadRendererUiLanguage,
|
||||
OnboardingLanguageSwitcher: onboarding.OnboardingLanguageSwitcher
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: Awaited<ReturnType<typeof loadLanguageRuntime>>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
runtime = await loadLanguageRuntime()
|
||||
await Promise.all([runtime.loadRendererUiLanguage('fr'), runtime.loadRendererUiLanguage('ja')])
|
||||
await runtime.i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each(['system', 'explicit'] as const)(
|
||||
'reconciles late native detection while preserving the %s preference',
|
||||
async (preference) => {
|
||||
const {
|
||||
getDefaultSettings,
|
||||
detectUiLanguage,
|
||||
useAppStore,
|
||||
usePluginLanguagePackStore,
|
||||
useLanguageReadiness,
|
||||
I18nProvider,
|
||||
i18n,
|
||||
OnboardingLanguageSwitcher
|
||||
} = runtime
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
Object.defineProperty(navigator, 'languages', { configurable: true, value: ['fr-FR'] })
|
||||
let finishDetection: (value: UiLanguageDetection) => void = () => {}
|
||||
const nativeDetection = new Promise<UiLanguageDetection>((resolve) => {
|
||||
finishDetection = resolve
|
||||
})
|
||||
let settings = getDefaultSettings('/tmp')
|
||||
const save = vi.fn(async (updates: Partial<GlobalSettings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
return settings
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
app: { getSystemLanguageDetection: () => nativeDetection },
|
||||
settings: { set: save },
|
||||
telemetryTrack: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
useAppStore.setState({ settings })
|
||||
usePluginLanguagePackStore.setState({ entries: [], loaded: true })
|
||||
function OnboardingGate(): React.JSX.Element {
|
||||
const ready = useLanguageReadiness((state) => state.ready)
|
||||
return ready ? (
|
||||
<>
|
||||
<p>Onboarding available</p>
|
||||
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
|
||||
</>
|
||||
) : (
|
||||
<p>Waiting for language</p>
|
||||
)
|
||||
}
|
||||
render(
|
||||
<I18nProvider>
|
||||
<OnboardingGate />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Waiting for language')).toBeTruthy()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Onboarding available')).toBeTruthy()
|
||||
expect(i18n.language).toBe('fr')
|
||||
expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe(
|
||||
'checked'
|
||||
)
|
||||
if (preference === 'explicit') {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
|
||||
})
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe('en')
|
||||
expect(i18n.language).toBe('en')
|
||||
}
|
||||
|
||||
await act(async () => finishDetection(detectUiLanguage(['ja-JP'], 'native')))
|
||||
|
||||
const expectedLocale = preference === 'system' ? 'ja' : 'en'
|
||||
expect(i18n.language).toBe(expectedLocale)
|
||||
expect(useAppStore.getState().settings?.uiLanguage).toBe(
|
||||
preference === 'system' ? 'system' : 'en'
|
||||
)
|
||||
expect(useLanguageReadiness.getState()).toMatchObject({
|
||||
ready: true,
|
||||
error: false,
|
||||
loading: false,
|
||||
detection: { locale: 'ja', source: 'native' }
|
||||
})
|
||||
expect(
|
||||
screen
|
||||
.getByRole('radio', { name: preference === 'system' ? '日本語' : 'English' })
|
||||
.getAttribute('data-state')
|
||||
).toBe('checked')
|
||||
expect(screen.getByText(/日本語/, { selector: 'p' })).toBeTruthy()
|
||||
expect(save).toHaveBeenCalledTimes(preference === 'system' ? 0 : 1)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { isWebClientLocation } = vi.hoisted(() => ({ isWebClientLocation: vi.fn() }))
|
||||
vi.mock('../lib/web-client-location', () => ({ isWebClientLocation }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
isWebClientLocation.mockReturnValue(false)
|
||||
Object.defineProperty(navigator, 'languages', { configurable: true, value: ['fr-FR', 'en-US'] })
|
||||
})
|
||||
|
||||
function installBridge(
|
||||
getSystemLanguageDetection?: (browserPreferences?: readonly string[]) => Promise<unknown>
|
||||
): void {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { app: { getSystemLanguageDetection } }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('client-local startup language', () => {
|
||||
it('uses the local native snapshot over Chromium preferences and freezes it for startup', async () => {
|
||||
const probe = vi.fn().mockResolvedValue({
|
||||
detectedTag: 'de-DE',
|
||||
status: 'unsupported',
|
||||
locale: 'en',
|
||||
source: 'native'
|
||||
})
|
||||
installBridge(probe)
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({
|
||||
detectedTag: 'de-DE',
|
||||
source: 'native'
|
||||
})
|
||||
await initializeLanguageDetection()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledWith(['fr-FR'])
|
||||
})
|
||||
|
||||
it('does not replace a C-only native result with browser English', async () => {
|
||||
installBridge(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ detectedTag: null, status: 'unknown', locale: 'en', source: 'native' })
|
||||
)
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({
|
||||
status: 'unknown',
|
||||
source: 'native'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses uncertain browser fallback when an older bridge lacks the optional snapshot', async () => {
|
||||
installBridge()
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({
|
||||
detectedTag: 'fr-FR',
|
||||
locale: 'fr',
|
||||
source: 'browser-fallback'
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the main-owned browser fallback when native detection is unavailable', async () => {
|
||||
const probe = vi.fn().mockResolvedValue({
|
||||
detectedTag: 'ja-JP',
|
||||
status: 'supported',
|
||||
locale: 'ja',
|
||||
source: 'browser-fallback'
|
||||
})
|
||||
installBridge(probe)
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({
|
||||
detectedTag: 'ja-JP',
|
||||
locale: 'ja',
|
||||
source: 'browser-fallback'
|
||||
})
|
||||
expect(probe).toHaveBeenCalledWith(['fr-FR'])
|
||||
})
|
||||
|
||||
it('uses browser preferences on web/mobile without asking the server for its locale', async () => {
|
||||
isWebClientLocation.mockReturnValue(true)
|
||||
const probe = vi.fn()
|
||||
installBridge(probe)
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({ locale: 'fr', source: 'browser' })
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds startup with browser fallback and reconciles a late native result', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finish: (value: unknown) => void = () => {}
|
||||
installBridge(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const { initializeLanguageDetection, useLanguageReadiness } = await import('./language-readiness')
|
||||
const detection = initializeLanguageDetection()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(await detection).toMatchObject({ locale: 'fr', source: 'browser-fallback' })
|
||||
finish({ detectedTag: 'ja-JP', locale: 'ja', status: 'supported', source: 'native' })
|
||||
await Promise.resolve()
|
||||
expect(useLanguageReadiness.getState().detection).toMatchObject({
|
||||
locale: 'ja',
|
||||
source: 'native'
|
||||
})
|
||||
const { getRendererLanguageDetection } = await import('../../../shared/ui-locale')
|
||||
expect(getRendererLanguageDetection().locale).toBe('ja')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({ locale: 'ja', source: 'native' })
|
||||
})
|
||||
|
||||
it('uses browser fallback when native IPC rejects', async () => {
|
||||
installBridge(() => Promise.reject(new Error('Native locale API unavailable')))
|
||||
const { initializeLanguageDetection } = await import('./language-readiness')
|
||||
expect(await initializeLanguageDetection()).toMatchObject({
|
||||
locale: 'fr',
|
||||
source: 'browser-fallback'
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
getBrowserLanguageDetection,
|
||||
setRendererLanguageDetection,
|
||||
type UiLanguageDetection
|
||||
} from '../../../shared/ui-locale'
|
||||
import { isWebClientLocation } from '../lib/web-client-location'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
|
||||
export const useLanguageReadiness = create<{
|
||||
ready: boolean
|
||||
error: boolean
|
||||
loading: boolean
|
||||
detection: UiLanguageDetection | null
|
||||
retryCount: number
|
||||
retryLanguageLoad: () => void
|
||||
}>()((set) => ({
|
||||
ready: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
detection: null,
|
||||
retryCount: 0,
|
||||
retryLanguageLoad: () =>
|
||||
set((state) => (state.loading ? state : { loading: true, retryCount: state.retryCount + 1 }))
|
||||
}))
|
||||
|
||||
let detectionPromise: Promise<UiLanguageDetection> | undefined
|
||||
|
||||
function publishLanguageDetection(detection: UiLanguageDetection): UiLanguageDetection {
|
||||
setRendererLanguageDetection(detection)
|
||||
useLanguageReadiness.setState({ detection })
|
||||
return detection
|
||||
}
|
||||
|
||||
export function initializeLanguageDetection(): Promise<UiLanguageDetection> {
|
||||
const currentDetection = useLanguageReadiness.getState().detection
|
||||
if (currentDetection) {
|
||||
return Promise.resolve(currentDetection)
|
||||
}
|
||||
detectionPromise ??= (async () => {
|
||||
let detection: UiLanguageDetection | null = null
|
||||
const browserClient = isWebClientLocation()
|
||||
const browserDetection = getBrowserLanguageDetection(
|
||||
browserClient ? 'browser' : 'browser-fallback'
|
||||
)
|
||||
if (!browserClient) {
|
||||
try {
|
||||
const snapshot = window.api?.app?.getSystemLanguageDetection?.(
|
||||
browserDetection.detectedTag ? [browserDetection.detectedTag] : []
|
||||
)
|
||||
if (snapshot) {
|
||||
detection = await withTimeout<UiLanguageDetection | null>(
|
||||
snapshot.then((nativeDetection) =>
|
||||
nativeDetection ? publishLanguageDetection(nativeDetection) : null
|
||||
),
|
||||
1000,
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Older local bridges do not provide the optional snapshot.
|
||||
}
|
||||
}
|
||||
detection ??= useLanguageReadiness.getState().detection ?? browserDetection
|
||||
return publishLanguageDetection(detection)
|
||||
})()
|
||||
return detectionPromise
|
||||
}
|
||||
@@ -20969,5 +20969,26 @@
|
||||
"unavailable": "This workspace is protected or unavailable."
|
||||
},
|
||||
"rowActions": "Actions for {{name}}"
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "Languages",
|
||||
"requestPrompt": "Which language would you like Orca to support?",
|
||||
"requestSuccess": "Language request recorded. You can continue onboarding.",
|
||||
"requestError": "Could not record your request. Try again.",
|
||||
"retry": "Retry",
|
||||
"requestAction": "Request language",
|
||||
"title": "Display language",
|
||||
"device": "System language: {{language}}",
|
||||
"unsupported": "System language: {{language}}. This language is not supported yet.",
|
||||
"unknown": "Your system language could not be detected. Choose a language below.",
|
||||
"loadError": "Could not load this language. Try again.",
|
||||
"saveError": "Could not save your language preference. Try again.",
|
||||
"other": "Other language…",
|
||||
"settingsError": "Could not load your settings.",
|
||||
"loadingSettings": "Loading settings…",
|
||||
"loading": "Loading language…",
|
||||
"saving": "Saving language…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20623,5 +20623,26 @@
|
||||
"truncated": "No se pudieron mostrar algunos favoritos. Gestiona tus favoritos en Jira para acortar la lista.",
|
||||
"unsupported": "Actualiza el host de Jira para usar los favoritos."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "Idiomas",
|
||||
"requestPrompt": "¿Qué idioma te gustaría que Orca admitiera?",
|
||||
"requestSuccess": "Solicitud de idioma registrada. Puedes continuar la configuración.",
|
||||
"requestError": "No se pudo registrar tu solicitud. Inténtalo de nuevo.",
|
||||
"retry": "Reintentar",
|
||||
"requestAction": "Solicitar idioma",
|
||||
"title": "Idioma de la interfaz",
|
||||
"device": "Idioma del sistema: {{language}}",
|
||||
"unsupported": "Idioma del sistema: {{language}}. Este idioma aún no es compatible.",
|
||||
"unknown": "No se pudo detectar el idioma del sistema. Elige uno a continuación.",
|
||||
"loadError": "No se pudo cargar este idioma. Inténtalo de nuevo.",
|
||||
"saveError": "No se pudo guardar tu preferencia de idioma. Inténtalo de nuevo.",
|
||||
"other": "Otro idioma…",
|
||||
"settingsError": "No se pudo cargar tu configuración.",
|
||||
"loadingSettings": "Cargando configuración…",
|
||||
"loading": "Cargando idioma…",
|
||||
"saving": "Guardando idioma…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20635,5 +20635,26 @@
|
||||
"preparingLabel": "Préparation de la mise à jour",
|
||||
"preparingAriaLabel": "Préparation de la mise à jour. Cliquez pour développer."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "Langues",
|
||||
"requestPrompt": "Quelle langue aimeriez-vous voir prise en charge par Orca ?",
|
||||
"requestSuccess": "Demande de langue enregistrée. Vous pouvez poursuivre la configuration.",
|
||||
"requestError": "Impossible d’enregistrer votre demande. Réessayez.",
|
||||
"retry": "Réessayer",
|
||||
"requestAction": "Demander une langue",
|
||||
"title": "Langue d’affichage",
|
||||
"device": "Langue du système : {{language}}",
|
||||
"unsupported": "Langue du système : {{language}}. Cette langue n’est pas encore prise en charge.",
|
||||
"unknown": "La langue du système n’a pas pu être détectée. Choisissez une langue ci-dessous.",
|
||||
"loadError": "Impossible de charger cette langue. Réessayez.",
|
||||
"saveError": "Impossible d’enregistrer votre préférence de langue. Réessayez.",
|
||||
"other": "Autre langue…",
|
||||
"settingsError": "Impossible de charger vos paramètres.",
|
||||
"loadingSettings": "Chargement des paramètres…",
|
||||
"loading": "Chargement de la langue…",
|
||||
"saving": "Enregistrement de la langue…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20635,5 +20635,26 @@
|
||||
"preparingLabel": "更新を準備中",
|
||||
"preparingAriaLabel": "更新を準備中。クリックして展開します。"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "言語",
|
||||
"requestPrompt": "Orcaに対応してほしい言語は何ですか?",
|
||||
"requestSuccess": "言語のリクエストを記録しました。設定を続けられます。",
|
||||
"requestError": "リクエストを記録できませんでした。再試行してください。",
|
||||
"retry": "再試行",
|
||||
"requestAction": "言語をリクエスト",
|
||||
"title": "表示言語",
|
||||
"device": "システム言語:{{language}}",
|
||||
"unsupported": "システム言語:{{language}}。この言語はまだサポートされていません。",
|
||||
"unknown": "システム言語を検出できませんでした。以下から選択してください。",
|
||||
"loadError": "この言語を読み込めませんでした。再試行してください。",
|
||||
"saveError": "言語設定を保存できませんでした。再試行してください。",
|
||||
"other": "その他の言語…",
|
||||
"settingsError": "設定を読み込めませんでした。",
|
||||
"loadingSettings": "設定を読み込み中…",
|
||||
"loading": "言語を読み込み中…",
|
||||
"saving": "言語を保存中…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20635,5 +20635,26 @@
|
||||
"preparingLabel": "업데이트 준비 중",
|
||||
"preparingAriaLabel": "업데이트 준비 중입니다. 클릭하여 펼치세요."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "언어",
|
||||
"requestPrompt": "Orca에서 어떤 언어를 지원하면 좋을까요?",
|
||||
"requestSuccess": "언어 요청이 기록되었습니다. 설정을 계속할 수 있습니다.",
|
||||
"requestError": "요청을 기록하지 못했습니다. 다시 시도하세요.",
|
||||
"retry": "다시 시도",
|
||||
"requestAction": "언어 지원 요청",
|
||||
"title": "표시 언어",
|
||||
"device": "시스템 언어: {{language}}",
|
||||
"unsupported": "시스템 언어: {{language}}. 이 언어는 아직 지원되지 않습니다.",
|
||||
"unknown": "시스템 언어를 감지하지 못했습니다. 아래에서 언어를 선택하세요.",
|
||||
"loadError": "이 언어를 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"saveError": "언어 설정을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"other": "다른 언어…",
|
||||
"settingsError": "설정을 불러오지 못했습니다.",
|
||||
"loadingSettings": "설정 불러오는 중…",
|
||||
"loading": "언어 불러오는 중…",
|
||||
"saving": "언어 저장 중…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20888,5 +20888,26 @@
|
||||
"unavailable": "Este espaço de trabalho está protegido ou indisponível."
|
||||
},
|
||||
"rowActions": "Ações para {{name}}"
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "Idiomas",
|
||||
"requestPrompt": "Qual idioma você gostaria que o Orca oferecesse?",
|
||||
"requestSuccess": "Solicitação de idioma registrada. Você pode continuar a configuração.",
|
||||
"requestError": "Não foi possível registrar sua solicitação. Tente novamente.",
|
||||
"retry": "Tentar novamente",
|
||||
"requestAction": "Solicitar idioma",
|
||||
"title": "Idioma de exibição",
|
||||
"device": "Idioma do sistema: {{language}}",
|
||||
"unsupported": "Idioma do sistema: {{language}}. Este idioma ainda não é compatível.",
|
||||
"unknown": "Não foi possível detectar o idioma do sistema. Escolha um idioma abaixo.",
|
||||
"loadError": "Não foi possível carregar este idioma. Tente novamente.",
|
||||
"saveError": "Não foi possível salvar sua preferência de idioma. Tente novamente.",
|
||||
"other": "Outro idioma…",
|
||||
"settingsError": "Não foi possível carregar suas configurações.",
|
||||
"loadingSettings": "Carregando configurações…",
|
||||
"loading": "Carregando idioma…",
|
||||
"saving": "Salvando idioma…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20635,5 +20635,26 @@
|
||||
"preparingLabel": "正在准备更新",
|
||||
"preparingAriaLabel": "正在准备更新。点击展开。"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"language": {
|
||||
"back": "语言",
|
||||
"requestPrompt": "你希望 Orca 支持哪种语言?",
|
||||
"requestSuccess": "语言请求已记录。你可以继续设置。",
|
||||
"requestError": "无法记录你的请求。请重试。",
|
||||
"retry": "重试",
|
||||
"requestAction": "请求语言支持",
|
||||
"title": "显示语言",
|
||||
"device": "系统语言:{{language}}",
|
||||
"unsupported": "系统语言:{{language}}。暂不支持此语言。",
|
||||
"unknown": "无法检测系统语言。请在下方选择语言。",
|
||||
"loadError": "无法加载此语言。请重试。",
|
||||
"saveError": "无法保存你的语言偏好设置。请重试。",
|
||||
"other": "其他语言…",
|
||||
"settingsError": "无法加载你的设置。",
|
||||
"loadingSettings": "正在加载设置…",
|
||||
"loading": "正在加载语言…",
|
||||
"saving": "正在保存语言…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,3 +53,7 @@ export function getUiLanguageChoiceLabel(
|
||||
export function resolveUiLocale(language: UiLanguage): string {
|
||||
return resolveRendererUiLocale(language)
|
||||
}
|
||||
|
||||
export function getUiLanguageNativeLabel(language: BuiltInUiLanguage): string {
|
||||
return UI_LANGUAGE_CHOICE_FALLBACKS[language]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TelemetrySettings } from './telemetry-settings-types'
|
||||
import type { GlobalSettingsBase } from './global-settings-base'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { OrcaWorkspaceLayout } from './orca-workspace-layout'
|
||||
@@ -525,15 +526,8 @@ export type GlobalSettings = GlobalSettingsBase & {
|
||||
/** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */
|
||||
gitlabProjects?: GitLabProjectSettings
|
||||
/** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it.
|
||||
* Holds only consent + identity, not volatile counters — those would amplify the debounced settings write. */
|
||||
telemetry?: {
|
||||
/** New users: true at install. Existing users: null until they resolve the first-launch banner. */
|
||||
optedIn: boolean | null
|
||||
/** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */
|
||||
installId: string
|
||||
/** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */
|
||||
existedBeforeTelemetryRelease: boolean
|
||||
}
|
||||
* Holds consent, identity, and frozen experiments, not volatile counters — those would amplify the debounced settings write. */
|
||||
telemetry?: TelemetrySettings
|
||||
/** One-shot cohort marker for the tab-switch keybinding swap. 'pending' =
|
||||
* pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */
|
||||
tabSwitchKeybindingSeed?: 'pending' | 'done'
|
||||
|
||||
@@ -129,7 +129,8 @@ export const commonPropsSchema = z
|
||||
// `.min(1)`: an empty install_id/session_id would collapse unrelated events into one synthetic user/session, corrupting analytics.
|
||||
install_id: z.string().min(1).max(64),
|
||||
session_id: z.string().min(1).max(64),
|
||||
orca_channel: z.enum(['stable', 'rc'])
|
||||
orca_channel: z.enum(['stable', 'rc']),
|
||||
onboarding_language_v1: z.enum(['control', 'treatment']).optional()
|
||||
})
|
||||
.strict()
|
||||
export type CommonProps = z.infer<typeof commonPropsSchema>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
onboardingLanguagePickerOpenedSchema,
|
||||
onboardingLanguageChangedSchema,
|
||||
onboardingLanguageRequestedSchema
|
||||
} from './telemetry-onboarding-language-schemas'
|
||||
import { agentTokenUsageSchema } from './telemetry-agent-token-usage-schema'
|
||||
import {
|
||||
agentErrorSchema,
|
||||
@@ -169,6 +174,9 @@ export const eventSchemas = {
|
||||
feature_wall_feature_selected: featureWallFeatureSelectedSchema,
|
||||
feature_wall_docs_clicked: featureWallDocsClickedSchema,
|
||||
|
||||
onboarding_language_picker_opened: onboardingLanguagePickerOpenedSchema,
|
||||
onboarding_language_changed: onboardingLanguageChangedSchema,
|
||||
onboarding_language_requested: onboardingLanguageRequestedSchema,
|
||||
onboarding_started: onboardingStartedSchema,
|
||||
onboarding_step_viewed: onboardingStepViewedSchema,
|
||||
onboarding_step_completed: onboardingStepCompletedSchema,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const properties = {
|
||||
system_language: z.string().max(128).nullable(),
|
||||
system_language_supported: z.boolean(),
|
||||
detection_source: z.enum(['native', 'browser', 'browser-fallback']),
|
||||
effective_language: z.string().max(256)
|
||||
}
|
||||
|
||||
export const onboardingLanguagePickerOpenedSchema = z.object(properties).strict()
|
||||
export const onboardingLanguageChangedSchema = z
|
||||
.object({
|
||||
...properties,
|
||||
selected_language: z.string().max(256)
|
||||
})
|
||||
.strict()
|
||||
export const onboardingLanguageRequestedSchema = z.object(properties).strict()
|
||||
@@ -0,0 +1,10 @@
|
||||
export type TelemetrySettings = {
|
||||
/** New users: true at install. Existing users: null until they resolve the first-launch banner. */
|
||||
optedIn: boolean | null
|
||||
/** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */
|
||||
installId: string
|
||||
/** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */
|
||||
existedBeforeTelemetryRelease: boolean
|
||||
/** Frozen 50/50 assignment for fresh installs; absent for profiles predating the experiment. */
|
||||
onboardingLanguageCohort?: 'control' | 'treatment'
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeSupportedUiLocale, resolveUiLocale } from './ui-locale'
|
||||
import {
|
||||
normalizeSupportedUiLocale,
|
||||
resolveUiLocale,
|
||||
detectUiLanguage,
|
||||
normalizeUiLocaleTag,
|
||||
shouldShowLanguageSwitcher
|
||||
} from './ui-locale'
|
||||
import {
|
||||
UI_LANGUAGE_CHINESE,
|
||||
UI_LANGUAGE_ENGLISH,
|
||||
@@ -79,3 +85,44 @@ describe('ui-locale', () => {
|
||||
expect(resolveUiLocale(UI_LANGUAGE_SYSTEM, 'pt-BR')).toBe('pt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('system language detection', () => {
|
||||
it.each([
|
||||
['en-US', 'en', 'supported'],
|
||||
['fr_CA.UTF-8', 'fr', 'supported'],
|
||||
['pt-PT', 'pt', 'supported'],
|
||||
['zh-Hans-TW', 'zh', 'supported'],
|
||||
['zh-Hant-CN', 'en', 'unsupported'],
|
||||
['zh-Latn-CN', 'en', 'unsupported'],
|
||||
['zh-MO', 'en', 'unsupported'],
|
||||
['zh_CN.UTF-8@hant', 'en', 'unsupported'],
|
||||
['zh_TW.UTF-8@hans', 'zh', 'supported'],
|
||||
['de-DE', 'en', 'unsupported'],
|
||||
['en--US', 'en', 'unknown'],
|
||||
['C.UTF-8', 'en', 'unknown'],
|
||||
['POSIX', 'en', 'unknown']
|
||||
])('resolves %s without confusing a fallback with detected English', (tag, locale, status) => {
|
||||
expect(detectUiLanguage([tag], 'native')).toMatchObject({ locale, status })
|
||||
})
|
||||
|
||||
it('retains an unsupported primary instead of using a supported secondary', () => {
|
||||
expect(detectUiLanguage(['de-DE', 'fr-FR', 'C'], 'native')).toMatchObject({
|
||||
detectedTag: 'de-DE',
|
||||
locale: 'en',
|
||||
status: 'unsupported'
|
||||
})
|
||||
})
|
||||
|
||||
it('hides only confident English and respects language order', () => {
|
||||
const english = detectUiLanguage(['en-GB', 'zh-CN'], 'native')
|
||||
expect(shouldShowLanguageSwitcher(english)).toBe(false)
|
||||
expect(shouldShowLanguageSwitcher(detectUiLanguage(['en'], 'browser-fallback'))).toBe(true)
|
||||
expect(shouldShowLanguageSwitcher(detectUiLanguage(['C', 'garbage!'], 'native'))).toBe(true)
|
||||
expect(shouldShowLanguageSwitcher(detectUiLanguage(['zh-CN', 'en-GB'], 'native'))).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves script modifiers while normalizing POSIX locales', () => {
|
||||
expect(normalizeUiLocaleTag('sr_RS.UTF-8@latin')).toBe('sr-Latn-RS')
|
||||
expect(normalizeUiLocaleTag('sr_RS@cyrillic')).toBe('sr-Cyrl-RS')
|
||||
})
|
||||
})
|
||||
|
||||
+82
-17
@@ -16,22 +16,90 @@ export type SupportedUiLocale = (typeof SUPPORTED_UI_LOCALES)[number]
|
||||
|
||||
export const DEFAULT_UI_LOCALE: SupportedUiLocale = 'en'
|
||||
|
||||
function normalizeLocaleTag(locale: string | undefined): string {
|
||||
return (locale ?? DEFAULT_UI_LOCALE).trim().toLowerCase().replace(/_/g, '-')
|
||||
export type UiLanguageDetection = {
|
||||
detectedTag: string | null
|
||||
status: 'supported' | 'unsupported' | 'unknown'
|
||||
locale: SupportedUiLocale
|
||||
source: 'native' | 'browser' | 'browser-fallback'
|
||||
}
|
||||
|
||||
export function normalizeUiLocaleTag(value: string | undefined): string | null {
|
||||
if (!value || value.length > 128) {
|
||||
return null
|
||||
}
|
||||
const [rawTag, modifier] = value.trim().split('@')
|
||||
const tag = rawTag.replace(/\.[A-Za-z0-9-]+$/, '').replace(/_/g, '-')
|
||||
if (/^(c|posix)$/i.test(tag)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const locale = new Intl.Locale(tag)
|
||||
const script = Object.entries({
|
||||
hans: 'Hans',
|
||||
hant: 'Hant',
|
||||
latin: 'Latn',
|
||||
cyrillic: 'Cyrl'
|
||||
}).find(([name]) => name === modifier?.toLowerCase())?.[1]
|
||||
return new Intl.Locale(locale, script ? { script } : undefined).baseName
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function matchUiLocale(tag: string): SupportedUiLocale | null {
|
||||
const locale = new Intl.Locale(tag)
|
||||
if (locale.language === 'zh') {
|
||||
if (locale.script) {
|
||||
return locale.script === 'Hans' ? 'zh' : null
|
||||
}
|
||||
return ['TW', 'HK', 'MO'].includes(locale.region ?? '') ? null : 'zh'
|
||||
}
|
||||
return SUPPORTED_UI_LOCALES.find((supported) => supported === locale.language) ?? null
|
||||
}
|
||||
|
||||
export function detectUiLanguage(
|
||||
preferences: readonly string[],
|
||||
source: UiLanguageDetection['source']
|
||||
): UiLanguageDetection {
|
||||
const detectedTag = preferences.map(normalizeUiLocaleTag).find((tag) => tag !== null) ?? null
|
||||
const match = detectedTag ? matchUiLocale(detectedTag) : null
|
||||
return {
|
||||
detectedTag,
|
||||
status: detectedTag ? (match ? 'supported' : 'unsupported') : 'unknown',
|
||||
locale: match ?? DEFAULT_UI_LOCALE,
|
||||
source
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSupportedUiLocale(locale: string | undefined): SupportedUiLocale {
|
||||
const tag = normalizeLocaleTag(locale)
|
||||
const primary = tag.split('-')[0]
|
||||
if (primary === 'zh') {
|
||||
if (tag.startsWith('zh-tw') || tag.startsWith('zh-hk') || tag.startsWith('zh-hant')) {
|
||||
return DEFAULT_UI_LOCALE
|
||||
}
|
||||
return 'zh'
|
||||
}
|
||||
return SUPPORTED_UI_LOCALES.includes(primary as SupportedUiLocale)
|
||||
? (primary as SupportedUiLocale)
|
||||
: DEFAULT_UI_LOCALE
|
||||
return detectUiLanguage(locale ? [locale] : [], 'browser').locale
|
||||
}
|
||||
|
||||
let rendererDetection: UiLanguageDetection | null = null
|
||||
|
||||
export function setRendererLanguageDetection(detection: UiLanguageDetection): void {
|
||||
rendererDetection = detection
|
||||
}
|
||||
|
||||
export function getBrowserLanguageDetection(
|
||||
source: UiLanguageDetection['source'] = 'browser'
|
||||
): UiLanguageDetection {
|
||||
return detectUiLanguage(
|
||||
typeof navigator === 'undefined' ? [] : [...(navigator.languages ?? []), navigator.language],
|
||||
source
|
||||
)
|
||||
}
|
||||
|
||||
export function getRendererLanguageDetection(): UiLanguageDetection {
|
||||
return rendererDetection ?? getBrowserLanguageDetection()
|
||||
}
|
||||
|
||||
export function shouldShowLanguageSwitcher(detection: UiLanguageDetection): boolean {
|
||||
return (
|
||||
detection.source === 'browser-fallback' ||
|
||||
detection.status !== 'supported' ||
|
||||
detection.locale !== 'en'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveUiLocale(
|
||||
@@ -66,10 +134,7 @@ export function resolveUiLocale(
|
||||
}
|
||||
|
||||
export function getRendererSystemLocale(): string {
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return navigator.language
|
||||
}
|
||||
return DEFAULT_UI_LOCALE
|
||||
return getRendererLanguageDetection().locale
|
||||
}
|
||||
|
||||
export function resolveRendererUiLocale(language: UiLanguage): string {
|
||||
|
||||
Reference in New Issue
Block a user