mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: add push device list, re-register, onboarding prompt and an i18n key harvester
This commit is contained in:
+73
-22
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"calendar": {
|
||||
"event_modal": {
|
||||
"participant_placeholder": "Add participant by email"
|
||||
}
|
||||
},
|
||||
"context_menu": {
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag"
|
||||
},
|
||||
"email_composer": {
|
||||
"attachment_reminder_title": "Forgot an attachment?",
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
"attachment_reminder_title": "Forgot an attachment?",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"save_draft": "Save Draft",
|
||||
"schedule_custom": "Pick date & time…",
|
||||
@@ -13,13 +18,15 @@
|
||||
"schedule_in_3h": "In 3 hours",
|
||||
"schedule_past_body": "The scheduled time must be in the future.",
|
||||
"schedule_past_title": "Pick a future time",
|
||||
"schedule_tomorrow": "Tomorrow, 8:00 AM",
|
||||
"schedule_too_late_body": "That is later than this server allows. Pick an earlier time.",
|
||||
"schedule_too_late_title": "Too far ahead",
|
||||
"schedule_tomorrow": "Tomorrow, 8:00 AM",
|
||||
"schedule_unsupported_body": "This mail server does not support scheduled send.",
|
||||
"schedule_unsupported_title": "Scheduling unavailable",
|
||||
"scheduled_body": "Your message will be sent at {time}.",
|
||||
"scheduled_title": "Scheduled",
|
||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"validation": {
|
||||
"subject_required": "Add a subject"
|
||||
}
|
||||
@@ -31,33 +38,77 @@
|
||||
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration."
|
||||
},
|
||||
"email_viewer": {
|
||||
"set_color": "Set tag",
|
||||
"remove_color": "Remove tag",
|
||||
"color_tag": {
|
||||
"title": "Color Tag",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"green": "Green",
|
||||
"none": "None",
|
||||
"orange": "Orange",
|
||||
"pink": "Pink",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": {
|
||||
"system_default": "System default",
|
||||
"restart_for_direction": "Restart the app to apply the new text direction."
|
||||
}
|
||||
"purple": "Purple",
|
||||
"red": "Red",
|
||||
"title": "Color Tag",
|
||||
"yellow": "Yellow"
|
||||
},
|
||||
"keywords": {
|
||||
"reset_defaults": "Reset to Defaults"
|
||||
}
|
||||
"remove_color": "Remove tag",
|
||||
"set_color": "Set tag"
|
||||
},
|
||||
"files": {
|
||||
"done": "Done",
|
||||
"share_add_people": "Add people",
|
||||
"share_empty": "Not shared with anyone yet",
|
||||
"share_error": "Failed to update sharing",
|
||||
"share_folder_hint": "Sharing a folder shares everything inside it.",
|
||||
"share_no_matches": "No matches",
|
||||
"share_no_users": "No other users found",
|
||||
"share_remove": "Remove access",
|
||||
"share_role_custom": "Custom",
|
||||
"share_search_placeholder": "Search by name or email",
|
||||
"shared_with": "Shared with"
|
||||
},
|
||||
"offline": {
|
||||
"banner": "You are offline",
|
||||
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
|
||||
},
|
||||
"push_prompt": {
|
||||
"body": "Turn on background notifications so new messages reach you while the app is closed.",
|
||||
"dismiss": "Not now",
|
||||
"enable": "Enable",
|
||||
"title": "Get notified about new mail"
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": {
|
||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||
"system_default": "System default"
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"reset_defaults": "Reset to Defaults"
|
||||
},
|
||||
"notifications": {
|
||||
"email": {
|
||||
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"phase_account": "Account",
|
||||
"phase_jmap": "Mail server subscription",
|
||||
"phase_permission": "Notification permission",
|
||||
"phase_platform": "Platform",
|
||||
"phase_relay": "Relay registration",
|
||||
"phase_token": "Device token (Firebase)",
|
||||
"phase_verify": "Verification",
|
||||
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
|
||||
"relay_invalid": "Enter a valid https:// URL",
|
||||
"setup_failed": "Setup failed",
|
||||
"status_android_only": "Background notifications are available on Android only."
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -12,7 +12,9 @@
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"sync-locales": "node scripts/sync-locales.mjs"
|
||||
"sync-locales": "node scripts/sync-locales.mjs",
|
||||
"i18n:harvest": "node scripts/harvest-rn-keys.mjs",
|
||||
"i18n:check": "node scripts/harvest-rn-keys.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -27,6 +29,7 @@
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Collect t('key', 'English fallback') calls whose key exists in neither the
|
||||
// vendored webmail catalog nor the RN overlay, and add them to
|
||||
// locales/rn/en.json using the inline fallback as the English text.
|
||||
// Run from the RN repo root: `node scripts/harvest-rn-keys.mjs [--check]`.
|
||||
// `--check` only reports (exit 1 when something is missing) - used by the
|
||||
// translation coverage test message.
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const SRC = join(ROOT, 'src');
|
||||
const EN = join(ROOT, 'locales', 'en', 'common.json');
|
||||
const OVERLAY = join(ROOT, 'locales', 'rn', 'en.json');
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out.set(key, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) {
|
||||
if (entry === '__tests__' || entry === 'node_modules') continue;
|
||||
walk(p, out);
|
||||
} else if (/\.tsx?$/.test(entry)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setDeep(obj, key, value) {
|
||||
const parts = key.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
if (cur[parts[parts.length - 1]] === undefined) cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function sortDeep(obj) {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
||||
return Object.keys(obj).sort().reduce((acc, k) => {
|
||||
acc[k] = sortDeep(obj[k]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const en = flatten(JSON.parse(readFileSync(EN, 'utf8')));
|
||||
const overlay = JSON.parse(readFileSync(OVERLAY, 'utf8'));
|
||||
const overlayFlat = flatten(overlay);
|
||||
|
||||
// t('key', 'fallback') / t('key', "fallback") / t('key', `fallback`) - the
|
||||
// fallback may span lines; keep it simple: single-quoted or double-quoted,
|
||||
// non-nested strings.
|
||||
const CALL_RE = /\bt\(\s*'([^']+)'\s*,\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")/g;
|
||||
const KEY_ONLY_RE = /\bt\(\s*'([^']+)'/g;
|
||||
|
||||
const added = [];
|
||||
const missingNoFallback = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const withFallback = new Map();
|
||||
for (const m of text.matchAll(CALL_RE)) {
|
||||
const value = (m[2] ?? m[3] ?? '').replace(/\\(['"])/g, '$1');
|
||||
if (value) withFallback.set(m[1], value);
|
||||
}
|
||||
for (const m of text.matchAll(KEY_ONLY_RE)) {
|
||||
const key = m[1];
|
||||
if (en.has(key) || overlayFlat.has(key)) continue;
|
||||
const fallback = withFallback.get(key);
|
||||
if (!fallback) {
|
||||
missingNoFallback.push(`${key} (${relative(ROOT, file)})`);
|
||||
continue;
|
||||
}
|
||||
if (!added.some((a) => a.key === key)) added.push({ key, fallback, file: relative(ROOT, file) });
|
||||
}
|
||||
}
|
||||
|
||||
if (added.length === 0 && missingNoFallback.length === 0) {
|
||||
console.log('All t() keys are covered.');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const a of added) console.log(`${CHECK_ONLY ? 'missing' : 'added'}: ${a.key} = ${JSON.stringify(a.fallback)} (${a.file})`);
|
||||
for (const m of missingNoFallback) console.log(`missing (no inline fallback, add by hand): ${m}`);
|
||||
|
||||
if (CHECK_ONLY) process.exit(1);
|
||||
for (const a of added) setDeep(overlay, a.key, a.fallback);
|
||||
writeFileSync(OVERLAY, JSON.stringify(sortDeep(overlay), null, 2) + '\n');
|
||||
console.log(`Wrote ${added.length} key(s) to locales/rn/en.json`);
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { BellRing, X } from 'lucide-react-native';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
dismissPushPrompt,
|
||||
getEffectiveRelayBaseUrl,
|
||||
isPushEnabledForAccount,
|
||||
isPushSupported,
|
||||
PushSetupError,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
wasPushPromptDismissed,
|
||||
} from '../lib/push-notifications';
|
||||
|
||||
// Mirrors the webmail's push-notification-prompt: a short delay after login
|
||||
// so the inbox renders first, and never again for an account once dismissed.
|
||||
const PROMPT_DELAY_MS = 1500;
|
||||
|
||||
/**
|
||||
* Once-per-account invitation to turn on background notifications. Shown
|
||||
* only where push can actually work (Android with Play services), when the
|
||||
* user has not disabled mail notifications, and until either "Enable" or
|
||||
* "Not now" has been tapped for the active account.
|
||||
*/
|
||||
export function PushOnboardingPrompt(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const emailNotificationsEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisible(false);
|
||||
setError(null);
|
||||
if (!isAuthenticated || !client || !activeAccountId || !emailNotificationsEnabled) return;
|
||||
if (!isPushSupported()) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
if (await wasPushPromptDismissed(activeAccountId)) return;
|
||||
if (await isPushEnabledForAccount(activeAccountId)) return;
|
||||
if (!cancelled) setVisible(true);
|
||||
})();
|
||||
}, PROMPT_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isAuthenticated, client, activeAccountId, emailNotificationsEnabled]);
|
||||
|
||||
if (!visible || !activeAccountId) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false);
|
||||
void dismissPushPrompt(activeAccountId);
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const relayBaseUrl = (await getEffectiveRelayBaseUrl()) || DEFAULT_RELAY_BASE_URL;
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
await setupPushNotifications({ relayBaseUrl, accountLabel: username ?? undefined });
|
||||
await dismissPushPrompt(activeAccountId);
|
||||
setVisible(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof PushSetupError || err instanceof Error
|
||||
? err.message
|
||||
: t('settings.notifications.push.setup_failed', 'Setup failed');
|
||||
setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card} accessibilityRole="summary">
|
||||
<View style={styles.iconWrap}>
|
||||
<BellRing size={18} color={c.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>
|
||||
{t('push_prompt.title', 'Get notified about new mail')}
|
||||
</Text>
|
||||
<Text style={styles.body}>
|
||||
{t(
|
||||
'push_prompt.body',
|
||||
'Turn on background notifications so new messages reach you while the app is closed.',
|
||||
)}
|
||||
</Text>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" onPress={() => void handleEnable()} loading={busy}>
|
||||
{t('push_prompt.enable', 'Enable')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={handleDismiss} disabled={busy}>
|
||||
{t('push_prompt.dismiss', 'Not now')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleDismiss}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
|
||||
style={styles.close}
|
||||
>
|
||||
<X size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.card,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: c.text },
|
||||
body: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
error: { ...typography.caption, color: c.error, marginTop: spacing.xs },
|
||||
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
||||
close: { padding: 2 },
|
||||
});
|
||||
}
|
||||
@@ -1,247 +1,392 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
|
||||
import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react-native';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useSettingsStore, type NotificationSound } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
getStoredRelayBaseUrl,
|
||||
isPushEnabledForAccount,
|
||||
isPushSupported,
|
||||
isValidRelayUrl,
|
||||
listPushDevices,
|
||||
PushSetupError,
|
||||
readPushAccountIds,
|
||||
revokePushDevice,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type PushDevice,
|
||||
type PushSetupPhase,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'ping', label: 'Ping' },
|
||||
{ value: 'pop', label: 'Pop' },
|
||||
{ value: 'none', label: 'Silent' },
|
||||
];
|
||||
|
||||
type PushStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'busy'; message: string }
|
||||
| { kind: 'enabled' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
type DevicesState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; devices: PushDevice[] }
|
||||
| { kind: 'error' };
|
||||
|
||||
const PHASE_KEYS: Record<PushSetupPhase, { key: string; fallback: string }> = {
|
||||
platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' },
|
||||
permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' },
|
||||
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token (Firebase)' },
|
||||
account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' },
|
||||
relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' },
|
||||
jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' },
|
||||
verify: { key: 'settings.notifications.push.phase_verify', fallback: 'Verification' },
|
||||
};
|
||||
|
||||
export function NotificationSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const sound = useSettingsStore((s) => s.notificationSoundChoice);
|
||||
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||
const emailSound = useSettingsStore((s) => s.emailNotificationSound);
|
||||
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||
const calSound = useSettingsStore((s) => s.calendarNotificationSound);
|
||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
|
||||
const supported = isPushSupported();
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const [hasSaved, setHasSaved] = useState(false);
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
const [devices, setDevices] = useState<DevicesState>({ kind: 'idle' });
|
||||
const [revokeTarget, setRevokeTarget] = useState<PushDevice | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const stored = await getStoredRelayBaseUrl();
|
||||
if (stored) {
|
||||
setRelayUrl(stored);
|
||||
setHasSaved(true);
|
||||
setPushStatus({ kind: 'enabled' });
|
||||
}
|
||||
const enabled = activeAccountId ? await isPushEnabledForAccount(activeAccountId) : false;
|
||||
if (cancelled) return;
|
||||
if (stored) setRelayUrl(stored);
|
||||
setPushEnabled(enabled);
|
||||
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
|
||||
})();
|
||||
}, []);
|
||||
return () => { cancelled = true; };
|
||||
}, [activeAccountId]);
|
||||
|
||||
const trimmed = relayUrl.trim().replace(/\/+$/, '');
|
||||
const isValidUrl = /^https?:\/\/.+/i.test(trimmed);
|
||||
const relayValid = isValidRelayUrl(trimmed);
|
||||
const busy = pushStatus.kind === 'busy';
|
||||
const canEnable = isValidUrl && !busy;
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (!isValidUrl) {
|
||||
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
|
||||
const refreshDevices = useCallback(async () => {
|
||||
if (!activeAccountId || !client) return;
|
||||
setDevices({ kind: 'loading' });
|
||||
try {
|
||||
const list = await listPushDevices({ accountId: activeAccountId, relayBaseUrl: trimmed });
|
||||
setDevices({ kind: 'loaded', devices: list });
|
||||
} catch {
|
||||
setDevices({ kind: 'error' });
|
||||
}
|
||||
}, [activeAccountId, client, trimmed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
void refreshDevices();
|
||||
// Only on mount / account change - the relay URL field should not refetch
|
||||
// on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeAccountId, supported]);
|
||||
|
||||
const describeError = (error: unknown): string => {
|
||||
if (error instanceof PushSetupError) {
|
||||
const phase = PHASE_KEYS[error.phase];
|
||||
return `${t(phase.key, phase.fallback)}: ${error.message}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : t('settings.notifications.push.setup_failed', 'Setup failed');
|
||||
};
|
||||
|
||||
const handleEnable = async (forceRecreate = false) => {
|
||||
if (!relayValid) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: t('settings.notifications.push.relay_invalid', 'Enter a valid https:// URL'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPushStatus({ kind: 'busy', message: 'Registering device…' });
|
||||
setPushStatus({
|
||||
kind: 'busy',
|
||||
message: t('settings.notifications.push.status_busy', 'Working…'),
|
||||
});
|
||||
try {
|
||||
await setStoredRelayBaseUrl(trimmed);
|
||||
await setupPushNotifications({
|
||||
relayBaseUrl: trimmed,
|
||||
accountLabel: username ?? undefined,
|
||||
forceRecreate,
|
||||
});
|
||||
setHasSaved(true);
|
||||
setPushEnabled(true);
|
||||
setPushStatus({ kind: 'enabled' });
|
||||
} catch (error) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : 'Setup failed',
|
||||
});
|
||||
setPushStatus({ kind: 'error', message: describeError(error) });
|
||||
} finally {
|
||||
void refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
setConfirmDisable(true);
|
||||
};
|
||||
|
||||
const performDisable = async () => {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({ kind: 'busy', message: 'Disabling…' });
|
||||
setPushStatus({
|
||||
kind: 'busy',
|
||||
message: t('settings.notifications.push.status_busy', 'Working…'),
|
||||
});
|
||||
try {
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId);
|
||||
}
|
||||
// The relay base URL is a device-wide setting shared with any other
|
||||
// accounts that are still using push. Only clear it (and reset the UI
|
||||
// to "not configured") when no accounts are using push any more —
|
||||
// otherwise the remaining accounts would lose their relay URL and
|
||||
// their next setup attempt would no-op.
|
||||
// accounts that are still using push. Only clear it when no accounts
|
||||
// are using push any more.
|
||||
const remaining = await readPushAccountIds();
|
||||
if (remaining.length === 0) {
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
}
|
||||
setPushEnabled(false);
|
||||
setPushStatus({ kind: 'idle' });
|
||||
} catch (error) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : 'Teardown failed',
|
||||
});
|
||||
setPushStatus({ kind: 'error', message: describeError(error) });
|
||||
} finally {
|
||||
void refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
const performRevoke = async () => {
|
||||
const device = revokeTarget;
|
||||
setRevokeTarget(null);
|
||||
if (!device || !activeAccountId) return;
|
||||
setRevokingId(device.id);
|
||||
try {
|
||||
await revokePushDevice({ accountId: activeAccountId, device, relayBaseUrl: trimmed });
|
||||
if (device.isThisDevice) {
|
||||
setPushEnabled(false);
|
||||
setPushStatus({ kind: 'idle' });
|
||||
}
|
||||
} catch (error) {
|
||||
setPushStatus({ kind: 'error', message: describeError(error) });
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
void refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
const relayStatusLabel = (status: PushDevice['relayStatus']) => {
|
||||
if (status === 'active') return t('settings.notifications.push.device_status_active', 'Delivering notifications');
|
||||
if (status === 'inactive') return t('settings.notifications.push.device_status_inactive', 'Never delivered a notification');
|
||||
return t('settings.notifications.push.device_status_unknown', 'Not known to this relay');
|
||||
};
|
||||
|
||||
const statusDescription = !supported
|
||||
? t('settings.notifications.push.status_android_only', 'Background notifications are available on Android only.')
|
||||
: pushStatus.kind === 'enabled'
|
||||
? t('settings.notifications.push.status_active', 'Active on this device')
|
||||
: pushStatus.kind === 'busy'
|
||||
? pushStatus.message
|
||||
: t('settings.notifications.push.status_inactive', 'Not enabled on this device');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Push Notifications"
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content."
|
||||
title={t('settings.notifications.push.title', 'Background Notifications')}
|
||||
description={t(
|
||||
'settings.notifications.push.description_mobile',
|
||||
'Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.pushCard}>
|
||||
<View style={styles.pushHeader}>
|
||||
<Text style={styles.fieldLabel}>Relay base URL</Text>
|
||||
<View style={styles.statusRow}>
|
||||
{pushStatus.kind === 'busy' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<Text style={styles.statusText}>{pushStatus.message}</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'enabled' && (
|
||||
<>
|
||||
<CheckCircle2 size={14} color={c.success} />
|
||||
<Text style={[styles.statusText, { color: c.success }]}>Active</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'error' && (
|
||||
<>
|
||||
<XCircle size={14} color={c.error} />
|
||||
<Text style={[styles.statusText, { color: c.error }]}>
|
||||
{pushStatus.message}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'idle' && (
|
||||
<Text style={[styles.statusText, { color: c.mutedForeground }]}>
|
||||
Not configured
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<SettingItem label={t('settings.notifications.push.enable', 'Enable')} description={statusDescription}>
|
||||
<View style={styles.row}>
|
||||
{pushStatus.kind === 'busy' && <ActivityIndicator size="small" color={c.primary} />}
|
||||
{pushStatus.kind === 'enabled' && <CheckCircle2 size={16} color={c.success} />}
|
||||
<ToggleSwitch
|
||||
checked={pushEnabled}
|
||||
onChange={(checked) => {
|
||||
if (checked) void handleEnable();
|
||||
else setConfirmDisable(true);
|
||||
}}
|
||||
disabled={!supported || busy || !client}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.fieldDescription}>
|
||||
Defaults to the hosted Bulwark relay. Change only if you self-host.
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.notifications.push.relay_label', 'Push relay')}
|
||||
description={t(
|
||||
'settings.notifications.push.relay_desc_mobile',
|
||||
'The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.',
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<View />
|
||||
</SettingItem>
|
||||
<TextInput
|
||||
value={relayUrl}
|
||||
onChangeText={setRelayUrl}
|
||||
placeholder={DEFAULT_RELAY_BASE_URL}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={supported && !busy}
|
||||
accessibilityLabel={t('settings.notifications.push.relay_label', 'Push relay')}
|
||||
style={[styles.urlInput, !relayValid && styles.urlInputInvalid]}
|
||||
/>
|
||||
{!relayValid && (
|
||||
<Text style={styles.errorText}>
|
||||
{t('settings.notifications.push.relay_invalid', 'Enter a valid https:// URL')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={relayUrl}
|
||||
onChangeText={setRelayUrl}
|
||||
placeholder={DEFAULT_RELAY_BASE_URL}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!busy}
|
||||
style={styles.urlInput}
|
||||
/>
|
||||
)}
|
||||
{pushEnabled && (
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="default"
|
||||
onPress={handleEnable}
|
||||
disabled={!canEnable}
|
||||
loading={busy}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => void handleEnable(true)}
|
||||
disabled={!supported || busy || !relayValid}
|
||||
>
|
||||
{hasSaved ? 'Re-register' : 'Enable'}
|
||||
{t('settings.notifications.push.reenable', 'Re-register')}
|
||||
</Button>
|
||||
{hasSaved && (
|
||||
<Button variant="outline" onPress={handleDisable} disabled={busy}>
|
||||
Disable
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{pushStatus.kind === 'error' && (
|
||||
<View style={styles.errorRow} accessibilityRole="alert">
|
||||
<XCircle size={14} color={c.error} />
|
||||
<Text style={[styles.errorText, { flex: 1 }]}>{pushStatus.message}</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Sound Selection" description="Pick the sound used for new notifications.">
|
||||
<SettingItem label="Notification Sound" description="Played when a new notification arrives.">
|
||||
<View style={styles.row}>
|
||||
{supported && client && (
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.push.devices_title', 'Registered devices')}
|
||||
description={t(
|
||||
'settings.notifications.push.devices_desc',
|
||||
'Every device registered to receive background notifications for this account. Revoking one stops it receiving immediately.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.devicesHeader}>
|
||||
<Text style={styles.fieldDescription}>
|
||||
{devices.kind === 'loading' && t('settings.notifications.push.devices_loading', 'Loading registered devices…')}
|
||||
{devices.kind === 'error' && t('settings.notifications.push.devices_error', 'Could not load registered devices.')}
|
||||
{devices.kind === 'loaded' && devices.devices.length === 0
|
||||
&& t('settings.notifications.push.devices_empty', 'No devices are registered for background notifications.')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={<Volume2 size={16} color={c.text} />}
|
||||
/>
|
||||
<Select
|
||||
value={sound}
|
||||
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)}
|
||||
options={SOUNDS}
|
||||
/>
|
||||
size="sm"
|
||||
onPress={() => void refreshDevices()}
|
||||
disabled={devices.kind === 'loading'}
|
||||
icon={<RefreshCw size={14} color={c.text} />}
|
||||
>
|
||||
{t('settings.notifications.push.devices_refresh', 'Refresh')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
{devices.kind === 'loaded' && devices.devices.length > 0 && (
|
||||
<View style={styles.deviceList}>
|
||||
{devices.devices.map((device, index) => (
|
||||
<View
|
||||
key={device.id}
|
||||
style={[styles.deviceRow, index > 0 && styles.deviceRowBorder]}
|
||||
>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.deviceTitle} numberOfLines={1}>
|
||||
{device.isThisDevice
|
||||
? t('settings.notifications.push.device_this', 'This device')
|
||||
: t('settings.notifications.push.device_other', 'Other device')}
|
||||
<Text style={styles.deviceId}> {device.deviceClientId.slice(0, 8)}</Text>
|
||||
</Text>
|
||||
<Text style={styles.fieldDescription}>
|
||||
{relayStatusLabel(device.relayStatus)}
|
||||
{device.expires
|
||||
? ` · ${t('settings.notifications.push.device_expires', 'Expires {date}', {
|
||||
date: new Date(device.expires).toLocaleDateString(),
|
||||
})}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => setRevokeTarget(device)}
|
||||
disabled={revokingId !== null}
|
||||
loading={revokingId === device.id}
|
||||
>
|
||||
{t('settings.notifications.push.revoke', 'Revoke')}
|
||||
</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Email" description="Notifications for incoming email.">
|
||||
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.email.title', 'Email Notifications')}
|
||||
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.notifications.email.enabled', 'Email notifications')}
|
||||
description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')}
|
||||
>
|
||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
|
||||
<ToggleSwitch
|
||||
checked={emailSound}
|
||||
onChange={(v) => update('emailNotificationSound', v)}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
{Platform.OS === 'android' && (
|
||||
<Text style={styles.fieldDescription}>
|
||||
{t(
|
||||
'settings.notifications.email.sound_channel_hint',
|
||||
'Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Calendar" description="Notifications for events and invitations.">
|
||||
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events.">
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.calendar.title', 'Calendar Notifications')}
|
||||
description={t('settings.notifications.calendar.description', 'Configure notifications for calendar events')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.notifications.calendar.enabled', 'Event notifications')}
|
||||
description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')}
|
||||
>
|
||||
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
|
||||
<ToggleSwitch
|
||||
checked={calSound}
|
||||
onChange={(v) => update('calendarNotificationSound', v)}
|
||||
disabled={!calEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
|
||||
<SettingItem
|
||||
label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')}
|
||||
description={t(
|
||||
'settings.notifications.calendar.invitation_parsing_desc',
|
||||
'Detect calendar invitations in email attachments and show calendar actions',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={invitationParsing}
|
||||
onChange={(v) => update('calendarInvitationParsingEnabled', v)}
|
||||
@@ -252,56 +397,82 @@ export function NotificationSettings() {
|
||||
<Dialog
|
||||
visible={confirmDisable}
|
||||
variant="destructive"
|
||||
title="Disable push notifications?"
|
||||
message="The device will stop receiving new-mail alerts from this server."
|
||||
confirmText="Disable"
|
||||
cancelText="Cancel"
|
||||
title={t('settings.notifications.push.confirm_disable_title', 'Disable background notifications?')}
|
||||
message={t(
|
||||
'settings.notifications.push.confirm_disable_message_mobile',
|
||||
'This device will stop receiving new-mail alerts for this account.',
|
||||
)}
|
||||
confirmText={t('settings.notifications.push.disable', 'Disable')}
|
||||
onCancel={() => setConfirmDisable(false)}
|
||||
onConfirm={performDisable}
|
||||
/>
|
||||
<Dialog
|
||||
visible={revokeTarget !== null}
|
||||
variant="destructive"
|
||||
title={t('settings.notifications.push.confirm_revoke_title', 'Revoke this device?')}
|
||||
message={
|
||||
revokeTarget?.isThisDevice
|
||||
? t(
|
||||
'settings.notifications.push.confirm_revoke_message_this',
|
||||
'This device will stop receiving background notifications immediately.',
|
||||
)
|
||||
: t(
|
||||
'settings.notifications.push.confirm_revoke_message',
|
||||
'It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.',
|
||||
)
|
||||
}
|
||||
confirmText={t('settings.notifications.push.revoke', 'Revoke')}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onConfirm={performRevoke}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
pushCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pushHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
fieldLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
},
|
||||
fieldDescription: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
urlInput: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'stretch',
|
||||
marginTop: 4,
|
||||
},
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
|
||||
statusText: { ...typography.caption, color: c.text },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
|
||||
});
|
||||
container: { gap: spacing.xxxl },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
fieldDescription: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
urlInput: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
urlInputInvalid: { borderColor: c.error },
|
||||
errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
|
||||
devicesHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
deviceList: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
deviceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
},
|
||||
deviceRowBorder: { borderTopWidth: 1, borderTopColor: c.border },
|
||||
deviceTitle: { ...typography.bodyMedium, color: c.text },
|
||||
deviceId: { ...typography.caption, color: c.mutedForeground, fontWeight: '400' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('translation coverage', () => {
|
||||
if (!en.has(m[1])) missing.push(`${m[1]} (${relative(SRC_ROOT, file)})`);
|
||||
}
|
||||
}
|
||||
expect(missing, `keys missing from locales/en/common.json + locales/rn/en.json:\n${missing.join('\n')}`).toEqual([]);
|
||||
expect(missing, `keys missing from locales/en/common.json + locales/rn/en.json (run: npm run i18n:harvest):\n${missing.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every template t() key prefix has at least one English entry', () => {
|
||||
|
||||
Reference in New Issue
Block a user