diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index cb796fa5..9f2d908c 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -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. diff --git a/locales/en/common.json b/locales/en/common.json index 4c3220f3..c8737bea 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 12c5d696..b5c5bd37 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -297,7 +297,7 @@ export function EventDetailSheet({ {reminders.length > 0 && ( } - text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')} + text={reminders.map((r) => formatReminder(r.minutesBefore, t)).join(', ')} /> )} {calendar && ( diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 73089d5d..0983f4cb 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -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, calendarId: string) => void | Promise; + onSave: ( + data: Partial, + calendarId: string, + options?: { sendSchedulingMessages?: boolean }, + ) => void | Promise; onDelete?: (event: CalendarEvent) => void; onClose: () => void; } @@ -201,6 +209,14 @@ export function EventModal({ const [customRule, setCustomRule] = React.useState(null); const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); const [reminders, setReminders] = React.useState([]); + // 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>({}); + const [customAmount, setCustomAmount] = React.useState('30'); + const [customUnit, setCustomUnit] = React.useState('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({ {reminders.map((r) => ( - {formatReminder(r.minutesBefore)} + {formatReminder(r.minutesBefore, t)} removeReminder(r.minutesBefore)} hitSlop={8} @@ -619,9 +643,51 @@ export function EventModal({ style={styles.popoverRow} onPress={() => addReminder(preset)} > - {formatReminder(preset)} + {formatReminder(preset, t)} ))} + { setReminderPickerOpen(false); setCustomReminderOpen(true); }} + > + {t('calendar.recurrence.custom', 'Custom…')} + + + )} + {customReminderOpen && ( + + setCustomAmount(v.replace(/[^0-9]/g, ''))} + keyboardType="number-pad" + style={styles.customAmountInput} + placeholder="30" + placeholderTextColor={c.textMuted} + /> + + {(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => ( + setCustomUnit(unit)} + style={[styles.unitChip, customUnit === unit && styles.unitChipActive]} + > + + {t(`calendar.alerts.unit_${unit}_before`, `${unit} before`)} + + + ))} + + )} @@ -674,6 +740,19 @@ export function EventModal({ setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase())) } /> + {attendees.length > 0 && ( + + + {t('calendar.participants.send_invitations', 'Send invitations to participants')} + + + + )} {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', diff --git a/src/lib/__tests__/calendar-alerts-preserved.test.ts b/src/lib/__tests__/calendar-alerts-preserved.test.ts new file mode 100644 index 00000000..9dd0b99e --- /dev/null +++ b/src/lib/__tests__/calendar-alerts-preserved.test.ts @@ -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'); + }); +}); diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index 0226009b..d3c94e17 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -69,34 +69,85 @@ export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] { .sort((a, b) => b.minutesBefore - a.minutesBefore); } -export function remindersToAlerts(reminders: Reminder[]): Record | undefined { - if (reminders.length === 0) return undefined; - const alerts: Record = {}; - 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 { + const out: Record = {}; + 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 = {}, +): Record | undefined { + if (reminders.length === 0 && Object.keys(preserved).length === 0) return undefined; + const alerts: Record = { ...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 = { + 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, diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index e58884c6..aa94446d 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -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; calendarId: string } + | { + kind: 'edit'; + event: CalendarEvent; + updates: Partial; + 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, calendarId: string) => { + async ( + data: Partial, + calendarId: string, + options?: { sendSchedulingMessages?: boolean }, + ) => { + const sendScheduling = options?.sendSchedulingMessages; if (modalEvent) { const updates: Partial = { ...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], diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 9408c7e3..57be3aa4 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -165,10 +165,20 @@ export interface CalendarState { ensureRange: (after: string, before: string) => Promise; refresh: () => Promise; handleStateChange: (change: StateChange) => Promise; - createEvent: (event: Partial, calendarId: string) => Promise; + // `options.sendSchedulingMessages` overrides the default (send iMIP when + // the event has participants) — the editor's "send invitations" switch. + createEvent: ( + event: Partial, + calendarId: string, + options?: { sendSchedulingMessages?: boolean }, + ) => Promise; // `changes` may be a plain partial or a JMAP patch with JSON-pointer keys // (e.g. `recurrenceOverrides/`). - updateEvent: (id: string, changes: Partial | Record) => Promise; + updateEvent: ( + id: string, + changes: Partial | Record, + options?: { sendSchedulingMessages?: boolean }, + ) => Promise; deleteEvent: (id: string) => Promise; // 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()( } }, - 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()( 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()( // Notify attendees if either the stored event or the incoming changes // carry participants. const schedule = - hasSchedulingParticipants(changes as Partial) || - hasSchedulingParticipants(storeEvent); - await apiUpdateEvent(realId, patch, schedule ? true : undefined, storeEvent?.accountId); + options?.sendSchedulingMessages ?? + (hasSchedulingParticipants(changes as Partial) || + hasSchedulingParticipants(storeEvent) + ? true + : undefined); + await apiUpdateEvent(realId, patch, schedule, storeEvent?.accountId); set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), });