mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show
This commit is contained in:
@@ -83,7 +83,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
|
||||
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
|
||||
|
||||
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing`
|
||||
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
|
||||
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
|
||||
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
|
||||
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
|
||||
@@ -231,7 +231,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
|
||||
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
|
||||
|
||||
- [ ] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial`
|
||||
- [x] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial` — fixed in 5fe8e40
|
||||
- What WEB does: whitelists the JSCalendar properties it sends (drops server-computed `utcStart`/`utcEnd`/`isOrigin`/`created`/`updated`, rewrites participants to `calendarAddress`, normalises all-day duration/timeZone) and links UIDs that exist in another calendar by adding the target calendar to `calendarIds` (ref `stores/calendar-store.ts:822-958`, changelog 1.5.x #113).
|
||||
- What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures").
|
||||
- Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch.
|
||||
|
||||
@@ -2888,7 +2888,8 @@
|
||||
"add": "Add reminder",
|
||||
"remove": "Remove reminder",
|
||||
"amount": "Reminder amount",
|
||||
"unit": "Reminder unit"
|
||||
"unit": "Reminder unit",
|
||||
"unit_minutes_after": "minutes after"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Calendar settings",
|
||||
|
||||
@@ -297,7 +297,7 @@ export function EventDetailSheet({
|
||||
{reminders.length > 0 && (
|
||||
<DetailRow
|
||||
icon={<Bell size={16} color={c.textMuted} />}
|
||||
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
|
||||
text={reminders.map((r) => formatReminder(r.minutesBefore, t)).join(', ')}
|
||||
/>
|
||||
)}
|
||||
{calendar && (
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'lucide-react-native';
|
||||
import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type {
|
||||
Alert,
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
EventLocation,
|
||||
@@ -51,9 +52,12 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
preservedAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
REMINDER_UNIT_MINUTES,
|
||||
type Reminder,
|
||||
type ReminderUnit,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||
import {
|
||||
@@ -88,7 +92,11 @@ interface EventModalProps {
|
||||
currentUserEmails?: string[];
|
||||
// Client-side iCal subscriptions are read-only targets (#762).
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
|
||||
onSave: (
|
||||
data: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => void | Promise<void>;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -201,6 +209,14 @@ export function EventModal({
|
||||
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
// 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>>({});
|
||||
const [customAmount, setCustomAmount] = React.useState('30');
|
||||
const [customUnit, setCustomUnit] = React.useState<ReminderUnit>('minutes');
|
||||
const [customReminderOpen, setCustomReminderOpen] = React.useState(false);
|
||||
// "Send invitations" (webmail default: on) decides sendSchedulingMessages.
|
||||
const [sendInvitations, setSendInvitations] = React.useState(true);
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [videoUrl, setVideoUrl] = React.useState('');
|
||||
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
|
||||
@@ -230,6 +246,9 @@ export function EventModal({
|
||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setPreserved(preservedAlerts(event.alerts));
|
||||
setSendInvitations(true);
|
||||
setCustomReminderOpen(false);
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
} else {
|
||||
@@ -245,6 +264,9 @@ export function EventModal({
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders([]);
|
||||
setPreserved({});
|
||||
setSendInvitations(true);
|
||||
setCustomReminderOpen(false);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
@@ -313,7 +335,7 @@ export function EventModal({
|
||||
// All-day events are date-only and carry no zone.
|
||||
timeZone: allDay ? null : getEffectiveTimeZone(),
|
||||
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
||||
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
|
||||
alerts: clearedOr(remindersToAlerts(reminders, preserved), 'alerts'),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: clearedOr(buildLocations(location), 'locations'),
|
||||
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
||||
@@ -344,7 +366,9 @@ export function EventModal({
|
||||
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
|
||||
if (data[key] === undefined) delete data[key];
|
||||
}
|
||||
await onSave(data, calendarId);
|
||||
await onSave(data, calendarId, {
|
||||
sendSchedulingMessages: attendees.length > 0 && sendInvitations,
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -588,7 +612,7 @@ export function EventModal({
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{reminders.map((r) => (
|
||||
<View key={r.minutesBefore} style={styles.reminderRow}>
|
||||
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
|
||||
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore, t)}</Text>
|
||||
<Pressable
|
||||
onPress={() => removeReminder(r.minutesBefore)}
|
||||
hitSlop={8}
|
||||
@@ -619,9 +643,51 @@ export function EventModal({
|
||||
style={styles.popoverRow}
|
||||
onPress={() => addReminder(preset)}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset, t)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
style={styles.popoverRow}
|
||||
onPress={() => { setReminderPickerOpen(false); setCustomReminderOpen(true); }}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{t('calendar.recurrence.custom', 'Custom…')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{customReminderOpen && (
|
||||
<View style={styles.customReminderRow}>
|
||||
<TextInput
|
||||
value={customAmount}
|
||||
onChangeText={(v) => setCustomAmount(v.replace(/[^0-9]/g, ''))}
|
||||
keyboardType="number-pad"
|
||||
style={styles.customAmountInput}
|
||||
placeholder="30"
|
||||
placeholderTextColor={c.textMuted}
|
||||
/>
|
||||
<View style={styles.customUnits}>
|
||||
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
||||
<Pressable
|
||||
key={unit}
|
||||
onPress={() => setCustomUnit(unit)}
|
||||
style={[styles.unitChip, customUnit === unit && styles.unitChipActive]}
|
||||
>
|
||||
<Text style={[styles.unitChipText, customUnit === unit && styles.unitChipTextActive]}>
|
||||
{t(`calendar.alerts.unit_${unit}_before`, `${unit} before`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
const amount = parseInt(customAmount, 10);
|
||||
if (!Number.isFinite(amount) || amount < 0) return;
|
||||
addReminder(amount * REMINDER_UNIT_MINUTES[customUnit]);
|
||||
setCustomReminderOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('calendar.alerts.add', 'Add reminder')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
@@ -674,6 +740,19 @@ export function EventModal({
|
||||
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
|
||||
}
|
||||
/>
|
||||
{attendees.length > 0 && (
|
||||
<View style={styles.allDayRow}>
|
||||
<Text style={[styles.fieldText, { flex: 1 }]}>
|
||||
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
</Text>
|
||||
<Switch
|
||||
value={sendInvitations}
|
||||
onValueChange={setSendInvitations}
|
||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{isEdit && onDelete && (
|
||||
@@ -842,6 +921,36 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
customReminderRow: {
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
customAmountInput: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
customUnits: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||||
unitChip: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
unitChipActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
unitChipText: { ...typography.caption, color: c.text },
|
||||
unitChipTextActive: { color: c.primaryForeground },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatReminder, preservedAlerts, remindersToAlerts } from '../calendar-alerts';
|
||||
|
||||
describe('preservedAlerts / remindersToAlerts', () => {
|
||||
const alerts = {
|
||||
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
|
||||
abs: { trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' }, action: 'display' },
|
||||
end: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' }, action: 'display' },
|
||||
mail: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' }, action: 'email' },
|
||||
};
|
||||
|
||||
it('keeps alerts the picker cannot represent', () => {
|
||||
expect(Object.keys(preservedAlerts(alerts)).sort()).toEqual(['abs', 'end', 'mail']);
|
||||
expect(preservedAlerts(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it('rebuilds the picker alerts on top of the preserved ones without key clashes', () => {
|
||||
const rebuilt = remindersToAlerts([{ minutesBefore: 10 }, { minutesBefore: 0 }], preservedAlerts(alerts))!;
|
||||
expect(Object.keys(rebuilt).sort()).toEqual(['abs', 'end', 'mail', 'reminder-1', 'reminder-2']);
|
||||
expect(rebuilt['reminder-1'].trigger).toEqual({ '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' });
|
||||
expect(rebuilt['reminder-2'].trigger.offset).toBe('PT0S');
|
||||
expect(rebuilt.abs).toBe(alerts.abs);
|
||||
});
|
||||
|
||||
it('returns undefined only when nothing is left', () => {
|
||||
expect(remindersToAlerts([], {})).toBeUndefined();
|
||||
expect(Object.keys(remindersToAlerts([], preservedAlerts(alerts))!)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReminder', () => {
|
||||
it('falls back to English plurals', () => {
|
||||
expect(formatReminder(0)).toBe('At time of event');
|
||||
expect(formatReminder(1)).toBe('1 minute before');
|
||||
expect(formatReminder(45)).toBe('45 minutes before');
|
||||
expect(formatReminder(120)).toBe('2 hours before');
|
||||
expect(formatReminder(60 * 24)).toBe('1 day before');
|
||||
expect(formatReminder(60 * 24 * 14)).toBe('2 weeks before');
|
||||
});
|
||||
|
||||
it('uses a plain translated string and ignores ICU plural templates it cannot expand', () => {
|
||||
const t = (key: string, fallback?: string) =>
|
||||
key === 'calendar.alerts.hours_before' ? '# Stunden vorher'
|
||||
: key === 'calendar.alerts.minutes_before' ? '{count, plural, one {# minute} other {# minutes}}'
|
||||
: fallback ?? key;
|
||||
expect(formatReminder(120, t)).toBe('2 Stunden vorher');
|
||||
expect(formatReminder(5, t)).toBe('5 minutes before');
|
||||
});
|
||||
});
|
||||
+65
-14
@@ -69,34 +69,85 @@ export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
|
||||
.sort((a, b) => b.minutesBefore - a.minutesBefore);
|
||||
}
|
||||
|
||||
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
|
||||
if (reminders.length === 0) return undefined;
|
||||
const alerts: Record<string, Alert> = {};
|
||||
reminders.forEach((r, i) => {
|
||||
alerts[`reminder-${i + 1}`] = {
|
||||
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
|
||||
// Alerts the picker can't represent (absolute triggers, end-relative offsets,
|
||||
// non-display actions) — kept as-is so a save doesn't drop them.
|
||||
export function preservedAlerts(alerts: CalendarEvent['alerts']): Record<string, Alert> {
|
||||
const out: Record<string, Alert> = {};
|
||||
if (!alerts) return out;
|
||||
for (const [id, alert] of Object.entries(alerts)) {
|
||||
const trigger = alert?.trigger;
|
||||
if (!trigger) continue;
|
||||
const representable =
|
||||
trigger.offset !== undefined &&
|
||||
(trigger.relativeTo === undefined || trigger.relativeTo === 'start') &&
|
||||
(!alert.action || alert.action === 'display') &&
|
||||
offsetToMinutesBefore(trigger.offset) !== null;
|
||||
if (!representable) out[id] = alert;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function remindersToAlerts(
|
||||
reminders: Reminder[],
|
||||
preserved: Record<string, Alert> = {},
|
||||
): Record<string, Alert> | undefined {
|
||||
if (reminders.length === 0 && Object.keys(preserved).length === 0) return undefined;
|
||||
const alerts: Record<string, Alert> = { ...preserved };
|
||||
let n = 0;
|
||||
for (const r of reminders) {
|
||||
let key = `reminder-${++n}`;
|
||||
while (key in alerts) key = `reminder-${++n}`;
|
||||
alerts[key] = {
|
||||
'@type': 'Alert',
|
||||
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore), relativeTo: 'start' },
|
||||
action: 'display',
|
||||
};
|
||||
});
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
export function formatReminder(minutesBefore: number): string {
|
||||
if (minutesBefore === 0) return 'At time of event';
|
||||
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
|
||||
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
const plain: Translate = (_key, fallback) => fallback ?? _key;
|
||||
|
||||
function plural(t: Translate, count: number, key: string, one: string, many: string): string {
|
||||
// Locale files carry ICU plurals ("{count, plural, one {# minute before} other {# minutes before}}")
|
||||
// that the mobile t() can't expand, so pick the English-style form and only
|
||||
// use the translation when it is a plain string.
|
||||
const raw = t(key, '');
|
||||
if (raw && !raw.includes('{')) return raw.replace('#', String(count));
|
||||
return (count === 1 ? one : many).replace('#', String(count));
|
||||
}
|
||||
|
||||
export function formatReminder(minutesBefore: number, t: Translate = plain): string {
|
||||
if (minutesBefore === 0) return t('calendar.alerts.at_time', 'At time of event');
|
||||
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} ${t('calendar.alerts.unit_minutes_after', 'minutes after')}`;
|
||||
if (minutesBefore < 60) {
|
||||
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
|
||||
}
|
||||
if (minutesBefore < 60 * 24) {
|
||||
const h = minutesBefore / 60;
|
||||
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
|
||||
if (Number.isInteger(h)) return plural(t, h, 'calendar.alerts.hours_before', '# hour before', '# hours before');
|
||||
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
|
||||
}
|
||||
if (minutesBefore < 60 * 24 * 7) {
|
||||
const d = minutesBefore / (60 * 24);
|
||||
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
|
||||
if (Number.isInteger(d)) return plural(t, d, 'calendar.alerts.days_before', '# day before', '# days before');
|
||||
return plural(t, Math.round(minutesBefore / 60), 'calendar.alerts.hours_before', '# hour before', '# hours before');
|
||||
}
|
||||
const w = minutesBefore / (60 * 24 * 7);
|
||||
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
|
||||
if (Number.isInteger(w)) return plural(t, w, 'calendar.alerts.weeks_before', '# week before', '# weeks before');
|
||||
return plural(t, Math.round(minutesBefore / (60 * 24)), 'calendar.alerts.days_before', '# day before', '# days before');
|
||||
}
|
||||
|
||||
export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks';
|
||||
|
||||
export const REMINDER_UNIT_MINUTES: Record<ReminderUnit, number> = {
|
||||
minutes: 1,
|
||||
hours: 60,
|
||||
days: 60 * 24,
|
||||
weeks: 60 * 24 * 7,
|
||||
};
|
||||
|
||||
// Presets offered by the reminder picker (minutes before start).
|
||||
export const REMINDER_PRESETS: number[] = [
|
||||
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
|
||||
|
||||
@@ -88,7 +88,13 @@ import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
type PendingAction =
|
||||
| { kind: 'edit'; event: CalendarEvent; updates: Partial<CalendarEvent>; calendarId: string }
|
||||
| {
|
||||
kind: 'edit';
|
||||
event: CalendarEvent;
|
||||
updates: Partial<CalendarEvent>;
|
||||
calendarId: string;
|
||||
sendScheduling?: boolean;
|
||||
}
|
||||
| { kind: 'delete'; event: CalendarEvent }
|
||||
| null;
|
||||
|
||||
@@ -464,14 +470,15 @@ export default function CalendarScreen() {
|
||||
try {
|
||||
if (action.kind === 'edit') {
|
||||
const { updates } = action;
|
||||
const opts = { sendSchedulingMessages: action.sendScheduling };
|
||||
switch (scope) {
|
||||
case 'this': {
|
||||
if (event.recurrenceId) {
|
||||
// Client-side expanded occurrence: write a one-shot override
|
||||
// on the master instead of touching the series.
|
||||
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId));
|
||||
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId), opts);
|
||||
} else {
|
||||
await updateEvent(event.id, updates);
|
||||
await updateEvent(event.id, updates, opts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -484,7 +491,7 @@ export default function CalendarScreen() {
|
||||
const targetCalendarId =
|
||||
action.calendarId || getPrimaryCalendarId(master) || '';
|
||||
try {
|
||||
await createEvent(newEventData, targetCalendarId);
|
||||
await createEvent(newEventData, targetCalendarId, opts);
|
||||
} catch (createError) {
|
||||
// Roll back the truncation so the series isn't left cut short.
|
||||
try {
|
||||
@@ -499,7 +506,7 @@ export default function CalendarScreen() {
|
||||
case 'all': {
|
||||
const master = await getMasterEvent(event);
|
||||
if (!master) throw new Error('Master event not found');
|
||||
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master));
|
||||
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master), opts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -544,7 +551,12 @@ export default function CalendarScreen() {
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (data: Partial<CalendarEvent>, calendarId: string) => {
|
||||
async (
|
||||
data: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => {
|
||||
const sendScheduling = options?.sendSchedulingMessages;
|
||||
if (modalEvent) {
|
||||
const updates: Partial<CalendarEvent> = { ...data };
|
||||
// Moving the event to another calendar: the store remaps the store id
|
||||
@@ -555,12 +567,12 @@ export default function CalendarScreen() {
|
||||
if (isRecurringSeriesMember(modalEvent)) {
|
||||
// Ask which occurrences the edit applies to; the actual write
|
||||
// happens in handleScopeSelect.
|
||||
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId });
|
||||
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling });
|
||||
return;
|
||||
}
|
||||
await updateEvent(modalEvent.id, updates);
|
||||
await updateEvent(modalEvent.id, updates, { sendSchedulingMessages: sendScheduling });
|
||||
} else {
|
||||
await createEvent(data, calendarId);
|
||||
await createEvent(data, calendarId, { sendSchedulingMessages: sendScheduling });
|
||||
}
|
||||
},
|
||||
[modalEvent, createEvent, updateEvent],
|
||||
|
||||
@@ -165,10 +165,20 @@ export interface CalendarState {
|
||||
ensureRange: (after: string, before: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
handleStateChange: (change: StateChange) => Promise<void>;
|
||||
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
|
||||
// `options.sendSchedulingMessages` overrides the default (send iMIP when
|
||||
// the event has participants) — the editor's "send invitations" switch.
|
||||
createEvent: (
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => Promise<CalendarEvent>;
|
||||
// `changes` may be a plain partial or a JMAP patch with JSON-pointer keys
|
||||
// (e.g. `recurrenceOverrides/<recurrenceId>`).
|
||||
updateEvent: (id: string, changes: Partial<CalendarEvent> | Record<string, unknown>) => Promise<void>;
|
||||
updateEvent: (
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
// Resolve a client-side expanded occurrence (or a master) to its master
|
||||
// event, fetching it from the server when the expansion replaced it.
|
||||
@@ -428,17 +438,19 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: async (event, calendarId) => {
|
||||
createEvent: async (event, calendarId, options) => {
|
||||
// Shared calendars live in the owner's account — route the create there,
|
||||
// and against the calendar's raw server id (calendarId is the namespaced
|
||||
// store id for shared calendars).
|
||||
const calendars = get().calendars;
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
const accountId = cal?.accountId;
|
||||
const schedule =
|
||||
options?.sendSchedulingMessages ?? (hasSchedulingParticipants(event) ? true : undefined);
|
||||
const created = await apiCreateEvent(
|
||||
event,
|
||||
cal?.originalId || calendarId,
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
schedule,
|
||||
accountId,
|
||||
);
|
||||
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
|
||||
@@ -465,7 +477,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
return full;
|
||||
},
|
||||
|
||||
updateEvent: async (id, changes) => {
|
||||
updateEvent: async (id, changes, options) => {
|
||||
// Resolve client-side expanded occurrence IDs back to the master event ID.
|
||||
const storeEvent = get().events.find((e) => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
@@ -484,9 +496,12 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
// Notify attendees if either the stored event or the incoming changes
|
||||
// carry participants.
|
||||
const schedule =
|
||||
hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
|
||||
hasSchedulingParticipants(storeEvent);
|
||||
await apiUpdateEvent(realId, patch, schedule ? true : undefined, storeEvent?.accountId);
|
||||
options?.sendSchedulingMessages ??
|
||||
(hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
|
||||
hasSchedulingParticipants(storeEvent)
|
||||
? true
|
||||
: undefined);
|
||||
await apiUpdateEvent(realId, patch, schedule, storeEvent?.accountId);
|
||||
set({
|
||||
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user