fix: translate the no-subject fallback in mail lists and notifications

The mail list, the unified inbox, a contact's recent emails and new-mail
notifications showed a hard-coded English "(no subject)". They now use
the webmail's `email_viewer.no_subject` (and `contacts.activity.no_subject`
for the contact view). The notification task reads the in-app language
override itself, as it cannot use the locale store.
This commit is contained in:
Linus Rath
2026-09-24 19:45:06 +02:00
parent 3c7b96e11a
commit 9135deed80
5 changed files with 49 additions and 4 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { getEvents, queryEvents } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useLocaleStore } from '../../stores/locale-store';
import { getEventColor } from '../../lib/calendar-utils';
import { hasCalendarCapability } from '../../lib/capabilities';
import SenderAvatar from '../SenderAvatar';
@@ -90,6 +91,7 @@ export function ContactActivity({ contact }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const t = useLocaleStore((s) => s.t);
const calendarEvents = useCalendarStore((s) => s.events);
const calendars = useCalendarStore((s) => s.calendars);
@@ -219,7 +221,7 @@ export function ContactActivity({ contact }: Props) {
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
{email.subject || t('contacts.activity.no_subject', '(No subject)')}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
@@ -155,6 +155,35 @@ describe('pushBackgroundTask notifications', () => {
expect.objectContaining({ emailId: 'm1', accountId: LOCAL, jmapAccountId: 'jmap-primary' }),
);
});
it("says a message has no subject in the app's language", async () => {
await AsyncStorage.setItem('webmail:locale:v1', JSON.stringify({ override: 'de' }));
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: '', from: [{ email: 'bob@example.com' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' }));
});
});
describe('selectNotifiableEmails', () => {
+15 -1
View File
@@ -3,6 +3,7 @@ import { NativeModules } from 'react-native';
import { jmapClient, type StoredCredentials } from '../api/jmap-client';
import { CAPABILITIES } from '../api/types';
import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types';
import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n';
import { secureFetch } from './client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from './oauth';
import {
@@ -23,6 +24,8 @@ import {
// import the Zustand store (would pull in React) so we read AsyncStorage
// directly. Keep this in sync if the store key ever changes.
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
// Mirrors STORAGE_KEY in `stores/locale-store.ts`, for the same reason.
const LOCALE_STORAGE_KEY = 'webmail:locale:v1';
// Mirrors the persist name in `stores/account-store.ts`. Used to map the
// relay's `accountLabel` (= username) back to a local account when the JMAP
// account id is not known yet.
@@ -70,6 +73,16 @@ export async function senderFaviconsAllowed(): Promise<boolean> {
}
}
/** The app's language: the in-app override, else the device's. */
export async function notificationLocale(): Promise<LocaleCode> {
try {
const raw = await AsyncStorage.getItem(LOCALE_STORAGE_KEY);
const override = raw ? (JSON.parse(raw) as { override?: unknown }).override : null;
if (typeof override === 'string' && isSupportedLocale(override)) return override;
} catch { /* the device language it is */ }
return detectDeviceLocale();
}
interface ShowNotificationOptions {
notificationId: string;
title: string;
@@ -449,6 +462,7 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
const groupKey = `bulwark-mail:${accountId}`;
const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId;
const favicons = await senderFaviconsAllowed();
const locale = await notificationLocale();
// Oldest first so the newest ends up on top of the tray.
const ordered = [...toNotify].sort(
@@ -459,7 +473,7 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const body = email.subject || translate(locale, 'email_viewer.no_subject', '(No Subject)');
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = favicons ? getFaviconDomain(address) : null;
+1 -1
View File
@@ -219,7 +219,7 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 2: Subject + tag pills */}
<View style={styles.subjectRow}>
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
{singleLine(item.subject) || '(no subject)'}
{singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')}
</Text>
{tags.slice(0, 3).map((tag) => (
<View key={tag.id} style={[styles.tagPill, { backgroundColor: tag.bg }]}>
+1 -1
View File
@@ -320,7 +320,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{singleLine(item.subject) || '(no subject)'}
{singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
</Text>
<View style={styles.line}>
{acc && accountIds.length > 1 && (