mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.77",
|
"version": "0.1.78",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.77",
|
"version": "0.1.78",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.77",
|
"version": "0.1.78",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Switch,
|
|
||||||
Platform,
|
Platform,
|
||||||
Alert as NativeAlert,
|
Alert as NativeAlert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
|
|||||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
|
||||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ export function EventModal({
|
|||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
placeholder={t('calendar.form.title', 'Title')}
|
placeholder={t('calendar.form.title', 'Title')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.titleInput}
|
style={styles.titleInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -515,12 +515,10 @@ export function EventModal({
|
|||||||
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
|
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
|
||||||
<View style={styles.allDayRow}>
|
<View style={styles.allDayRow}>
|
||||||
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
||||||
<Switch
|
<ToggleSwitch
|
||||||
value={allDay}
|
value={allDay}
|
||||||
onValueChange={setAllDay}
|
onValueChange={setAllDay}
|
||||||
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||||
thumbColor={allDay ? c.primary : c.textMuted}
|
|
||||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -673,7 +671,7 @@ export function EventModal({
|
|||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
style={styles.customAmountInput}
|
style={styles.customAmountInput}
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.mutedForeground}
|
||||||
/>
|
/>
|
||||||
<View style={styles.customUnits}>
|
<View style={styles.customUnits}>
|
||||||
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
||||||
@@ -708,7 +706,7 @@ export function EventModal({
|
|||||||
value={location}
|
value={location}
|
||||||
onChangeText={setLocation}
|
onChangeText={setLocation}
|
||||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.fieldButton}
|
style={styles.fieldButton}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -718,7 +716,7 @@ export function EventModal({
|
|||||||
value={videoUrl}
|
value={videoUrl}
|
||||||
onChangeText={setVideoUrl}
|
onChangeText={setVideoUrl}
|
||||||
placeholder="https://meet.example.com/…"
|
placeholder="https://meet.example.com/…"
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.mutedForeground}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -734,7 +732,7 @@ export function EventModal({
|
|||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={setDescription}
|
||||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.mutedForeground}
|
||||||
multiline
|
multiline
|
||||||
style={styles.descriptionInput}
|
style={styles.descriptionInput}
|
||||||
/>
|
/>
|
||||||
@@ -756,12 +754,10 @@ export function EventModal({
|
|||||||
<Text style={[styles.fieldText, { flex: 1 }]}>
|
<Text style={[styles.fieldText, { flex: 1 }]}>
|
||||||
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<ToggleSwitch
|
||||||
value={sendInvitations}
|
value={sendInvitations}
|
||||||
onValueChange={setSendInvitations}
|
onValueChange={setSendInvitations}
|
||||||
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
|
||||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
fieldText: { ...typography.body, color: c.text },
|
fieldText: { ...typography.body, color: c.text },
|
||||||
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
reminderRemove: { padding: 2 },
|
reminderRemove: { padding: 2 },
|
||||||
customReminderRow: {
|
customReminderRow: {
|
||||||
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
customAmountInput: {
|
customAmountInput: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.popover,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
popoverRow: {
|
popoverRow: {
|
||||||
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||||
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
color: c.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
textAlignVertical: 'top',
|
textAlignVertical: 'top',
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
addBtn: {
|
addBtn: {
|
||||||
width: 38,
|
width: 38,
|
||||||
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
backgroundColor: c.surface,
|
backgroundColor: c.background,
|
||||||
textAlignVertical: 'top',
|
textAlignVertical: 'top',
|
||||||
},
|
},
|
||||||
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||||
|
|||||||
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
info: { flex: 1, minWidth: 0 },
|
info: { flex: 1, minWidth: 0 },
|
||||||
name: { ...typography.bodyMedium, color: c.text },
|
name: { ...typography.bodyMedium, color: c.text },
|
||||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||||
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
|
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
|
type LayoutChangeEvent,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
|
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
@@ -68,6 +69,9 @@ export function SettingsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrowest label column before a row's control moves under the label. */
|
||||||
|
const STACK_BELOW = 140;
|
||||||
|
|
||||||
// The label of the SettingItem a control sits in. A switch has no text of
|
// The label of the SettingItem a control sits in. A switch has no text of
|
||||||
// its own, so without it a screen reader only announces "switch, off".
|
// its own, so without it a screen reader only announces "switch, off".
|
||||||
const SettingLabelContext = React.createContext<string | undefined>(undefined);
|
const SettingLabelContext = React.createContext<string | undefined>(undefined);
|
||||||
@@ -95,10 +99,27 @@ export function SettingItem({
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const highlight = useSearchHighlight(label);
|
const highlight = useSearchHighlight(label);
|
||||||
const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
|
// The control sits on the right. When that leaves the label column narrower
|
||||||
|
// than STACK_BELOW (a long translation, two buttons), the control moves
|
||||||
|
// under the label, as the webmail stacks it on a phone. A new row width
|
||||||
|
// (rotation, split view) measures again.
|
||||||
|
const [stacked, setStacked] = useState(false);
|
||||||
|
const rowWidth = React.useRef(0);
|
||||||
|
const onRowLayout = (e: LayoutChangeEvent) => {
|
||||||
|
highlight.onLayout(e);
|
||||||
|
const width = e.nativeEvent.layout.width;
|
||||||
|
if (Math.abs(width - rowWidth.current) > 1) {
|
||||||
|
rowWidth.current = width;
|
||||||
|
setStacked(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onContentLayout = (e: LayoutChangeEvent) => {
|
||||||
|
if (!stacked && children && e.nativeEvent.layout.width < STACK_BELOW) setStacked(true);
|
||||||
|
};
|
||||||
|
const rowStyle = [styles.settingItem, stacked && styles.settingItemStacked, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<View style={styles.settingContent}>
|
<View style={[styles.settingContent, stacked && styles.settingContentStacked]} onLayout={onContentLayout}>
|
||||||
<View style={styles.settingLabelRow}>
|
<View style={styles.settingLabelRow}>
|
||||||
<Text style={styles.settingLabel}>{label}</Text>
|
<Text style={styles.settingLabel}>{label}</Text>
|
||||||
{locked && <Lock size={12} color={c.mutedForeground} />}
|
{locked && <Lock size={12} color={c.mutedForeground} />}
|
||||||
@@ -119,7 +140,7 @@ export function SettingItem({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={highlight.ref}
|
ref={highlight.ref}
|
||||||
onLayout={highlight.onLayout}
|
onLayout={onRowLayout}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityRole={link === 'external' ? 'link' : 'button'}
|
accessibilityRole={link === 'external' ? 'link' : 'button'}
|
||||||
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
|
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
|
||||||
@@ -129,7 +150,7 @@ export function SettingItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
|
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
|
||||||
{body}
|
{body}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -366,6 +387,16 @@ function makeStyles(c: ThemePalette) {
|
|||||||
settingContent: {
|
settingContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
// The control under the label instead of beside it (see SettingItem).
|
||||||
|
settingItemStacked: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
settingContentStacked: {
|
||||||
|
flex: 0,
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
},
|
||||||
settingLabelRow: {
|
settingLabelRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { I18nManager } from 'react-native';
|
import { I18nManager } from 'react-native';
|
||||||
import { getLocales } from 'expo-localization';
|
import { getLocales } from 'expo-localization';
|
||||||
import { formatMessage, type MessageParams } from './format';
|
import { formatMessage, type MessageParams } from './format';
|
||||||
|
import { setDeviceLocales } from '../lib/intl-locale';
|
||||||
|
|
||||||
// English is the fallback for every lookup, so it is loaded eagerly. The other
|
// English is the fallback for every lookup, so it is loaded eagerly. The other
|
||||||
// catalogs are loaded on first use; see CATALOG_LOADERS below.
|
// catalogs are loaded on first use; see CATALOG_LOADERS below.
|
||||||
@@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode {
|
|||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the device locales to the date formatters (lib/intl-locale), so an
|
||||||
|
* en-AU phone formats dates as en-AU while reading the English catalog.
|
||||||
|
*/
|
||||||
|
export function registerDeviceLocales(): void {
|
||||||
|
try {
|
||||||
|
setDeviceLocales(getLocales().map((l) => ({ appLocale: resolveLocaleTag(l), languageTag: l.languageTag })));
|
||||||
|
} catch {
|
||||||
|
setDeviceLocales([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDeviceLocales();
|
||||||
|
|
||||||
function lookup(dict: Dictionary, key: string): string | undefined {
|
function lookup(dict: Dictionary, key: string): string | undefined {
|
||||||
let current: unknown = dict;
|
let current: unknown = dict;
|
||||||
for (const part of key.split('.')) {
|
for (const part of key.split('.')) {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({
|
|||||||
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
|
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { intlLocale, resetIntlLocaleCache } from '../intl-locale';
|
import { intlLocale } from '../intl-locale';
|
||||||
|
import { registerDeviceLocales } from '../../i18n';
|
||||||
import { formatCalendarDate, formatListDate } from '../date-format';
|
import { formatCalendarDate, formatListDate } from '../date-format';
|
||||||
import { formatHeaderDate } from '../email-date';
|
import { formatHeaderDate } from '../email-date';
|
||||||
|
|
||||||
function setDevice(...tags: string[]) {
|
function setDevice(...tags: string[]) {
|
||||||
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
|
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
|
||||||
resetIntlLocaleCache();
|
registerDeviceLocales();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('intlLocale', () => {
|
describe('intlLocale', () => {
|
||||||
|
|||||||
+18
-15
@@ -3,11 +3,25 @@
|
|||||||
// order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the
|
// order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the
|
||||||
// date formatters the device's full tag when its language matches the app
|
// date formatters the device's full tag when its language matches the app
|
||||||
// language, so an Australian or British phone gets its own dates (#75).
|
// language, so an Australian or British phone gets its own dates (#75).
|
||||||
import { getLocales } from 'expo-localization';
|
//
|
||||||
import { resolveLocaleTag } from '../i18n';
|
// This module stays free of react-native and expo because the device sync
|
||||||
|
// engine reaches it through quote-header (see device-sync purity test). The
|
||||||
|
// i18n module reads the device locales and registers them here on load.
|
||||||
|
|
||||||
|
export interface DeviceLocale {
|
||||||
|
/** The app language this device locale selects, from resolveLocaleTag. */
|
||||||
|
appLocale: string | null;
|
||||||
|
languageTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceLocales: DeviceLocale[] = [];
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function setDeviceLocales(locales: DeviceLocale[]): void {
|
||||||
|
deviceLocales = locales;
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`.
|
// Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`.
|
||||||
// The app has its own time-format and week-start settings, so keep only the
|
// The app has its own time-format and week-start settings, so keep only the
|
||||||
// language, script and region.
|
// language, script and region.
|
||||||
@@ -25,14 +39,8 @@ function isUsable(tag: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deviceTagFor(locale: string): string | null {
|
function deviceTagFor(locale: string): string | null {
|
||||||
let devices: ReturnType<typeof getLocales>;
|
for (const l of deviceLocales) {
|
||||||
try {
|
if (!l.languageTag || l.appLocale !== locale) continue;
|
||||||
devices = getLocales();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (const l of devices) {
|
|
||||||
if (!l.languageTag || resolveLocaleTag(l) !== locale) continue;
|
|
||||||
const tag = baseTag(l.languageTag);
|
const tag = baseTag(l.languageTag);
|
||||||
// A tag without a region adds nothing over the language subtag.
|
// A tag without a region adds nothing over the language subtag.
|
||||||
if (tag.includes('-') && isUsable(tag)) return tag;
|
if (tag.includes('-') && isUsable(tag)) return tag;
|
||||||
@@ -52,8 +60,3 @@ export function intlLocale(locale: string | null | undefined): string {
|
|||||||
cache.set(lang, resolved);
|
cache.set(lang, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test hook: forget the resolved tags so a changed device locale is seen. */
|
|
||||||
export function resetIntlLocaleCache(): void {
|
|
||||||
cache.clear();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, Switch, useWindowDimensions,
|
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { usePreventRemove } from '@react-navigation/native';
|
import { usePreventRemove } from '@react-navigation/native';
|
||||||
@@ -79,6 +79,7 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
|||||||
import type { EmailAddress, Identity } from '../api/types';
|
import type { EmailAddress, Identity } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
import ToggleSwitch from '../components/ToggleSwitch';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
||||||
|
|
||||||
@@ -2861,7 +2862,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
<View style={styles.switchRow}>
|
<View style={styles.switchRow}>
|
||||||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||||||
<Switch
|
<ToggleSwitch
|
||||||
value={overrideEnabled}
|
value={overrideEnabled}
|
||||||
onValueChange={setOverrideEnabled}
|
onValueChange={setOverrideEnabled}
|
||||||
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
||||||
|
|||||||
@@ -1982,7 +1982,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
|
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
|
||||||
|
|
||||||
field: { gap: 4 },
|
field: { gap: 4 },
|
||||||
fieldLabel: { ...typography.caption, color: c.textMuted },
|
fieldLabel: { ...typography.caption, color: c.mutedForeground },
|
||||||
row2: { flexDirection: 'row', gap: spacing.sm },
|
row2: { flexDirection: 'row', gap: spacing.sm },
|
||||||
|
|
||||||
input: {
|
input: {
|
||||||
@@ -1990,7 +1990,8 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: c.surface,
|
// The shared Input: transparent, bordered (webmail contact form).
|
||||||
|
backgroundColor: 'transparent',
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: c.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
sectionHeaderText: { ...typography.small, color: c.primary },
|
sectionHeaderText: { ...typography.captionMedium, color: c.mutedForeground },
|
||||||
|
|
||||||
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||||
|
|
||||||
|
|||||||
@@ -416,7 +416,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
|||||||
<Text style={styles.headerTitle} numberOfLines={1} accessibilityRole="header">{tabLabel}</Text>
|
<Text style={styles.headerTitle} numberOfLines={1} accessibilityRole="header">{tabLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
{/* Keyed per page: React would otherwise reuse the list's ScrollView
|
||||||
|
and open the page at the list's scroll offset. */}
|
||||||
|
<ScrollView key={`page-${selectedTab}`} ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||||
<View ref={paneContentRef}>
|
<View ref={paneContentRef}>
|
||||||
<SearchHighlightContext.Provider value={searchHighlight}>
|
<SearchHighlightContext.Provider value={searchHighlight}>
|
||||||
{scopeBanner}
|
{scopeBanner}
|
||||||
@@ -458,6 +460,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
key="list"
|
||||||
style={styles.scrollArea}
|
style={styles.scrollArea}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
|||||||
Reference in New Issue
Block a user