Files
webmail/components/settings/language-settings.tsx
T
Linus Rath aa62855759 Merge branch 'feat/do-ux-settings-fixes' into feat/desktop-offline
# Conflicts:
#	components/layout/__tests__/account-switcher-mountain-view.test.tsx
#	components/layout/account-switcher.tsx
#	components/settings/accessibility-settings.tsx
#	components/settings/reading-settings.tsx
2026-11-04 09:44:51 +01:00

175 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { DateFormat, DateLocale, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
import { formatDate } from '@/lib/utils';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { getEffectiveTimeZone } from '@/lib/timezone';
import { useIsDesktopApp } from '@/hooks/use-is-desktop-app';
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
import { TimeZonePicker } from './time-zone-picker';
export function LanguageSettings() {
const t = useTranslations('settings.language_region');
const tDays = useTranslations('calendar.days');
const tGeneral = useTranslations('uxs_general');
const desktop = useIsDesktopApp();
const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, timeZone, updateSetting } = useSettingsStore();
// Subscribe to locale changes so the preview re-renders on language switch
// (formatDate reads it via getState() and would otherwise stay stale).
const locale = useLocaleStore((s) => s.locale);
const preview = useMemo(() => {
// Build sample timestamps for each bucket so users see what their pick
// will look like in practice. Use offsets relative to "now" so the
// bucketing is stable even though the wall-clock keeps moving.
void locale; void dateFormat; void dateLocale; void timeFormat; void timeZone;
const now = new Date();
const today = new Date(now);
today.setHours(15, 31, 0, 0);
const thisWeek = new Date(now);
thisWeek.setDate(now.getDate() - 2);
thisWeek.setHours(15, 31, 0, 0);
const older = new Date(now);
older.setMonth(now.getMonth() - 2);
older.setHours(15, 31, 0, 0);
// The date order samples use last year: this year's dates read "Oct 24"
// when the order follows the language.
const lastYear = new Date(older);
lastYear.setFullYear(now.getFullYear() - 1);
return {
today: formatDate(today),
thisWeek: formatDate(thisWeek),
older: formatDate(older),
// Every dropdown option carries a sample of what it renders, so the
// choices read alike instead of mixing names, hints and patterns.
dateFormat: {
smart: formatDate(thisWeek, { dateFormat: 'smart' }),
relative: formatDate(thisWeek, { dateFormat: 'relative' }),
full: formatDate(older, { dateFormat: 'full' }),
},
dateLocale: {
auto: formatDate(lastYear, { dateFormat: 'smart', dateLocale: 'auto' }),
iso: formatDate(lastYear, { dateFormat: 'smart', dateLocale: 'iso' }),
'en-GB': formatDate(lastYear, { dateFormat: 'smart', dateLocale: 'en-GB' }),
'en-US': formatDate(lastYear, { dateFormat: 'smart', dateLocale: 'en-US' }),
},
};
}, [locale, dateFormat, dateLocale, timeFormat, timeZone]);
// CJK labels use full-width parentheses.
const withExample = (label: string, example: string) => {
if (!example) return label;
return /^(zh|ja)/.test(getEffectiveLocale()) ? `${label}(${example})` : `${label} (${example})`;
};
// Current wall-clock in the effective zone so a pick can be sanity-checked.
const timeZonePreview = useMemo(() => {
void timeZone; void timeFormat; void locale;
try {
return new Date().toLocaleTimeString(locale && locale !== 'auto' ? locale : undefined, {
timeZone: getEffectiveTimeZone(),
hour: '2-digit',
minute: '2-digit',
hour12: timeFormat === '12h',
timeZoneName: 'short',
});
} catch {
return '';
}
}, [timeZone, timeFormat, locale]);
return (
<SettingsSection id="language" title={t('title')} description={t('description')}>
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
<div className="flex flex-col items-end gap-2">
<Select
value={dateFormat}
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
options={[
{ value: 'smart', label: withExample(t('date_format.smart'), preview.dateFormat.smart) },
{ value: 'relative', label: withExample(t('date_format.relative'), preview.dateFormat.relative) },
{ value: 'full', label: withExample(t('date_format.full'), preview.dateFormat.full) },
]}
/>
<div className="text-xs text-muted-foreground text-end space-y-0.5 font-mono">
<div>
<span className="opacity-70">{t('date_format.preview_today')} </span>
<span className="text-foreground/90">{preview.today}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
<span className="text-foreground/90">{preview.thisWeek}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_older')} </span>
<span className="text-foreground/90">{preview.older}</span>
</div>
</div>
</div>
</SettingItem>
<SettingItem advanced label={t('date_locale.label')} description={t('date_locale.description')}>
<Select
value={dateLocale}
onChange={(value) => updateSetting('dateLocale', value as DateLocale)}
options={[
{ value: 'auto', label: withExample(t('date_locale.auto'), preview.dateLocale.auto) },
{ value: 'iso', label: withExample(t('date_locale.iso'), preview.dateLocale.iso) },
{ value: 'en-GB', label: withExample(t('date_locale.dmy'), preview.dateLocale['en-GB']) },
{ value: 'en-US', label: withExample(t('date_locale.mdy'), preview.dateLocale['en-US']) },
]}
/>
</SettingItem>
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
<RadioGroup
value={timeFormat}
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
options={[
{ value: '12h', label: t('time_format.12h') },
{ value: '24h', label: t('time_format.24h') },
]}
/>
</SettingItem>
<SettingItem
label={t('time_zone.label')}
description={desktop ? tGeneral('time_zone_desktop_description') : t('time_zone.description')}
>
<div className="flex flex-col items-end gap-2">
<TimeZonePicker value={timeZone} onChange={(value) => updateSetting('timeZone', value)} />
{timeZonePreview && (
<div className="text-xs text-muted-foreground text-end font-mono">
<span className="opacity-70">{t('time_zone.preview_now')} </span>
<span className="text-foreground/90">{timeZonePreview}</span>
</div>
)}
</div>
</SettingItem>
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
<Select
value={firstDayOfWeek.toString()}
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
options={[
{ value: '1', label: tDays('monday') },
{ value: '6', label: tDays('saturday') },
{ value: '0', label: tDays('sunday') },
]}
/>
</SettingItem>
</SettingsSection>
);
}