feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes

This commit is contained in:
Linus Rath
2026-08-29 18:29:34 +02:00
parent fe45a444d3
commit 22697fd111
8 changed files with 383 additions and 158 deletions
+198 -2
View File
@@ -6,9 +6,16 @@
"not_starred": "Not starred"
},
"calendar": {
"agenda": {
"days_clear": "The next {count} days are clear.",
"no_upcoming": "No upcoming events"
},
"event_modal": {
"participant_placeholder": "Add participant by email"
},
"events": {
"tap_to_create": "Tap + to create one"
},
"invitation": {
"accept": "Yes",
"add_error": "Could not add the event",
@@ -38,6 +45,12 @@
"edit_calendar": "Edit calendar",
"new_calendar": "New calendar"
},
"settings": {
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
"time_zone": "Time zone",
"time_zone_auto": "Device time zone",
"time_zone_desc": "Zone used for new events and for interpreting the calendar. \"Device\" follows the phone."
},
"share": {
"add_people": "Add people",
"error": "Failed to update sharing",
@@ -58,16 +71,29 @@
"done": "Done",
"remove": "Remove",
"retry": "Retry",
"undo": "Undo"
"undo": "Undo",
"update": "Update"
},
"context_menu": {
"color_tag": "Tag",
"remove_color": "Remove tag"
},
"email_composer": {
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image",
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
"recipient_move_to": "Move to {field}",
"recipient_remove": "Remove",
"save_draft": "Save Draft",
"schedule_custom": "Pick date & time…",
"schedule_in_1h": "In 1 hour",
@@ -75,6 +101,7 @@
"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_tomorrow_morning": "Tomorrow morning",
"schedule_too_late_body": "That is later than this server allows. Pick an earlier time.",
"schedule_too_late_title": "Too far ahead",
"schedule_unsupported_body": "This mail server does not support scheduled send.",
@@ -85,6 +112,7 @@
"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",
"undo_send_label": "Sending in {seconds}s…",
"uploading": "Uploading...",
"validation": {
"subject_required": "Add a subject"
}
@@ -121,6 +149,23 @@
"unknown_tag": "not defined here"
},
"email_viewer": {
"address_actions": {
"add_contact": "Add to contacts",
"compose": "New message",
"copy": "Copy address",
"copy_failed": "Could not copy the address."
},
"attachment_actions": {
"open": "Open",
"preview": "Preview",
"save": "Save",
"share": "Share"
},
"attachment_failed": "Could not open attachment",
"authentication": {
"spoof_warning": "The sender address could not be verified - this message may be spoofed."
},
"bundle_too_large": "The attachments are too large to bundle.",
"color_tag": {
"blue": "Blue",
"green": "Green",
@@ -132,8 +177,22 @@
"title": "Color Tag",
"yellow": "Yellow"
},
"forwarded": "Forwarded",
"preview": {
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
"set_color": "Set tag"
"replied": "Replied",
"set_color": "Set tag",
"show_all_count": "Show all ({count})",
"show_less": "Show less",
"smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile",
"unsubscribe_banner": {
"no_identity": "No sending identity available"
}
},
"files": {
"done": "Done",
@@ -148,6 +207,12 @@
"share_search_placeholder": "Search by name or email",
"shared_with": "Shared with"
},
"identities": {
"badge": {
"to": "to {identity}",
"via": "via {identity}"
}
},
"login": {
"mobile": {
"add_subtitle": "You're signed in to these already.",
@@ -274,6 +339,9 @@
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
},
"toolbar_labels": {
"description_mobile": "Display text labels next to toolbar icons."
}
},
"badges": {
@@ -281,12 +349,114 @@
"not_implemented": "Not implemented",
"unavailable": "Unavailable"
},
"downloads": {
"attachment_template": {
"description_mobile": "Template used when saving or sharing an attachment."
},
"email_template": {
"description_mobile": "Template used when exporting a message as an .eml file."
},
"strip_diacritics": {
"description_mobile": "Convert accented letters to plain ASCII (ä → a)."
},
"transform_description": "Applied to every exported filename.",
"transform_title": "Filename Transform"
},
"email_behavior": {
"archive_mode": {
"no_archive": "No archive folder found.",
"reorganize_result": "Moved {moved} of {total} emails.",
"reorganizing": "Reorganizing…"
},
"attachment_click_action": {
"description_mobile": "What happens when you tap an attachment."
},
"auto_select_reply_identity": {
"description_mobile": "When replying, send from the address the message was originally sent to."
},
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
"swipe_actions": {
"move": "Move to folder",
"pin": "Pin"
},
"swipe_left_action": {
"description_mobile": "Action when you drag a row from its right edge towards the left.",
"label_mobile": "Swipe left (right → left)"
},
"swipe_right_action": {
"description_mobile": "Action when you drag a row from its left edge towards the right.",
"label_mobile": "Swipe right (left → right)"
}
},
"experimental": {
"title": "Experimental Feature"
},
"identities": {
"cannot_delete": "The primary identity cannot be removed.",
"cannot_delete_title": "Cannot delete",
"count": "{count, plural, =0 {No identities} one {# identity} other {# identities}}",
"create": "New identity",
"delete_confirm": "Remove \"{name}\"?",
"delete_failed": "Delete failed",
"delete_title": "Delete identity",
"description_mobile": "Manage sender names, email addresses, and signatures. Tap a row to edit.",
"display_name": "Display name",
"edit": "Edit identity",
"email_address": "Email address",
"email_locked": "JMAP does not allow changing an identity's email; create a new one instead.",
"invalid_email": "Enter a valid email address for this identity.",
"invalid_email_title": "Invalid email",
"new": "New",
"no_name": "(no name)",
"primary": "primary",
"save_failed": "Save failed",
"text_signature": "Plain-text signature"
},
"keywords": {
"description_mobile": "Colored tags to organize your mail.",
"reset_defaults": "Reset to Defaults"
},
"layout": {
"description": "Tune the email list interactions for mobile.",
"quick_actions": {
"description": "The three buttons shown between Prev/Next at the bottom of an open email. Defaults to Reply, Reply All and Forward.",
"label": "Reader quick actions",
"mark_unread": "Mark Read/Unread",
"relocated": "{count, plural, one {{actions} is moved to the top toolbar so you can still use it.} other {{actions} are moved to the top toolbar so you can still use them.}}",
"slot": "Slot {n}",
"spam": "Spam",
"star": "Star",
"tag": "Tag"
},
"show_avatars_in_junk": "Show sender avatars in Junk",
"show_avatars_in_junk_description": "Load sender logos for messages in the Junk folder. Off keeps spam senders from being contacted.",
"show_folder_total_count": "Show total message count on folders",
"show_folder_total_count_description": "Show the total next to the unread count in the folder list.",
"swipe_mode": {
"instant": "Instant (swipe = action)",
"reveal": "Reveal (swipe, then tap)"
},
"unified_cross_account": "Unified views across accounts",
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
},
"message_list_order": {
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
"preset_chronological": "Newest first",
"preset_custom": "Custom",
"preset_starred_first": "Starred first",
"preset_tagged_first": "Tagged first",
"preset_unread_first": "Unread first",
"scope": "Apply to",
"scope_all": "All folders",
"scope_inbox": "Inbox only",
"tag": "Tag",
"title": "Message list order"
},
"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."
@@ -357,6 +527,23 @@
"title": "Public Keys"
}
},
"sidebar_apps": {
"add": "Add app",
"empty": "No apps added yet.",
"form": {
"name": "Name",
"name_placeholder": "App name",
"open_mode": "Open mode",
"show_on_mobile": "Show on mobile",
"url": "URL"
},
"keep_loaded_description_mobile": "Keep inline apps running in the background when switching away.",
"open_mode": {
"inline": "Inline",
"new_tab": "New tab",
"tab": "Tab"
}
},
"smime": {
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
@@ -364,6 +551,15 @@
"tabs": {
"plugins": "Plugins"
},
"templates": {
"delete_title": "Delete template"
},
"themes": {
"default_description": "The default light and dark palettes.",
"default_name": "Bulwark",
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
},
"vacation": {
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
+23 -18
View File
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
type Theme = ThemeMode;
@@ -60,7 +61,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySubject,
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
row.unread ? { color: c.text, fontWeight: '500' } : { color: c.textSecondary },
]}
numberOfLines={1}
>
@@ -82,6 +83,7 @@ export function AppearanceSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const theme = useSettingsStore((s) => s.theme);
const t = useLocaleStore((s) => s.t);
const setTheme = useSettingsStore((s) => s.setTheme);
const fontSize = useSettingsStore((s) => s.fontSize);
const setFontSize = useSettingsStore((s) => s.setFontSize);
@@ -99,52 +101,55 @@ export function AppearanceSettings() {
}, [hydrated, hydrate]);
return (
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
<SettingItem label="Theme" description="Choose between light, dark, or system theme.">
<SettingsSection
title={t('settings.appearance.title', "Appearance")}
description={t('settings.appearance.description', "Customize the look and feel of your webmail")}
>
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
<RadioGroup
value={theme}
onChange={(v) => setTheme(v as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
{ value: 'light', label: t('settings.appearance.theme.light', "Light") },
{ value: 'dark', label: t('settings.appearance.theme.dark', "Dark") },
{ value: 'system', label: t('settings.appearance.theme.system', "System") },
]}
/>
</SettingItem>
<SettingItem label="Font Size" description="Adjust text size.">
<SettingItem label={t('settings.appearance.font_size.label', "Font Size")} description={t('settings.appearance.font_size.description', "Adjust text size for better readability")}>
<RadioGroup
value={fontSize}
onChange={(v) => setFontSize(v as FontSize)}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'small', label: t('settings.appearance.font_size.small', "Small") },
{ value: 'medium', label: t('settings.appearance.font_size.medium', "Medium") },
{ value: 'large', label: t('settings.appearance.font_size.large', "Large") },
]}
/>
</SettingItem>
<View>
<SettingItem label="List Density" description="How tight the email list is packed." noBorder />
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder />
<RadioGroup
value={density}
onChange={(v) => setDensity(v as Density)}
options={[
{ value: 'extra-compact', label: 'Extra' },
{ value: 'compact', label: 'Compact' },
{ value: 'regular', label: 'Regular' },
{ value: 'comfortable', label: 'Comfy' },
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
{ value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
{ value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
{ value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
]}
/>
<DensityPreview density={density} />
<View style={styles.divider} />
</View>
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
<SettingItem label={t('settings.appearance.toolbar_labels.label', "Show Toolbar Labels")} description={t('settings.appearance.toolbar_labels.description_mobile', "Display text labels next to toolbar icons.")}>
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
</SettingItem>
<SettingItem label="Animations" description="Enable or disable interface animations.">
<SettingItem label={t('settings.appearance.animations.label', "Enable Animations")} description={t('settings.appearance.animations.description', "Show smooth transitions and effects")}>
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
</SettingsSection>
@@ -190,7 +195,7 @@ function makeStyles(c: ThemePalette) {
},
densityPreviewText: {
fontSize: 11,
color: 'rgba(161,161,170,0.7)',
color: c.textMuted,
},
divider: {
height: 1,
+19 -17
View File
@@ -6,11 +6,13 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
export function ComposingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const t = useLocaleStore((s) => s.t);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
@@ -40,29 +42,29 @@ export function ComposingSettings() {
};
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
{ label: 'Off', value: 0 },
{ label: '5s', value: 5 },
{ label: '10s', value: 10 },
{ label: '20s', value: 20 },
{ label: '30s', value: 30 },
// Same set the webmail accepts (0/10/30/60); the store rejects others.
{ label: t('settings.email_behavior.send_delay.off', "Off"), value: 0 },
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 10 }), value: 10 },
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 30 }), value: 30 },
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 60 }), value: 60 },
];
return (
<SettingsSection
title="Composing"
description="How the composer behaves when you reply, forward, or send."
title={t('settings.composer.title', "Composer")}
description={t('settings.composer.description', "Configure email composition settings")}
>
<SettingItem
label="Auto-select reply identity"
description="When replying, pick the identity that received the original message."
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")}
>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Undo send delay</Text>
<Text style={styles.subLabel}>{t('settings.email_behavior.send_delay.label', "Undo send / send delay")}</Text>
<Text style={styles.subDescription}>
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
{t('settings.email_behavior.send_delay.description_mobile', "Hold outgoing mail for a few seconds so you can cancel it. Requires server support.")}
</Text>
<View style={styles.segmentRow}>
{SEND_DELAY_OPTIONS.map((opt) => {
@@ -83,8 +85,8 @@ export function ComposingSettings() {
</View>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
label={t('settings.email_behavior.attachment_reminder.label', "Attachment Reminder")}
description={t('settings.email_behavior.attachment_reminder.description', "Warn before sending when your message mentions attachments but none are attached")}
>
<ToggleSwitch
checked={attachmentReminderEnabled}
@@ -94,9 +96,9 @@ export function ComposingSettings() {
{attachmentReminderEnabled && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Trigger keywords</Text>
<Text style={styles.subLabel}>{t('settings.email_behavior.attachment_reminder.keywords_label', "Trigger keywords")}</Text>
<Text style={styles.subDescription}>
Words that, when found in the body or subject without an attachment, trigger the reminder.
{t('settings.email_behavior.attachment_reminder.keywords_description', "Words or phrases that trigger the reminder when found in your message")}
</Text>
<View style={styles.chips}>
@@ -114,7 +116,7 @@ export function ComposingSettings() {
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="e.g. attached"
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
@@ -129,7 +131,7 @@ export function ComposingSettings() {
disabled={!newKeyword.trim()}
icon={<Plus size={14} color={c.primaryForeground} />}
>
Add
{t('settings.email_behavior.attachment_reminder.add', "Add")}
</Button>
</View>
</View>
+29 -23
View File
@@ -22,6 +22,7 @@ import {
updateIdentity,
} from '../../api/identity';
import type { Identity } from '../../api/types';
import { useLocaleStore } from '../../stores/locale-store';
type DraftIdentity = {
id: string;
@@ -51,6 +52,7 @@ export function IdentitySettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
const t = useLocaleStore((s) => s.t);
const loading = useSettingsStore((s) => s.loading);
const error = useSettingsStore((s) => s.error);
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
@@ -72,7 +74,7 @@ export function IdentitySettings() {
const name = editing.name.trim();
const email = editing.email.trim();
if (!email || !EMAIL_RE.test(email)) {
Alert.alert('Invalid email', 'Enter a valid email address for this identity.');
Alert.alert(t('settings.identities.invalid_email_title', "Invalid email"), t('settings.identities.invalid_email', "Enter a valid email address for this identity."));
return;
}
setSaving(true);
@@ -95,7 +97,7 @@ export function IdentitySettings() {
closeEditor();
await fetchIdentities();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
Alert.alert(t('settings.identities.save_failed', "Save failed"), err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
@@ -103,16 +105,16 @@ export function IdentitySettings() {
const confirmDelete = (identity: Identity) => {
if (!identity.mayDelete) {
Alert.alert('Cannot delete', 'The primary identity cannot be removed.');
Alert.alert(t('settings.identities.cannot_delete_title', "Cannot delete"), t('settings.identities.cannot_delete', "The primary identity cannot be removed."));
return;
}
Alert.alert(
'Delete identity',
`Remove "${identity.name || identity.email}"?`,
t('settings.identities.delete_title', "Delete identity"),
t('settings.identities.delete_confirm', 'Remove "{name}"?', { name: identity.name || identity.email }),
[
{ text: 'Cancel', style: 'cancel' },
{ text: t('common.cancel', "Cancel"), style: 'cancel' },
{
text: 'Delete',
text: t('common.delete', "Delete"),
style: 'destructive',
onPress: async () => {
setDeletingId(identity.id);
@@ -120,7 +122,7 @@ export function IdentitySettings() {
await deleteIdentity(identity.id);
await fetchIdentities();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
Alert.alert(t('settings.identities.delete_failed', "Delete failed"), err instanceof Error ? err.message : String(err));
} finally {
setDeletingId(null);
}
@@ -132,12 +134,14 @@ export function IdentitySettings() {
return (
<SettingsSection
title="Identities"
description="Manage sender names, email addresses, and signatures. Tap a row to edit."
title={t('settings.identities.title', "Sending Identities")}
description={t('settings.identities.description_mobile', "Manage sender names, email addresses, and signatures. Tap a row to edit.")}
>
<View style={styles.headerRow}>
<Text style={styles.count}>
{loading ? 'Loading…' : identities.length === 1 ? '1 identity' : `${identities.length} identities`}
{loading
? t('common.loading', "Loading...")
: t('settings.identities.count', '{count, plural, =0 {No identities} one {# identity} other {# identities}}', { count: identities.length })}
</Text>
<Button
variant="default"
@@ -146,7 +150,7 @@ export function IdentitySettings() {
icon={<Plus size={14} color={c.primaryForeground} />}
disabled={loading}
>
New
{t('settings.identities.new', "New")}
</Button>
</View>
@@ -163,12 +167,12 @@ export function IdentitySettings() {
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
>
<View style={{ flex: 1 }}>
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
<Text style={styles.identityName}>{identity.name || t('settings.identities.no_name', "(no name)")}</Text>
<Text style={styles.identityEmail}>{identity.email}</Text>
</View>
{!identity.mayDelete ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>primary</Text>
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
</View>
) : (
<Pressable
@@ -176,6 +180,8 @@ export function IdentitySettings() {
hitSlop={8}
style={styles.identityDelete}
disabled={deletingId === identity.id}
accessibilityRole="button"
accessibilityLabel={t('common.delete', "Delete")}
>
{deletingId === identity.id ? (
<ActivityIndicator size="small" color={c.error} />
@@ -192,14 +198,14 @@ export function IdentitySettings() {
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit identity' : 'New identity'}
{editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<Pressable onPress={closeEditor} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('common.close', "Close")}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Display name</Text>
<Text style={styles.fieldLabel}>{t('settings.identities.display_name', "Display name")}</Text>
<TextInput
value={editing?.name ?? ''}
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
@@ -207,7 +213,7 @@ export function IdentitySettings() {
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Email address</Text>
<Text style={styles.fieldLabel}>{t('settings.identities.email_address', "Email address")}</Text>
<TextInput
value={editing?.email ?? ''}
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
@@ -220,9 +226,9 @@ export function IdentitySettings() {
keyboardType="email-address"
/>
{!!editing?.id && (
<Text style={styles.hint}>JMAP does not allow changing an identity&apos;s email; create a new one instead.</Text>
<Text style={styles.hint}>{t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")}</Text>
)}
<Text style={styles.fieldLabel}>Plain-text signature</Text>
<Text style={styles.fieldLabel}>{t('settings.identities.text_signature', "Plain-text signature")}</Text>
<TextInput
value={editing?.textSignature ?? ''}
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
@@ -233,14 +239,14 @@ export function IdentitySettings() {
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>{t('common.cancel', "Cancel")}</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
{t('common.save', "Save")}
</Button>
</View>
</View>
@@ -271,7 +277,7 @@ function makeStyles(c: ThemePalette) {
identityDelete: { padding: 6 },
badge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
backgroundColor: c.primaryBg,
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
+17 -11
View File
@@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
import { DARK_COLORS } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
type Keyword = KeywordDef;
@@ -17,6 +18,7 @@ export function KeywordSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const keywords = useKeywordsStore((s) => s.keywords);
const t = useLocaleStore((s) => s.t);
const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update);
const removeKeyword = useKeywordsStore((s) => s.remove);
@@ -45,7 +47,7 @@ export function KeywordSettings() {
};
return (
<SettingsSection title="Keywords & Labels" description="Colored tags to organize your mail.">
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
<View style={{ gap: spacing.sm }}>
{keywords.map((kw) => {
if (editingId === kw.id) {
@@ -66,10 +68,10 @@ export function KeywordSettings() {
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
@@ -89,11 +91,11 @@ export function KeywordSettings() {
<View style={styles.bottomActions}>
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Add keyword</Text>
<Text style={styles.outlineBtnText}>{t('settings.keywords.add_keyword', "Add Tag")}</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<RotateCcw size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Reset defaults</Text>
<Text style={styles.outlineBtnText}>{t('settings.keywords.reset_defaults', "Reset to Defaults")}</Text>
</Pressable>
</View>
)}
@@ -112,6 +114,7 @@ interface KeywordFormProps {
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
@@ -133,21 +136,21 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
return (
<View style={styles.form}>
<View>
<Text style={styles.formLabel}>Label</Text>
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
<TextInput
value={label}
onChangeText={setLabel}
placeholder="e.g. Important"
placeholder={t('settings.keywords.label_placeholder', "e.g. Work, Personal, Urgent")}
placeholderTextColor={c.mutedForeground}
style={styles.input}
maxLength={30}
autoFocus
/>
{isDuplicate && <Text style={styles.errorText}>An ID with that name already exists.</Text>}
{isDuplicate && <Text style={styles.errorText}>{t('settings.keywords.id_exists', "This tag ID already exists")}</Text>}
</View>
<View>
<Text style={styles.formLabel}>Color</Text>
<Text style={styles.formLabel}>{t('settings.keywords.color_field', "Color")}</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = c.tags[key];
@@ -155,6 +158,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Pressable
key={key}
onPress={() => setColor(key)}
accessibilityRole="radio"
accessibilityState={{ selected: color === key }}
accessibilityLabel={key}
style={[
styles.colorSwatch,
{ backgroundColor: p.dot },
@@ -169,7 +175,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<View style={styles.formActions}>
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
<X size={14} color={c.text} />
<Text style={styles.cancelFormText}>Cancel</Text>
<Text style={styles.cancelFormText}>{t('common.cancel', "Cancel")}</Text>
</Pressable>
<Pressable
style={[styles.saveBtn, !isValid && { opacity: 0.5 }]}
@@ -177,7 +183,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
disabled={!isValid}
>
<Check size={14} color={c.primaryForeground} />
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
<Text style={styles.saveBtnText}>{initial ? t('common.save', "Save") : t('common.add', "Add")}</Text>
</Pressable>
</View>
</View>
+40 -38
View File
@@ -17,11 +17,13 @@ import {
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useLocaleStore } from '../../stores/locale-store';
export function ReadingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const t = useLocaleStore((s) => s.t);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
@@ -59,7 +61,7 @@ export function ReadingSettings() {
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) {
setReorganizeResult('No archive folder found.');
setReorganizeResult(t('settings.email_behavior.archive_mode.no_archive', "No archive folder found."));
return;
}
@@ -100,58 +102,58 @@ export function ReadingSettings() {
if (ids.length < PAGE) break;
}
setReorganizeResult(`Moved ${moved} of ${total} emails.`);
setReorganizeResult(t('settings.email_behavior.archive_mode.reorganize_result', 'Moved {moved} of {total} emails.', { moved, total }));
} catch (err) {
setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.');
setReorganizeResult(err instanceof Error ? err.message : t('settings.email_behavior.archive_mode.reorganize_error', "Failed to reorganize archive"));
} finally {
setReorganizing(false);
}
};
return (
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
<SettingItem label="Mark as Read" description="When to flag an email as read.">
<SettingsSection title={t('settings.email_behavior.title', "Email Behavior")} description={t('settings.email_behavior.description', "Configure how emails are handled")}>
<SettingItem label={t('settings.email_behavior.mark_read.label', "Mark as Read")} description={t('settings.email_behavior.mark_read.description', "When to mark emails as read when opened")}>
<Select
value={String(markAsReadDelay)}
onChange={(v) => update('markAsReadDelay', Number(v))}
options={[
{ value: '0', label: 'Instant' },
{ value: '3000', label: 'After 3s' },
{ value: '5000', label: 'After 5s' },
{ value: '-1', label: 'Never' },
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
{ value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") },
{ value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
<SettingItem label={t('settings.email_behavior.delete_action.label', "Delete Action")} description={t('settings.email_behavior.delete_action.description', "What happens when you delete an email")} noBorder />
<Select
value={deleteAction}
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'trash-and-read', label: 'Move to Trash and mark as read' },
{ value: 'permanent', label: 'Permanently delete' },
{ value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") },
{ value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") },
{ value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") },
]}
/>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
<Text style={styles.warningText}>{t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")}</Text>
</View>
)}
<View style={styles.divider} />
</View>
<View style={styles.group}>
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
<SettingItem label={t('settings.email_behavior.archive_mode.label', "Archive in")} description={t('settings.email_behavior.archive_mode.description', "How to organize emails when archiving")} noBorder />
<Select
value={archiveMode}
onChange={(v) => update('archiveMode', v as ArchiveMode)}
options={[
{ value: 'single', label: 'Single folder' },
{ value: 'year', label: 'By year' },
{ value: 'month', label: 'By year/month' },
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
]}
/>
{archiveMode !== 'single' && (
@@ -167,7 +169,7 @@ export function ReadingSettings() {
<FolderSync size={14} color={c.text} />
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
{reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")}
</Text>
</Pressable>
{reorganizeResult && (
@@ -178,31 +180,31 @@ export function ReadingSettings() {
<View style={styles.divider} />
</View>
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
<SettingItem label={t('settings.email_behavior.permanently_delete_junk.label', "Permanently Delete Junk")} description={t('settings.email_behavior.permanently_delete_junk.description', "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash")}>
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
</SettingItem>
<SettingItem label="Show Preview" description="Preview text under each subject.">
<SettingItem label={t('settings.email_behavior.show_preview.label', "Show Preview Text")} description={t('settings.email_behavior.show_preview.description', "Display email preview in the list")}>
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
<SettingItem label={t('settings.email_behavior.disable_threading.label', "Disable Conversation Grouping")} description={t('settings.email_behavior.disable_threading.description', "Show emails as individual messages instead of grouped by conversation")}>
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>
<SettingItem label="Include Group Inboxes" description="Also show group and shared inboxes in the unified All Inboxes view.">
<SettingItem label={t('settings.appearance.unified_mailbox.include_group.label', "Include group inboxes")} description={t('settings.appearance.unified_mailbox.include_group.description', "Also merge shared/group inboxes into the unified view.")}>
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<SettingItem label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")} description={t('settings.email_behavior.plain_text_mode.description_mobile', "Compose and read in plain text only.")}>
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem>
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
<SettingItem label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")}>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
</SettingItem>
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
<SettingItem label={t('settings.email_behavior.attachment_reminder.label', "Attachment Reminder")} description={t('settings.email_behavior.attachment_reminder.description', "Warn before sending when your message mentions attachments but none are attached")}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(v) => update('attachmentReminderEnabled', v)}
@@ -211,8 +213,8 @@ export function ReadingSettings() {
{attachmentReminderEnabled && (
<View style={styles.group}>
<Text style={styles.subLabel}>Trigger Keywords</Text>
<Text style={styles.subDesc}>Add words that should trigger the reminder.</Text>
<Text style={styles.subLabel}>{t('settings.email_behavior.attachment_reminder.keywords_label', "Trigger keywords")}</Text>
<Text style={styles.subDesc}>{t('settings.email_behavior.attachment_reminder.keywords_description', "Words or phrases that trigger the reminder when found in your message")}</Text>
<View style={styles.chipRow}>
{attachmentReminderKeywords.map((kw) => (
<View key={kw} style={styles.chip}>
@@ -235,7 +237,7 @@ export function ReadingSettings() {
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="Add a keyword"
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
placeholderTextColor={c.mutedForeground}
style={styles.keywordInput}
onSubmitEditing={() => {
@@ -256,35 +258,35 @@ export function ReadingSettings() {
setNewKeyword('');
}}
>
<Text style={styles.addKeywordText}>Add</Text>
<Text style={styles.addKeywordText}>{t('settings.email_behavior.attachment_reminder.add', "Add")}</Text>
</Pressable>
</View>
<View style={styles.divider} />
</View>
)}
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
<SettingItem label={t('settings.email_behavior.hide_inline_image_attachments.label', "Hide inline images from attachments")} description={t('settings.email_behavior.hide_inline_image_attachments.description', "Images embedded in the message body are not listed as separate attachments")}>
<ToggleSwitch checked={hideInlineImageAttachments} onChange={(v) => update('hideInlineImageAttachments', v)} />
</SettingItem>
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
<SettingItem label={t('settings.email_behavior.attachment_click_action.label', "Attachment Click Action")} description={t('settings.email_behavior.attachment_click_action.description_mobile', "What happens when you tap an attachment.")}>
<Select
value={mailAttachmentAction}
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
options={[
{ value: 'preview', label: 'Preview' },
{ value: 'download', label: 'Download' },
{ value: 'preview', label: t('settings.email_behavior.attachment_click_action.preview', "Preview when possible") },
{ value: 'download', label: t('settings.email_behavior.attachment_click_action.download', "Download immediately") },
]}
/>
</SettingItem>
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
<SettingItem label={t('settings.email_behavior.attachment_position.label', "Attachment Position")} description={t('settings.email_behavior.attachment_position.description', "Where to display attachments in the email header")}>
<Select
value={attachmentPosition}
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: 'Beside sender' },
{ value: 'below-header', label: 'Below header' },
{ value: 'beside-sender', label: t('settings.email_behavior.attachment_position.beside-sender', "Next to sender") },
{ value: 'below-header', label: t('settings.email_behavior.attachment_position.below-header', "Below header") },
]}
/>
</SettingItem>
@@ -324,7 +326,7 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
<SettingItem label={t('settings.email_behavior.emails_per_page.label', "Emails Per Page")} description={t('settings.email_behavior.emails_per_page.description', "Number of emails to load at once")}>
<Select
value={String(emailsPerPage)}
onChange={(v) => update('emailsPerPage', Number(v))}
+21 -18
View File
@@ -8,6 +8,7 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
export function SidebarAppsSettings() {
const c = useColors();
@@ -16,6 +17,7 @@ export function SidebarAppsSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const apps = useSettingsStore((s) => s.sidebarApps);
const t = useLocaleStore((s) => s.t);
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
@@ -44,16 +46,16 @@ export function SidebarAppsSettings() {
return (
<View style={styles.container}>
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
<SettingItem label="Keep apps loaded" description="Maintain app state when switching away.">
<SettingsSection title={t('settings.sidebar_apps.title', "Sidebar Apps")} description={t('settings.sidebar_apps.description', "Manage custom apps and links in your sidebar")}>
<SettingItem label={t('settings.sidebar_apps.keep_loaded', "Keep Apps Loaded")} description={t('settings.sidebar_apps.keep_loaded_description_mobile', "Keep inline apps running in the background when switching away.")}>
<ToggleSwitch checked={keepLoaded} onChange={(v) => update('keepAppsLoaded', v)} />
</SettingItem>
</SettingsSection>
<SettingsSection title="Manage Apps" description="Add, edit, or remove sidebar apps.">
<SettingsSection title={t('settings.sidebar_apps.manage_title', "Custom Apps")} description={t('settings.sidebar_apps.manage_description', "Add, edit, or remove custom apps from your sidebar")}>
<View style={{ gap: spacing.md }}>
{apps.length === 0 && !isAdding && (
<Text style={styles.emptyText}>No apps added yet.</Text>
<Text style={styles.emptyText}>{t('settings.sidebar_apps.empty', "No apps added yet.")}</Text>
)}
{apps.map((app) => {
@@ -83,15 +85,15 @@ export function SidebarAppsSettings() {
]}>
<Text style={[
styles.modeBadgeText,
{ color: app.openMode === 'inline' ? '#60a5fa' : c.mutedForeground },
{ color: app.openMode === 'inline' ? c.primary : c.mutedForeground },
]}>
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
{app.openMode === 'inline' ? t('settings.sidebar_apps.open_mode.inline', "Inline") : t('settings.sidebar_apps.open_mode.tab', "Tab")}
</Text>
</View>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)} accessibilityRole="button" accessibilityLabel={t('common.edit', "Edit")}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)} accessibilityRole="button" accessibilityLabel={t('common.delete', "Delete")}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
@@ -108,7 +110,7 @@ export function SidebarAppsSettings() {
{!isAdding && editingId === null && (
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
<Plus size={16} color={c.text} />
<Text style={styles.addBtnText}>Add app</Text>
<Text style={styles.addBtnText}>{t('settings.sidebar_apps.add', "Add app")}</Text>
</Pressable>
)}
</View>
@@ -126,6 +128,7 @@ interface AppFormProps {
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
const c = useColors();
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [name, setName] = useState(initial?.name ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
@@ -136,18 +139,18 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
return (
<View style={formStyles.form}>
<View>
<Text style={formStyles.label}>Name</Text>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.name', "Name")}</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="App name"
placeholder={t('settings.sidebar_apps.form.name_placeholder', "App name")}
placeholderTextColor={c.mutedForeground}
style={formStyles.input}
/>
</View>
<View>
<Text style={formStyles.label}>URL</Text>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.url', "URL")}</Text>
<TextInput
value={url}
onChangeText={setUrl}
@@ -160,7 +163,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
</View>
<View>
<Text style={formStyles.label}>Open mode</Text>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.open_mode', "Open mode")}</Text>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<Pressable
onPress={() => setOpenMode('tab')}
@@ -168,7 +171,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
>
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
New tab
{t('settings.sidebar_apps.open_mode.new_tab', "New tab")}
</Text>
</Pressable>
<Pressable
@@ -177,25 +180,25 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
>
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
Inline
{t('settings.sidebar_apps.open_mode.inline', "Inline")}
</Text>
</Pressable>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={formStyles.label}>Show on mobile</Text>
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")}</Text>
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} />
</View>
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
<Button variant="ghost" size="sm" onPress={onCancel}>Cancel</Button>
<Button variant="ghost" size="sm" onPress={onCancel}>{t('common.cancel', "Cancel")}</Button>
<Button
size="sm"
disabled={!canSave}
onPress={() => onSave({ name: name.trim(), url: url.trim(), icon: 'Globe', openMode, showOnMobile })}
>
{initial ? 'Update' : 'Add'}
{initial ? t('common.update', "Update") : t('common.add', "Add")}
</Button>
</View>
</View>
+36 -31
View File
@@ -16,10 +16,12 @@ import Button from '../Button';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
import { useLocaleStore } from '../../stores/locale-store';
export function TemplateSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const tr = useLocaleStore((s) => s.t);
const templates = useTemplatesStore((s) => s.templates);
const hydrated = useTemplatesStore((s) => s.hydrated);
const hydrate = useTemplatesStore((s) => s.hydrate);
@@ -68,7 +70,7 @@ export function TemplateSettings() {
const saveDraft = () => {
const name = draftName.trim();
if (!name) {
Alert.alert('Template name required');
Alert.alert(tr('settings.templates.validation.empty', "Template name is required"));
return;
}
if (!editing) return;
@@ -82,11 +84,11 @@ export function TemplateSettings() {
const confirmDelete = (t: EmailTemplate) => {
Alert.alert(
'Delete template',
`Permanently delete "${t.name}"?`,
tr('settings.templates.delete_title', "Delete template"),
tr('settings.templates.delete_confirm', 'Permanently delete "{name}"?', { name: t.name }),
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteTemplate(t.id) },
{ text: tr('common.cancel', 'Cancel'), style: 'cancel' },
{ text: tr('common.delete', 'Delete'), style: 'destructive', onPress: () => deleteTemplate(t.id) },
],
);
};
@@ -94,40 +96,43 @@ export function TemplateSettings() {
const exportTemplatesAsShare = async () => {
const json = exportAll();
try {
await Share.share({ message: json, title: 'Templates export' });
await Share.share({ message: json, title: tr('settings.templates.export', 'Export') });
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unable to share');
Alert.alert(tr('settings.templates.export_failed', 'Export failed'), err instanceof Error ? err.message : tr('settings.templates.share_unavailable', 'Unable to share'));
}
};
const performImport = () => {
const result = importTemplates(importText);
if (result.error) {
Alert.alert('Import failed', result.error);
Alert.alert(tr('settings.templates.import_failed', 'Import failed'), result.error);
return;
}
setImportText('');
setImportVisible(false);
Alert.alert('Imported', `${result.count} template${result.count === 1 ? '' : 's'} added.`);
Alert.alert(
tr('settings.templates.imported', 'Imported'),
tr('settings.templates.imported_count', '{count, plural, one {# template} other {# templates}} added.', { count: result.count }),
);
};
return (
<View style={styles.container}>
<SettingsSection title="Templates" description="Reusable snippets for common replies. Tap one to edit; long-press to delete.">
<SettingsSection title={tr('settings.templates.title', 'Email Templates')} description={tr('settings.templates.description_mobile', 'Reusable snippets for common replies. Tap one to edit; long-press to delete.')}>
<View style={styles.headerRow}>
<View style={styles.countRow}>
<FileText size={16} color={c.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
{tr('settings.templates.count', '{count, plural, one {# template} other {# templates}}', { count: templates.length })}
</Text>
</View>
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
New
{tr('settings.templates.add', 'New Template')}
</Button>
</View>
{templates.length === 0 ? (
<Text style={styles.emptyHint}>No templates yet. Tap "New" to add one.</Text>
<Text style={styles.emptyHint}>{tr('settings.templates.no_templates', 'No templates yet')}</Text>
) : (
<View style={styles.list}>
{templates.map((t) => (
@@ -143,7 +148,7 @@ export function TemplateSettings() {
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
)}
</View>
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}>
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete} accessibilityRole="button" accessibilityLabel={tr('common.delete', 'Delete')}>
<Trash2 size={16} color={c.error} />
</Pressable>
</Pressable>
@@ -152,7 +157,7 @@ export function TemplateSettings() {
)}
</SettingsSection>
<SettingsSection title="Export / Import" description="Back up or restore your templates as JSON.">
<SettingsSection title={tr('settings.templates.export_import', 'Export & Import')} description={tr('settings.templates.export_import_description', 'Back up your templates or transfer them to another device')}>
<View style={styles.actions}>
<Button
variant="outline"
@@ -161,7 +166,7 @@ export function TemplateSettings() {
icon={<Download size={14} color={c.text} />}
onPress={() => { void exportTemplatesAsShare(); }}
>
Share JSON
{tr('settings.templates.export', 'Export')}
</Button>
<Button
variant="outline"
@@ -169,7 +174,7 @@ export function TemplateSettings() {
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportVisible(true)}
>
Import
{tr('settings.templates.import', 'Import')}
</Button>
</View>
</SettingsSection>
@@ -180,42 +185,42 @@ export function TemplateSettings() {
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit template' : 'New template'}
{editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<Pressable onPress={closeEditor} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Name</Text>
<Text style={styles.fieldLabel}>{tr('settings.templates.name', 'Template Name')}</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Quick reply"
placeholder={tr('settings.templates.name_placeholder', 'e.g., Follow-up email')}
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Subject</Text>
<Text style={styles.fieldLabel}>{tr('settings.templates.subject', 'Subject')}</Text>
<TextInput
value={draftSubject}
onChangeText={setDraftSubject}
placeholder="(optional)"
placeholder={tr('settings.templates.subject_placeholder', 'Email subject line')}
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Body</Text>
<Text style={styles.fieldLabel}>{tr('settings.templates.body', 'Body')}</Text>
<TextInput
value={draftBody}
onChangeText={setDraftBody}
placeholder="Hi {recipient_name}, …"
placeholder={tr('settings.templates.body_placeholder', 'Email body content...')}
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor}>Cancel</Button>
<Button variant="default" size="sm" onPress={saveDraft}>Save</Button>
<Button variant="outline" size="sm" onPress={closeEditor}>{tr('common.cancel', 'Cancel')}</Button>
<Button variant="default" size="sm" onPress={saveDraft}>{tr('common.save', 'Save')}</Button>
</View>
</View>
</View>
@@ -226,8 +231,8 @@ export function TemplateSettings() {
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Import templates</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8}>
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
<X size={20} color={c.text} />
</Pressable>
</View>
@@ -245,9 +250,9 @@ export function TemplateSettings() {
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setImportVisible(false)}>Cancel</Button>
<Button variant="outline" size="sm" onPress={() => setImportVisible(false)}>{tr('common.cancel', 'Cancel')}</Button>
<Button variant="default" size="sm" onPress={performImport} disabled={!importText.trim()}>
Import
{tr('settings.templates.import', 'Import')}
</Button>
</View>
</View>