mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 { 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
@@ -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).
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user