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.
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
'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>
|
||
);
|
||
}
|