mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +00:00
fix: translate the last export errors, dates and participant counts
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
This commit is contained in:
@@ -417,6 +417,7 @@
|
||||
"delete_item_title": "Delete this item?",
|
||||
"delete_items_title": "{count, plural, one {Delete # item?} other {Delete # items?}}",
|
||||
"done": "Done",
|
||||
"download_failed_status": "Download failed (HTTP {status})",
|
||||
"folder_empty": "This folder is empty",
|
||||
"invalid_name": "Invalid name",
|
||||
"invalid_name_slash": "Names cannot contain \"/\".",
|
||||
|
||||
@@ -25,6 +25,7 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'components/contacts/ContactPickerSheet.tsx': [],
|
||||
'components/contacts/TagAssignSheet.tsx': [],
|
||||
'components/contacts/ContactListRow.tsx': [],
|
||||
'components/contacts/FieldBlock.tsx': [],
|
||||
};
|
||||
|
||||
describe('translated contacts screens', () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
|
||||
<View style={styles.detailRow}>
|
||||
<Users size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText}>
|
||||
{count} {t('calendar.participants.title', 'Participants').toLowerCase()}
|
||||
{t('calendar.participants.count', '{count, plural, one {# participant} other {# participants}}', { count })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -340,7 +340,7 @@ export function EventDetailSheet({
|
||||
<View style={styles.participantsHeader}>
|
||||
<Users size={16} color={c.textMuted} />
|
||||
<Text style={styles.participantsHeaderText}>
|
||||
{participants.length} {t('calendar.participants.title', 'Participants').toLowerCase()}
|
||||
{t('calendar.participants.count', '{count, plural, one {# participant} other {# participants}}', { count: participants.length })}
|
||||
</Text>
|
||||
</View>
|
||||
{participants.map((p) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
|
||||
import { format, isAfter, parseISO } from 'date-fns';
|
||||
import { format, isAfter, parseISO, type Locale } from 'date-fns';
|
||||
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
@@ -11,6 +11,7 @@ import { getEvents, queryEvents } from '../../api/calendar';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
|
||||
import { getEventColor } from '../../lib/calendar-utils';
|
||||
import { getDateFnsLocale } from '../../lib/calendar-locale';
|
||||
import { hasCalendarCapability } from '../../lib/capabilities';
|
||||
import { singleLine } from '../../lib/single-line';
|
||||
import { prefetchMessage } from '../../lib/email-detail-cache';
|
||||
@@ -64,13 +65,13 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string): string {
|
||||
function formatRelativeDate(iso: string, locale: Locale): string {
|
||||
try {
|
||||
const d = parseISO(iso);
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
? format(d, 'MMM d')
|
||||
: format(d, 'MMM d, yyyy');
|
||||
? format(d, 'MMM d', { locale })
|
||||
: format(d, 'MMM d, yyyy', { locale });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -94,6 +95,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
|
||||
@@ -222,7 +224,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt, dateLocale)}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{singleLine(email.subject) || t('contacts.activity.no_subject', '(No subject)')}
|
||||
@@ -260,7 +262,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
{event.title || t('contacts.activity.no_title', '(No title)')}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
{formatRelativeDate(event.utcStart || event.start, dateLocale)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
|
||||
|
||||
@@ -59,11 +60,18 @@ interface FieldRowProps {
|
||||
export function FieldRow({ onRemove, children }: FieldRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1 }}>{children}</View>
|
||||
{onRemove && (
|
||||
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
|
||||
<Pressable
|
||||
onPress={onRemove}
|
||||
hitSlop={8}
|
||||
style={styles.removeBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.remove', 'Remove')}
|
||||
>
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -354,7 +354,11 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
|
||||
const editIdx = editableIds.indexOf(rule.id);
|
||||
|
||||
if (readonly) {
|
||||
const label = rule.originLabel || t('settings.filters.origin_external', 'External');
|
||||
// The parser names known tools (product names) and falls back
|
||||
// to "External", which is shown in the app language.
|
||||
const label = rule.originLabel && rule.originLabel !== 'External'
|
||||
? rule.originLabel
|
||||
: t('settings.filters.origin_external', 'External');
|
||||
const hasStructured = rule.origin === 'external' && rule.conditions.length > 0 && rule.actions.length > 0;
|
||||
return (
|
||||
<View key={rule.id} style={styles.ruleRow}>
|
||||
|
||||
@@ -278,10 +278,10 @@ export function VacationSettings() {
|
||||
{htmlEnabled && (
|
||||
<View style={styles.editorBlock}>
|
||||
<View style={styles.editorToolbar}>
|
||||
<ToolbarButton c={c} onPress={() => editorRef.current?.exec('bold')}><Bold size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} onPress={() => editorRef.current?.exec('italic')}><Italic size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} onPress={() => editorRef.current?.exec('underline')}><Underline size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} onPress={() => editorRef.current?.exec('insertUnorderedList')}><ListIcon size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} label={t('email_composer.toolbar.bold', 'Bold')} onPress={() => editorRef.current?.exec('bold')}><Bold size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} label={t('email_composer.toolbar.italic', 'Italic')} onPress={() => editorRef.current?.exec('italic')}><Italic size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} label={t('email_composer.toolbar.underline', 'Underline')} onPress={() => editorRef.current?.exec('underline')}><Underline size={16} color={c.text} /></ToolbarButton>
|
||||
<ToolbarButton c={c} label={t('email_composer.toolbar.bullet_list', 'Bullet list')} onPress={() => editorRef.current?.exec('insertUnorderedList')}><ListIcon size={16} color={c.text} /></ToolbarButton>
|
||||
</View>
|
||||
<View style={styles.editorFrame}>
|
||||
<RichTextEditor
|
||||
@@ -359,11 +359,15 @@ export function VacationSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({ c, onPress, children }: { c: ThemePalette; onPress: () => void; children: React.ReactNode }) {
|
||||
function ToolbarButton({
|
||||
c, label, onPress, children,
|
||||
}: { c: ThemePalette; label: string; onPress: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: radius.sm,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { jmapClient } from '../api/jmap-client';
|
||||
import { getDownloadUrl, isStaleUploadCopy } from '../api/blob';
|
||||
import type { Attachment, Email } from '../api/types';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { t } from '../stores/locale-store';
|
||||
import {
|
||||
attachmentDownloadFilename,
|
||||
emailExportFilename,
|
||||
@@ -197,7 +198,7 @@ async function downloadInto(
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
throw new Error(t('files.download_failed_status', 'Download failed (HTTP {status})', { status: response.status }));
|
||||
}
|
||||
if (dest.exists) dest.delete();
|
||||
const buffer = await response.arrayBuffer();
|
||||
@@ -249,7 +250,7 @@ export async function shareAttachment(
|
||||
return;
|
||||
}
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
@@ -287,7 +288,7 @@ export async function downloadAttachment(
|
||||
|
||||
async function offerSavedFile(file: File, filename: string, mimeType: string): Promise<void> {
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
await Sharing.shareAsync(file.uri, {
|
||||
mimeType,
|
||||
@@ -399,7 +400,7 @@ export async function shareLocalFile(
|
||||
): Promise<void> {
|
||||
if (!opts.forceSheet && Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
|
||||
@@ -441,7 +442,7 @@ async function authedBlobFetch(url: string): Promise<Response> {
|
||||
if (r.status === 401 && (await jmapClient.forceRefreshToken())) {
|
||||
r = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
|
||||
}
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
if (!r.ok) throw new Error(t('files.download_failed_status', 'Download failed (HTTP {status})', { status: r.status }));
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -534,7 +535,7 @@ export async function shareEmailEml(
|
||||
: safeFilename(subjectFallback);
|
||||
const downloaded = await cacheBlobFile(blobId, filename, RFC822, accountId);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
|
||||
Reference in New Issue
Block a user