mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes
This commit is contained in:
+198
-2
@@ -6,9 +6,16 @@
|
|||||||
"not_starred": "Not starred"
|
"not_starred": "Not starred"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
"agenda": {
|
||||||
|
"days_clear": "The next {count} days are clear.",
|
||||||
|
"no_upcoming": "No upcoming events"
|
||||||
|
},
|
||||||
"event_modal": {
|
"event_modal": {
|
||||||
"participant_placeholder": "Add participant by email"
|
"participant_placeholder": "Add participant by email"
|
||||||
},
|
},
|
||||||
|
"events": {
|
||||||
|
"tap_to_create": "Tap + to create one"
|
||||||
|
},
|
||||||
"invitation": {
|
"invitation": {
|
||||||
"accept": "Yes",
|
"accept": "Yes",
|
||||||
"add_error": "Could not add the event",
|
"add_error": "Could not add the event",
|
||||||
@@ -38,6 +45,12 @@
|
|||||||
"edit_calendar": "Edit calendar",
|
"edit_calendar": "Edit calendar",
|
||||||
"new_calendar": "New 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": {
|
"share": {
|
||||||
"add_people": "Add people",
|
"add_people": "Add people",
|
||||||
"error": "Failed to update sharing",
|
"error": "Failed to update sharing",
|
||||||
@@ -58,16 +71,29 @@
|
|||||||
"done": "Done",
|
"done": "Done",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"undo": "Undo"
|
"undo": "Undo",
|
||||||
|
"update": "Update"
|
||||||
},
|
},
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
"color_tag": "Tag",
|
"color_tag": "Tag",
|
||||||
"remove_color": "Remove tag"
|
"remove_color": "Remove tag"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
|
"attach_camera": "Camera",
|
||||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
"attachment_reminder_title": "Forgot an attachment?",
|
"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.",
|
"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",
|
"save_draft": "Save Draft",
|
||||||
"schedule_custom": "Pick date & time…",
|
"schedule_custom": "Pick date & time…",
|
||||||
"schedule_in_1h": "In 1 hour",
|
"schedule_in_1h": "In 1 hour",
|
||||||
@@ -75,6 +101,7 @@
|
|||||||
"schedule_past_body": "The scheduled time must be in the future.",
|
"schedule_past_body": "The scheduled time must be in the future.",
|
||||||
"schedule_past_title": "Pick a future time",
|
"schedule_past_title": "Pick a future time",
|
||||||
"schedule_tomorrow": "Tomorrow, 8:00 AM",
|
"schedule_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_body": "That is later than this server allows. Pick an earlier time.",
|
||||||
"schedule_too_late_title": "Too far ahead",
|
"schedule_too_late_title": "Too far ahead",
|
||||||
"schedule_unsupported_body": "This mail server does not support scheduled send.",
|
"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_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||||
"send_timeout_title": "No answer from the server",
|
"send_timeout_title": "No answer from the server",
|
||||||
"undo_send_label": "Sending in {seconds}s…",
|
"undo_send_label": "Sending in {seconds}s…",
|
||||||
|
"uploading": "Uploading...",
|
||||||
"validation": {
|
"validation": {
|
||||||
"subject_required": "Add a subject"
|
"subject_required": "Add a subject"
|
||||||
}
|
}
|
||||||
@@ -121,6 +149,23 @@
|
|||||||
"unknown_tag": "not defined here"
|
"unknown_tag": "not defined here"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"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": {
|
"color_tag": {
|
||||||
"blue": "Blue",
|
"blue": "Blue",
|
||||||
"green": "Green",
|
"green": "Green",
|
||||||
@@ -132,8 +177,22 @@
|
|||||||
"title": "Color Tag",
|
"title": "Color Tag",
|
||||||
"yellow": "Yellow"
|
"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",
|
"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": {
|
"files": {
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
@@ -148,6 +207,12 @@
|
|||||||
"share_search_placeholder": "Search by name or email",
|
"share_search_placeholder": "Search by name or email",
|
||||||
"shared_with": "Shared with"
|
"shared_with": "Shared with"
|
||||||
},
|
},
|
||||||
|
"identities": {
|
||||||
|
"badge": {
|
||||||
|
"to": "to {identity}",
|
||||||
|
"via": "via {identity}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"mobile": {
|
"mobile": {
|
||||||
"add_subtitle": "You're signed in to these already.",
|
"add_subtitle": "You're signed in to these already.",
|
||||||
@@ -274,6 +339,9 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||||
"system_default": "System default"
|
"system_default": "System default"
|
||||||
|
},
|
||||||
|
"toolbar_labels": {
|
||||||
|
"description_mobile": "Display text labels next to toolbar icons."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
@@ -281,12 +349,114 @@
|
|||||||
"not_implemented": "Not implemented",
|
"not_implemented": "Not implemented",
|
||||||
"unavailable": "Unavailable"
|
"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": {
|
"experimental": {
|
||||||
"title": "Experimental Feature"
|
"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": {
|
"keywords": {
|
||||||
|
"description_mobile": "Colored tags to organize your mail.",
|
||||||
"reset_defaults": "Reset to Defaults"
|
"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": {
|
"notifications": {
|
||||||
"email": {
|
"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."
|
"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"
|
"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": {
|
"smime": {
|
||||||
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
|
"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."
|
"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": {
|
"tabs": {
|
||||||
"plugins": "Plugins"
|
"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": {
|
"vacation": {
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
|
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
type Theme = ThemeMode;
|
type Theme = ThemeMode;
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.densitySubject,
|
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}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -82,6 +83,7 @@ export function AppearanceSettings() {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const theme = useSettingsStore((s) => s.theme);
|
const theme = useSettingsStore((s) => s.theme);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const setTheme = useSettingsStore((s) => s.setTheme);
|
const setTheme = useSettingsStore((s) => s.setTheme);
|
||||||
const fontSize = useSettingsStore((s) => s.fontSize);
|
const fontSize = useSettingsStore((s) => s.fontSize);
|
||||||
const setFontSize = useSettingsStore((s) => s.setFontSize);
|
const setFontSize = useSettingsStore((s) => s.setFontSize);
|
||||||
@@ -99,52 +101,55 @@ export function AppearanceSettings() {
|
|||||||
}, [hydrated, hydrate]);
|
}, [hydrated, hydrate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
|
<SettingsSection
|
||||||
<SettingItem label="Theme" description="Choose between light, dark, or system theme.">
|
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
|
<RadioGroup
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(v) => setTheme(v as Theme)}
|
onChange={(v) => setTheme(v as Theme)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'light', label: 'Light' },
|
{ value: 'light', label: t('settings.appearance.theme.light', "Light") },
|
||||||
{ value: 'dark', label: 'Dark' },
|
{ value: 'dark', label: t('settings.appearance.theme.dark', "Dark") },
|
||||||
{ value: 'system', label: 'System' },
|
{ value: 'system', label: t('settings.appearance.theme.system', "System") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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
|
<RadioGroup
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
onChange={(v) => setFontSize(v as FontSize)}
|
onChange={(v) => setFontSize(v as FontSize)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'small', label: 'Small' },
|
{ value: 'small', label: t('settings.appearance.font_size.small', "Small") },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: t('settings.appearance.font_size.medium', "Medium") },
|
||||||
{ value: 'large', label: 'Large' },
|
{ value: 'large', label: t('settings.appearance.font_size.large', "Large") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<View>
|
<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
|
<RadioGroup
|
||||||
value={density}
|
value={density}
|
||||||
onChange={(v) => setDensity(v as Density)}
|
onChange={(v) => setDensity(v as Density)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'extra-compact', label: 'Extra' },
|
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
|
||||||
{ value: 'compact', label: 'Compact' },
|
{ value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
|
||||||
{ value: 'regular', label: 'Regular' },
|
{ value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
|
||||||
{ value: 'comfortable', label: 'Comfy' },
|
{ value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DensityPreview density={density} />
|
<DensityPreview density={density} />
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
</View>
|
</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} />
|
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
|
||||||
</SettingItem>
|
</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} />
|
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -190,7 +195,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
densityPreviewText: {
|
densityPreviewText: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: 'rgba(161,161,170,0.7)',
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import Button from '../Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
export function ComposingSettings() {
|
export function ComposingSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
|
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||||
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
||||||
@@ -40,29 +42,29 @@ export function ComposingSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
|
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
|
||||||
{ label: 'Off', value: 0 },
|
// Same set the webmail accepts (0/10/30/60); the store rejects others.
|
||||||
{ label: '5s', value: 5 },
|
{ label: t('settings.email_behavior.send_delay.off', "Off"), value: 0 },
|
||||||
{ label: '10s', value: 10 },
|
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 10 }), value: 10 },
|
||||||
{ label: '20s', value: 20 },
|
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 30 }), value: 30 },
|
||||||
{ label: '30s', value: 30 },
|
{ label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 60 }), value: 60 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Composing"
|
title={t('settings.composer.title', "Composer")}
|
||||||
description="How the composer behaves when you reply, forward, or send."
|
description={t('settings.composer.description', "Configure email composition settings")}
|
||||||
>
|
>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Auto-select reply identity"
|
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
|
||||||
description="When replying, pick the identity that received the original message."
|
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} />
|
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<View style={styles.subBlock}>
|
<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}>
|
<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>
|
</Text>
|
||||||
<View style={styles.segmentRow}>
|
<View style={styles.segmentRow}>
|
||||||
{SEND_DELAY_OPTIONS.map((opt) => {
|
{SEND_DELAY_OPTIONS.map((opt) => {
|
||||||
@@ -83,8 +85,8 @@ export function ComposingSettings() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Attachment reminder"
|
label={t('settings.email_behavior.attachment_reminder.label', "Attachment Reminder")}
|
||||||
description="Warn before sending if the message mentions an attachment but none is attached."
|
description={t('settings.email_behavior.attachment_reminder.description', "Warn before sending when your message mentions attachments but none are attached")}
|
||||||
>
|
>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={attachmentReminderEnabled}
|
checked={attachmentReminderEnabled}
|
||||||
@@ -94,9 +96,9 @@ export function ComposingSettings() {
|
|||||||
|
|
||||||
{attachmentReminderEnabled && (
|
{attachmentReminderEnabled && (
|
||||||
<View style={styles.subBlock}>
|
<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}>
|
<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>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.chips}>
|
<View style={styles.chips}>
|
||||||
@@ -114,7 +116,7 @@ export function ComposingSettings() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={newKeyword}
|
value={newKeyword}
|
||||||
onChangeText={setNewKeyword}
|
onChangeText={setNewKeyword}
|
||||||
placeholder="e.g. attached"
|
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -129,7 +131,7 @@ export function ComposingSettings() {
|
|||||||
disabled={!newKeyword.trim()}
|
disabled={!newKeyword.trim()}
|
||||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||||
>
|
>
|
||||||
Add
|
{t('settings.email_behavior.attachment_reminder.add', "Add")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
updateIdentity,
|
updateIdentity,
|
||||||
} from '../../api/identity';
|
} from '../../api/identity';
|
||||||
import type { Identity } from '../../api/types';
|
import type { Identity } from '../../api/types';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
type DraftIdentity = {
|
type DraftIdentity = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +52,7 @@ export function IdentitySettings() {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const identities = useSettingsStore((s) => s.identities);
|
const identities = useSettingsStore((s) => s.identities);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const loading = useSettingsStore((s) => s.loading);
|
const loading = useSettingsStore((s) => s.loading);
|
||||||
const error = useSettingsStore((s) => s.error);
|
const error = useSettingsStore((s) => s.error);
|
||||||
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
|
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
|
||||||
@@ -72,7 +74,7 @@ export function IdentitySettings() {
|
|||||||
const name = editing.name.trim();
|
const name = editing.name.trim();
|
||||||
const email = editing.email.trim();
|
const email = editing.email.trim();
|
||||||
if (!email || !EMAIL_RE.test(email)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -95,7 +97,7 @@ export function IdentitySettings() {
|
|||||||
closeEditor();
|
closeEditor();
|
||||||
await fetchIdentities();
|
await fetchIdentities();
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -103,16 +105,16 @@ export function IdentitySettings() {
|
|||||||
|
|
||||||
const confirmDelete = (identity: Identity) => {
|
const confirmDelete = (identity: Identity) => {
|
||||||
if (!identity.mayDelete) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Delete identity',
|
t('settings.identities.delete_title', "Delete identity"),
|
||||||
`Remove "${identity.name || identity.email}"?`,
|
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',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
setDeletingId(identity.id);
|
setDeletingId(identity.id);
|
||||||
@@ -120,7 +122,7 @@ export function IdentitySettings() {
|
|||||||
await deleteIdentity(identity.id);
|
await deleteIdentity(identity.id);
|
||||||
await fetchIdentities();
|
await fetchIdentities();
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
@@ -132,12 +134,14 @@ export function IdentitySettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Identities"
|
title={t('settings.identities.title', "Sending Identities")}
|
||||||
description="Manage sender names, email addresses, and signatures. Tap a row to edit."
|
description={t('settings.identities.description_mobile', "Manage sender names, email addresses, and signatures. Tap a row to edit.")}
|
||||||
>
|
>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<Text style={styles.count}>
|
<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>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -146,7 +150,7 @@ export function IdentitySettings() {
|
|||||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
New
|
{t('settings.identities.new', "New")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -163,12 +167,12 @@ export function IdentitySettings() {
|
|||||||
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
|
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<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>
|
<Text style={styles.identityEmail}>{identity.email}</Text>
|
||||||
</View>
|
</View>
|
||||||
{!identity.mayDelete ? (
|
{!identity.mayDelete ? (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>primary</Text>
|
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -176,6 +180,8 @@ export function IdentitySettings() {
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.identityDelete}
|
style={styles.identityDelete}
|
||||||
disabled={deletingId === identity.id}
|
disabled={deletingId === identity.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.delete', "Delete")}
|
||||||
>
|
>
|
||||||
{deletingId === identity.id ? (
|
{deletingId === identity.id ? (
|
||||||
<ActivityIndicator size="small" color={c.error} />
|
<ActivityIndicator size="small" color={c.error} />
|
||||||
@@ -192,14 +198,14 @@ export function IdentitySettings() {
|
|||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<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>
|
</Text>
|
||||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
<Pressable onPress={closeEditor} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('common.close', "Close")}>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||||
<Text style={styles.fieldLabel}>Display name</Text>
|
<Text style={styles.fieldLabel}>{t('settings.identities.display_name', "Display name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={editing?.name ?? ''}
|
value={editing?.name ?? ''}
|
||||||
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
|
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
|
||||||
@@ -207,7 +213,7 @@ export function IdentitySettings() {
|
|||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.fieldLabel}>Email address</Text>
|
<Text style={styles.fieldLabel}>{t('settings.identities.email_address', "Email address")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={editing?.email ?? ''}
|
value={editing?.email ?? ''}
|
||||||
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
|
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
|
||||||
@@ -220,9 +226,9 @@ export function IdentitySettings() {
|
|||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
/>
|
/>
|
||||||
{!!editing?.id && (
|
{!!editing?.id && (
|
||||||
<Text style={styles.hint}>JMAP does not allow changing an identity'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
|
<TextInput
|
||||||
value={editing?.textSignature ?? ''}
|
value={editing?.textSignature ?? ''}
|
||||||
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
|
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
|
||||||
@@ -233,14 +239,14 @@ export function IdentitySettings() {
|
|||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.modalActions}>
|
<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
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={() => { void saveDraft(); }}
|
onPress={() => { void saveDraft(); }}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
Save
|
{t('common.save', "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -271,7 +277,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
identityDelete: { padding: 6 },
|
identityDelete: { padding: 6 },
|
||||||
badge: {
|
badge: {
|
||||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
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 },
|
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||||
import { DARK_COLORS } from '../../theme/tokens';
|
import { DARK_COLORS } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
type Keyword = KeywordDef;
|
type Keyword = KeywordDef;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export function KeywordSettings() {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const keywords = useKeywordsStore((s) => s.keywords);
|
const keywords = useKeywordsStore((s) => s.keywords);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const addKeyword = useKeywordsStore((s) => s.add);
|
const addKeyword = useKeywordsStore((s) => s.add);
|
||||||
const updateKeyword = useKeywordsStore((s) => s.update);
|
const updateKeyword = useKeywordsStore((s) => s.update);
|
||||||
const removeKeyword = useKeywordsStore((s) => s.remove);
|
const removeKeyword = useKeywordsStore((s) => s.remove);
|
||||||
@@ -45,7 +47,7 @@ export function KeywordSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }}>
|
<View style={{ gap: spacing.sm }}>
|
||||||
{keywords.map((kw) => {
|
{keywords.map((kw) => {
|
||||||
if (editingId === kw.id) {
|
if (editingId === kw.id) {
|
||||||
@@ -66,10 +68,10 @@ export function KeywordSettings() {
|
|||||||
<Text style={styles.kwLabel}>{kw.label}</Text>
|
<Text style={styles.kwLabel}>{kw.label}</Text>
|
||||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
<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} />
|
<Pencil size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</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} />
|
<Trash2 size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -89,11 +91,11 @@ export function KeywordSettings() {
|
|||||||
<View style={styles.bottomActions}>
|
<View style={styles.bottomActions}>
|
||||||
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
|
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
|
||||||
<Plus size={14} color={c.mutedForeground} />
|
<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>
|
||||||
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
||||||
<RotateCcw size={14} color={c.mutedForeground} />
|
<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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -112,6 +114,7 @@ interface KeywordFormProps {
|
|||||||
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [label, setLabel] = useState(initial?.label ?? '');
|
||||||
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
||||||
|
|
||||||
@@ -133,21 +136,21 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
return (
|
return (
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.formLabel}>Label</Text>
|
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={label}
|
value={label}
|
||||||
onChangeText={setLabel}
|
onChangeText={setLabel}
|
||||||
placeholder="e.g. Important"
|
placeholder={t('settings.keywords.label_placeholder', "e.g. Work, Personal, Urgent")}
|
||||||
placeholderTextColor={c.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
autoFocus
|
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>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.formLabel}>Color</Text>
|
<Text style={styles.formLabel}>{t('settings.keywords.color_field', "Color")}</Text>
|
||||||
<View style={styles.palette}>
|
<View style={styles.palette}>
|
||||||
{PALETTE_KEYS.map((key) => {
|
{PALETTE_KEYS.map((key) => {
|
||||||
const p = c.tags[key];
|
const p = c.tags[key];
|
||||||
@@ -155,6 +158,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={key}
|
key={key}
|
||||||
onPress={() => setColor(key)}
|
onPress={() => setColor(key)}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected: color === key }}
|
||||||
|
accessibilityLabel={key}
|
||||||
style={[
|
style={[
|
||||||
styles.colorSwatch,
|
styles.colorSwatch,
|
||||||
{ backgroundColor: p.dot },
|
{ backgroundColor: p.dot },
|
||||||
@@ -169,7 +175,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
<View style={styles.formActions}>
|
<View style={styles.formActions}>
|
||||||
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
|
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
|
||||||
<X size={14} color={c.text} />
|
<X size={14} color={c.text} />
|
||||||
<Text style={styles.cancelFormText}>Cancel</Text>
|
<Text style={styles.cancelFormText}>{t('common.cancel', "Cancel")}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.saveBtn, !isValid && { opacity: 0.5 }]}
|
style={[styles.saveBtn, !isValid && { opacity: 0.5 }]}
|
||||||
@@ -177,7 +183,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
<Check size={14} color={c.primaryForeground} />
|
<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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
import { useEmailStore } from '../../stores/email-store';
|
import { useEmailStore } from '../../stores/email-store';
|
||||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
export function ReadingSettings() {
|
export function ReadingSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
const update = useSettingsStore((s) => s.updateSetting);
|
const update = useSettingsStore((s) => s.updateSetting);
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ export function ReadingSettings() {
|
|||||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||||
);
|
);
|
||||||
if (!archiveMailbox) {
|
if (!archiveMailbox) {
|
||||||
setReorganizeResult('No archive folder found.');
|
setReorganizeResult(t('settings.email_behavior.archive_mode.no_archive', "No archive folder found."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,58 +102,58 @@ export function ReadingSettings() {
|
|||||||
if (ids.length < PAGE) break;
|
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) {
|
} 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 {
|
} finally {
|
||||||
setReorganizing(false);
|
setReorganizing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
|
<SettingsSection title={t('settings.email_behavior.title', "Email Behavior")} description={t('settings.email_behavior.description', "Configure how emails are handled")}>
|
||||||
<SettingItem label="Mark as Read" description="When to flag an email as read.">
|
<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
|
<Select
|
||||||
value={String(markAsReadDelay)}
|
value={String(markAsReadDelay)}
|
||||||
onChange={(v) => update('markAsReadDelay', Number(v))}
|
onChange={(v) => update('markAsReadDelay', Number(v))}
|
||||||
options={[
|
options={[
|
||||||
{ value: '0', label: 'Instant' },
|
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
|
||||||
{ value: '3000', label: 'After 3s' },
|
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
|
||||||
{ value: '5000', label: 'After 5s' },
|
{ value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") },
|
||||||
{ value: '-1', label: 'Never' },
|
{ value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<View style={styles.group}>
|
<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
|
<Select
|
||||||
value={deleteAction}
|
value={deleteAction}
|
||||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'trash', label: 'Move to Trash' },
|
{ value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") },
|
||||||
{ value: 'trash-and-read', label: 'Move to Trash and mark as read' },
|
{ value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") },
|
||||||
{ value: 'permanent', label: 'Permanently delete' },
|
{ value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{deleteAction === 'permanent' && (
|
{deleteAction === 'permanent' && (
|
||||||
<View style={styles.warning}>
|
<View style={styles.warning}>
|
||||||
<AlertTriangle size={14} color={c.error} />
|
<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>
|
||||||
)}
|
)}
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.group}>
|
<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
|
<Select
|
||||||
value={archiveMode}
|
value={archiveMode}
|
||||||
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'single', label: 'Single folder' },
|
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
|
||||||
{ value: 'year', label: 'By year' },
|
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
|
||||||
{ value: 'month', label: 'By year/month' },
|
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{archiveMode !== 'single' && (
|
{archiveMode !== 'single' && (
|
||||||
@@ -167,7 +169,7 @@ export function ReadingSettings() {
|
|||||||
<FolderSync size={14} color={c.text} />
|
<FolderSync size={14} color={c.text} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.inlineBtnText}>
|
<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>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{reorganizeResult && (
|
{reorganizeResult && (
|
||||||
@@ -178,31 +180,31 @@ export function ReadingSettings() {
|
|||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
</View>
|
</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)} />
|
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
||||||
</SettingItem>
|
</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)} />
|
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||||
</SettingItem>
|
</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)} />
|
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||||
</SettingItem>
|
</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)} />
|
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
|
||||||
</SettingItem>
|
</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)} />
|
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
|
||||||
</SettingItem>
|
</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)} />
|
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
|
||||||
</SettingItem>
|
</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
|
<ToggleSwitch
|
||||||
checked={attachmentReminderEnabled}
|
checked={attachmentReminderEnabled}
|
||||||
onChange={(v) => update('attachmentReminderEnabled', v)}
|
onChange={(v) => update('attachmentReminderEnabled', v)}
|
||||||
@@ -211,8 +213,8 @@ export function ReadingSettings() {
|
|||||||
|
|
||||||
{attachmentReminderEnabled && (
|
{attachmentReminderEnabled && (
|
||||||
<View style={styles.group}>
|
<View style={styles.group}>
|
||||||
<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.subDesc}>Add words that should trigger the reminder.</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}>
|
<View style={styles.chipRow}>
|
||||||
{attachmentReminderKeywords.map((kw) => (
|
{attachmentReminderKeywords.map((kw) => (
|
||||||
<View key={kw} style={styles.chip}>
|
<View key={kw} style={styles.chip}>
|
||||||
@@ -235,7 +237,7 @@ export function ReadingSettings() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={newKeyword}
|
value={newKeyword}
|
||||||
onChangeText={setNewKeyword}
|
onChangeText={setNewKeyword}
|
||||||
placeholder="Add a keyword"
|
placeholder={t('settings.email_behavior.attachment_reminder.add_placeholder', "Add keyword...")}
|
||||||
placeholderTextColor={c.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.keywordInput}
|
style={styles.keywordInput}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
@@ -256,35 +258,35 @@ export function ReadingSettings() {
|
|||||||
setNewKeyword('');
|
setNewKeyword('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.addKeywordText}>Add</Text>
|
<Text style={styles.addKeywordText}>{t('settings.email_behavior.attachment_reminder.add', "Add")}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
</View>
|
</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)} />
|
<ToggleSwitch checked={hideInlineImageAttachments} onChange={(v) => update('hideInlineImageAttachments', v)} />
|
||||||
</SettingItem>
|
</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
|
<Select
|
||||||
value={mailAttachmentAction}
|
value={mailAttachmentAction}
|
||||||
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
|
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'preview', label: 'Preview' },
|
{ value: 'preview', label: t('settings.email_behavior.attachment_click_action.preview', "Preview when possible") },
|
||||||
{ value: 'download', label: 'Download' },
|
{ value: 'download', label: t('settings.email_behavior.attachment_click_action.download', "Download immediately") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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
|
<Select
|
||||||
value={attachmentPosition}
|
value={attachmentPosition}
|
||||||
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
|
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'beside-sender', label: 'Beside sender' },
|
{ value: 'beside-sender', label: t('settings.email_behavior.attachment_position.beside-sender', "Next to sender") },
|
||||||
{ value: 'below-header', label: 'Below header' },
|
{ value: 'below-header', label: t('settings.email_behavior.attachment_position.below-header', "Below header") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
@@ -324,7 +326,7 @@ export function ReadingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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
|
<Select
|
||||||
value={String(emailsPerPage)}
|
value={String(emailsPerPage)}
|
||||||
onChange={(v) => update('emailsPerPage', Number(v))}
|
onChange={(v) => update('emailsPerPage', Number(v))}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Button from '../Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
|
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
export function SidebarAppsSettings() {
|
export function SidebarAppsSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -16,6 +17,7 @@ export function SidebarAppsSettings() {
|
|||||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
const update = useSettingsStore((s) => s.updateSetting);
|
const update = useSettingsStore((s) => s.updateSetting);
|
||||||
const apps = useSettingsStore((s) => s.sidebarApps);
|
const apps = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
|
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
|
||||||
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
|
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
|
||||||
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
|
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
|
||||||
@@ -44,16 +46,16 @@ export function SidebarAppsSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
|
<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="Keep apps loaded" description="Maintain app state when switching away.">
|
<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)} />
|
<ToggleSwitch checked={keepLoaded} onChange={(v) => update('keepAppsLoaded', v)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</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 }}>
|
<View style={{ gap: spacing.md }}>
|
||||||
{apps.length === 0 && !isAdding && (
|
{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) => {
|
{apps.map((app) => {
|
||||||
@@ -83,15 +85,15 @@ export function SidebarAppsSettings() {
|
|||||||
]}>
|
]}>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.modeBadgeText,
|
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>
|
</Text>
|
||||||
</View>
|
</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} />
|
<Pencil size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</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} />
|
<Trash2 size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -108,7 +110,7 @@ export function SidebarAppsSettings() {
|
|||||||
{!isAdding && editingId === null && (
|
{!isAdding && editingId === null && (
|
||||||
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
|
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
|
||||||
<Plus size={16} color={c.text} />
|
<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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -126,6 +128,7 @@ interface AppFormProps {
|
|||||||
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
|
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
const [url, setUrl] = useState(initial?.url ?? '');
|
const [url, setUrl] = useState(initial?.url ?? '');
|
||||||
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
||||||
@@ -136,18 +139,18 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={formStyles.form}>
|
<View style={formStyles.form}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={formStyles.label}>Name</Text>
|
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.name', "Name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
placeholder="App name"
|
placeholder={t('settings.sidebar_apps.form.name_placeholder', "App name")}
|
||||||
placeholderTextColor={c.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={formStyles.input}
|
style={formStyles.input}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text style={formStyles.label}>URL</Text>
|
<Text style={formStyles.label}>{t('settings.sidebar_apps.form.url', "URL")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
@@ -160,7 +163,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<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 }}>
|
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setOpenMode('tab')}
|
onPress={() => setOpenMode('tab')}
|
||||||
@@ -168,7 +171,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
>
|
>
|
||||||
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
|
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
|
||||||
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
|
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
|
||||||
New tab
|
{t('settings.sidebar_apps.open_mode.new_tab', "New tab")}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -177,25 +180,25 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
>
|
>
|
||||||
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
|
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
|
||||||
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
|
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
|
||||||
Inline
|
{t('settings.sidebar_apps.open_mode.inline', "Inline")}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
<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} />
|
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
onPress={() => onSave({ name: name.trim(), url: url.trim(), icon: 'Globe', openMode, showOnMobile })}
|
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>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import Button from '../Button';
|
|||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
|
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
export function TemplateSettings() {
|
export function TemplateSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const tr = useLocaleStore((s) => s.t);
|
||||||
const templates = useTemplatesStore((s) => s.templates);
|
const templates = useTemplatesStore((s) => s.templates);
|
||||||
const hydrated = useTemplatesStore((s) => s.hydrated);
|
const hydrated = useTemplatesStore((s) => s.hydrated);
|
||||||
const hydrate = useTemplatesStore((s) => s.hydrate);
|
const hydrate = useTemplatesStore((s) => s.hydrate);
|
||||||
@@ -68,7 +70,7 @@ export function TemplateSettings() {
|
|||||||
const saveDraft = () => {
|
const saveDraft = () => {
|
||||||
const name = draftName.trim();
|
const name = draftName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
Alert.alert('Template name required');
|
Alert.alert(tr('settings.templates.validation.empty', "Template name is required"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -82,11 +84,11 @@ export function TemplateSettings() {
|
|||||||
|
|
||||||
const confirmDelete = (t: EmailTemplate) => {
|
const confirmDelete = (t: EmailTemplate) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Delete template',
|
tr('settings.templates.delete_title', "Delete template"),
|
||||||
`Permanently delete "${t.name}"?`,
|
tr('settings.templates.delete_confirm', 'Permanently delete "{name}"?', { name: t.name }),
|
||||||
[
|
[
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: tr('common.cancel', 'Cancel'), style: 'cancel' },
|
||||||
{ text: 'Delete', style: 'destructive', onPress: () => deleteTemplate(t.id) },
|
{ text: tr('common.delete', 'Delete'), style: 'destructive', onPress: () => deleteTemplate(t.id) },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -94,40 +96,43 @@ export function TemplateSettings() {
|
|||||||
const exportTemplatesAsShare = async () => {
|
const exportTemplatesAsShare = async () => {
|
||||||
const json = exportAll();
|
const json = exportAll();
|
||||||
try {
|
try {
|
||||||
await Share.share({ message: json, title: 'Templates export' });
|
await Share.share({ message: json, title: tr('settings.templates.export', 'Export') });
|
||||||
} catch (err) {
|
} 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 performImport = () => {
|
||||||
const result = importTemplates(importText);
|
const result = importTemplates(importText);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
Alert.alert('Import failed', result.error);
|
Alert.alert(tr('settings.templates.import_failed', 'Import failed'), result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setImportText('');
|
setImportText('');
|
||||||
setImportVisible(false);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<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.headerRow}>
|
||||||
<View style={styles.countRow}>
|
<View style={styles.countRow}>
|
||||||
<FileText size={16} color={c.mutedForeground} />
|
<FileText size={16} color={c.mutedForeground} />
|
||||||
<Text style={styles.count}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
|
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
|
||||||
New
|
{tr('settings.templates.add', 'New Template')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{templates.length === 0 ? (
|
{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}>
|
<View style={styles.list}>
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
@@ -143,7 +148,7 @@ export function TemplateSettings() {
|
|||||||
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
|
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</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} />
|
<Trash2 size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -152,7 +157,7 @@ export function TemplateSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</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}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -161,7 +166,7 @@ export function TemplateSettings() {
|
|||||||
icon={<Download size={14} color={c.text} />}
|
icon={<Download size={14} color={c.text} />}
|
||||||
onPress={() => { void exportTemplatesAsShare(); }}
|
onPress={() => { void exportTemplatesAsShare(); }}
|
||||||
>
|
>
|
||||||
Share JSON
|
{tr('settings.templates.export', 'Export')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -169,7 +174,7 @@ export function TemplateSettings() {
|
|||||||
icon={<Upload size={14} color={c.text} />}
|
icon={<Upload size={14} color={c.text} />}
|
||||||
onPress={() => setImportVisible(true)}
|
onPress={() => setImportVisible(true)}
|
||||||
>
|
>
|
||||||
Import
|
{tr('settings.templates.import', 'Import')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -180,42 +185,42 @@ export function TemplateSettings() {
|
|||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<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>
|
</Text>
|
||||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
<Pressable onPress={closeEditor} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||||
<Text style={styles.fieldLabel}>Name</Text>
|
<Text style={styles.fieldLabel}>{tr('settings.templates.name', 'Template Name')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={draftName}
|
value={draftName}
|
||||||
onChangeText={setDraftName}
|
onChangeText={setDraftName}
|
||||||
placeholder="Quick reply"
|
placeholder={tr('settings.templates.name_placeholder', 'e.g., Follow-up email')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.fieldLabel}>Subject</Text>
|
<Text style={styles.fieldLabel}>{tr('settings.templates.subject', 'Subject')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={draftSubject}
|
value={draftSubject}
|
||||||
onChangeText={setDraftSubject}
|
onChangeText={setDraftSubject}
|
||||||
placeholder="(optional)"
|
placeholder={tr('settings.templates.subject_placeholder', 'Email subject line')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.fieldLabel}>Body</Text>
|
<Text style={styles.fieldLabel}>{tr('settings.templates.body', 'Body')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={draftBody}
|
value={draftBody}
|
||||||
onChangeText={setDraftBody}
|
onChangeText={setDraftBody}
|
||||||
placeholder="Hi {recipient_name}, …"
|
placeholder={tr('settings.templates.body_placeholder', 'Email body content...')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
multiline
|
multiline
|
||||||
style={[styles.input, styles.bodyInput]}
|
style={[styles.input, styles.bodyInput]}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.modalActions}>
|
<View style={styles.modalActions}>
|
||||||
<Button variant="outline" size="sm" onPress={closeEditor}>Cancel</Button>
|
<Button variant="outline" size="sm" onPress={closeEditor}>{tr('common.cancel', 'Cancel')}</Button>
|
||||||
<Button variant="default" size="sm" onPress={saveDraft}>Save</Button>
|
<Button variant="default" size="sm" onPress={saveDraft}>{tr('common.save', 'Save')}</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -226,8 +231,8 @@ export function TemplateSettings() {
|
|||||||
<View style={styles.modalOverlay}>
|
<View style={styles.modalOverlay}>
|
||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>Import templates</Text>
|
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
|
||||||
<Pressable onPress={() => setImportVisible(false)} hitSlop={8}>
|
<Pressable onPress={() => setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -245,9 +250,9 @@ export function TemplateSettings() {
|
|||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.modalActions}>
|
<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()}>
|
<Button variant="default" size="sm" onPress={performImport} disabled={!importText.trim()}>
|
||||||
Import
|
{tr('settings.templates.import', 'Import')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user