fix: translate the mail list and label its icon buttons

The filter sheet, the .eml import result, the selection count, the
folder totals and the offline sync banner were English literals or
built by gluing translated words to numbers. They now use t() with ICU
arguments and plurals; filter chips go through a "{field}: {value}"
message so translators control the order. The invitation trust
warnings used keys that no catalog had, so they are now literal t()
calls and harvested.

For screen readers (audit U8) the hamburger, select-all checkbox,
filter, sort and compose buttons, the selection toolbar, search and
chip clear buttons, and the close buttons of the move, tag, template,
identity, QR and action sheets get translated labels and roles.
This commit is contained in:
Linus Rath
2026-09-24 20:49:55 +02:00
parent ccdc4dee56
commit 9c0c35c7ba
13 changed files with 281 additions and 86 deletions
+27 -1
View File
@@ -56,6 +56,10 @@
"response_sent": "Response sent", "response_sent": "Response sent",
"tentative": "Maybe", "tentative": "Maybe",
"title": "Calendar invitation", "title": "Calendar invitation",
"trust_authentication_failed": "This message failed sender authentication (SPF/DKIM/DMARC).",
"trust_authentication_missing": "The sender could not be verified.",
"trust_sender_mismatch": "The sender differs from the event organizer.",
"trust_sender_mismatch_unverified": "The sender does not match the organizer and the message is not authenticated.",
"trust_verified": "Sender verified" "trust_verified": "Sender verified"
}, },
"management": { "management": {
@@ -266,6 +270,15 @@
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived", "emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}", "emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
"error": "Error", "error": "Error",
"filter_chip": "{field}: {value}",
"filter_title": "Filter emails",
"folder_counts": "{total} total · {unread} unread",
"import": {
"none": "No messages were imported.",
"partial": "{imported} imported, {failed} failed.",
"success": "{count, plural, one {# message imported.} other {# messages imported.}}",
"title": "Import"
},
"marked_as_spam": "Marked as spam", "marked_as_spam": "Marked as spam",
"marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam", "marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam",
"marked_not_spam": "Marked as not spam", "marked_not_spam": "Marked as not spam",
@@ -285,10 +298,12 @@
"outbox_discard": "Discard", "outbox_discard": "Discard",
"outbox_failed": "{count} changes could not be saved to the server.", "outbox_failed": "{count} changes could not be saved to the server.",
"recent_searches": "Recent searches", "recent_searches": "Recent searches",
"remove_filter": "Remove filter {label}",
"scope_all_folders": "All folders", "scope_all_folders": "All folders",
"scope_this_folder": "This folder", "scope_this_folder": "This folder",
"search_placeholder": "Search mail...", "search_placeholder": "Search mail...",
"showing_cached": "Showing cached mail", "showing_cached": "Showing cached mail",
"sort_toggle_hint": "Reverses the mail sort order",
"swipe_move": "Move", "swipe_move": "Move",
"swipe_spam": "Spam", "swipe_spam": "Spam",
"total": "total", "total": "total",
@@ -475,7 +490,18 @@
}, },
"offline": { "offline": {
"banner": "You are offline", "banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect" "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect",
"sync": {
"cached_count": "{count, plural, one {# new message cached} other {# new messages cached}} • {size}",
"cancelled": "Sync cancelled",
"failed": "Offline sync failed",
"processed": "{completed}/{total} processed",
"ready": "Offline mail ready",
"syncing": "Syncing offline mail",
"title": "Offline sync",
"unable_to_download": "Unable to download",
"up_to_date": "Already up to date"
}
}, },
"push_prompt": { "push_prompt": {
"body": "Turn on background notifications so new messages reach you while the app is closed.", "body": "Turn on background notifications so new messages reach you while the app is closed.",
+4 -1
View File
@@ -60,7 +60,10 @@ export function IdentitySheet({
</View> </View>
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text> <Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}> <Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
+4 -1
View File
@@ -92,7 +92,10 @@ export function MoveSheet({
</View> </View>
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text> <Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}> <Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
+29 -17
View File
@@ -10,11 +10,13 @@ import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync'; import { formatBytes } from '../lib/offline-sync';
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 { useLocaleStore } from '../stores/locale-store';
export function OfflineCacheBanner(): React.ReactElement | null { export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const sync = useOfflineCacheStore((s) => s.sync); const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort); const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync); const resetSync = useOfflineCacheStore((s) => s.resetSync);
@@ -32,9 +34,9 @@ export function OfflineCacheBanner(): React.ReactElement | null {
// permanent fixture; the Settings screen still shows the cache stats. // permanent fixture; the Settings screen still shows the cache stats.
React.useEffect(() => { React.useEffect(() => {
if (sync.phase === 'done' || sync.phase === 'cancelled') { if (sync.phase === 'done' || sync.phase === 'cancelled') {
const t = setTimeout(() => resetSync(), 4000); const timer = setTimeout(() => resetSync(), 4000);
setHideTimer(t); setHideTimer(timer);
return () => clearTimeout(t); return () => clearTimeout(timer);
} }
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
return undefined; return undefined;
@@ -48,30 +50,34 @@ export function OfflineCacheBanner(): React.ReactElement | null {
? Math.min(100, Math.round((sync.completed / sync.total) * 100)) ? Math.min(100, Math.round((sync.completed / sync.total) * 100))
: sync.phase === 'done' ? 100 : 0; : sync.phase === 'done' ? 100 : 0;
let title = 'Offline sync'; let title = t('offline.sync.title', 'Offline sync');
let subtitle = ''; let subtitle = '';
switch (sync.phase) { switch (sync.phase) {
case 'scanning': case 'scanning':
title = 'Syncing offline mail'; title = t('offline.sync.syncing', 'Syncing offline mail');
subtitle = 'Scanning recent messages…'; subtitle = t('settings.offline.scanning', 'Scanning recent mail…');
break; break;
case 'fetching': case 'fetching':
title = 'Syncing offline mail'; title = t('offline.sync.syncing', 'Syncing offline mail');
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`; subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
break; break;
case 'done': case 'done':
title = 'Offline mail ready'; title = t('offline.sync.ready', 'Offline mail ready');
subtitle = sync.fetched > 0 subtitle = sync.fetched > 0
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}` ? t(
: 'Already up to date'; 'offline.sync.cached_count',
'{count, plural, one {# new message cached} other {# new messages cached}} • {size}',
{ count: sync.fetched, size: formatBytes(sync.bytes) },
)
: t('offline.sync.up_to_date', 'Already up to date');
break; break;
case 'cancelled': case 'cancelled':
title = 'Sync cancelled'; title = t('offline.sync.cancelled', 'Sync cancelled');
subtitle = `${sync.completed}/${sync.total} processed`; subtitle = t('offline.sync.processed', '{completed}/{total} processed', { completed: sync.completed, total: sync.total });
break; break;
case 'error': case 'error':
title = 'Offline sync failed'; title = t('offline.sync.failed', 'Offline sync failed');
subtitle = sync.message ?? 'Unable to download'; subtitle = sync.message ?? t('offline.sync.unable_to_download', 'Unable to download');
break; break;
default: default:
return null; return null;
@@ -94,12 +100,18 @@ export function OfflineCacheBanner(): React.ReactElement | null {
)} )}
</View> </View>
{showCancel && ( {showCancel && (
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}> <Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6} accessibilityRole="button">
<Text style={styles.cancelText}>Cancel</Text> <Text style={styles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable> </Pressable>
)} )}
{showDismiss && ( {showDismiss && (
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}> <Pressable
style={styles.dismiss}
onPress={resetSync}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.dismiss', 'Dismiss')}
>
<X size={14} color={c.primaryForeground} /> <X size={14} color={c.primaryForeground} />
</Pressable> </Pressable>
)} )}
+4 -1
View File
@@ -76,7 +76,10 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
<SafeAreaView style={styles.overlay}> <SafeAreaView style={styles.overlay}>
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}> <Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={24} color={c.text} /> <X size={24} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.title}>{t('login.mobile.qr_title', 'Sign-in code')}</Text> <Text style={styles.title}>{t('login.mobile.qr_title', 'Sign-in code')}</Text>
+4 -1
View File
@@ -83,7 +83,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Text style={styles.title}> <Text style={styles.title}>
{t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })} {t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })}
</Text> </Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}> <Pressable onPress={onClose} hitSlop={8} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
+4 -1
View File
@@ -79,7 +79,10 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View> </View>
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text> <Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}> <Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@@ -12,6 +12,27 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'screens/FilesScreen.tsx': [], 'screens/FilesScreen.tsx': [],
'components/files/ShareSheet.tsx': [], 'components/files/ShareSheet.tsx': [],
'components/files/FilePreviewModal.tsx': [], 'components/files/FilePreviewModal.tsx': [],
// The empty date filter shows the input format.
'screens/EmailListScreen.tsx': ['YYYY-MM-DD'],
'screens/ScheduledScreen.tsx': [],
'screens/EmailSourceScreen.tsx': [],
'components/OfflineBanner.tsx': [],
'components/OfflineCacheBanner.tsx': [],
'components/UndoSnackbar.tsx': [],
'components/UpdateBanner.tsx': [],
'components/ToastHost.tsx': [],
'components/PushOnboardingPrompt.tsx': [],
'components/MoveSheet.tsx': [],
'components/TagSheet.tsx': [],
'components/TemplateSheet.tsx': [],
'components/IdentitySheet.tsx': [],
'components/QrScanModal.tsx': [],
'components/email/ActionSheet.tsx': [],
'components/email/AddressActionSheet.tsx': [],
'components/email/CalendarInvitationBanner.tsx': [],
'components/email/ListAttachmentChips.tsx': [],
'components/email/ReadReceiptBanner.tsx': [],
'components/email/UnsubscribeBanner.tsx': [],
}; };
describe('translated screens', () => { describe('translated screens', () => {
+6 -1
View File
@@ -5,6 +5,7 @@ import { X } from 'lucide-react-native';
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 { useSheetDrag } from '../../lib/use-sheet-drag'; import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useLocaleStore } from '../../stores/locale-store';
export interface ActionSheetItem { export interface ActionSheetItem {
key: string; key: string;
@@ -34,6 +35,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const slideY = React.useRef(new Animated.Value(500)).current; const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -72,7 +74,10 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
<Text style={styles.title} numberOfLines={1}>{title}</Text> <Text style={styles.title} numberOfLines={1}>{title}</Text>
{subtitle ? <Text style={styles.subtitle} numberOfLines={1}>{subtitle}</Text> : null} {subtitle ? <Text style={styles.subtitle} numberOfLines={1}>{subtitle}</Text> : null}
</View> </View>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}> <Pressable onPress={onClose} hitSlop={8} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@@ -39,24 +39,25 @@ interface Props {
jmapAccountId?: string; jmapAccountId?: string;
} }
const TRUST_REASON_KEYS: Record<NonNullable<InvitationTrustAssessment['reason']>, [string, string]> = { // Literal t() calls so the keys are harvested into the catalog.
sender_mismatch_unverified: [ function trustReasonText(
'calendar.invitation.trust_sender_mismatch_unverified', reason: NonNullable<InvitationTrustAssessment['reason']>,
'The sender does not match the organizer and the message is not authenticated.', t: (key: string, fallback?: string) => string,
], ): string {
authentication_failed: [ switch (reason) {
'calendar.invitation.trust_authentication_failed', case 'sender_mismatch_unverified':
'This message failed sender authentication (SPF/DKIM/DMARC).', return t(
], 'calendar.invitation.trust_sender_mismatch_unverified',
sender_mismatch: [ 'The sender does not match the organizer and the message is not authenticated.',
'calendar.invitation.trust_sender_mismatch', );
'The sender differs from the event organizer.', case 'authentication_failed':
], return t('calendar.invitation.trust_authentication_failed', 'This message failed sender authentication (SPF/DKIM/DMARC).');
authentication_missing: [ case 'sender_mismatch':
'calendar.invitation.trust_authentication_missing', return t('calendar.invitation.trust_sender_mismatch', 'The sender differs from the event organizer.');
'The sender could not be verified.', case 'authentication_missing':
], return t('calendar.invitation.trust_authentication_missing', 'The sender could not be verified.');
}; }
}
export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors(); const c = useColors();
@@ -264,7 +265,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
)} )}
<Text style={[styles.trustText, { color: trustColor }]} numberOfLines={3}> <Text style={[styles.trustText, { color: trustColor }]} numberOfLines={3}>
{trust.reason {trust.reason
? t(TRUST_REASON_KEYS[trust.reason][0], TRUST_REASON_KEYS[trust.reason][1]) ? trustReasonText(trust.reason, t)
: t('calendar.invitation.trust_verified', 'Sender verified')} : t('calendar.invitation.trust_verified', 'Sender verified')}
</Text> </Text>
</View> </View>
@@ -287,7 +288,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
{organizer ? ( {organizer ? (
<Row <Row
icon={<CalendarPlus size={15} color={c.textMuted} />} icon={<CalendarPlus size={15} color={c.textMuted} />}
text={`${t('calendar.invitation.organizer', 'Organizer')}: ${organizer}`} text={t('email_viewer.calendar_invitation.organizer', 'Organized by {name}', { name: organizer })}
styles={styles} styles={styles}
/> />
) : null} ) : null}
+135 -39
View File
@@ -52,8 +52,8 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import type { Attachment, Email } from '../api/types'; import type { Attachment, Email } from '../api/types';
function getSenderName(email: Email): string { function getSenderName(email: Email, unknownLabel: string): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; return email.from?.[0]?.name || email.from?.[0]?.email || unknownLabel;
} }
function getSenderEmail(email: Email): string | undefined { function getSenderEmail(email: Email): string | undefined {
@@ -61,12 +61,12 @@ function getSenderEmail(email: Email): string | undefined {
} }
// Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12). // Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12).
function getCounterpart(email: Email, showRecipient: boolean): { name: string; email?: string } { function getCounterpart(email: Email, showRecipient: boolean, unknownLabel: string): { name: string; email?: string } {
if (showRecipient) { if (showRecipient) {
const to = email.to?.[0] ?? email.cc?.[0] ?? email.bcc?.[0]; const to = email.to?.[0] ?? email.cc?.[0] ?? email.bcc?.[0];
if (to) return { name: to.name || to.email, email: to.email }; if (to) return { name: to.name || to.email, email: to.email };
} }
return { name: getSenderName(email), email: getSenderEmail(email) }; return { name: getSenderName(email, unknownLabel), email: getSenderEmail(email) };
} }
function isUnread(email: Email): boolean { function isUnread(email: Email): boolean {
@@ -128,7 +128,11 @@ const EmailRow = React.memo(function EmailRow({
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const locale = useLocaleStore((s) => s.locale); const locale = useLocaleStore((s) => s.locale);
const tr = useLocaleStore((s) => s.t); const tr = useLocaleStore((s) => s.t);
const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient); const { name: senderName, email: senderEmail } = getCounterpart(
item,
showRecipient,
tr('email_viewer.unknown_sender', 'Unknown'),
);
const unread = isUnread(item); const unread = isUnread(item);
const starred = isStarred(item); const starred = isStarred(item);
const pinned = isPinned(item); const pinned = isPinned(item);
@@ -493,12 +497,12 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
})); }));
const { imported, failed } = await importEmails(files, targetMailboxId); const { imported, failed } = await importEmails(files, targetMailboxId);
Alert.alert( Alert.alert(
'Import', t('email_list.import.title', 'Import'),
failed > 0 failed > 0
? `${imported} imported, ${failed} failed.` ? t('email_list.import.partial', '{imported} imported, {failed} failed.', { imported, failed })
: imported === 0 : imported === 0
? 'No messages were imported.' ? t('email_list.import.none', 'No messages were imported.')
: `${imported} message${imported === 1 ? '' : 's'} imported.`, : t('email_list.import.success', '{count, plural, one {# message imported.} other {# messages imported.}}', { count: imported }),
); );
} finally { } finally {
setImporting(false); setImporting(false);
@@ -965,14 +969,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{/* Header */} {/* Header */}
{selectionMode ? ( {selectionMode ? (
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerButton}> <Pressable
onPress={clearSelection}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.clear_selection', 'Clear selection')}
>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text> <Text style={styles.headerTitle}>
{t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size })}
</Text>
<Pressable <Pressable
onPress={() => { void handleBulkStar(); }} onPress={() => { void handleBulkStar(); }}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')}
> >
<Star <Star
size={20} size={20}
@@ -984,6 +997,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => { void handleBulkMarkReadToggle(); }} onPress={() => { void handleBulkMarkReadToggle(); }}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allSelectedAreRead
? t('email_list.batch_actions.mark_unread', 'Mark as unread')
: t('email_list.batch_actions.mark_read', 'Mark as read')}
> >
{allSelectedAreRead ? ( {allSelectedAreRead ? (
<MailIcon size={20} color={c.text} /> <MailIcon size={20} color={c.text} />
@@ -995,6 +1012,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => setTagSheetOpen(true)} onPress={() => setTagSheetOpen(true)}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.tag', 'Tag')}
> >
<Tag size={20} color={c.text} /> <Tag size={20} color={c.text} />
</Pressable> </Pressable>
@@ -1002,6 +1021,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => setBatchMoveOpen(true)} onPress={() => setBatchMoveOpen(true)}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.move', 'Move')}
> >
<FolderInput size={20} color={c.text} /> <FolderInput size={20} color={c.text} />
</Pressable> </Pressable>
@@ -1010,6 +1031,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => { void handleBulkSpam(); }} onPress={() => { void handleBulkSpam(); }}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')} accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')}
> >
{inJunk ? ( {inJunk ? (
@@ -1024,6 +1046,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => { void handleBulkArchive(); }} onPress={() => { void handleBulkArchive(); }}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.archive', 'Archive')}
> >
<Archive size={20} color={c.text} /> <Archive size={20} color={c.text} />
</Pressable> </Pressable>
@@ -1032,13 +1056,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => { void handleBulkDelete(); }} onPress={() => { void handleBulkDelete(); }}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')}
> >
<Trash2 size={20} color={c.text} /> <Trash2 size={20} color={c.text} />
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}> <Pressable
onPress={() => setDrawerOpen(true)}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')}
>
<Menu size={20} color={c.textMuted} /> <Menu size={20} color={c.textMuted} />
</Pressable> </Pressable>
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
@@ -1050,6 +1081,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.headerButton} style={styles.headerButton}
disabled={importing} disabled={importing}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.import_email', 'Import .eml or .zip')}
> >
{importing ? ( {importing ? (
<ActivityIndicator size="small" color={c.textMuted} /> <ActivityIndicator size="small" color={c.textMuted} />
@@ -1067,7 +1100,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{/* Search bar (always visible) */} {/* Search bar (always visible) */}
<View style={styles.searchBar}> <View style={styles.searchBar}>
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}> <Pressable
style={styles.checkboxButton}
onPress={toggleSelectAllVisible}
hitSlop={6}
accessibilityRole="checkbox"
accessibilityLabel={t('email_list.batch_actions.select_all', 'Select all')}
accessibilityState={{ checked: allSelected ? true : selectionMode ? 'mixed' : false }}
>
{allSelected ? ( {allSelected ? (
<SquareCheck size={18} color={c.primary} /> <SquareCheck size={18} color={c.primary} />
) : selectionMode ? ( ) : selectionMode ? (
@@ -1099,6 +1139,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => { setSearchInput(''); setSearchQuery(''); }} onPress={() => { setSearchInput(''); setSearchQuery(''); }}
hitSlop={8} hitSlop={8}
style={styles.searchClearButton} style={styles.searchClearButton}
accessibilityRole="button"
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
> >
<X size={14} color={c.textMuted} /> <X size={14} color={c.textMuted} />
</Pressable> </Pressable>
@@ -1108,8 +1150,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.filterButton} style={styles.filterButton}
onPress={() => setSortAscending(!sortAscending)} onPress={() => setSortAscending(!sortAscending)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={sortAscending ? 'Sorted oldest first' : 'Sorted newest first'} accessibilityLabel={sortAscending
accessibilityHint="Reverses the mail sort order" ? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first')
: t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')}
accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')}
> >
{sortAscending ? ( {sortAscending ? (
<ArrowUpNarrowWide size={18} color={c.primary} /> <ArrowUpNarrowWide size={18} color={c.primary} />
@@ -1120,6 +1164,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable <Pressable
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]} style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
onPress={() => setFilterMenuOpen(true)} onPress={() => setFilterMenuOpen(true)}
accessibilityRole="button"
accessibilityLabel={t('advanced_search.advanced_filters_tooltip', 'Advanced search filters')}
accessibilityValue={activeFilterCount > 0 ? { text: String(activeFilterCount) } : undefined}
> >
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} /> <Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
{activeFilterCount > 0 && ( {activeFilterCount > 0 && (
@@ -1141,7 +1188,12 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Search size={12} color={c.textMuted} /> <Search size={12} color={c.textMuted} />
<Text style={styles.recentSearchText} numberOfLines={1}>{q}</Text> <Text style={styles.recentSearchText} numberOfLines={1}>{q}</Text>
</Pressable> </Pressable>
<Pressable onPress={() => removeRecentSearch(q)} hitSlop={8}> <Pressable
onPress={() => removeRecentSearch(q)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('advanced_search.suggestions_remove_recent', 'Remove from recent searches')}
>
<X size={12} color={c.textMuted} /> <X size={12} color={c.textMuted} />
</Pressable> </Pressable>
</View> </View>
@@ -1202,28 +1254,40 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/> />
) : null} ) : null}
{filters.from ? ( {filters.from ? (
<FilterChip label={`${t('advanced_search.from', 'From')}: ${filters.from}`} onRemove={() => setFilterField('from', undefined)} /> <FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.from', 'From'), value: filters.from })}
onRemove={() => setFilterField('from', undefined)}
/>
) : null} ) : null}
{filters.to ? ( {filters.to ? (
<FilterChip label={`${t('advanced_search.to', 'To')}: ${filters.to}`} onRemove={() => setFilterField('to', undefined)} /> <FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.to', 'To'), value: filters.to })}
onRemove={() => setFilterField('to', undefined)}
/>
) : null} ) : null}
{filters.subject ? ( {filters.subject ? (
<FilterChip label={`${t('advanced_search.subject', 'Subject')}: ${filters.subject}`} onRemove={() => setFilterField('subject', undefined)} /> <FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.subject', 'Subject'), value: filters.subject })}
onRemove={() => setFilterField('subject', undefined)}
/>
) : null} ) : null}
{filters.body ? ( {filters.body ? (
<FilterChip label={`${t('advanced_search.body', 'Body')}: ${filters.body}`} onRemove={() => setFilterField('body', undefined)} /> <FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.body', 'Body'), value: filters.body })}
onRemove={() => setFilterField('body', undefined)}
/>
) : null} ) : null}
{filters.dateAfter ? ( {filters.dateAfter ? (
<FilterChip <FilterChip
icon={<CalendarDays size={12} color={c.textSecondary} />} icon={<CalendarDays size={12} color={c.textSecondary} />}
label={`${t('advanced_search.after', 'After')} ${filters.dateAfter}`} label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.date_after', 'After'), value: filters.dateAfter })}
onRemove={() => setFilterField('dateAfter', undefined)} onRemove={() => setFilterField('dateAfter', undefined)}
/> />
) : null} ) : null}
{filters.dateBefore ? ( {filters.dateBefore ? (
<FilterChip <FilterChip
icon={<CalendarDays size={12} color={c.textSecondary} />} icon={<CalendarDays size={12} color={c.textSecondary} />}
label={`${t('advanced_search.before', 'Before')} ${filters.dateBefore}`} label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.date_before', 'Before'), value: filters.dateBefore })}
onRemove={() => setFilterField('dateBefore', undefined)} onRemove={() => setFilterField('dateBefore', undefined)}
/> />
) : null} ) : null}
@@ -1232,7 +1296,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
icon={filters.isUnread icon={filters.isUnread
? <MailIcon size={12} color={c.textSecondary} /> ? <MailIcon size={12} color={c.textSecondary} />
: <MailOpen size={12} color={c.textSecondary} />} : <MailOpen size={12} color={c.textSecondary} />}
label={filters.isUnread ? t('email_list.unread', 'Unread') : t('advanced_search.read', 'Read')} label={filters.isUnread ? t('advanced_search.unread', 'Unread') : t('advanced_search.read', 'Read')}
onRemove={() => cycleTriStateTo('isUnread', undefined)} onRemove={() => cycleTriStateTo('isUnread', undefined)}
/> />
)} )}
@@ -1259,6 +1323,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}} }}
style={styles.clearAllButton} style={styles.clearAllButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
> >
<Text style={styles.clearAllText}>{t('advanced_search.clear', 'Clear')}</Text> <Text style={styles.clearAllText}>{t('advanced_search.clear', 'Clear')}</Text>
</Pressable> </Pressable>
@@ -1352,7 +1417,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</Text> </Text>
{currentMailbox && !hasActiveSearchOrFilter ? ( {currentMailbox && !hasActiveSearchOrFilter ? (
<Text style={styles.hintText}> <Text style={styles.hintText}>
{currentMailbox.totalEmails} {t('email_list.total', 'total')} · {currentMailbox.unreadEmails} {t('email_list.unread', 'unread')} {t('email_list.folder_counts', '{total} total · {unread} unread', {
total: currentMailbox.totalEmails,
unread: currentMailbox.unreadEmails,
})}
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -1377,6 +1445,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable <Pressable
onPress={onComposePress} onPress={onComposePress}
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]} style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
accessibilityRole="button"
accessibilityLabel={t('sidebar.mobile.compose', 'Compose')}
> >
<SquarePen size={24} color={c.background} /> <SquarePen size={24} color={c.background} />
</Pressable> </Pressable>
@@ -1395,20 +1465,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<TouchableWithoutFeedback> <TouchableWithoutFeedback>
<View style={styles.filterMenu}> <View style={styles.filterMenu}>
<View style={styles.filterMenuHeader}> <View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>Filter emails</Text> <Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
<View style={styles.filterMenuHeaderActions}> <View style={styles.filterMenuHeaderActions}>
<Pressable <Pressable
onPress={() => setFilters({})} onPress={() => setFilters({})}
style={styles.filterMenuHeaderBtn} style={styles.filterMenuHeaderBtn}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
> >
<RotateCcw size={12} color={c.textSecondary} /> <RotateCcw size={12} color={c.textSecondary} />
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text> <Text style={styles.filterMenuHeaderBtnText}>{t('advanced_search.clear', 'Clear')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => setFilterMenuOpen(false)} onPress={() => setFilterMenuOpen(false)}
style={styles.filterMenuClose} style={styles.filterMenuClose}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
> >
<X size={16} color={c.textSecondary} /> <X size={16} color={c.textSecondary} />
</Pressable> </Pressable>
@@ -1418,11 +1491,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled">
<View style={styles.filterFieldRow}> <View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}> <View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>From</Text> <Text style={styles.filterFieldLabel}>{t('advanced_search.from', 'From')}</Text>
<TextInput <TextInput
value={filters.from ?? ''} value={filters.from ?? ''}
onChangeText={(v) => setFilterField('from', v)} onChangeText={(v) => setFilterField('from', v)}
placeholder="sender@example.com" placeholder={t('advanced_search.from_placeholder', 'Sender email or name')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -1430,11 +1503,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/> />
</View> </View>
<View style={styles.filterFieldHalf}> <View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>To</Text> <Text style={styles.filterFieldLabel}>{t('advanced_search.to', 'To')}</Text>
<TextInput <TextInput
value={filters.to ?? ''} value={filters.to ?? ''}
onChangeText={(v) => setFilterField('to', v)} onChangeText={(v) => setFilterField('to', v)}
placeholder="recipient@example.com" placeholder={t('advanced_search.to_placeholder', 'Recipient email or name')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -1496,7 +1569,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterFieldRow}> <View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}> <View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>After</Text> <Text style={styles.filterFieldLabel}>{t('advanced_search.date_after', 'After')}</Text>
<Pressable <Pressable
style={styles.filterDateButton} style={styles.filterDateButton}
onPress={() => setDatePickerField('dateAfter')} onPress={() => setDatePickerField('dateAfter')}
@@ -1509,6 +1582,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable <Pressable
onPress={() => setFilterField('dateAfter', undefined)} onPress={() => setFilterField('dateAfter', undefined)}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('advanced_search.clear', 'Clear')}
> >
<X size={14} color={c.textMuted} /> <X size={14} color={c.textMuted} />
</Pressable> </Pressable>
@@ -1516,7 +1591,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</Pressable> </Pressable>
</View> </View>
<View style={styles.filterFieldHalf}> <View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>Before</Text> <Text style={styles.filterFieldLabel}>{t('advanced_search.date_before', 'Before')}</Text>
<Pressable <Pressable
style={styles.filterDateButton} style={styles.filterDateButton}
onPress={() => setDatePickerField('dateBefore')} onPress={() => setDatePickerField('dateBefore')}
@@ -1529,6 +1604,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable <Pressable
onPress={() => setFilterField('dateBefore', undefined)} onPress={() => setFilterField('dateBefore', undefined)}
hitSlop={6} hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('advanced_search.clear', 'Clear')}
> >
<X size={14} color={c.textMuted} /> <X size={14} color={c.textMuted} />
</Pressable> </Pressable>
@@ -1540,13 +1617,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterToggleGroup}> <View style={styles.filterToggleGroup}>
<TriToggle <TriToggle
icon={<Paperclip size={14} color={c.textSecondary} />} icon={<Paperclip size={14} color={c.textSecondary} />}
label="Has attachment" label={t('email_list.has_attachment', 'Has attachment')}
value={filters.hasAttachment} value={filters.hasAttachment}
onPress={() => cycleTriState('hasAttachment')} onPress={() => cycleTriState('hasAttachment')}
/> />
<TriToggle <TriToggle
icon={<Star size={14} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />} icon={<Star size={14} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />}
label="Starred" label={t('advanced_search.starred', 'Starred')}
value={filters.isStarred} value={filters.isStarred}
onPress={() => cycleTriState('isStarred')} onPress={() => cycleTriState('isStarred')}
/> />
@@ -1558,7 +1635,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MailIcon size={14} color={c.textSecondary} /> <MailIcon size={14} color={c.textSecondary} />
) )
} }
label={filters.isUnread === false ? 'Read' : 'Unread'} label={filters.isUnread === false ? t('advanced_search.read', 'Read') : t('advanced_search.unread', 'Unread')}
value={filters.isUnread} value={filters.isUnread}
onPress={() => cycleTriState('isUnread')} onPress={() => cycleTriState('isUnread')}
/> />
@@ -1588,7 +1665,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.pickerSheet}> <View style={styles.pickerSheet}>
<View style={styles.pickerHeader}> <View style={styles.pickerHeader}>
<Pressable onPress={() => setDatePickerField(null)} hitSlop={8}> <Pressable onPress={() => setDatePickerField(null)} hitSlop={8}>
<Text style={styles.pickerDone}>Done</Text> <Text style={styles.pickerDone}>{t('common.done', 'Done')}</Text>
</Pressable> </Pressable>
</View> </View>
<DateTimePicker <DateTimePicker
@@ -1664,12 +1741,24 @@ function FilterChip({
}) { }) {
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);
return ( return (
<Pressable style={styles.chip} onPress={onPress} disabled={!onPress}> <Pressable
style={styles.chip}
onPress={onPress}
disabled={!onPress}
accessibilityRole={onPress ? 'button' : undefined}
>
{icon} {icon}
<Text style={styles.chipText} numberOfLines={1}>{label}</Text> <Text style={styles.chipText} numberOfLines={1}>{label}</Text>
{onRemove ? ( {onRemove ? (
<Pressable onPress={onRemove} hitSlop={8} style={styles.chipRemove}> <Pressable
onPress={onRemove}
hitSlop={8}
style={styles.chipRemove}
accessibilityRole="button"
accessibilityLabel={t('email_list.remove_filter', 'Remove filter {label}', { label })}
>
<X size={11} color={c.textMuted} /> <X size={11} color={c.textMuted} />
</Pressable> </Pressable>
) : null} ) : null}
@@ -1681,7 +1770,12 @@ function ScopeChip({ label, active, onPress }: { label: string; active: boolean;
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
return ( return (
<Pressable onPress={onPress} style={[styles.triToggle, active && styles.triToggleOn]}> <Pressable
onPress={onPress}
style={[styles.triToggle, active && styles.triToggleOn]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text style={[styles.triToggleText, active && styles.triToggleTextOn]} numberOfLines={1}>{label}</Text> <Text style={[styles.triToggleText, active && styles.triToggleTextOn]} numberOfLines={1}>{label}</Text>
</Pressable> </Pressable>
); );
@@ -1710,6 +1804,8 @@ function TriToggle({
state === 'on' && styles.triToggleOn, state === 'on' && styles.triToggleOn,
state === 'off' && styles.triToggleOff, state === 'off' && styles.triToggleOff,
]} ]}
accessibilityRole="button"
accessibilityState={{ selected: state !== 'unset' }}
> >
{icon} {icon}
<Text <Text
+15 -2
View File
@@ -67,7 +67,13 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}> <Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} /> <ArrowLeft size={22} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
@@ -82,7 +88,14 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
> >
{copied ? <Check size={20} color={c.success} /> : <Copy size={20} color={raw ? c.text : c.textMuted} />} {copied ? <Check size={20} color={c.success} /> : <Copy size={20} color={raw ? c.text : c.textMuted} />}
</Pressable> </Pressable>
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}> <Pressable
onPress={onShare}
style={styles.headerBtn}
hitSlop={8}
disabled={!raw}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.attachment_actions.share', 'Share')}
>
<Share2 size={20} color={raw ? c.text : c.textMuted} /> <Share2 size={20} color={raw ? c.text : c.textMuted} />
</Pressable> </Pressable>
</View> </View>
+7 -1
View File
@@ -277,7 +277,13 @@ export default function ScheduledScreen({ navigation }: Props) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}> <Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} /> <ArrowLeft size={22} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{t('sidebar.scheduled', 'Scheduled')}</Text> <Text style={styles.headerTitle} numberOfLines={1}>{t('sidebar.scheduled', 'Scheduled')}</Text>