feat: add push device list, re-register, onboarding prompt and an i18n key harvester

This commit is contained in:
Linus Rath
2026-08-29 17:53:59 +02:00
parent 00faceb25f
commit f6f26f7950
6 changed files with 684 additions and 202 deletions
+73 -22
View File
@@ -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
View File
@@ -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",
+99
View File
@@ -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`);
+158
View File
@@ -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 },
});
}
+349 -178
View File
@@ -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' },
});
}
+1 -1
View File
@@ -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', () => {