feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants, scheduling messages, and inline calendar invitation banner in email viewer with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
This commit is contained in:
+130
-16
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
@@ -22,9 +22,10 @@ interface CalendarStore {
|
||||
setSupported: (supported: boolean) => void;
|
||||
fetchCalendars: (client: JMAPClient) => Promise<void>;
|
||||
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
|
||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
|
||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
||||
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
|
||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>;
|
||||
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
setSelectedDate: (date: Date) => void;
|
||||
setViewMode: (mode: CalendarViewMode) => void;
|
||||
@@ -59,12 +60,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
const calendars = await client.getCalendars();
|
||||
const { selectedCalendarIds } = get();
|
||||
const validIds = calendars.map(c => c.id);
|
||||
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
|
||||
set({
|
||||
calendars,
|
||||
isLoading: false,
|
||||
selectedCalendarIds: selectedCalendarIds.length === 0
|
||||
? calendars.map(c => c.id)
|
||||
: selectedCalendarIds,
|
||||
selectedCalendarIds: stillValid.length > 0 ? stillValid : validIds,
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch calendars:', error);
|
||||
@@ -75,11 +76,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
fetchEvents: async (client, start, end) => {
|
||||
set({ isLoadingEvents: true, error: null });
|
||||
try {
|
||||
const { selectedCalendarIds } = get();
|
||||
const events = await client.queryCalendarEvents({
|
||||
after: start,
|
||||
before: end,
|
||||
inCalendars: selectedCalendarIds.length > 0 ? selectedCalendarIds : undefined,
|
||||
});
|
||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||
} catch (error) {
|
||||
@@ -88,10 +87,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: async (client, event) => {
|
||||
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const created = await client.createCalendarEvent(event);
|
||||
const created = await client.createCalendarEvent(event, sendSchedulingMessages);
|
||||
set((state) => ({ events: [...state.events, created] }));
|
||||
return created;
|
||||
} catch (error) {
|
||||
@@ -101,10 +100,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateEvent: async (client, id, updates) => {
|
||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.updateCalendarEvent(id, updates);
|
||||
await client.updateCalendarEvent(id, updates, sendSchedulingMessages);
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||
}));
|
||||
@@ -115,28 +114,143 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
rsvpEvent: async (client, eventId, participantId, status) => {
|
||||
set({ error: null });
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(participantId)) {
|
||||
set({ error: 'Invalid participant ID' });
|
||||
throw new Error('Invalid participant ID');
|
||||
}
|
||||
try {
|
||||
const patchKey = `participants/${participantId}/participationStatus`;
|
||||
await client.updateCalendarEvent(
|
||||
eventId,
|
||||
{ [patchKey]: status } as unknown as Partial<CalendarEvent>,
|
||||
true
|
||||
);
|
||||
set((state) => ({
|
||||
events: state.events.map(e => {
|
||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||
return {
|
||||
...e,
|
||||
participants: {
|
||||
...e.participants,
|
||||
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to RSVP:', error);
|
||||
set({ error: 'Failed to update RSVP' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
importEvents: async (client, events, calendarId) => {
|
||||
let imported = 0;
|
||||
for (const event of events) {
|
||||
const src = event as Partial<CalendarEvent>;
|
||||
try {
|
||||
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
|
||||
if (src.participants) {
|
||||
cleanParticipants = {};
|
||||
for (const [key, p] of Object.entries(src.participants)) {
|
||||
const participant: Record<string, unknown> = {
|
||||
'@type': 'Participant',
|
||||
name: p.name,
|
||||
email: p.email,
|
||||
description: p.description,
|
||||
sendTo: p.sendTo,
|
||||
kind: p.kind,
|
||||
roles: p.roles,
|
||||
participationStatus: p.participationStatus,
|
||||
participationComment: p.participationComment,
|
||||
expectReply: p.expectReply,
|
||||
scheduleAgent: p.scheduleAgent,
|
||||
scheduleForceSend: p.scheduleForceSend,
|
||||
scheduleId: p.scheduleId,
|
||||
delegatedTo: p.delegatedTo,
|
||||
delegatedFrom: p.delegatedFrom,
|
||||
memberOf: p.memberOf,
|
||||
locationId: p.locationId,
|
||||
language: p.language,
|
||||
links: p.links,
|
||||
};
|
||||
Object.keys(participant).forEach(k => {
|
||||
if (participant[k] === undefined || participant[k] === null) delete participant[k];
|
||||
});
|
||||
cleanParticipants[key] = participant as unknown as CalendarParticipant;
|
||||
}
|
||||
}
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
...event,
|
||||
calendarIds: { [calendarId]: true },
|
||||
uid: src.uid,
|
||||
title: src.title,
|
||||
description: src.description,
|
||||
descriptionContentType: src.descriptionContentType,
|
||||
start: src.start,
|
||||
duration: src.duration,
|
||||
timeZone: src.timeZone,
|
||||
showWithoutTime: src.showWithoutTime,
|
||||
status: src.status,
|
||||
freeBusyStatus: src.freeBusyStatus,
|
||||
privacy: src.privacy,
|
||||
color: src.color,
|
||||
keywords: src.keywords,
|
||||
categories: src.categories,
|
||||
locale: src.locale,
|
||||
locations: src.locations,
|
||||
virtualLocations: src.virtualLocations,
|
||||
links: src.links,
|
||||
recurrenceRules: src.recurrenceRules,
|
||||
recurrenceOverrides: src.recurrenceOverrides,
|
||||
excludedRecurrenceRules: src.excludedRecurrenceRules,
|
||||
alerts: src.alerts,
|
||||
participants: cleanParticipants,
|
||||
};
|
||||
Object.keys(data).forEach(k => {
|
||||
const v = (data as Record<string, unknown>)[k];
|
||||
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
||||
});
|
||||
const created = await client.createCalendarEvent(data);
|
||||
set((state) => ({ events: [...state.events, created] }));
|
||||
imported++;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '';
|
||||
if (msg.includes('already exists') && src.uid) {
|
||||
const { events: storeEvents } = get();
|
||||
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
|
||||
if (alreadyInStore) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const all = await client.queryCalendarEvents({});
|
||||
const matching = all.filter((e) => e.uid === src.uid);
|
||||
if (matching.length > 0) {
|
||||
const existingIds = new Set(storeEvents.map((e) => e.id));
|
||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||
if (newEvents.length > 0) {
|
||||
set((state) => ({ events: [...state.events, ...newEvents] }));
|
||||
}
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error
|
||||
}
|
||||
}
|
||||
debug.error('Failed to import event:', event.title, error);
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
},
|
||||
|
||||
deleteEvent: async (client, id) => {
|
||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.deleteCalendarEvent(id);
|
||||
await client.deleteCalendarEvent(id, sendSchedulingMessages);
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => e.id !== id),
|
||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||
|
||||
Reference in New Issue
Block a user