mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: set a calendar's default reminders and start new events with them
This commit is contained in:
@@ -41,6 +41,8 @@ import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from
|
||||
import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants';
|
||||
import { expandRecurringEvents } from '../../lib/recurrence-expansion';
|
||||
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 USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
@@ -698,6 +700,60 @@ live('live Stalwart calendar', () => {
|
||||
}, 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)', () => {
|
||||
it('ends at its start, stored without a duration or imported from an .ics', async () => {
|
||||
await connectSingleton();
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
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 { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone';
|
||||
import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||
@@ -1295,6 +1295,9 @@ export interface CalendarUpdates {
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
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).
|
||||
|
||||
@@ -10,11 +10,22 @@ import {
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Check } from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { Check, Plus, X } from '../icons';
|
||||
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 { 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 Button from '../Button';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
@@ -24,6 +35,79 @@ export interface CalendarEditValues {
|
||||
name: string;
|
||||
color: 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 {
|
||||
@@ -47,15 +131,26 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
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(() => {
|
||||
if (!visible) return;
|
||||
setName(calendar?.name ?? '');
|
||||
setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]);
|
||||
setDescription(calendar?.description ?? '');
|
||||
setTimedReminders(initialTimed);
|
||||
setAllDayReminders(initialAllDay);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
}, [visible, calendar]);
|
||||
}, [visible, calendar, initialTimed, initialAllDay]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = name.trim();
|
||||
@@ -63,7 +158,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
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();
|
||||
} catch (e) {
|
||||
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]}
|
||||
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}
|
||||
</ScrollView>
|
||||
<View style={styles.footer}>
|
||||
@@ -185,5 +317,32 @@ function makeStyles(c: ThemePalette) {
|
||||
swatchActive: { borderWidth: 2, borderColor: c.text },
|
||||
error: { ...typography.caption, color: c.error },
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { formatCalendarDate } from '../../lib/date-format';
|
||||
import {
|
||||
alertsToReminders,
|
||||
calendarDefaultAlerts,
|
||||
reminderFieldsToSave,
|
||||
preservedAlerts,
|
||||
withOwnAlertIds,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
REMINDER_UNIT_MINUTES,
|
||||
@@ -228,6 +230,10 @@ export function EventModal({
|
||||
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||
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)
|
||||
// ride along untouched so a save never drops them.
|
||||
const [preserved, setPreserved] = React.useState<Record<string, Alert>>({});
|
||||
@@ -268,6 +274,7 @@ export function EventModal({
|
||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setUseDefaults(false);
|
||||
setPreserved(preservedAlerts(event.alerts));
|
||||
setSendInvitations(true);
|
||||
setCustomReminderOpen(false);
|
||||
@@ -286,6 +293,7 @@ export function EventModal({
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders([]);
|
||||
setUseDefaults(true);
|
||||
setPreserved({});
|
||||
setSendInvitations(true);
|
||||
setCustomReminderOpen(false);
|
||||
@@ -369,7 +377,11 @@ export function EventModal({
|
||||
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
||||
// Untouched reminders are not sent: an event on its calendar's
|
||||
// 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'),
|
||||
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
||||
};
|
||||
@@ -455,6 +467,18 @@ export function EventModal({
|
||||
const availablePresets = REMINDER_PRESETS.filter(
|
||||
(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 (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
@@ -654,7 +678,36 @@ export function EventModal({
|
||||
</Section>
|
||||
|
||||
<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 }}>
|
||||
{reminders.map((r) => (
|
||||
<View key={r.minutesBefore} style={styles.reminderRow}>
|
||||
@@ -672,7 +725,7 @@ export function EventModal({
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{availablePresets.length > 0 && (
|
||||
{!onDefaults && availablePresets.length > 0 && (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => setReminderPickerOpen((v) => !v)}
|
||||
@@ -1035,6 +1088,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
addRowText: { ...typography.body, color: c.primary },
|
||||
defaultsCaption: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
popover: {
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
preservedAlerts,
|
||||
reminderFieldsToSave,
|
||||
withOwnAlertIds,
|
||||
calendarDefaultAlerts,
|
||||
defaultAlertIdPrefix,
|
||||
remindersToDefaultAlerts,
|
||||
sameReminders,
|
||||
} from '../calendar-alerts';
|
||||
import { translate, type MessageParams } from '../../i18n';
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* ones the event had, so an event that uses its calendar's default alerts
|
||||
|
||||
@@ -93,7 +93,7 @@ import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
|
||||
import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit';
|
||||
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 { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
import { moveCalendar } from '../lib/calendar-sort';
|
||||
@@ -880,7 +880,10 @@ export default function CalendarScreen() {
|
||||
} else {
|
||||
// Only what the user changed: an untouched colour another client
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user