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": {
|
"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
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user