Handle restricted language configuration fallback V2 (#5154)

## Summary
- restrict supported languages to the validated list from app-config
instead of always adding an extra fallback
- set the effective fallback locale to the preferred configured language
and switch away from disallowed selections automatically

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27)
This commit is contained in:
Anthony Stirling
2025-12-10 11:12:18 +00:00
committed by GitHub
parent de438d00e1
commit f17ad56def
11 changed files with 187 additions and 60 deletions
@@ -14,8 +14,9 @@ export default function AppConfigLoader() {
useEffect(() => {
if (!loading && config) {
// Update supported languages if config specifies a language filter
updateSupportedLanguages(config.languages);
// Update supported languages and apply default locale from server config
// Priority: localStorage > config.defaultLocale > browser detection > fallback
updateSupportedLanguages(config.languages, config.defaultLocale);
}
}, [config, loading]);
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
// Types
interface LanguageSelectorProps {
@@ -163,13 +163,35 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
// Get the filtered list of supported languages from i18n
// This respects server config (ui.languages) applied by AppConfigLoader
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
.filter(lang => lang !== 'cimode'); // Exclude i18next debug language
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code))
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
label: `${name} (${code})`,
}));
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
// Calculate dropdown width and grid columns based on number of languages
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
const dropdownWidth = languageOptions.length <= 4 ? 300
: languageOptions.length <= 9 ? 400
: 600;
const gridColumns = languageOptions.length <= 4 ? 2
: languageOptions.length <= 9 ? 3
: 4;
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position (only for button mode)
if (!compact) {
@@ -219,10 +241,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
<Menu
opened={opened}
onChange={setOpened}
width={600}
width={dropdownWidth}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
zIndex={Z_INDEX_CONFIG_MODAL}
withinPortal
transitionProps={{
transition: 'scale-y',
duration: 120,
@@ -278,21 +301,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
];
const isDisabled = !enabledLanguages.includes(option.value);
return (
<div
className={styles.languageGrid}
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
>
{languageOptions.map((option, index) => (
<LanguageItem
key={option.value}
option={option}
@@ -303,10 +318,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
rippleEffect={rippleEffect}
pendingLanguage={pendingLanguage}
compact={compact}
disabled={isDisabled}
disabled={false}
/>
);
})}
))}
</div>
</Menu.Dropdown>
</Menu>