mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: send calendar reminders in the app language
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
This commit is contained in:
@@ -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}}",
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
-20
@@ -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';
|
||||
|
||||
@@ -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<void> {
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let rescheduling: Promise<void> | null = null;
|
||||
let queued = false;
|
||||
@@ -64,11 +66,15 @@ async function ensurePermission(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
} 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<void> {
|
||||
try {
|
||||
@@ -98,10 +111,12 @@ export async function cancelAllCalendarNotifications(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleOne(alert: ScheduledAlert): Promise<void> {
|
||||
async function scheduleOne(alert: ScheduledAlert, locale: string): Promise<void> {
|
||||
const data: Record<string, unknown> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user