feat: set a calendar's default reminders and start new events with them

This commit is contained in:
Linus Rath
2026-10-29 04:02:40 +01:00
parent 1bce37bb56
commit 9b87100a15
7 changed files with 380 additions and 12 deletions
@@ -41,6 +41,8 @@ import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from
import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants'; import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants';
import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { expandRecurringEvents } from '../../lib/recurrence-expansion';
import { getEventStartDate } from '../../lib/calendar-utils'; import { getEventStartDate } from '../../lib/calendar-utils';
import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts';
import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler';
const SERVER = process.env.BULWARK_LIVE_JMAP; const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -698,6 +700,60 @@ live('live Stalwart calendar', () => {
}, 60_000); }, 60_000);
}); });
describe('a calendar\'s default reminders (#1209)', () => {
let defaultsCalendarId = '';
let otherCalendarId = '';
afterAll(async () => {
const ids = [defaultsCalendarId, otherCalendarId].filter(Boolean);
if (ids.length > 0) {
await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: ids, onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
}
}, 30_000);
it('are set and cleared in the calendar and given to a new event that uses them', async () => {
await connectSingleton();
const [[, made]] = await raw(client, [['Calendar/set', {
accountId: client.accountId, create: { c: { name: `${RUN} defaults`, color: '#3b82f6' } },
}, '0']]);
defaultsCalendarId = made.created?.c?.id ?? '';
expect(defaultsCalendarId, JSON.stringify(made.notCreated)).toBeTruthy();
await useCalendarStore.getState().fetchCalendars();
// As the calendar editor saves them.
// Another calendar of the account with defaults under the plain ids
// the editor wrote before: 1.0 wants them unique in the account.
const plain = await raw(client, [['Calendar/set', {
accountId: client.accountId,
create: { o: { name: `${RUN} other defaults`, defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 5 }]) } },
}, '0']]);
otherCalendarId = plain[0][1].created?.o?.id ?? '';
const target = { id: defaultsCalendarId };
await useCalendarStore.getState().updateCalendar(defaultsCalendarId, {
defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix(target, false)),
defaultAlertsWithoutTime: remindersToDefaultAlerts([{ minutesBefore: 60 * 24 }], {}, defaultAlertIdPrefix(target, true)),
});
await useCalendarStore.getState().fetchCalendars();
const cal = useCalendarStore.getState().calendars.find((c) => c.id === defaultsCalendarId)!;
expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]);
expect(alertsToReminders(calendarDefaultAlerts(cal, true))).toEqual([{ minutesBefore: 60 * 24 }]);
// As the event editor creates an event on the defaults.
const created = await useCalendarStore.getState().createEvent({
'@type': 'Event', title: `${RUN} on defaults`, start: '2027-12-03T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', useDefaultAlerts: true,
} as Partial<CalendarEvent>, defaultsCalendarId);
const [stored] = await getEvents([created.id]);
expect(stored.useDefaultAlerts).toBe(true);
const effective = getEffectiveAlerts(stored, useCalendarStore.getState().calendars) ?? {};
expect(alertsToReminders(effective)).toEqual([{ minutesBefore: 15 }]);
// Emptied.
await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { defaultAlertsWithTime: remindersToDefaultAlerts([]) });
const [[, after]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [defaultsCalendarId], properties: ['defaultAlertsWithTime'] }, '0']]);
expect(Object.keys(after.list[0].defaultAlertsWithTime ?? {})).toEqual([]);
}, 60_000);
});
describe('a zero-length event (#837)', () => { describe('a zero-length event (#837)', () => {
it('ends at its start, stored without a duration or imported from an .ics', async () => { it('ends at its start, stored without a duration or imported from an .ics', async () => {
await connectSingleton(); await connectSingleton();
+4 -1
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types'; import type { Alert, Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection';
@@ -1295,6 +1295,9 @@ export interface CalendarUpdates {
description?: string | null; description?: string | null;
sortOrder?: number; sortOrder?: number;
isSubscribed?: boolean; isSubscribed?: boolean;
// `{}` clears them: Stalwart refuses null for these maps.
defaultAlertsWithTime?: Record<string, Alert>;
defaultAlertsWithoutTime?: Record<string, Alert>;
} }
// Rename / recolour / describe a calendar (Calendar/set update). // Rename / recolour / describe a calendar (Calendar/set update).
+164 -5
View File
@@ -10,11 +10,22 @@ import {
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native'; } from 'react-native';
import { Check } from '../icons'; import { Check, Plus, X } from '../icons';
import type { Calendar } from '../../api/types'; import type { Alert, Calendar } from '../../api/types';
import {
REMINDER_PRESETS,
alertsToReminders,
calendarDefaultAlerts,
defaultAlertIdPrefix,
formatReminder,
preservedAlerts,
remindersToDefaultAlerts,
sameReminders,
type Reminder,
} from '../../lib/calendar-alerts';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button'; import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
@@ -24,6 +35,79 @@ export interface CalendarEditValues {
name: string; name: string;
color: string; color: string;
description: string; description: string;
/** Set only when the user changed them. */
defaultAlertsWithTime?: Record<string, Alert>;
defaultAlertsWithoutTime?: Record<string, Alert>;
}
/** One list of a calendar's default reminders: the times, a remove button each, presets to add. */
function DefaultRemindersEditor({
label,
reminders,
keptCount,
onChange,
styles,
c,
t,
}: {
label: string;
reminders: Reminder[];
keptCount: number;
onChange: (next: Reminder[]) => void;
styles: ReturnType<typeof makeStyles>;
c: ThemePalette;
t: TranslateFn;
}) {
const [picking, setPicking] = React.useState(false);
const available = REMINDER_PRESETS.filter((p) => !reminders.some((r) => r.minutesBefore === p));
return (
<View style={styles.reminders}>
<Text style={styles.label}>{label}</Text>
{reminders.length === 0 && keptCount === 0 && (
<Text style={styles.muted}>{t('calendar.alerts.none', 'No reminders')}</Text>
)}
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.reminderText}>{formatReminder(r.minutesBefore, t)}</Text>
<Pressable
onPress={() => onChange(reminders.filter((x) => x.minutesBefore !== r.minutesBefore))}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
{keptCount > 0 && (
<Text style={styles.muted}>
{t('calendar.alerts.kept', "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}", { count: keptCount })}
</Text>
)}
{available.length > 0 && (
<Pressable style={styles.addRow} onPress={() => setPicking((v) => !v)} accessibilityRole="button">
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>{t('calendar.alerts.add', 'Add reminder')}</Text>
</Pressable>
)}
{picking && (
<View style={styles.presets}>
{available.map((preset) => (
<Pressable
key={preset}
style={styles.preset}
onPress={() => {
onChange([...reminders, { minutesBefore: preset }]);
setPicking(false);
}}
>
<Text style={styles.presetText}>{formatReminder(preset, t)}</Text>
</Pressable>
))}
</View>
)}
</View>
);
} }
interface CalendarEditSheetProps { interface CalendarEditSheetProps {
@@ -47,15 +131,26 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
const [description, setDescription] = React.useState(''); const [description, setDescription] = React.useState('');
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
// The calendar's default reminders (#1209): the owner's to set, for an
// existing calendar. What the lists can't show is written back unchanged.
const editsDefaults = !!calendar && !calendar.isShared;
const initialTimed = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, false)), [calendar]);
const initialAllDay = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, true)), [calendar]);
const keptTimed = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, false)), [calendar]);
const keptAllDay = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, true)), [calendar]);
const [timedReminders, setTimedReminders] = React.useState<Reminder[]>([]);
const [allDayReminders, setAllDayReminders] = React.useState<Reminder[]>([]);
React.useEffect(() => { React.useEffect(() => {
if (!visible) return; if (!visible) return;
setName(calendar?.name ?? ''); setName(calendar?.name ?? '');
setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]); setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]);
setDescription(calendar?.description ?? ''); setDescription(calendar?.description ?? '');
setTimedReminders(initialTimed);
setAllDayReminders(initialAllDay);
setError(null); setError(null);
setSaving(false); setSaving(false);
}, [visible, calendar]); }, [visible, calendar, initialTimed, initialAllDay]);
const handleSave = async () => { const handleSave = async () => {
const trimmed = name.trim(); const trimmed = name.trim();
@@ -63,7 +158,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await onSave({ name: trimmed, color, description: description.trim() }); const values: CalendarEditValues = { name: trimmed, color, description: description.trim() };
// Ids unique in the account: Stalwart 1.0 refuses one another calendar uses.
if (calendar && editsDefaults && !sameReminders(timedReminders, initialTimed)) {
values.defaultAlertsWithTime = remindersToDefaultAlerts(timedReminders, keptTimed, defaultAlertIdPrefix(calendar, false));
}
if (calendar && editsDefaults && !sameReminders(allDayReminders, initialAllDay)) {
values.defaultAlertsWithoutTime = remindersToDefaultAlerts(allDayReminders, keptAllDay, defaultAlertIdPrefix(calendar, true));
}
await onSave(values);
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong')); setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong'));
@@ -120,6 +223,35 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
style={[styles.input, styles.multiline]} style={[styles.input, styles.multiline]}
multiline multiline
/> />
{editsDefaults && (
<>
<Text style={styles.sectionTitle}>{t('calendar.management.default_alerts', 'Default reminders')}</Text>
<Text style={styles.muted}>
{t(
'calendar.management.default_alerts_hint',
"New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.",
)}
</Text>
<DefaultRemindersEditor
label={t('calendar.management.default_alerts_timed', 'Events with a time')}
reminders={timedReminders}
keptCount={Object.keys(keptTimed).length}
onChange={setTimedReminders}
styles={styles}
c={c}
t={t}
/>
<DefaultRemindersEditor
label={t('calendar.management.default_alerts_all_day', 'All-day events')}
reminders={allDayReminders}
keptCount={Object.keys(keptAllDay).length}
onChange={setAllDayReminders}
styles={styles}
c={c}
t={t}
/>
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
@@ -185,5 +317,32 @@ function makeStyles(c: ThemePalette) {
swatchActive: { borderWidth: 2, borderColor: c.text }, swatchActive: { borderWidth: 2, borderColor: c.text },
error: { ...typography.caption, color: c.error }, error: { ...typography.caption, color: c.error },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
muted: { ...typography.caption, color: c.textMuted },
reminders: { gap: spacing.xs },
reminderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 36,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
reminderText: { ...typography.body, color: c.text },
addRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
addRowText: { ...typography.bodyMedium, color: c.primary },
presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
preset: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
presetText: { ...typography.caption, color: c.text },
}); });
} }
+57 -3
View File
@@ -50,8 +50,10 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format'; import { formatCalendarDate } from '../../lib/date-format';
import { import {
alertsToReminders, alertsToReminders,
calendarDefaultAlerts,
reminderFieldsToSave, reminderFieldsToSave,
preservedAlerts, preservedAlerts,
withOwnAlertIds,
formatReminder, formatReminder,
REMINDER_PRESETS, REMINDER_PRESETS,
REMINDER_UNIT_MINUTES, REMINDER_UNIT_MINUTES,
@@ -228,6 +230,10 @@ export function EventModal({
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null); const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]); const [reminders, setReminders] = React.useState<Reminder[]>([]);
// A new event starts on its calendar's default reminders (#1209) until
// the user customizes them.
const [useDefaults, setUseDefaults] = React.useState(false);
const onDefaults = !event && useDefaults;
// Alerts the picker can't show (absolute / end-relative / non-display) // Alerts the picker can't show (absolute / end-relative / non-display)
// ride along untouched so a save never drops them. // ride along untouched so a save never drops them.
const [preserved, setPreserved] = React.useState<Record<string, Alert>>({}); const [preserved, setPreserved] = React.useState<Record<string, Alert>>({});
@@ -268,6 +274,7 @@ export function EventModal({
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null); setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false); setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts)); setReminders(alertsToReminders(event.alerts));
setUseDefaults(false);
setPreserved(preservedAlerts(event.alerts)); setPreserved(preservedAlerts(event.alerts));
setSendInvitations(true); setSendInvitations(true);
setCustomReminderOpen(false); setCustomReminderOpen(false);
@@ -286,6 +293,7 @@ export function EventModal({
setCustomRule(null); setCustomRule(null);
setRecurrenceEditorOpen(false); setRecurrenceEditorOpen(false);
setReminders([]); setReminders([]);
setUseDefaults(true);
setPreserved({}); setPreserved({});
setSendInvitations(true); setSendInvitations(true);
setCustomReminderOpen(false); setCustomReminderOpen(false);
@@ -369,7 +377,11 @@ export function EventModal({
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
// Untouched reminders are not sent: an event on its calendar's // Untouched reminders are not sent: an event on its calendar's
// default alerts keeps them. // default alerts keeps them.
...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds), // A new event on its calendar's defaults: the server copies them
// into it (sending them as alerts too would double them).
...(onDefaults
? { useDefaultAlerts: true }
: reminderFieldsToSave(event, reminders, preserved, defaultAlertIds)),
locations: clearedOr(buildLocations(location), 'locations'), locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
}; };
@@ -455,6 +467,18 @@ export function EventModal({
const availablePresets = REMINDER_PRESETS.filter( const availablePresets = REMINDER_PRESETS.filter(
(p) => !reminders.some((r) => r.minutesBefore === p), (p) => !reminders.some((r) => r.minutesBefore === p),
); );
// The selected calendar's defaults, for an event with a time or all day.
const calendarDefaults = calendarDefaultAlerts(selectedCalendar, allDay);
const defaultReminders = alertsToReminders(calendarDefaults);
// Start the event's own reminders from the defaults, to change them.
const customizeReminders = () => {
setReminders(defaultReminders);
// Under ids of their own: Stalwart 1.0 drops an event's alert under a
// default alert's id.
const kept = preservedAlerts(calendarDefaults);
setPreserved(withOwnAlertIds(Object.values(kept), Object.keys(calendarDefaults)));
setUseDefaults(false);
};
return ( return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} <SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
@@ -654,7 +678,36 @@ export function EventModal({
</Section> </Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label={t('calendar.alerts.title', 'Reminders')}> <Section icon={<Bell size={16} color={c.textMuted} />} label={t('calendar.alerts.title', 'Reminders')}>
{reminders.length > 0 && ( {onDefaults && (
<View style={{ gap: spacing.xs }}>
<Text style={styles.defaultsCaption}>{t('calendar.alerts.calendar_defaults', 'Calendar defaults')}</Text>
{defaultReminders.length === 0 && (
<Text style={styles.defaultsCaption}>{t('calendar.alerts.none', 'No reminders')}</Text>
)}
{defaultReminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore, t)}</Text>
</View>
))}
<Pressable style={styles.addRow} onPress={customizeReminders} accessibilityRole="button">
<Text style={styles.addRowText}>{t('calendar.alerts.customize', 'Customize')}</Text>
</Pressable>
</View>
)}
{!onDefaults && !event && (
<Pressable
style={styles.addRow}
onPress={() => {
setUseDefaults(true);
setReminderPickerOpen(false);
setCustomReminderOpen(false);
}}
accessibilityRole="button"
>
<Text style={styles.addRowText}>{t('calendar.alerts.use_defaults', 'Use calendar defaults')}</Text>
</Pressable>
)}
{!onDefaults && reminders.length > 0 && (
<View style={{ gap: spacing.xs }}> <View style={{ gap: spacing.xs }}>
{reminders.map((r) => ( {reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}> <View key={r.minutesBefore} style={styles.reminderRow}>
@@ -672,7 +725,7 @@ export function EventModal({
))} ))}
</View> </View>
)} )}
{availablePresets.length > 0 && ( {!onDefaults && availablePresets.length > 0 && (
<Pressable <Pressable
style={styles.addRow} style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)} onPress={() => setReminderPickerOpen((v) => !v)}
@@ -1035,6 +1088,7 @@ function makeStyles(c: ThemePalette) {
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
}, },
addRowText: { ...typography.body, color: c.primary }, addRowText: { ...typography.body, color: c.primary },
defaultsCaption: { ...typography.caption, color: c.textMuted },
popover: { popover: {
borderWidth: 1, borderWidth: 1,
+42
View File
@@ -8,6 +8,10 @@ import {
preservedAlerts, preservedAlerts,
reminderFieldsToSave, reminderFieldsToSave,
withOwnAlertIds, withOwnAlertIds,
calendarDefaultAlerts,
defaultAlertIdPrefix,
remindersToDefaultAlerts,
sameReminders,
} from '../calendar-alerts'; } from '../calendar-alerts';
import { translate, type MessageParams } from '../../i18n'; import { translate, type MessageParams } from '../../i18n';
import type { Alert } from '../../api/types'; import type { Alert } from '../../api/types';
@@ -164,3 +168,41 @@ describe('withOwnAlertIds', () => {
expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']); expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']);
}); });
}); });
describe('a calendar\'s default reminders (#1209)', () => {
const email = { '@type': 'Alert', action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } as Alert;
it('picks the defaults for timed or all-day events', () => {
const cal = {
defaultAlertsWithTime: { a: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } as Alert },
defaultAlertsWithoutTime: null,
};
expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]);
expect(calendarDefaultAlerts(cal, true)).toEqual({});
expect(calendarDefaultAlerts(undefined, false)).toEqual({});
});
it('writes the defaults under ids of their own and keeps what the list cannot show', () => {
const alerts = remindersToDefaultAlerts([{ minutesBefore: 10 }, { minutesBefore: 60 * 24 }], { 'default-1': email });
expect(alerts['default-1']).toBe(email);
expect(Object.keys(alerts).sort()).toEqual(['default-1', 'default-2', 'default-3']);
expect(alertsToReminders(alerts)).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 60 }, { minutesBefore: 10 }]);
// Emptied: a map Stalwart takes, not null.
expect(remindersToDefaultAlerts([])).toEqual({});
});
it('gives each calendar and list ids no other calendar of the account uses (Stalwart 1.0)', () => {
const b = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, false));
const c = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'acc-2:c', originalId: 'c' }, false));
const bAllDay = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, true));
const ids = [...Object.keys(b), ...Object.keys(c), ...Object.keys(bAllDay)];
expect(new Set(ids).size).toBe(3);
expect(Object.keys(b)).toEqual(['cal-b-timed-1']);
});
it('tells a changed list from the same times in another order', () => {
expect(sameReminders([{ minutesBefore: 5 }, { minutesBefore: 15 }], [{ minutesBefore: 15 }, { minutesBefore: 5 }])).toBe(true);
expect(sameReminders([{ minutesBefore: 5 }], [{ minutesBefore: 10 }])).toBe(false);
expect(sameReminders([], [{ minutesBefore: 10 }])).toBe(false);
});
});
+52 -1
View File
@@ -1,4 +1,4 @@
import type { Alert, CalendarEvent } from '../api/types'; import type { Alert, Calendar, CalendarEvent } from '../api/types';
import { formatMessage, type MessageParams } from '../i18n/format'; import { formatMessage, type MessageParams } from '../i18n/format';
// A reminder, expressed as "minutes before the event start". 0 means "at the // A reminder, expressed as "minutes before the event start". 0 means "at the
@@ -130,6 +130,57 @@ function reminderMinutes(reminders: Reminder[]): number[] {
return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a); return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a);
} }
/** Whether two reminder lists remind at the same times. */
export function sameReminders(a: Reminder[], b: Reminder[]): boolean {
const x = reminderMinutes(a);
const y = reminderMinutes(b);
return x.length === y.length && x.every((minutes, i) => minutes === y[i]);
}
// ─── A calendar's default alerts (#1209) ─────────────────
/** The alerts `calendar` gives an event with a time, or an all-day one, that uses its defaults. */
export function calendarDefaultAlerts(
calendar: Pick<Calendar, 'defaultAlertsWithTime' | 'defaultAlertsWithoutTime'> | null | undefined,
allDay: boolean,
): Record<string, Alert> {
return (allDay ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime) ?? {};
}
/**
* A calendar's default alerts as the editor's reminders set them, with the
* ones it can't show (`preserved`) kept as they were. Under ids of their own,
* `${idPrefix}-1` and on: Stalwart 1.0 refuses default alert ids that another
* calendar of the account uses ("Default alert ids must be unique across all
* calendars in the account."), so the prefix names the calendar and the
* list; it also shows them in an event that uses them under the same ids,
* where an event's own reminder-N alerts must not meet them. An emptied list
* is `{}`: Stalwart refuses null for these maps.
*/
/** The id prefix of `calendar`'s default alerts for timed or all-day events, unique in its account. */
export function defaultAlertIdPrefix(calendar: Pick<Calendar, 'id' | 'originalId'>, allDay: boolean): string {
return `cal-${calendar.originalId || calendar.id}-${allDay ? 'allday' : 'timed'}`;
}
export function remindersToDefaultAlerts(
reminders: Reminder[],
preserved: Record<string, Alert> = {},
idPrefix = 'default',
): Record<string, Alert> {
const alerts: Record<string, Alert> = { ...preserved };
let n = 0;
for (const minutesBefore of reminderMinutes(reminders)) {
let key = `${idPrefix}-${++n}`;
while (key in alerts) key = `${idPrefix}-${++n}`;
alerts[key] = {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(minutesBefore), relativeTo: 'start' },
action: 'display',
};
}
return alerts;
}
/** /**
* What an editor save writes for the reminders: nothing while they are the * What an editor save writes for the reminders: nothing while they are the
* ones the event had, so an event that uses its calendar's default alerts * ones the event had, so an event that uses its calendar's default alerts
+5 -2
View File
@@ -93,7 +93,7 @@ import { buildReplyTo } from '../lib/calendar-invitation';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit'; import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit';
import { participantLimitMessage } from '../lib/calendar-participants'; import { participantLimitMessage } from '../lib/calendar-participants';
import { maxParticipantsPerEvent } from '../api/calendar'; import { maxParticipantsPerEvent, type CalendarUpdates } from '../api/calendar';
import { useContactNameResolver } from '../lib/contact-name-resolver'; import { useContactNameResolver } from '../lib/contact-name-resolver';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { moveCalendar } from '../lib/calendar-sort'; import { moveCalendar } from '../lib/calendar-sort';
@@ -880,7 +880,10 @@ export default function CalendarScreen() {
} else { } else {
// Only what the user changed: an untouched colour another client // Only what the user changed: an untouched colour another client
// wrote may be one Stalwart 1.0 refuses. // wrote may be one Stalwart 1.0 refuses.
const changes = changedCalendarFields(calendarEditTarget.calendar, values); const changes: CalendarUpdates = changedCalendarFields(calendarEditTarget.calendar, values);
// The default reminders come only when the user changed them.
if (values.defaultAlertsWithTime) changes.defaultAlertsWithTime = values.defaultAlertsWithTime;
if (values.defaultAlertsWithoutTime) changes.defaultAlertsWithoutTime = values.defaultAlertsWithoutTime;
if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes); if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes);
} }
}, },