Files
SRCmail/components/email/calendar-invitation-banner.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df 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.
2026-02-17 01:09:57 +01:00

390 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Calendar,
CalendarCheck,
CalendarX,
Clock,
MapPin,
Users,
Loader2,
Check,
HelpCircle,
X,
AlertCircle,
ChevronDown,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import type { Email, CalendarEvent } from '@/lib/jmap/types';
import {
findCalendarAttachment,
getInvitationMethod,
formatEventSummary,
findParticipantByEmail,
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card';
interface CalendarInvitationBannerProps {
email: Email;
}
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const t = useTranslations('email_viewer.calendar_invitation');
const format = useFormatter();
const client = useAuthStore((s) => s.client);
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
const { calendars, supportsCalendar, importEvents, rsvpEvent, events: storeEvents } = useCalendarStore();
const [state, setState] = useState<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const attachment = findCalendarAttachment(email);
const parseEvent = useCallback(async () => {
if (!client || !attachment) return;
setState('loading');
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
setState('error');
}
} catch {
setState('error');
}
}, [client, attachment, supportsCalendar]);
useEffect(() => {
if (attachment) {
parseEvent();
}
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (calendars.length > 0 && !selectedCalendarId) {
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
setSelectedCalendarId(defaultCal.id);
}
}, [calendars, selectedCalendarId]);
if (!attachment) return null;
const method = parsedEvent ? getInvitationMethod(parsedEvent) : 'unknown';
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const existingEvent = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid)
: null;
const myParticipantParsed = parsedEvent && currentUserEmail
? findParticipantByEmail(parsedEvent, currentUserEmail)
: null;
const myParticipantServer = existingEvent && currentUserEmail
? findParticipantByEmail(existingEvent, currentUserEmail)
: null;
const myParticipant = myParticipantServer || myParticipantParsed;
const currentRsvp = rsvpStatus
|| myParticipantServer?.participant.participationStatus
|| myParticipantParsed?.participant.participationStatus
|| null;
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
if (!client || !parsedEvent || isProcessing) return;
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
setIsProcessing(true);
try {
if (existingEvent && myParticipant) {
await rsvpEvent(client, existingEvent.id, myParticipant.id, status);
setRsvpStatus(status);
setState('rsvp-sent');
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
);
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status);
}
setRsvpStatus(status);
setState('rsvp-sent');
} else {
setState('error');
}
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const handleImport = async (calendarId?: string) => {
const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
if (!client || !parsedEvent || !calId || isProcessing) {
if (!calId) setState('error');
return;
}
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
if (count > 0) {
setState('imported');
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return format.dateTime(date, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
if (state === 'loading') {
return (
<div className="flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-primary" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('loading')}</span>
</div>
);
}
if (state === 'error') {
return (
<div className="flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
</div>
);
}
if (state === 'imported') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('added')}</span>
</div>
);
}
if (state === 'rsvp-sent') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('rsvp_sent')}</span>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{/* Event info row */}
<div className="flex items-start gap-2 flex-wrap">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn(
"text-sm font-medium",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{isCancellation ? t('cancelled_title') : t('title')}
{summary?.title && `: ${summary.title}`}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{summary?.start && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
)}
{summary?.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{summary.location}
</span>
)}
{summary?.organizer && (
<span>{t('organizer', { name: summary.organizer })}</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{t('attendees', { count: summary.attendeeCount })}
</span>
)}
</div>
</div>
</div>
{/* Action row */}
{!isCancellation && (
<div className="flex items-center gap-2 ml-6 flex-wrap">
{supportsCalendar && myParticipant && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'accepted'
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'tentative'
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'declined'
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{supportsCalendar && existingEvent && !myParticipant && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
{t('already_in_calendar')}
</span>
)}
{supportsCalendar && !existingEvent && (
<div className="relative">
<button
onClick={() => {
if (calendars.length <= 1) {
handleImport();
} else {
setShowCalendarPicker(!showCalendarPicker);
}
}}
disabled={isProcessing}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button>
{showCalendarPicker && calendars.length > 1 && (
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-md shadow-lg border border-border z-10 py-1">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>
)}
</div>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
</div>
)}
</div>
);
}