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": { "context_menu": {
"color_tag": "Tag", "color_tag": "Tag",
"remove_color": "Remove tag" "remove_color": "Remove tag"
}, },
"email_composer": { "email_composer": {
"attachment_reminder_title": "Forgot an attachment?",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?", "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.", "editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"save_draft": "Save Draft", "save_draft": "Save Draft",
"schedule_custom": "Pick date & time…", "schedule_custom": "Pick date & time…",
@@ -13,13 +18,15 @@
"schedule_in_3h": "In 3 hours", "schedule_in_3h": "In 3 hours",
"schedule_past_body": "The scheduled time must be in the future.", "schedule_past_body": "The scheduled time must be in the future.",
"schedule_past_title": "Pick a future time", "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_body": "That is later than this server allows. Pick an earlier time.",
"schedule_too_late_title": "Too far ahead", "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_body": "This mail server does not support scheduled send.",
"schedule_unsupported_title": "Scheduling unavailable", "schedule_unsupported_title": "Scheduling unavailable",
"scheduled_body": "Your message will be sent at {time}.", "scheduled_body": "Your message will be sent at {time}.",
"scheduled_title": "Scheduled", "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": { "validation": {
"subject_required": "Add a subject" "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." "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration."
}, },
"email_viewer": { "email_viewer": {
"set_color": "Set tag",
"remove_color": "Remove tag",
"color_tag": { "color_tag": {
"title": "Color Tag",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue", "blue": "Blue",
"purple": "Purple", "green": "Green",
"none": "None",
"orange": "Orange",
"pink": "Pink", "pink": "Pink",
"none": "None" "purple": "Purple",
} "red": "Red",
}, "title": "Color Tag",
"settings": { "yellow": "Yellow"
"appearance": {
"language": {
"system_default": "System default",
"restart_for_direction": "Restart the app to apply the new text direction."
}
}, },
"keywords": { "remove_color": "Remove tag",
"reset_defaults": "Reset to Defaults" "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": { "offline": {
"banner": "You are offline", "banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect" "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", "test:watch": "vitest",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs", "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": { "dependencies": {
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -27,6 +29,7 @@
"expo-crypto": "~15.0.9", "expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8", "expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8", "expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.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 { import {
ActivityIndicator, ActivityIndicator,
Platform,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native'; import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react-native';
import Button from '../Button'; import Button from '../Button';
import Dialog from '../Dialog'; 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 { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store'; 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 { import {
DEFAULT_RELAY_BASE_URL, DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl, getStoredRelayBaseUrl,
isPushEnabledForAccount,
isPushSupported,
isValidRelayUrl,
listPushDevices,
PushSetupError,
readPushAccountIds, readPushAccountIds,
revokePushDevice,
setStoredRelayBaseUrl, setStoredRelayBaseUrl,
setupPushNotifications, setupPushNotifications,
teardownPushNotificationsForAccount, teardownPushNotificationsForAccount,
type PushDevice,
type PushSetupPhase,
} from '../../lib/push-notifications'; } 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 = type PushStatus =
| { kind: 'idle' } | { kind: 'idle' }
| { kind: 'busy'; message: string } | { kind: 'busy'; message: string }
| { kind: 'enabled' } | { kind: 'enabled' }
| { kind: 'error'; message: string }; | { 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() { export function NotificationSettings() {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const username = useAuthStore((s) => s.username); const username = useAuthStore((s) => s.username);
const activeAccountId = useAuthStore((s) => s.activeAccountId); const activeAccountId = useAuthStore((s) => s.activeAccountId);
const client = useAuthStore((s) => s.client);
const hydrated = useSettingsStore((s) => s.hydrated); const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate); const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting); const update = useSettingsStore((s) => s.updateSetting);
const sound = useSettingsStore((s) => s.notificationSoundChoice);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled); const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const emailSound = useSettingsStore((s) => s.emailNotificationSound);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const calSound = useSettingsStore((s) => s.calendarNotificationSound);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const supported = isPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); 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 [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false); 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(() => { useEffect(() => {
if (!hydrated) void hydrate(); if (!hydrated) void hydrate();
}, [hydrated, hydrate]); }, [hydrated, hydrate]);
useEffect(() => { useEffect(() => {
let cancelled = false;
void (async () => { void (async () => {
const stored = await getStoredRelayBaseUrl(); const stored = await getStoredRelayBaseUrl();
if (stored) { const enabled = activeAccountId ? await isPushEnabledForAccount(activeAccountId) : false;
setRelayUrl(stored); if (cancelled) return;
setHasSaved(true); if (stored) setRelayUrl(stored);
setPushStatus({ kind: 'enabled' }); setPushEnabled(enabled);
} setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})(); })();
}, []); return () => { cancelled = true; };
}, [activeAccountId]);
const trimmed = relayUrl.trim().replace(/\/+$/, ''); const trimmed = relayUrl.trim().replace(/\/+$/, '');
const isValidUrl = /^https?:\/\/.+/i.test(trimmed); const relayValid = isValidRelayUrl(trimmed);
const busy = pushStatus.kind === 'busy'; const busy = pushStatus.kind === 'busy';
const canEnable = isValidUrl && !busy;
const handleEnable = async () => { const refreshDevices = useCallback(async () => {
if (!isValidUrl) { if (!activeAccountId || !client) return;
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' }); 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; return;
} }
setPushStatus({ kind: 'busy', message: 'Registering device…' }); setPushStatus({
kind: 'busy',
message: t('settings.notifications.push.status_busy', 'Working…'),
});
try { try {
await setStoredRelayBaseUrl(trimmed); await setStoredRelayBaseUrl(trimmed);
await setupPushNotifications({ await setupPushNotifications({
relayBaseUrl: trimmed, relayBaseUrl: trimmed,
accountLabel: username ?? undefined, accountLabel: username ?? undefined,
forceRecreate,
}); });
setHasSaved(true); setPushEnabled(true);
setPushStatus({ kind: 'enabled' }); setPushStatus({ kind: 'enabled' });
} catch (error) { } catch (error) {
setPushStatus({ setPushStatus({ kind: 'error', message: describeError(error) });
kind: 'error', } finally {
message: error instanceof Error ? error.message : 'Setup failed', void refreshDevices();
});
} }
}; };
const handleDisable = () => {
setConfirmDisable(true);
};
const performDisable = async () => { const performDisable = async () => {
setConfirmDisable(false); setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' }); setPushStatus({
kind: 'busy',
message: t('settings.notifications.push.status_busy', 'Working…'),
});
try { try {
if (activeAccountId) { if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId); await teardownPushNotificationsForAccount(activeAccountId);
} }
// The relay base URL is a device-wide setting shared with any other // 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 // accounts that are still using push. Only clear it when no accounts
// to "not configured") when no accounts are using push any more — // are using push any more.
// otherwise the remaining accounts would lose their relay URL and
// their next setup attempt would no-op.
const remaining = await readPushAccountIds(); const remaining = await readPushAccountIds();
if (remaining.length === 0) { if (remaining.length === 0) {
await setStoredRelayBaseUrl(null); await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL); setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
} }
setPushEnabled(false);
setPushStatus({ kind: 'idle' }); setPushStatus({ kind: 'idle' });
} catch (error) { } catch (error) {
setPushStatus({ setPushStatus({ kind: 'error', message: describeError(error) });
kind: 'error', } finally {
message: error instanceof Error ? error.message : 'Teardown failed', 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<SettingsSection <SettingsSection
title="Push Notifications" title={t('settings.notifications.push.title', 'Background Notifications')}
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content." 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}> <SettingItem label={t('settings.notifications.push.enable', 'Enable')} description={statusDescription}>
<View style={styles.pushHeader}> <View style={styles.row}>
<Text style={styles.fieldLabel}>Relay base URL</Text> {pushStatus.kind === 'busy' && <ActivityIndicator size="small" color={c.primary} />}
<View style={styles.statusRow}> {pushStatus.kind === 'enabled' && <CheckCircle2 size={16} color={c.success} />}
{pushStatus.kind === 'busy' && ( <ToggleSwitch
<> checked={pushEnabled}
<ActivityIndicator size="small" color={c.primary} /> onChange={(checked) => {
<Text style={styles.statusText}>{pushStatus.message}</Text> if (checked) void handleEnable();
</> else setConfirmDisable(true);
)} }}
{pushStatus.kind === 'enabled' && ( disabled={!supported || busy || !client}
<> />
<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>
</View> </View>
<Text style={styles.fieldDescription}> </SettingItem>
Defaults to the hosted Bulwark relay. Change only if you self-host.
<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> </Text>
<TextInput )}
value={relayUrl} {pushEnabled && (
onChangeText={setRelayUrl}
placeholder={DEFAULT_RELAY_BASE_URL}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!busy}
style={styles.urlInput}
/>
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
variant="default" variant="outline"
onPress={handleEnable} size="sm"
disabled={!canEnable} onPress={() => void handleEnable(true)}
loading={busy} disabled={!supported || busy || !relayValid}
> >
{hasSaved ? 'Re-register' : 'Enable'} {t('settings.notifications.push.reenable', 'Re-register')}
</Button> </Button>
{hasSaved && (
<Button variant="outline" onPress={handleDisable} disabled={busy}>
Disable
</Button>
)}
</View> </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>
<SettingsSection title="Sound Selection" description="Pick the sound used for new notifications."> {supported && client && (
<SettingItem label="Notification Sound" description="Played when a new notification arrives."> <SettingsSection
<View style={styles.row}> 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 <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
icon={<Volume2 size={16} color={c.text} />} onPress={() => void refreshDevices()}
/> disabled={devices.kind === 'loading'}
<Select icon={<RefreshCw size={14} color={c.text} />}
value={sound} >
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)} {t('settings.notifications.push.devices_refresh', 'Refresh')}
options={SOUNDS} </Button>
/>
</View> </View>
</SettingItem> {devices.kind === 'loaded' && devices.devices.length > 0 && (
</SettingsSection> <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."> <SettingsSection
<SettingItem label="Email Notifications" description="Show desktop alerts for new email."> 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)} /> <ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem> </SettingItem>
<SettingItem label="Email Sound" description="Play a sound when new email arrives."> {Platform.OS === 'android' && (
<ToggleSwitch <Text style={styles.fieldDescription}>
checked={emailSound} {t(
onChange={(v) => update('emailNotificationSound', v)} 'settings.notifications.email.sound_channel_hint',
disabled={!emailEnabled} 'Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings.',
/> )}
</SettingItem> </Text>
)}
</SettingsSection> </SettingsSection>
<SettingsSection title="Calendar" description="Notifications for events and invitations."> <SettingsSection
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events."> 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)} /> <ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
</SettingItem> </SettingItem>
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts."> <SettingItem
<ToggleSwitch label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')}
checked={calSound} description={t(
onChange={(v) => update('calendarNotificationSound', v)} 'settings.notifications.calendar.invitation_parsing_desc',
disabled={!calEnabled} 'Detect calendar invitations in email attachments and show calendar actions',
/> )}
</SettingItem> >
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
<ToggleSwitch <ToggleSwitch
checked={invitationParsing} checked={invitationParsing}
onChange={(v) => update('calendarInvitationParsingEnabled', v)} onChange={(v) => update('calendarInvitationParsingEnabled', v)}
@@ -252,56 +397,82 @@ export function NotificationSettings() {
<Dialog <Dialog
visible={confirmDisable} visible={confirmDisable}
variant="destructive" variant="destructive"
title="Disable push notifications?" title={t('settings.notifications.push.confirm_disable_title', 'Disable background notifications?')}
message="The device will stop receiving new-mail alerts from this server." message={t(
confirmText="Disable" 'settings.notifications.push.confirm_disable_message_mobile',
cancelText="Cancel" 'This device will stop receiving new-mail alerts for this account.',
)}
confirmText={t('settings.notifications.push.disable', 'Disable')}
onCancel={() => setConfirmDisable(false)} onCancel={() => setConfirmDisable(false)}
onConfirm={performDisable} 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> </View>
); );
} }
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
return StyleSheet.create({ return StyleSheet.create({
container: { gap: spacing.xxxl }, container: { gap: spacing.xxxl },
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
pushCard: { fieldDescription: {
borderWidth: 1, ...typography.caption,
borderColor: c.border, color: c.mutedForeground,
borderRadius: radius.md, flexShrink: 1,
padding: spacing.lg, },
gap: spacing.sm, urlInput: {
}, ...typography.body,
pushHeader: { color: c.text,
flexDirection: 'row', backgroundColor: c.surface,
alignItems: 'center', borderWidth: 1,
justifyContent: 'space-between', borderColor: c.border,
gap: spacing.md, borderRadius: radius.sm,
}, paddingHorizontal: spacing.md,
fieldLabel: { paddingVertical: 8,
...typography.bodyMedium, alignSelf: 'stretch',
color: c.text, },
}, urlInputInvalid: { borderColor: c.error },
fieldDescription: { errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
...typography.caption, errorText: { ...typography.caption, color: c.error },
color: c.mutedForeground, actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
}, devicesHeader: {
urlInput: { flexDirection: 'row',
...typography.body, alignItems: 'center',
color: c.text, justifyContent: 'space-between',
backgroundColor: c.surface, gap: spacing.sm,
borderWidth: 1, },
borderColor: c.border, deviceList: {
borderRadius: radius.sm, borderWidth: 1,
paddingHorizontal: spacing.md, borderColor: c.border,
paddingVertical: 8, borderRadius: radius.md,
alignSelf: 'stretch', },
marginTop: 4, deviceRow: {
}, flexDirection: 'row',
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 }, alignItems: 'center',
statusText: { ...typography.caption, color: c.text }, gap: spacing.md,
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: 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)})`); 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', () => { it('every template t() key prefix has at least one English entry', () => {