mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
fix: translate the viewer's leftovers and label its icon buttons
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
This commit is contained in:
@@ -351,6 +351,11 @@
|
|||||||
},
|
},
|
||||||
"delete_permanently_message": "This message will be deleted permanently and cannot be recovered.",
|
"delete_permanently_message": "This message will be deleted permanently and cannot be recovered.",
|
||||||
"delete_permanently_title": "Delete permanently?",
|
"delete_permanently_title": "Delete permanently?",
|
||||||
|
"details": {
|
||||||
|
"attachment_count_size": "{count, plural, one {# file} other {# files}} · {size}",
|
||||||
|
"received_after_delay": "{date} · {delay} delivery time",
|
||||||
|
"spam_score_value": "{score} ({status})"
|
||||||
|
},
|
||||||
"export_failed": "Export failed",
|
"export_failed": "Export failed",
|
||||||
"forwarded": "Forwarded",
|
"forwarded": "Forwarded",
|
||||||
"load_failed": "Failed to load email",
|
"load_failed": "Failed to load email",
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
|||||||
'components/email/ListAttachmentChips.tsx': [],
|
'components/email/ListAttachmentChips.tsx': [],
|
||||||
'components/email/ReadReceiptBanner.tsx': [],
|
'components/email/ReadReceiptBanner.tsx': [],
|
||||||
'components/email/UnsubscribeBanner.tsx': [],
|
'components/email/UnsubscribeBanner.tsx': [],
|
||||||
|
'screens/EmailThreadScreen.tsx': [],
|
||||||
|
// Protocol names on the authentication chips.
|
||||||
|
'components/email/MessageHeader.tsx': ['SPF', 'DKIM', 'DMARC'],
|
||||||
|
'components/email/MessageContent.tsx': [],
|
||||||
|
'components/email/ThreadMessageCard.tsx': [],
|
||||||
|
'components/email/QuickReplyBox.tsx': [],
|
||||||
|
'components/email/AttachmentChips.tsx': [],
|
||||||
|
'components/email/AttachmentPreviewModal.tsx': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('translated screens', () => {
|
describe('translated screens', () => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
||||||
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||||
import { getCalendarColor, timePattern } from '../../lib/calendar-utils';
|
import { getCalendarColor, timePattern } from '../../lib/calendar-utils';
|
||||||
|
import { getDateFnsLocale } from '../../lib/calendar-locale';
|
||||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||||
|
|
||||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||||
@@ -63,6 +64,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
|
||||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||||
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||||
const calendars = useCalendarStore((s) => s.calendars);
|
const calendars = useCalendarStore((s) => s.calendars);
|
||||||
@@ -173,8 +175,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
const startDate = startStr ? parseISO(startStr) : null;
|
const startDate = startStr ? parseISO(startStr) : null;
|
||||||
const dateLabel = startDate && !isNaN(startDate.getTime())
|
const dateLabel = startDate && !isNaN(startDate.getTime())
|
||||||
? (event.showWithoutTime
|
? (event.showWithoutTime
|
||||||
? format(startDate, 'EEEE, MMM d, yyyy')
|
? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale })
|
||||||
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`))
|
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale }))
|
||||||
: null;
|
: null;
|
||||||
const organizer = getOrganizerName(event);
|
const organizer = getOrganizerName(event);
|
||||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||||
|
|||||||
@@ -61,13 +61,16 @@ function authTone(result?: string): 'ok' | 'warn' | 'bad' | 'none' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuthChip({ label, result, styles, c }: { label: string; result?: string; styles: ReturnType<typeof makeStyles>; c: ThemePalette }) {
|
function AuthChip({ label, result, styles, c }: { label: string; result?: string; styles: ReturnType<typeof makeStyles>; c: ThemePalette }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const tone = authTone(result);
|
const tone = authTone(result);
|
||||||
const color = tone === 'ok' ? c.success : tone === 'bad' ? c.error : tone === 'warn' ? c.warning : c.textMuted;
|
const color = tone === 'ok' ? c.success : tone === 'bad' ? c.error : tone === 'warn' ? c.warning : c.textMuted;
|
||||||
const Icon = tone === 'ok' ? ShieldCheck : tone === 'bad' ? ShieldAlert : ShieldQuestion;
|
const Icon = tone === 'ok' ? ShieldCheck : tone === 'bad' ? ShieldAlert : ShieldQuestion;
|
||||||
return (
|
return (
|
||||||
<View style={[styles.chip, { borderColor: color }]}>
|
<View style={[styles.chip, { borderColor: color }]}>
|
||||||
<Icon size={12} color={color} />
|
<Icon size={12} color={color} />
|
||||||
<Text style={[styles.chipText, { color }]}>{label} {result ?? '-'}</Text>
|
<Text style={[styles.chipText, { color }]}>
|
||||||
|
{label} {result ? t(`email_viewer.authentication.result.${result}`, result) : '-'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,13 +134,22 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
const auth: AuthenticationResults | undefined = info.auth;
|
const auth: AuthenticationResults | undefined = info.auth;
|
||||||
const hasAuth = !!(auth && (auth.spf || auth.dkim || auth.dmarc || auth.iprev));
|
const hasAuth = !!(auth && (auth.spf || auth.dkim || auth.dmarc || auth.iprev));
|
||||||
const attachmentsSummary = email.attachments?.length
|
const attachmentsSummary = email.attachments?.length
|
||||||
? `${email.attachments.length} · ${formatSize(email.attachments.reduce((n, a) => n + (a.size ?? 0), 0))}`
|
? t('email_viewer.details.attachment_count_size', '{count, plural, one {# file} other {# files}} · {size}', {
|
||||||
|
count: email.attachments.length,
|
||||||
|
size: formatSize(email.attachments.reduce((n, a) => n + (a.size ?? 0), 0)),
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
const receivedAt = formatFullDateTime(email.receivedAt, timeFormat, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.block}>
|
<View style={styles.block}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined} hitSlop={4}>
|
<Pressable
|
||||||
|
onPress={from ? () => onAddressPress(from) : undefined}
|
||||||
|
hitSlop={4}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||||
|
>
|
||||||
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
@@ -165,7 +177,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
{!compact && email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
|
{!compact && email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
{onToggleStar && (
|
{onToggleStar && (
|
||||||
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.star}>
|
<Pressable
|
||||||
|
onPress={() => onToggleStar(email)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.star}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={starred ? t('email_viewer.unstar', 'Unstar') : t('email_viewer.star', 'Star')}
|
||||||
|
>
|
||||||
<Star size={16} color={starred ? c.starred : c.textMuted} fill={starred ? c.starred : 'transparent'} />
|
<Star size={16} color={starred ? c.starred : c.textMuted} fill={starred ? c.starred : 'transparent'} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -237,7 +255,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
<DetailRow label={t('email_viewer.details.sent', 'Sent')} value={formatFullDateTime(email.sentAt, timeFormat, locale)} styles={styles} />
|
<DetailRow label={t('email_viewer.details.sent', 'Sent')} value={formatFullDateTime(email.sentAt, timeFormat, locale)} styles={styles} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label={t('email_viewer.details.received', 'Received')}
|
label={t('email_viewer.details.received', 'Received')}
|
||||||
value={`${formatFullDateTime(email.receivedAt, timeFormat, locale)}${delta !== null && delta > 60000 ? ` · ${formatDelta(delta)} ${t('email_viewer.details.delivery_time', 'Delivery time').toLowerCase()}` : ''}`}
|
value={delta !== null && delta > 60000
|
||||||
|
? t('email_viewer.details.received_after_delay', '{date} · {delay} delivery time', { date: receivedAt, delay: formatDelta(delta) })
|
||||||
|
: receivedAt}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -258,7 +278,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
<DetailRow label={t('email_viewer.authentication.policy', 'Policy')} value={auth?.dmarc?.policy} styles={styles} />
|
<DetailRow label={t('email_viewer.authentication.policy', 'Policy')} value={auth?.dmarc?.policy} styles={styles} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label={t('email_viewer.authentication.spam_score', 'Spam Score')}
|
label={t('email_viewer.authentication.spam_score', 'Spam Score')}
|
||||||
value={info.spamScore ? `${info.spamScore.score} (${info.spamScore.status})` : undefined}
|
value={info.spamScore
|
||||||
|
? t('email_viewer.details.spam_score_value', '{score} ({status})', { score: info.spamScore.score, status: info.spamScore.status })
|
||||||
|
: undefined}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-ut
|
|||||||
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
|
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
|
||||||
import { htmlToPlainText } from '../../lib/compose-html';
|
import { htmlToPlainText } from '../../lib/compose-html';
|
||||||
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
||||||
import { emailDisplayDate, formatFullDateTime } from '../../lib/email-date';
|
import { emailDisplayDate } from '../../lib/email-date';
|
||||||
|
import { buildQuoteHeader, quoteHeaderLabels } from '../../lib/quote-header';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -87,7 +88,15 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
|||||||
const picked = pickEmailBody(email);
|
const picked = pickEmailBody(email);
|
||||||
const original = picked.text ?? (picked.html ? htmlToPlainText(picked.html) : plainTextBody(email));
|
const original = picked.text ?? (picked.html ? htmlToPlainText(picked.html) : plainTextBody(email));
|
||||||
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
|
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
|
||||||
const header = `${formatFullDateTime(emailDisplayDate(email), timeFormat, locale)}, ${from.name ? `${from.name} <${from.email}>` : from.email}:`;
|
// The header the composer puts above a quoted reply ("On …, X wrote:").
|
||||||
|
const header = buildQuoteHeader({
|
||||||
|
mode: 'reply',
|
||||||
|
email: { from, subject: email.subject, receivedAt: emailDisplayDate(email) },
|
||||||
|
timeFormat,
|
||||||
|
locale,
|
||||||
|
unknownLabel: t('common.unknown', 'Unknown'),
|
||||||
|
labels: quoteHeaderLabels(t),
|
||||||
|
});
|
||||||
const threading = computeReplyThreadingHeaders(email);
|
const threading = computeReplyThreadingHeaders(email);
|
||||||
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
|
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
|
||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
@@ -98,7 +107,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
|||||||
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
|
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
|
||||||
// Signed as the composer signs a plain-text reply; an alias without a
|
// Signed as the composer signs a plain-text reply; an alias without a
|
||||||
// signature of its own carries the primary identity's.
|
// signature of its own carries the primary identity's.
|
||||||
textBody: signPlainTextReply(body, `${header}\n${quoted}`, signatureIdentityFor(identity, identities), {
|
textBody: signPlainTextReply(body, `${header.text}${quoted}`, signatureIdentityFor(identity, identities), {
|
||||||
position: signaturePosition,
|
position: signaturePosition,
|
||||||
separator: signatureSeparatorEnabled,
|
separator: signatureSeparatorEnabled,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ export const DEFAULT_QUOTE_HEADER_LABELS: QuoteHeaderLabels = {
|
|||||||
subjectLabel: 'Subject',
|
subjectLabel: 'Subject',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The labels in the app language. Shared by the composer and the quick reply
|
||||||
|
* box so a reply carries the same header whichever one sent it.
|
||||||
|
*/
|
||||||
|
export function quoteHeaderLabels(t: (key: string, fallback: string) => string): QuoteHeaderLabels {
|
||||||
|
return {
|
||||||
|
replyLine: t('quote_header.reply_line', 'On {date}, {from} wrote:'),
|
||||||
|
forwardedSeparator: t('quote_header.forwarded_separator', '---------- Forwarded message ----------'),
|
||||||
|
fromLabel: t('quote_header.from_label', 'From'),
|
||||||
|
dateLabel: t('quote_header.date_label', 'Date'),
|
||||||
|
subjectLabel: t('quote_header.subject_label', 'Subject'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuoteHeader {
|
export interface QuoteHeader {
|
||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
rewriteCidImagesForEditor, replaceInlineImagePlaceholders, sniffImageMime, QUOTED_BLOCK_START,
|
rewriteCidImagesForEditor, replaceInlineImagePlaceholders, sniffImageMime, QUOTED_BLOCK_START,
|
||||||
} from '../lib/compose-html';
|
} from '../lib/compose-html';
|
||||||
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
|
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
|
||||||
import { buildQuoteHeader, formatQuoteDate, type QuoteHeaderLabels } from '../lib/quote-header';
|
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
|
||||||
import {
|
import {
|
||||||
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
|
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
|
||||||
} from '../lib/recipients';
|
} from '../lib/recipients';
|
||||||
@@ -482,13 +482,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||||
|
|
||||||
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => ({
|
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
|
||||||
replyLine: t('quote_header.reply_line', 'On {date}, {from} wrote:'),
|
|
||||||
forwardedSeparator: t('quote_header.forwarded_separator', '---------- Forwarded message ----------'),
|
|
||||||
fromLabel: t('quote_header.from_label', 'From'),
|
|
||||||
dateLabel: t('quote_header.date_label', 'Date'),
|
|
||||||
subjectLabel: t('quote_header.subject_label', 'Subject'),
|
|
||||||
}), [t]);
|
|
||||||
|
|
||||||
// Every address that is "us": the login, the account's primary address and
|
// Every address that is "us": the login, the account's primary address and
|
||||||
// every identity. Reply-all must not send the user a copy, and replying to
|
// every identity. Reply-all must not send the user a copy, and replying to
|
||||||
|
|||||||
@@ -676,7 +676,13 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<View style={styles.toolbar}>
|
<View style={styles.toolbar}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
|
<Pressable
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
style={styles.toolbarBack}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
|
>
|
||||||
<ArrowLeft size={22} color={c.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.toolbarActions}>
|
<View style={styles.toolbarActions}>
|
||||||
@@ -1077,6 +1083,8 @@ function EmailPane({
|
|||||||
onPress={email ? () => onToggleStar(email) : undefined}
|
onPress={email ? () => onToggleStar(email) : undefined}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.subjectStar}
|
style={styles.subjectStar}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={shown.keywords?.$flagged ? t('email_viewer.unstar', 'Unstar') : t('email_viewer.star', 'Star')}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
size={18}
|
size={18}
|
||||||
@@ -1246,7 +1254,13 @@ function MoreMenuSheet({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.sheetHeader}>
|
<View style={styles.sheetHeader}>
|
||||||
<Text style={styles.sheetTitle}>{t('email_viewer.more_actions', 'More actions')}</Text>
|
<Text style={styles.sheetTitle}>{t('email_viewer.more_actions', 'More actions')}</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>
|
||||||
@@ -1389,7 +1403,13 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.sheetHeader}>
|
<View style={styles.sheetHeader}>
|
||||||
<Text style={styles.sheetTitle}>{t('email_viewer.tag', 'Tag')}</Text>
|
<Text style={styles.sheetTitle}>{t('email_viewer.tag', 'Tag')}</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user