From 22697fd111f0721ddb1d50580683b57fe2c0c883 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:29:34 +0200 Subject: [PATCH] feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes --- locales/rn/en.json | 200 +++++++++++++++++- .../settings/AppearanceSettings.tsx | 41 ++-- src/components/settings/ComposingSettings.tsx | 36 ++-- src/components/settings/IdentitySettings.tsx | 52 +++-- src/components/settings/KeywordSettings.tsx | 28 ++- src/components/settings/ReadingSettings.tsx | 78 +++---- .../settings/SidebarAppsSettings.tsx | 39 ++-- src/components/settings/TemplateSettings.tsx | 67 +++--- 8 files changed, 383 insertions(+), 158 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 2462ef5c..ff7b1525 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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\"" diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index 673cc3b3..b2ac8b0f 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -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 }) { @@ -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 ( - - + + 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") }, ]} /> - + 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") }, ]} /> - + 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") }, ]} /> - + - + @@ -190,7 +195,7 @@ function makeStyles(c: ThemePalette) { }, densityPreviewText: { fontSize: 11, - color: 'rgba(161,161,170,0.7)', + color: c.textMuted, }, divider: { height: 1, diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index bf422acb..1f14da76 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -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 ( - Undo send delay + {t('settings.email_behavior.send_delay.label', "Undo send / send delay")} - 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.")} {SEND_DELAY_OPTIONS.map((opt) => { @@ -83,8 +85,8 @@ export function ComposingSettings() { - Trigger keywords + {t('settings.email_behavior.attachment_reminder.keywords_label', "Trigger keywords")} - 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")} @@ -114,7 +116,7 @@ export function ComposingSettings() { } > - Add + {t('settings.email_behavior.attachment_reminder.add', "Add")} diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index 96a3e1e6..4e15f449 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -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 ( - {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 })} @@ -163,12 +167,12 @@ export function IdentitySettings() { style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]} > - {identity.name || '(no name)'} + {identity.name || t('settings.identities.no_name', "(no name)")} {identity.email} {!identity.mayDelete ? ( - primary + {t('settings.identities.primary', "primary")} ) : ( {deletingId === identity.id ? ( @@ -192,14 +198,14 @@ export function IdentitySettings() { - {editing?.id ? 'Edit identity' : 'New identity'} + {editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")} - + - Display name + {t('settings.identities.display_name', "Display name")} setEditing((d) => (d ? { ...d, name } : d))} @@ -207,7 +213,7 @@ export function IdentitySettings() { placeholderTextColor={c.textMuted} style={styles.input} /> - Email address + {t('settings.identities.email_address', "Email address")} setEditing((d) => (d ? { ...d, email } : d))} @@ -220,9 +226,9 @@ export function IdentitySettings() { keyboardType="email-address" /> {!!editing?.id && ( - JMAP does not allow changing an identity's email; create a new one instead. + {t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")} )} - Plain-text signature + {t('settings.identities.text_signature', "Plain-text signature")} setEditing((d) => (d ? { ...d, textSignature } : d))} @@ -233,14 +239,14 @@ export function IdentitySettings() { /> - + @@ -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 }, diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index 108b1508..23a4fcdf 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -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 ( - + {keywords.map((kw) => { if (editingId === kw.id) { @@ -66,10 +68,10 @@ export function KeywordSettings() { {kw.label} $label:{kw.id} - setEditingId(kw.id)}> + setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}> - deleteKeyword(kw.id)}> + deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}> @@ -89,11 +91,11 @@ export function KeywordSettings() { setIsAdding(true)}> - Add keyword + {t('settings.keywords.add_keyword', "Add Tag")} - Reset defaults + {t('settings.keywords.reset_defaults', "Reset to Defaults")} )} @@ -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(initial?.color ?? 'blue'); @@ -133,21 +136,21 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp return ( - Label + {t('settings.keywords.label_field', "Display Name")} - {isDuplicate && An ID with that name already exists.} + {isDuplicate && {t('settings.keywords.id_exists', "This tag ID already exists")}} - Color + {t('settings.keywords.color_field', "Color")} {PALETTE_KEYS.map((key) => { const p = c.tags[key]; @@ -155,6 +158,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp 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 - Cancel + {t('common.cancel', "Cancel")} - {initial ? 'Save' : 'Add'} + {initial ? t('common.save', "Save") : t('common.add', "Add")} diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index fe207fb9..ccc712e0 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -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 ( - - + + 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' && ( - Permanent deletion cannot be undone. + {t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")} )} - + 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") }, ]} /> - + update('emailsPerPage', Number(v))} diff --git a/src/components/settings/SidebarAppsSettings.tsx b/src/components/settings/SidebarAppsSettings.tsx index fc57fb2b..7a57eb18 100644 --- a/src/components/settings/SidebarAppsSettings.tsx +++ b/src/components/settings/SidebarAppsSettings.tsx @@ -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 ( - - + + update('keepAppsLoaded', v)} /> - + {apps.length === 0 && !isAdding && ( - No apps added yet. + {t('settings.sidebar_apps.empty', "No apps added yet.")} )} {apps.map((app) => { @@ -83,15 +85,15 @@ export function SidebarAppsSettings() { ]}> - {app.openMode === 'inline' ? 'Inline' : 'Tab'} + {app.openMode === 'inline' ? t('settings.sidebar_apps.open_mode.inline', "Inline") : t('settings.sidebar_apps.open_mode.tab', "Tab")} - setEditingId(app.id)}> + setEditingId(app.id)} accessibilityRole="button" accessibilityLabel={t('common.edit', "Edit")}> - removeApp(app.id)}> + removeApp(app.id)} accessibilityRole="button" accessibilityLabel={t('common.delete', "Delete")}> @@ -108,7 +110,7 @@ export function SidebarAppsSettings() { {!isAdding && editingId === null && ( setIsAdding(true)}> - Add app + {t('settings.sidebar_apps.add', "Add app")} )} @@ -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 ( - Name + {t('settings.sidebar_apps.form.name', "Name")} - URL + {t('settings.sidebar_apps.form.url', "URL")} - Open mode + {t('settings.sidebar_apps.form.open_mode', "Open mode")} setOpenMode('tab')} @@ -168,7 +171,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) { > - New tab + {t('settings.sidebar_apps.open_mode.new_tab', "New tab")} - Inline + {t('settings.sidebar_apps.open_mode.inline', "Inline")} - Show on mobile + {t('settings.sidebar_apps.form.show_on_mobile', "Show on mobile")} - + diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index 7ae95eb4..fe3a7793 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -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 ( - + - {templates.length === 1 ? '1 template' : `${templates.length} templates`} + {tr('settings.templates.count', '{count, plural, one {# template} other {# templates}}', { count: templates.length })} {templates.length === 0 ? ( - No templates yet. Tap "New" to add one. + {tr('settings.templates.no_templates', 'No templates yet')} ) : ( {templates.map((t) => ( @@ -143,7 +148,7 @@ export function TemplateSettings() { {t.subject} )} - confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}> + confirmDelete(t)} hitSlop={8} style={styles.listRowDelete} accessibilityRole="button" accessibilityLabel={tr('common.delete', 'Delete')}> @@ -152,7 +157,7 @@ export function TemplateSettings() { )} - + @@ -180,42 +185,42 @@ export function TemplateSettings() { - {editing?.id ? 'Edit template' : 'New template'} + {editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')} - + - Name + {tr('settings.templates.name', 'Template Name')} - Subject + {tr('settings.templates.subject', 'Subject')} - Body + {tr('settings.templates.body', 'Body')} - - + + @@ -226,8 +231,8 @@ export function TemplateSettings() { - Import templates - setImportVisible(false)} hitSlop={8}> + {tr('settings.templates.import', 'Import')} + setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}> @@ -245,9 +250,9 @@ export function TemplateSettings() { /> - +