mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.78",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.78",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.78",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Platform,
|
||||
Alert as NativeAlert,
|
||||
} from 'react-native';
|
||||
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
@@ -459,7 +459,7 @@ export function EventModal({
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('calendar.form.title', 'Title')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.titleInput}
|
||||
/>
|
||||
|
||||
@@ -515,12 +515,10 @@ export function EventModal({
|
||||
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
|
||||
<View style={styles.allDayRow}>
|
||||
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||
thumbColor={allDay ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -673,7 +671,7 @@ export function EventModal({
|
||||
keyboardType="number-pad"
|
||||
style={styles.customAmountInput}
|
||||
placeholder="30"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
/>
|
||||
<View style={styles.customUnits}>
|
||||
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
||||
@@ -708,7 +706,7 @@ export function EventModal({
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
@@ -718,7 +716,7 @@ export function EventModal({
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
@@ -734,7 +732,7 @@ export function EventModal({
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
/>
|
||||
@@ -756,12 +754,10 @@ export function EventModal({
|
||||
<Text style={[styles.fieldText, { flex: 1 }]}>
|
||||
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={sendInvitations}
|
||||
onValueChange={setSendInvitations}
|
||||
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fieldText: { ...typography.body, color: c.text },
|
||||
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
customReminderRow: {
|
||||
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
customAmountInput: {
|
||||
...typography.body,
|
||||
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.popover,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlignVertical: 'top',
|
||||
|
||||
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
addBtn: {
|
||||
width: 38,
|
||||
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
|
||||
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
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,
|
||||
ScrollView,
|
||||
ViewStyle,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
|
||||
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
|
||||
// its own, so without it a screen reader only announces "switch, off".
|
||||
const SettingLabelContext = React.createContext<string | undefined>(undefined);
|
||||
@@ -95,10 +99,27 @@ export function SettingItem({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
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 = (
|
||||
<>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={[styles.settingContent, stacked && styles.settingContentStacked]} onLayout={onContentLayout}>
|
||||
<View style={styles.settingLabelRow}>
|
||||
<Text style={styles.settingLabel}>{label}</Text>
|
||||
{locked && <Lock size={12} color={c.mutedForeground} />}
|
||||
@@ -119,7 +140,7 @@ export function SettingItem({
|
||||
return (
|
||||
<Pressable
|
||||
ref={highlight.ref}
|
||||
onLayout={highlight.onLayout}
|
||||
onLayout={onRowLayout}
|
||||
onPress={onPress}
|
||||
accessibilityRole={link === 'external' ? 'link' : 'button'}
|
||||
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
|
||||
@@ -129,7 +150,7 @@ export function SettingItem({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
|
||||
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
|
||||
{body}
|
||||
</View>
|
||||
);
|
||||
@@ -366,6 +387,16 @@ function makeStyles(c: ThemePalette) {
|
||||
settingContent: {
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { I18nManager } from 'react-native';
|
||||
import { getLocales } from 'expo-localization';
|
||||
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
|
||||
// catalogs are loaded on first use; see CATALOG_LOADERS below.
|
||||
@@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode {
|
||||
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 {
|
||||
let current: unknown = dict;
|
||||
for (const part of key.split('.')) {
|
||||
|
||||
@@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({
|
||||
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 { formatHeaderDate } from '../email-date';
|
||||
|
||||
function setDevice(...tags: string[]) {
|
||||
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
|
||||
resetIntlLocaleCache();
|
||||
registerDeviceLocales();
|
||||
}
|
||||
|
||||
describe('intlLocale', () => {
|
||||
|
||||
+18
-15
@@ -3,11 +3,25 @@
|
||||
// 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
|
||||
// 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>();
|
||||
|
||||
export function setDeviceLocales(locales: DeviceLocale[]): void {
|
||||
deviceLocales = locales;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// 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
|
||||
// language, script and region.
|
||||
@@ -25,14 +39,8 @@ function isUsable(tag: string): boolean {
|
||||
}
|
||||
|
||||
function deviceTagFor(locale: string): string | null {
|
||||
let devices: ReturnType<typeof getLocales>;
|
||||
try {
|
||||
devices = getLocales();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const l of devices) {
|
||||
if (!l.languageTag || resolveLocaleTag(l) !== locale) continue;
|
||||
for (const l of deviceLocales) {
|
||||
if (!l.languageTag || l.appLocale !== locale) continue;
|
||||
const tag = baseTag(l.languageTag);
|
||||
// A tag without a region adds nothing over the language subtag.
|
||||
if (tag.includes('-') && isUsable(tag)) return tag;
|
||||
@@ -52,8 +60,3 @@ export function intlLocale(locale: string | null | undefined): string {
|
||||
cache.set(lang, 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 {
|
||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, Switch, useWindowDimensions,
|
||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
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 { RootStackParamList } from '../navigation/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import ToggleSwitch from '../components/ToggleSwitch';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
||||
|
||||
@@ -2861,7 +2862,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
)}
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={overrideEnabled}
|
||||
onValueChange={setOverrideEnabled}
|
||||
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 },
|
||||
|
||||
field: { gap: 4 },
|
||||
fieldLabel: { ...typography.caption, color: c.textMuted },
|
||||
fieldLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
row2: { flexDirection: 'row', gap: spacing.sm },
|
||||
|
||||
input: {
|
||||
@@ -1990,7 +1990,8 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
// The shared Input: transparent, bordered (webmail contact form).
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
...typography.body,
|
||||
|
||||
@@ -758,7 +758,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: c.primary },
|
||||
sectionHeaderText: { ...typography.captionMedium, color: c.mutedForeground },
|
||||
|
||||
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>
|
||||
</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}>
|
||||
<SearchHighlightContext.Provider value={searchHighlight}>
|
||||
{scopeBanner}
|
||||
@@ -458,6 +460,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
key="list"
|
||||
style={styles.scrollArea}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
Reference in New Issue
Block a user