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:
@@ -11,6 +11,7 @@ import {
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
@@ -22,7 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal } from "@/components/calendar/event-modal";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
@@ -32,10 +33,16 @@ export default function CalendarPage() {
|
||||
const {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||
const { identities } = useIdentityStore();
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
identities.map(id => id.email).filter(Boolean),
|
||||
[identities]
|
||||
);
|
||||
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
@@ -100,7 +107,7 @@ export default function CalendarPage() {
|
||||
if (client && calendars.length > 0) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, selectedCalendarIds, dateRange, fetchEvents]);
|
||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
@@ -153,19 +160,23 @@ export default function CalendarPage() {
|
||||
setShowEventModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
if (editEvent) {
|
||||
await updateEvent(client, editEvent.id, data);
|
||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
const created = await createEvent(client, data);
|
||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||
if (!created) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
toast.success(t("notifications.event_created"));
|
||||
if (sendSchedulingMessages) {
|
||||
toast.success(t("notifications.invitation_sent"));
|
||||
} else {
|
||||
toast.success(t("notifications.event_created"));
|
||||
}
|
||||
}
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
@@ -174,16 +185,26 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
||||
|
||||
const handleDeleteEvent = useCallback(async (id: string) => {
|
||||
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await deleteEvent(client, id);
|
||||
await deleteEvent(client, id, sendSchedulingMessages);
|
||||
toast.success(t("notifications.event_deleted"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, deleteEvent, t]);
|
||||
|
||||
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await rsvpEvent(client, eventId, participantId, status);
|
||||
toast.success(t("notifications.rsvp_updated"));
|
||||
} catch {
|
||||
toast.error(t("notifications.rsvp_error"));
|
||||
}
|
||||
}, [client, rsvpEvent, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -330,7 +351,9 @@ export default function CalendarPage() {
|
||||
defaultDate={defaultModalDate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,8 +51,6 @@ export default function ContactsPage() {
|
||||
addLocalContact,
|
||||
updateLocalContact,
|
||||
deleteLocalContact,
|
||||
getGroups,
|
||||
getIndividuals,
|
||||
getGroupMembers,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
@@ -84,8 +82,8 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
const groups = useMemo(() => getGroups(), [contacts]);
|
||||
const individuals = useMemo(() => getIndividuals(), [contacts]);
|
||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||
|
||||
@@ -862,8 +862,8 @@ export default function Home() {
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: flex grow
|
||||
"md:flex-1 md:relative"
|
||||
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
|
||||
"md:flex-1 md:min-w-0 md:relative"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
|
||||
Reference in New Issue
Block a user