fix: stop reporting calendar, task and contact changes as done when a plugin stopped them

This commit is contained in:
Linus Rath
2026-10-14 19:56:06 +02:00
parent 8204678847
commit f8448c6a45
13 changed files with 173 additions and 76 deletions
+30 -23
View File
@@ -667,20 +667,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (client) toggleTaskCompleteFn(client, task);
}, [client, toggleTaskCompleteFn]);
// false: a plugin stopped it and said why, so the task form stays open.
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
if (!client) return;
if (editTask) {
await taskStore.updateTask(client, editTask.id, data);
} else {
await taskStore.createTask(client, data);
}
if (!client) return false;
const saved = editTask
? await taskStore.updateTask(client, editTask.id, data)
: (await taskStore.createTask(client, data)) !== null;
if (!saved) return false;
setShowTaskModal(false);
setEditTask(null);
return true;
}, [client, editTask, taskStore]);
const handleDeleteTask = useCallback(async (id: string) => {
if (!client) return;
await taskStore.deleteTask(client, id);
if (!(await taskStore.deleteTask(client, id))) return;
setShowTaskModal(false);
setEditTask(null);
}, [client, taskStore]);
@@ -959,7 +960,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
// false: a plugin stopped it and said why; the form stays open.
if (!(await updateEvent(client, editEvent.id, data, sendSchedulingMessages))) return;
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
@@ -1106,17 +1108,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return;
}
try {
await deleteEvent(client, id, sendSchedulingMessages);
toast.success(t("notifications.event_deleted"));
if (await deleteEvent(client, id, sendSchedulingMessages)) toast.success(t("notifications.event_deleted"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, deleteEvent, events, editEvent, t]);
// null: no series found; "stopped": a plugin refused to shorten it.
const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{
master: CalendarEvent;
originalRules: CalendarEvent["recurrenceRules"];
} | null> => {
} | null | "stopped"> => {
const master = await findMasterEvent(event);
if (!master) return null;
const originalRules = master.recurrenceRules
@@ -1131,7 +1133,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
until,
count: null,
}));
await updateEvent(client!, master.id, { recurrenceRules: truncatedRules });
if (!(await updateEvent(client!, master.id, { recurrenceRules: truncatedRules }))) return "stopped";
return { master, originalRules };
}, [client, findMasterEvent, updateEvent]);
@@ -1143,8 +1145,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
) => {
if (!client) return;
try {
await rsvpEvent(client, eventId, participantId, status, undefined, scope);
toast.success(t("notifications.rsvp_updated"));
if (await rsvpEvent(client, eventId, participantId, status, undefined, scope)) toast.success(t("notifications.rsvp_updated"));
} catch {
toast.error(t("notifications.rsvp_error"));
}
@@ -1162,6 +1163,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
setPendingScopeAction(null);
// false when a plugin stopped the change (it says why): no "updated".
let done = true;
try {
if (type === "edit" && updates) {
switch (scope) {
@@ -1170,7 +1173,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// The store writes one occurrence through its own (synthetic)
// id, or as a recurrence override on the base event it was
// expanded from.
await updateEvent(client, event.id, updates, sendScheduling);
done = await updateEvent(client, event.id, updates, sendScheduling);
break;
}
// An override outside an expanded series: override it on the master.
@@ -1179,14 +1182,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const overridePatch = occurrence
&& buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates));
if (master && overridePatch) {
await updateEvent(client, master.id, overridePatch, sendScheduling);
done = await updateEvent(client, master.id, overridePatch, sendScheduling);
} else {
await updateEvent(client, event.id, updates, sendScheduling);
done = await updateEvent(client, event.id, updates, sendScheduling);
}
break;
}
case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event);
if (result === "stopped") return;
if (!result) {
toast.error(t("notifications.event_error"));
return;
@@ -1234,7 +1238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
const allUpdates = { ...updates };
delete (allUpdates as Record<string, unknown>).recurrenceId;
await updateEvent(client, master.id, allUpdates, sendScheduling);
done = await updateEvent(client, master.id, allUpdates, sendScheduling);
break;
}
default: {
@@ -1242,6 +1246,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
throw new Error(`Unhandled scope: ${_exhaustive}`);
}
}
if (!done) return;
toast.success(t("notifications.event_updated"));
} else {
switch (scope) {
@@ -1249,21 +1254,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
// The store destroys a server occurrence, or excludes a
// browser-expanded one on its base event.
await deleteEvent(client, event.id, sendScheduling);
done = await deleteEvent(client, event.id, sendScheduling);
break;
}
// An override outside an expanded series: exclude it on the master.
const delMaster = await findMasterEvent(event);
const excludePatch = delMaster && buildFallbackExcludePatch(overrideContextOf(event, delMaster));
if (delMaster && excludePatch) {
await updateEvent(client, delMaster.id, excludePatch, sendScheduling);
done = await updateEvent(client, delMaster.id, excludePatch, sendScheduling);
} else {
await deleteEvent(client, event.id, sendScheduling);
done = await deleteEvent(client, event.id, sendScheduling);
}
break;
}
case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event);
if (result === "stopped") return;
if (!result) {
toast.error(t("notifications.event_error"));
return;
@@ -1276,7 +1282,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
toast.error(t("notifications.event_error"));
return;
}
await deleteEvent(client, master.id, sendScheduling);
done = await deleteEvent(client, master.id, sendScheduling);
break;
}
default: {
@@ -1284,6 +1290,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
throw new Error(`Unhandled scope: ${_exhaustive}`);
}
}
if (!done) return;
toast.success(t("notifications.event_deleted"));
}
try {
@@ -1338,7 +1345,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
? `${detailEvent.description}${separator}${note}`
: `--- ${timestamp} ---\n${note}`;
try {
await updateEvent(client, detailEvent.id, { description: newDescription });
if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return;
setDetailEvent({ ...detailEvent, description: newDescription });
toast.success(t("detail.note_saved"));
} catch {
@@ -189,6 +189,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
setStep("importing");
try {
const count = await importEvents(client, eventsToImport, calendarId);
if (count === null) {
// A plugin stopped the import and said why.
setStep("preview");
return;
}
toast.success(t("success", { count }));
onClose();
} catch {
+3 -2
View File
@@ -14,7 +14,8 @@ import { taskToView } from "@/lib/plugin-projection";
interface TaskModalProps {
task?: CalendarTask | null;
calendars: Calendar[];
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
/** Resolving false keeps the form open (a plugin stopped the save). */
onSave: (data: Partial<CalendarTask>) => void | boolean | Promise<void | boolean>;
onDelete?: (id: string) => void;
onClose: () => void;
isMobile?: boolean;
@@ -145,7 +146,7 @@ export function TaskModal({
data.id = task.id;
}
await onSave(data);
if ((await onSave(data)) === false) return;
onClose();
} finally {
setSaving(false);
+12 -10
View File
@@ -338,21 +338,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
// Contacts in the category afterwards; a plugin may stop some updates.
let added = 0;
for (const contactId of contactIds) {
const contact = contacts.find(c => c.id === contactId);
if (!contact) continue;
const existingKeywords = contact.keywords || {};
if (existingKeywords[keyword]) continue; // already has this keyword
if (existingKeywords[keyword]) { added++; continue; } // already has this keyword
const updatedKeywords = { ...existingKeywords, [keyword]: true };
if (supportsSync && client) {
await updateContact(client, contactId, { keywords: updatedKeywords });
} else {
await updateLocalContact(contactId, { keywords: updatedKeywords });
}
const updated = supportsSync && client
? await updateContact(client, contactId, { keywords: updatedKeywords })
: await updateLocalContact(contactId, { keywords: updatedKeywords });
if (updated !== false) added++;
}
const msg = contactIds.length === 1
if (added === 0) return;
const msg = added === 1
? t("category_added", { name: keyword })
: t("category_added_plural", { count: contactIds.length, name: keyword });
: t("category_added_plural", { count: added, name: keyword });
toast.success(msg);
} catch (error) {
console.error('Failed to add contacts to category:', error);
@@ -694,10 +696,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (!confirmed) return;
try {
await bulkDeleteContacts(
if (!(await bulkDeleteContacts(
supportsSync && client ? client : null,
Array.from(selectedContactIds)
);
))) return;
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
setView("list");
} catch (error) {
@@ -522,7 +522,7 @@ describe('CalendarInvitationBanner', () => {
},
},
});
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
@@ -543,6 +543,73 @@ describe('CalendarInvitationBanner', () => {
'accepted',
null,
);
expect(await screen.findByText('Response sent')).toBeInTheDocument();
});
it('says nothing was sent when a plugin stops the answer', async () => {
mocks.calendarState.events = [
{
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-6',
title: 'Sparse Event',
start: '2026-03-28T09:00:00Z',
participants: {
parsedAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.clientMock.getCalendarEvent.mockResolvedValue({
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
participants: {
canonicalAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
});
mocks.calendarState.rsvpEvent.mockResolvedValue(false);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
});
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-6',
'canonicalAttendee',
'accepted',
null,
);
// The plugin that stopped it says why; the banner must not claim it went.
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled());
expect(screen.queryByText('Response sent')).not.toBeInTheDocument();
});
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
@@ -580,7 +647,7 @@ describe('CalendarInvitationBanner', () => {
},
},
]);
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
@@ -639,7 +706,7 @@ describe('CalendarInvitationBanner', () => {
},
},
]);
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
mocks.calendarState.updateEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
@@ -671,7 +738,7 @@ describe('CalendarInvitationBanner', () => {
});
it('shows counter changes and lets organizers apply the proposal', async () => {
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(true);
mocks.calendarState.events = [
{
id: 'event-5',
@@ -599,7 +599,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0)
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
// false: a plugin stopped it and said why; keep the old answer.
if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -609,7 +610,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
if (!repairedParticipants) {
setActionError(t('action_failed'));
} else {
await updateEvent(client, eventForRsvp.id, {
const updated = await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
@@ -618,6 +619,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
if (!updated) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -626,6 +628,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionError(t('action_failed'));
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
if (imported === null) return;
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
@@ -633,7 +636,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
@@ -682,6 +685,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
if (count === null) return;
if (count > 0) {
setActionNotice(t('added'));
setState('parsed');
@@ -740,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setIsProcessing(true);
try {
await updateEvent(client, existingEvent.id, proposalPatch, true);
if (!(await updateEvent(client, existingEvent.id, proposalPatch, true))) return;
setActionNotice(t('proposal_applied'));
setState('parsed');
setRsvpStatus(null);
+3 -1
View File
@@ -299,7 +299,9 @@ function TasksPanel({ onClose }: { onClose: () => void }) {
const title = draft.trim();
if (!title) return;
setDraft("");
await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } });
// null: a plugin stopped it; give the user their text back.
const created = await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } });
if (!created) setDraft(title);
}}
>
<label className="flex items-center gap-3 rounded-full px-1 py-1.5 text-sm text-primary hover:bg-muted/60">
@@ -101,7 +101,7 @@ describe('updateEvent hooks', () => {
const after = vi.fn();
calendarHooks.onAfterEventUpdate.register('t', after);
await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' });
expect(await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' })).toBe(true);
expect(before).toHaveBeenCalledWith({ eventId: 'e1', patch: { title: 'Renamed' } });
expect(after).toHaveBeenCalledTimes(1);
@@ -115,7 +115,7 @@ describe('updateEvent hooks', () => {
const after = vi.fn();
calendarHooks.onAfterEventUpdate.register('t', after);
await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' });
expect(await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' })).toBe(false);
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events[0].title).toBe('Planning');
@@ -161,7 +161,7 @@ describe('deleteEvent hooks', () => {
const after = vi.fn();
calendarHooks.onAfterEventDelete.register('t', after);
await useCalendarStore.getState().deleteEvent(client, 'e1');
expect(await useCalendarStore.getState().deleteEvent(client, 'e1')).toBe(true);
expect(before).toHaveBeenCalledWith({ eventId: 'e1', title: 'Planning' });
expect(after).toHaveBeenCalledTimes(1);
@@ -175,7 +175,7 @@ describe('deleteEvent hooks', () => {
const after = vi.fn();
calendarHooks.onAfterEventDelete.register('t', after);
await useCalendarStore.getState().deleteEvent(client, 'e1');
expect(await useCalendarStore.getState().deleteEvent(client, 'e1')).toBe(false);
expect(client.deleteCalendarEvent).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events).toHaveLength(1);
@@ -204,7 +204,7 @@ describe('rsvpEvent hooks', () => {
const after = vi.fn();
calendarHooks.onEventRsvp.register('t', after);
await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted');
expect(await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted')).toBe(true);
expect(before).toHaveBeenCalledWith({ eventId: 'e1', status: 'accepted' });
expect(after).toHaveBeenCalledTimes(1);
@@ -218,7 +218,7 @@ describe('rsvpEvent hooks', () => {
const after = vi.fn();
calendarHooks.onEventRsvp.register('t', after);
await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted');
expect(await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted')).toBe(false);
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('needs-action');
@@ -268,7 +268,8 @@ describe('importEvents hooks', () => {
const count = await useCalendarStore.getState().importEvents(client, file, 'c1');
expect(count).toBe(0);
// null, not 0: "0 events imported" would be a success message.
expect(count).toBeNull();
expect(client.getCalendarEvents).not.toHaveBeenCalled();
expect(client.batchCreateCalendarEvents).not.toHaveBeenCalled();
expect(after).not.toHaveBeenCalled();
+2 -2
View File
@@ -224,7 +224,7 @@ describe('contact-store plugin hooks: contact CRUD', () => {
contactHooks.onBeforeContactDelete.register('test', before);
contactHooks.onAfterContactDelete.register('test', after);
await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c']);
expect(await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c'])).toBe(true);
await flush();
expect(before).toHaveBeenCalledOnce();
@@ -241,7 +241,7 @@ describe('contact-store plugin hooks: contact CRUD', () => {
const client = makeClient();
contactHooks.onBeforeContactDelete.register('test', () => false);
await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b']);
expect(await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b'])).toBe(false);
expect(client.deleteContact).not.toHaveBeenCalled();
expect(useContactStore.getState().contacts).toHaveLength(2);
+4 -4
View File
@@ -98,7 +98,7 @@ describe('create, update and delete', () => {
const after = vi.fn();
taskHooks.onTaskUpdate.register('t', after);
await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' });
expect(await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' })).toBe(true);
expect(before).toHaveBeenCalledWith({ taskId: 't1', patch: { title: 'Renamed' } });
expect(after).toHaveBeenCalledTimes(1);
@@ -111,7 +111,7 @@ describe('create, update and delete', () => {
const after = vi.fn();
taskHooks.onTaskUpdate.register('t', after);
await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' });
expect(await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' })).toBe(false);
expect(client.updateCalendarTask).not.toHaveBeenCalled();
expect(useTaskStore.getState().tasks[0].title).toBe('Write report');
@@ -137,7 +137,7 @@ describe('create, update and delete', () => {
const after = vi.fn();
taskHooks.onTaskDelete.register('t', after);
await useTaskStore.getState().deleteTask(client, 't1');
expect(await useTaskStore.getState().deleteTask(client, 't1')).toBe(true);
expect(before).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' });
expect(after).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' });
@@ -149,7 +149,7 @@ describe('create, update and delete', () => {
const after = vi.fn();
taskHooks.onTaskDelete.register('t', after);
await useTaskStore.getState().deleteTask(client, 't1');
expect(await useTaskStore.getState().deleteTask(client, 't1')).toBe(false);
expect(client.deleteCalendarTask).not.toHaveBeenCalled();
expect(useTaskStore.getState().tasks).toHaveLength(1);
+14 -11
View File
@@ -590,10 +590,10 @@ interface CalendarStore {
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise<void>;
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<boolean>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<boolean>;
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<boolean>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number | null>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
@@ -890,8 +890,8 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
if (!(await calendarHooks.onBeforeEventUpdate.intercept({ eventId: id, patch: updates }))) return;
updateEvent: async (client, id, updates, sendSchedulingMessages): Promise<boolean> => {
if (!(await calendarHooks.onBeforeEventUpdate.intercept({ eventId: id, patch: updates }))) return false;
set({ error: null });
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
@@ -971,9 +971,10 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: 'Failed to update event' });
throw error;
}
return true;
},
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series') => {
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series'): Promise<boolean> => {
set({ error: null });
// JMAP participant IDs are opaque strings - they can contain @, ., :,
// / etc. The id is RFC 6901-escaped below before being embedded in the
@@ -982,7 +983,7 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: 'Invalid participant ID' });
throw new Error('Invalid participant ID');
}
if (!(await calendarHooks.onBeforeEventRsvp.intercept({ eventId, status }))) return;
if (!(await calendarHooks.onBeforeEventRsvp.intercept({ eventId, status }))) return false;
try {
// Resolve shared event IDs and client-side expanded occurrence IDs.
// Scope 'series' answers for the whole series, so an expanded
@@ -1041,18 +1042,19 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: 'Failed to update RSVP' });
throw error;
}
return true;
},
importEvents: async (client, events, calendarId) => {
if (!(await calendarHooks.onBeforeEventsImport.intercept({ calendarId, count: events.length }))) return 0;
if (!(await calendarHooks.onBeforeEventsImport.intercept({ calendarId, count: events.length }))) return null;
const imported = await importEventsUnhooked(client, events, calendarId);
void calendarHooks.onEventsImport.emit({ calendarId, imported, skipped: Math.max(0, events.length - imported) });
return imported;
},
deleteEvent: async (client, id, sendSchedulingMessages) => {
deleteEvent: async (client, id, sendSchedulingMessages): Promise<boolean> => {
const title = get().events.find((e) => e.id === id)?.title || '';
if (!(await calendarHooks.onBeforeEventDelete.intercept({ eventId: id, title }))) return;
if (!(await calendarHooks.onBeforeEventDelete.intercept({ eventId: id, title }))) return false;
set({ error: null });
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
@@ -1091,6 +1093,7 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: 'Failed to delete event' });
throw error;
}
return true;
},
setSelectedDate: (date) => {
+5 -3
View File
@@ -388,7 +388,8 @@ interface ContactStore {
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
selectAllContacts: (ids: string[]) => void;
clearSelection: () => void;
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
/** false when a plugin stopped it (the plugin tells the user why). */
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<boolean>;
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
createAddressBook: (client: IJMAPClient, name: string) => Promise<AddressBook>;
@@ -997,8 +998,8 @@ export const useContactStore = create<ContactStore>()(
clearSelection: () => set({ selectedContactIds: new Set<string>(), lastSelectedContactId: null }),
bulkDeleteContacts: async (client, ids) => {
if (ids.length === 0) return;
if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return;
if (ids.length === 0) return true;
if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return false;
set({ error: null });
const { supportsSync, contacts } = get();
const deletedIds = new Set(ids);
@@ -1031,6 +1032,7 @@ export const useContactStore = create<ContactStore>()(
if (deletedIds.size > 0) {
void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) });
}
return true;
},
bulkAddToGroup: async (client, groupId, contactIds) => {
+8 -5
View File
@@ -21,8 +21,9 @@ interface TaskStore {
fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise<void>;
/** Resolves to null when a plugin stopped the create. */
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask | null>;
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<void>;
deleteTask: (client: IJMAPClient, id: string) => Promise<void>;
/** false when a plugin stopped it (the plugin tells the user why). */
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<boolean>;
deleteTask: (client: IJMAPClient, id: string) => Promise<boolean>;
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
clearTasks: () => void;
}
@@ -79,7 +80,7 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
},
updateTask: async (client, id, updates) => {
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return;
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return false;
const before = get().tasks.find(t => t.id === id);
const newId = await client.updateCalendarTask(id, updates);
if (newId && newId !== id) {
@@ -90,24 +91,26 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
const moved = get().tasks.find(t => t.id === newId)
?? ({ ...before, ...updates, id: newId } as CalendarTask);
void taskHooks.onTaskUpdate.emit(taskToView(moved));
return;
return true;
}
const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask;
set({
tasks: get().tasks.map(t => t.id === id ? updated : t),
});
void taskHooks.onTaskUpdate.emit(taskToView(updated));
return true;
},
deleteTask: async (client, id) => {
const title = get().tasks.find(t => t.id === id)?.title || '';
if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return;
if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return false;
await client.deleteCalendarTask(id);
set({
tasks: get().tasks.filter(t => t.id !== id),
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
});
void taskHooks.onTaskDelete.emit({ taskId: id, title });
return true;
},
toggleTaskComplete: async (client, task) => {