diff --git a/locales/rn/en.json b/locales/rn/en.json index 1366b4ac..a0993041 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -7,6 +7,7 @@ }, "calendar": { "alerts": { + "minutes_after": "{count, plural, one {# minute after} other {# minutes after}}", "unit_minutes_after": "minutes after" }, "colors": { @@ -41,6 +42,7 @@ "picker_unavailable": "The file picker is not installed in this build. Rebuild the app to enable importing.", "picker_unavailable_title": "Import unavailable", "refused_count": "{count, plural, one {# event could not be imported:} other {# events could not be imported:}}", + "refused_message": "{count, plural, =1 {The event could not be imported: {reason}} one {# event could not be imported: {reason}} other {# events could not be imported: {reason}}}", "selected_count": "{count} selected" }, "invitation": { @@ -76,6 +78,12 @@ "edit_calendar": "Edit calendar", "new_calendar": "New calendar" }, + "notifications": { + "channel_name": "Calendar reminders", + "starts_in_days": "{count, plural, one {Starts in # day} other {Starts in # days}}", + "starts_in_hours": "{count, plural, one {Starts in # hour} other {Starts in # hours}}", + "starts_in_minutes": "{count, plural, one {Starts in # minute} other {Starts in # minutes}}" + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", "every_count_days": "{count, plural, one {Every # day} other {Every # days}}", diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts index 3e7928fd..6147cf08 100644 --- a/src/lib/__tests__/calendar-alert-scheduler.test.ts +++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts @@ -99,8 +99,26 @@ describe('getUpcomingAlerts', () => { const alerts = getUpcomingAlerts([event, cancelled, acknowledged], [task, doneTask], [], { now, horizonMs: 24 * HOUR }); expect(alerts.map((a) => a.eventId + '/' + a.alertId)).toEqual(['ev1/a1', 'ev1/a2', 't1/x']); expect(alerts[0].key).toBe(`ev1:a1:${new Date('2026-03-01T08:45:00Z').getTime()}`); - expect(alerts[0].body).toBe('Starts in 15 min'); + expect(alerts[0].body).toBe('Starts in 15 minutes'); expect(alerts[2].kind).toBe('task'); + expect(alerts[2].body).toBe('Task due'); + }); + + it('words the reminders through the translator it is given', () => { + const untitled = { ...event, title: '' }; + const task = { + ...untitled, + id: 't1', + due: '2026-03-01T12:00:00Z', + alerts: { x: { trigger: { '@type': 'OffsetTrigger' as const, offset: 'PT0S' }, action: 'display' as const } }, + }; + const t = (key: string, _fallback: string, params?: Record) => + params ? `${key}(${params.count})` : key; + const [first, , due] = getUpcomingAlerts([untitled], [task], [], { now, horizonMs: 24 * HOUR }, t); + expect(first.title).toBe('calendar.events.no_title'); + expect(first.body).toBe('calendar.notifications.starts_in_minutes(15)'); + expect(due.title).toBe('calendar.tasks.no_title'); + expect(due.body).toBe('calendar.notifications.task_due'); }); it('drops alerts already in the past or beyond the horizon and honours the limit', () => { diff --git a/src/lib/__tests__/calendar-alerts-preserved.test.ts b/src/lib/__tests__/calendar-alerts-preserved.test.ts index 9dd0b99e..39706ffb 100644 --- a/src/lib/__tests__/calendar-alerts-preserved.test.ts +++ b/src/lib/__tests__/calendar-alerts-preserved.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { formatReminder, preservedAlerts, remindersToAlerts } from '../calendar-alerts'; +import { formatMessage, type MessageParams } from '../../i18n/format'; describe('preservedAlerts / remindersToAlerts', () => { const alerts = { @@ -38,12 +39,13 @@ describe('formatReminder', () => { expect(formatReminder(60 * 24 * 14)).toBe('2 weeks before'); }); - it('uses a plain translated string and ignores ICU plural templates it cannot expand', () => { - const t = (key: string, fallback?: string) => - key === 'calendar.alerts.hours_before' ? '# Stunden vorher' - : key === 'calendar.alerts.minutes_before' ? '{count, plural, one {# minute} other {# minutes}}' - : fallback ?? key; + it('hands the count to the translation, which picks the plural form', () => { + const t = (key: string, fallback?: string, params?: MessageParams) => + key === 'calendar.alerts.hours_before' + ? formatMessage('{count, plural, one {# Stunde vorher} other {# Stunden vorher}}', params, 'de') + : formatMessage(fallback ?? key, params, 'en'); expect(formatReminder(120, t)).toBe('2 Stunden vorher'); + expect(formatReminder(60, t)).toBe('1 Stunde vorher'); expect(formatReminder(5, t)).toBe('5 minutes before'); }); }); diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts index 034609df..fba9b6d3 100644 --- a/src/lib/__tests__/calendar-alerts.test.ts +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -6,6 +6,7 @@ import { remindersToAlerts, formatReminder, } from '../calendar-alerts'; +import { translate, type MessageParams } from '../../i18n'; describe('calendar-alerts', () => { describe('offsetToMinutesBefore', () => { @@ -75,6 +76,15 @@ describe('calendar-alerts', () => { expect(formatReminder(60)).toBe('1 hour before'); expect(formatReminder(60 * 24)).toBe('1 day before'); expect(formatReminder(60 * 24 * 7)).toBe('1 week before'); + expect(formatReminder(-1)).toBe('1 minute after'); + expect(formatReminder(-30)).toBe('30 minutes after'); + }); + + it('uses the catalog plurals of the app language', () => { + const de = (key: string, fallback?: string, params?: MessageParams) => translate('de', key, fallback, params); + expect(formatReminder(1, de)).toBe(translate('de', 'calendar.alerts.minutes_before', '', { count: 1 })); + expect(formatReminder(120, de)).toBe(translate('de', 'calendar.alerts.hours_before', '', { count: 2 })); + expect(formatReminder(120, de)).not.toContain('hours'); }); }); }); diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts index a0cba412..e678dbbc 100644 --- a/src/lib/__tests__/calendar-notifications.test.ts +++ b/src/lib/__tests__/calendar-notifications.test.ts @@ -146,6 +146,28 @@ describe('calendar reminders (B17)', () => { expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); }); + it('words reminders in the app language and rewords them after a switch', async () => { + const { useLocaleStore } = await import('../../stores/locale-store'); + await mod.rescheduleCalendarNotifications(); + + let request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.body).toBe('Starts in 15 minutes'); + expect(request.content.data).toMatchObject({ key: upcomingKey, locale: 'en' }); + expect(vi.mocked(N.setNotificationChannelAsync).mock.calls[0][1]).toMatchObject({ name: 'Calendar reminders' }); + + // Pending in English; the app is now in German. + h.pending = [{ identifier: 'en-1', content: { data: { tag: TAG, key: upcomingKey, locale: 'en' } } }]; + vi.mocked(N.scheduleNotificationAsync).mockClear(); + useLocaleStore.setState({ locale: 'de' }); + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('en-1'); + request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toMatchObject({ key: upcomingKey, locale: 'de' }); + // The channel is renamed too. + expect(N.setNotificationChannelAsync).toHaveBeenCalledTimes(2); + }); + it('keeps the scheduled reminders when the upcoming events cannot be loaded', async () => { h.pending = [reminder('keep', upcomingKey)]; h.loadEventsInRange.mockRejectedValue(new Error('offline')); @@ -244,6 +266,7 @@ describe('tapping a calendar reminder', () => { expect(request.content.data).toEqual({ tag: TAG, key: expect.any(String), + locale: 'en', eventId: 'acc-2:ev1:2026-09-24T10:00:00Z', kind: 'event', serverId: 'ev1', diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index fee59fbb..a224e7c4 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -1,5 +1,6 @@ import { parseISO } from 'date-fns'; import type { Alert, Calendar, CalendarEvent } from '../api/types'; +import { formatMessage, type MessageParams } from '../i18n/format'; import { getTaskDueDate, parseDuration } from './calendar-utils'; import { seriesIdOf, stableOccurrenceKey } from './recurrence-instances'; @@ -113,29 +114,38 @@ export interface AlertWindow { limit?: number; } -function formatWhen(fireTimeMs: number, startMs: number): string { +/** Translates a reminder's texts; the notifications pass the app's t(). */ +export type AlertTranslate = (key: string, fallback: string, params?: MessageParams) => string; + +// Without a translator: the English text. +const english: AlertTranslate = (_key, fallback, params) => formatMessage(fallback, params, 'en'); + +/** "Starts in 15 minutes" for a reminder firing ahead of the start. */ +function startsIn(fireTimeMs: number, startMs: number, t: AlertTranslate): string { const diffMin = Math.round((startMs - fireTimeMs) / 60000); - if (diffMin <= 0) return 'now'; - if (diffMin < 60) return `in ${diffMin} min`; - if (diffMin < 24 * 60) { - const h = Math.round(diffMin / 60); - return `in ${h} h`; + if (diffMin <= 0) return t('calendar.notifications.alert_now', 'Starting now'); + if (diffMin < 60) { + return t('calendar.notifications.starts_in_minutes', '{count, plural, one {Starts in # minute} other {Starts in # minutes}}', { count: diffMin }); } - const d = Math.round(diffMin / (24 * 60)); - return `in ${d} d`; + if (diffMin < 24 * 60) { + return t('calendar.notifications.starts_in_hours', '{count, plural, one {Starts in # hour} other {Starts in # hours}}', { count: Math.round(diffMin / 60) }); + } + return t('calendar.notifications.starts_in_days', '{count, plural, one {Starts in # day} other {Starts in # days}}', { count: Math.round(diffMin / (24 * 60)) }); } /** * Every display alert of the given events/tasks that fires inside the * window, soonest first. Cancelled events, completed tasks and acknowledged * alerts are skipped (#572). Expanded occurrences carry their own utcStart, - * so each occurrence yields its own alert. + * so each occurrence yields its own alert. Titles and bodies are in the + * language of `t` (English without it). */ export function getUpcomingAlerts( events: CalendarEvent[], tasks: CalendarEvent[], calendars: Calendar[], window: AlertWindow, + t: AlertTranslate = english, ): ScheduledAlert[] { const out: ScheduledAlert[] = []; const until = window.now + window.horizonMs; @@ -156,8 +166,8 @@ export function getUpcomingAlerts( eventId: event.id, alertId, fireTimeMs, - title: event.title || '(No title)', - body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`, + title: event.title || t('calendar.events.no_title', '(No title)'), + body: Number.isNaN(startMs) ? '' : startsIn(fireTimeMs, startMs, t), kind: 'event', serverId: seriesIdOf(event), accountId: event.accountId, @@ -181,8 +191,8 @@ export function getUpcomingAlerts( eventId: task.id, alertId, fireTimeMs, - title: task.title || '(No title)', - body: 'Task due', + title: task.title || t('calendar.tasks.no_title', '(No title)'), + body: t('calendar.notifications.task_due', 'Task due'), kind: 'task', serverId: task.originalId ?? task.id, accountId: task.accountId, diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index d3c94e17..87c47c21 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -1,4 +1,5 @@ import type { Alert, CalendarEvent } from '../api/types'; +import { formatMessage, type MessageParams } from '../i18n/format'; // A reminder, expressed as "minutes before the event start". 0 means "at the // time of the event". Negative values (reminder *after* start) are preserved @@ -106,37 +107,38 @@ export function remindersToAlerts( return alerts; } -type Translate = (key: string, fallback?: string) => string; -const plain: Translate = (_key, fallback) => fallback ?? _key; - -function plural(t: Translate, count: number, key: string, one: string, many: string): string { - // Locale files carry ICU plurals ("{count, plural, one {# minute before} other {# minutes before}}") - // that the mobile t() can't expand, so pick the English-style form and only - // use the translation when it is a plain string. - const raw = t(key, ''); - if (raw && !raw.includes('{')) return raw.replace('#', String(count)); - return (count === 1 ? one : many).replace('#', String(count)); -} +type Translate = (key: string, fallback?: string, params?: MessageParams) => string; +// Without a translator: the English text, plurals resolved. +const plain: Translate = (key, fallback, params) => formatMessage(fallback ?? key, params, 'en'); +/** "15 minutes before", "1 day before", "At time of event" in the language of `t`. */ export function formatReminder(minutesBefore: number, t: Translate = plain): string { + const minutes = (count: number) => + t('calendar.alerts.minutes_before', '{count, plural, one {# minute before} other {# minutes before}}', { count }); + const hours = (count: number) => + t('calendar.alerts.hours_before', '{count, plural, one {# hour before} other {# hours before}}', { count }); + const days = (count: number) => + t('calendar.alerts.days_before', '{count, plural, one {# day before} other {# days before}}', { count }); + const weeks = (count: number) => + t('calendar.alerts.weeks_before', '{count, plural, one {# week before} other {# weeks before}}', { count }); + if (minutesBefore === 0) return t('calendar.alerts.at_time', 'At time of event'); - if (minutesBefore < 0) return `${Math.abs(minutesBefore)} ${t('calendar.alerts.unit_minutes_after', 'minutes after')}`; - if (minutesBefore < 60) { - return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before'); + if (minutesBefore < 0) { + return t('calendar.alerts.minutes_after', '{count, plural, one {# minute after} other {# minutes after}}', { + count: Math.abs(minutesBefore), + }); } + if (minutesBefore < 60) return minutes(minutesBefore); if (minutesBefore < 60 * 24) { const h = minutesBefore / 60; - if (Number.isInteger(h)) return plural(t, h, 'calendar.alerts.hours_before', '# hour before', '# hours before'); - return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before'); + return Number.isInteger(h) ? hours(h) : minutes(minutesBefore); } if (minutesBefore < 60 * 24 * 7) { const d = minutesBefore / (60 * 24); - if (Number.isInteger(d)) return plural(t, d, 'calendar.alerts.days_before', '# day before', '# days before'); - return plural(t, Math.round(minutesBefore / 60), 'calendar.alerts.hours_before', '# hour before', '# hours before'); + return Number.isInteger(d) ? days(d) : hours(Math.round(minutesBefore / 60)); } const w = minutesBefore / (60 * 24 * 7); - if (Number.isInteger(w)) return plural(t, w, 'calendar.alerts.weeks_before', '# week before', '# weeks before'); - return plural(t, Math.round(minutesBefore / (60 * 24)), 'calendar.alerts.days_before', '# day before', '# days before'); + return Number.isInteger(w) ? weeks(w) : days(Math.round(minutesBefore / (60 * 24))); } export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks'; diff --git a/src/lib/calendar-ics-export.ts b/src/lib/calendar-ics-export.ts index 29699ad1..2d29a5d3 100644 --- a/src/lib/calendar-ics-export.ts +++ b/src/lib/calendar-ics-export.ts @@ -1,4 +1,5 @@ import type { CalendarEvent } from '../api/types'; +import { t } from '../stores/locale-store'; // Port of the webmail's lib/calendar-ics-export.ts: serialise one event as // an RFC 5545 VCALENDAR so it can be shared as a .ics file. @@ -91,7 +92,8 @@ function pushAlerts(lines: string[], event: CalendarEvent): void { } else if (trigger['@type'] === 'AbsoluteTrigger' && trigger.when) { lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`); } - lines.push(`DESCRIPTION:${escapeText(event.title || 'Reminder')}`); + // Other calendar apps show this when the alarm goes off. + lines.push(`DESCRIPTION:${escapeText(event.title || t('calendar.tasks.alert', 'Reminder'))}`); lines.push('END:VALARM'); } } @@ -204,7 +206,7 @@ export async function shareEventICS(event: CalendarEvent): Promise { if (file.exists) file.delete(); file.write(eventToICS(event)); 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: 'text/calendar', UTI: 'com.apple.ical.ics' }); } diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index a61e92bf..19cb47de 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -5,6 +5,7 @@ import { jmapClient } from '../api/jmap-client'; import { useAuthStore } from '../stores/auth-store'; import { loadEventsInRange, useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useLocaleStore } from '../stores/locale-store'; import { hasCalendarCapability } from './capabilities'; import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; @@ -37,7 +38,8 @@ let permissionRequested = false; // Only the Calendar tab asks for notification permission; the launch-time // sync uses what was granted and never prompts out of the blue. let mayAskPermission = false; -let channelReady = false; +// The language the Android channel was last named in; null until created. +let channelLocale: string | null = null; let rescheduleTimer: ReturnType | null = null; let rescheduling: Promise | null = null; let queued = false; @@ -64,11 +66,15 @@ async function ensurePermission(): Promise { return permissionGranted; } +// The channel's name shows in the system notification settings, so it is +// (re)named in the app language; setting an existing channel only renames it. async function ensureChannel(): Promise { - if (channelReady || Platform.OS !== 'android') return; + if (Platform.OS !== 'android') return; + const { locale, t } = useLocaleStore.getState(); + if (channelLocale === locale) return; try { await Notifications.setNotificationChannelAsync(CHANNEL_ID, { - name: 'Calendar reminders', + name: t('calendar.notifications.channel_name', 'Calendar reminders'), importance: Notifications.AndroidImportance.HIGH, sound: 'default', vibrationPattern: [0, 250, 250, 250], @@ -76,7 +82,7 @@ async function ensureChannel(): Promise { } catch { // Channel creation failing only degrades to the default channel. } - channelReady = true; + channelLocale = locale; } function alertKeyOf(request: Notifications.NotificationRequest): string | null { @@ -84,6 +90,13 @@ function alertKeyOf(request: Notifications.NotificationRequest): string | null { return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null; } +// A reminder worded in another language than the app's now. Ones scheduled +// before reminders recorded their language count as current. +function isStaleLanguage(request: Notifications.NotificationRequest, locale: string): boolean { + const data = request.content.data as { locale?: unknown } | undefined; + return typeof data?.locale === 'string' && data.locale !== locale; +} + /** Cancel every calendar reminder this app scheduled. */ export async function cancelAllCalendarNotifications(): Promise { try { @@ -98,10 +111,12 @@ export async function cancelAllCalendarNotifications(): Promise { } } -async function scheduleOne(alert: ScheduledAlert): Promise { +async function scheduleOne(alert: ScheduledAlert, locale: string): Promise { const data: Record = { tag: DATA_TAG, key: alert.key, + // The language the title and body are in, to reword it on a switch. + locale, eventId: alert.eventId, kind: alert.kind, // Enough to find the event again when the reminder is tapped. @@ -204,11 +219,14 @@ export async function rescheduleCalendarNotifications(): Promise { if (!events) return; await ensureTasksLoaded(); const { tasks, calendars } = useCalendarStore.getState(); + // Reminders fire while the app is closed, so they are worded now, in + // the app language. + const { locale, t } = useLocaleStore.getState(); const wanted = getUpcomingAlerts(events, tasks, calendars, { now, horizonMs: HORIZON_MS, limit: MAX_SCHEDULED, - }); + }, t); const wantedByKey = new Map(wanted.map((a) => [a.key, a])); const pending = await Notifications.getAllScheduledNotificationsAsync(); @@ -216,7 +234,7 @@ export async function rescheduleCalendarNotifications(): Promise { for (const request of pending) { const key = alertKeyOf(request); if (key === null) continue; - if (wantedByKey.has(key)) { + if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) { present.add(key); } else { await Notifications.cancelScheduledNotificationAsync(request.identifier); @@ -225,7 +243,7 @@ export async function rescheduleCalendarNotifications(): Promise { for (const alert of wanted) { if (present.has(alert.key)) continue; try { - await scheduleOne(alert); + await scheduleOne(alert, locale); } catch { // A single bad trigger must not block the rest. } @@ -358,6 +376,10 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea scheduleSoon(); } }); + // Reword the pending reminders and rename the channel in the new language. + useLocaleStore.subscribe((state, prev) => { + if (state.locale !== prev.locale) scheduleSoon(); + }); onStateChangeType('CalendarEvent', scheduleSoon); onStateChangeType('Calendar', scheduleSoon); AppState.addEventListener('change', (state) => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index cfe0066d..bfac097a 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createPersistStorage } from './persist-storage'; +import { t } from './locale-store'; import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange } from '../api/types'; import { normalizeAllDayDuration } from '../lib/calendar-utils'; import { @@ -175,19 +176,22 @@ export interface ImportResult { export class ImportRefusedError extends Error { refused: RefusedImport[]; constructor(refused: RefusedImport[]) { - const first = refused[0]?.reason ?? 'unknown error'; - super( - refused.length === 1 - ? `The event could not be imported: ${first}` - : `${refused.length} events could not be imported: ${first}`, - ); + super(t( + 'calendar.import.refused_message', + '{count, plural, =1 {The event could not be imported: {reason}} one {# event could not be imported: {reason}} other {# events could not be imported: {reason}}}', + { count: refused.length, reason: refused[0]?.reason ?? unknownReason() }, + )); this.name = 'ImportRefusedError'; this.refused = refused; } } +function unknownReason(): string { + return t('identities.validation_errors.unknown_error', 'Unknown error'); +} + function errorReason(err: unknown): string { - return err instanceof Error && err.message ? err.message : 'unknown error'; + return err instanceof Error && err.message ? err.message : unknownReason(); } export interface LoadedRange {