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:
Matthieu MALVACHE
2026-02-17 01:09:57 +01:00
committed by Matthieu MALVACHE
parent 74763b384d
commit b65b2f26df
30 changed files with 2352 additions and 137 deletions
+8 -1
View File
@@ -3,9 +3,10 @@
import { useMemo } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { Calendar as CalendarIcon, MapPin } from "lucide-react";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
interface CalendarAgendaViewProps {
@@ -179,6 +180,12 @@ export function CalendarAgendaView({
<span className="truncate">{locationName}</span>
</div>
)}
{getParticipantCount(ev) > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Users className="w-3 h-3 flex-shrink-0" />
<span>{getParticipantCount(ev)}</span>
</div>
)}
{calendar && (
<div className="text-xs text-muted-foreground mt-0.5">
{calendar.name}
+3 -1
View File
@@ -180,7 +180,9 @@ export function CalendarDayView({
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
toast.error(t("notifications.event_move_error"));
}
+3 -1
View File
@@ -123,7 +123,9 @@ export function CalendarMonthView({
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
toast.error(t("notifications.event_move_error"));
}
+3 -1
View File
@@ -214,7 +214,9 @@ export function CalendarWeekView({
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
toast.error(t("notifications.event_move_error"));
}
+8
View File
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
interface EventCardProps {
event: CalendarEvent;
@@ -138,6 +140,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
{timeString}
</div>
)}
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
<Users className="w-3 h-3" />
<span>{getParticipantCount(event)}</span>
</div>
)}
</button>
);
}
+257 -13
View File
@@ -1,21 +1,32 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2 } from "lucide-react";
import { X, Trash2, Check, HelpCircle, XCircle, Users } from "lucide-react";
import { format, parseISO, addHours } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card";
import { ParticipantInput } from "./participant-input";
import {
isOrganizer,
getUserParticipantId,
getUserStatus,
getParticipantList,
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
interface EventModalProps {
event?: CalendarEvent | null;
calendars: Calendar[];
defaultDate?: Date;
onSave: (data: Partial<CalendarEvent>) => void;
onDelete?: (id: string) => void;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
currentUserEmails?: string[];
}
function formatDateInput(d: Date): string {
@@ -50,11 +61,39 @@ export function EventModal({
defaultDate,
onSave,
onDelete,
onRsvp,
onClose,
currentUserEmails = [],
}: EventModalProps) {
const t = useTranslations("calendar");
const isEdit = !!event;
const userIsOrganizer = useMemo(() => {
if (!event) return true;
if (!event.participants) return true;
return isOrganizer(event, currentUserEmails);
}, [event, currentUserEmails]);
const isAttendeeMode = useMemo(() => {
if (!event || !event.participants) return false;
return !event.isOrigin && !userIsOrganizer;
}, [event, userIsOrganizer]);
const userParticipantId = useMemo(() => {
if (!event) return null;
return getUserParticipantId(event, currentUserEmails);
}, [event, currentUserEmails]);
const userCurrentStatus = useMemo(() => {
if (!event) return null;
return getUserStatus(event, currentUserEmails);
}, [event, currentUserEmails]);
const existingParticipants = useMemo(() => {
if (!event) return [];
return getParticipantList(event);
}, [event]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (defaultDate) {
@@ -114,6 +153,27 @@ export function EventModal({
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
if (!event?.participants) return [];
return existingParticipants
.filter(p => !p.isOrganizer)
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const statusCounts = useMemo(() => {
if (!event?.participants) return null;
return getStatusCounts(event);
}, [event]);
const handleAddAttendee = useCallback((p: { name: string; email: string }) => {
setAttendees(prev => [...prev, p]);
}, []);
const handleRemoveAttendee = useCallback((email: string) => {
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
}, []);
const handleSave = useCallback(() => {
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
@@ -202,8 +262,26 @@ export function EventModal({
};
}
onSave(data);
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, onSave]);
if (attendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0];
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail },
attendees
) as Record<string, CalendarParticipant>;
} else if (attendees.length === 0 && event?.participants) {
data.participants = null;
}
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
onSave(data, shouldSendScheduling);
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
onRsvp(event.id, userParticipantId, status);
onClose();
}, [event, userParticipantId, onRsvp, onClose]);
const modalRef = useRef<HTMLDivElement>(null);
@@ -212,12 +290,12 @@ export function EventModal({
if (e.key === "Escape") onClose();
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleSave();
if (!isAttendeeMode) handleSave();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, handleSave]);
}, [onClose, handleSave, isAttendeeMode]);
useEffect(() => {
const modal = modalRef.current;
@@ -243,6 +321,105 @@ export function EventModal({
return () => modal.removeEventListener("keydown", handler);
}, []);
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) {
const startD = parseISO(event.start);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, "HH:mm")} {format(endD, "HH:mm")}
</span>
)}
</div>
{event.description && (
<p className="text-sm text-muted-foreground">{event.description}</p>
)}
{locationName && (
<p className="text-sm text-muted-foreground">{locationName}</p>
)}
<div className="text-xs text-muted-foreground">
{t("participants.you_attendee")}
</div>
{participants.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Users className="w-4 h-4" />
{t("participants.title")}
</div>
<div className="space-y-1 pl-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">{p.name || p.email}</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
</div>
))}
</div>
</div>
)}
</div>
<div className="px-5 py-4 border-t border-border">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">RSVP</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
>
<Check className="w-4 h-4 mr-1" />
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
>
<HelpCircle className="w-4 h-4 mr-1" />
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "outline"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
>
<XCircle className="w-4 h-4 mr-1" />
{t("participants.declined")}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
@@ -290,6 +467,28 @@ export function EventModal({
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
{t("participants.title")}
</span>
</label>
<ParticipantInput
participants={attendees}
onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee}
/>
{isEdit && statusCounts && (existingParticipants.length > 0) && (
<p className="text-xs text-muted-foreground mt-1.5">
{t("participants.status_summary", {
accepted: statusCounts.accepted,
pending: statusCounts.tentative + statusCounts['needs-action'],
})}
</p>
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
@@ -393,19 +592,41 @@ export function EventModal({
</select>
</div>
</div>
{attendees.length > 0 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sendInvitations"
checked={sendInvitations}
onChange={(e) => setSendInvitations(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="sendInvitations" className="text-sm">
{t("participants.send_invitations")}
</label>
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
{isEdit && onDelete ? (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id); onClose(); }}
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
@@ -442,3 +663,26 @@ export function EventModal({
</div>
);
}
function StatusBadge({ status, isOrganizer, t }: {
status: CalendarParticipant['participationStatus'];
isOrganizer: boolean;
t: ReturnType<typeof useTranslations>;
}) {
if (isOrganizer) {
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
}
const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400",
declined: "text-red-600 dark:text-red-400",
tentative: "text-amber-600 dark:text-amber-400",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
name: string;
email: string;
}
interface ParticipantInputProps {
participants: Participant[];
onAdd: (participant: Participant) => void;
onRemove: (email: string) => void;
disabled?: boolean;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
const t = useTranslations("calendar.participants");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateSuggestions = useCallback((q: string) => {
if (q.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
const results = useContactStore.getState().getAutocomplete(q);
const existing = new Set(participants.map(p => p.email.toLowerCase()));
const filtered = results.filter(r => !existing.has(r.email.toLowerCase()));
setSuggestions(filtered.slice(0, 8));
setShowSuggestions(filtered.length > 0);
setActiveIndex(-1);
}, [participants]);
const handleInputChange = useCallback((value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => updateSuggestions(value), 200);
}, [updateSuggestions]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const addParticipant = useCallback((p: Participant) => {
if (!p.email || !EMAIL_REGEX.test(p.email)) return;
if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return;
onAdd(p);
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
inputRef.current?.focus();
}, [participants, onAdd]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" && showSuggestions) {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp" && showSuggestions) {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < suggestions.length) {
addParticipant(suggestions[activeIndex]);
} else if (query.trim() && EMAIL_REGEX.test(query.trim())) {
addParticipant({ name: "", email: query.trim() });
}
} else if (e.key === "Escape") {
setShowSuggestions(false);
}
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
setTimeout(() => setShowSuggestions(false), 200);
}, []);
return (
<div className="space-y-2">
<div className="relative">
<Input
ref={inputRef}
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }}
onBlur={handleBlur}
placeholder={t("email_placeholder")}
disabled={disabled}
role="combobox"
aria-expanded={showSuggestions}
aria-controls="participant-suggestions"
aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined}
aria-autocomplete="list"
/>
{showSuggestions && (
<ul
ref={listRef}
id="participant-suggestions"
role="listbox"
className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
>
{suggestions.map((s, i) => (
<li
key={s.email}
id={`suggestion-${i}`}
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</li>
))}
</ul>
)}
</div>
{participants.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{participants.map((p) => (
<span
key={p.email}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
<span className="truncate">{p.name || p.email}</span>
{!disabled && (
<button
type="button"
onClick={() => onRemove(p.email)}
className="flex-shrink-0 p-0.5 rounded-full hover:bg-muted-foreground/20 transition-colors min-w-[20px] min-h-[20px] flex items-center justify-center"
aria-label={`${t("remove")} ${p.name || p.email}`}
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
</div>
);
}