mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
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:
+27
-1
@@ -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.",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user