diff --git a/README.md b/README.md index 25c4a828..88483848 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - JMAP Calendar integration (RFC 8984) with capability detection - Month, week, day, and agenda views - Event create, edit, and delete with recurrence rules and reminders +- Participant scheduling with iTIP invitations (organizer/attendee roles, RSVP) +- Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import) - Multi-day events spanning across days, column-based overlap layout - Mini-calendar sidebar with calendar visibility toggles - Locale-aware date formatting (respects user's language) diff --git a/ROADMAP.md b/ROADMAP.md index 15287fa4..4bf7bc71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,6 +146,9 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Event notifications with client-side alert evaluation and toast display - [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch - [x] Configurable notification settings (enable/disable, sound toggle) +- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete) +- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display) +- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete) ### Email Filters - [x] JMAP Sieve Scripts (RFC 9661) with capability detection @@ -178,6 +181,8 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Unit tests for Sieve parser (14 tests) - [x] Unit tests for calendar alerts (36 tests) - [x] Unit tests for calendar notification store (8 tests) +- [x] Unit tests for calendar invitation parsing (25 tests) +- [x] Unit tests for calendar participants (26 tests) - [x] XSS attack vector testing - [x] Playwright E2E framework setup @@ -190,7 +195,6 @@ This document tracks the development status and planned features for JMAP Webmai ## Planned Features ### Advanced Features -- [ ] Participant scheduling with iTIP invitations - [ ] Free/busy queries (Principal/getAvailability) - [ ] Calendar sharing UI (JMAP Sharing RFC 9670) - [ ] Email templates diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 44055266..3c5dcb9c 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -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) => { + const handleSaveEvent = useCallback(async (data: Partial, 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} /> )} diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index ffbd7f63..da6b37d1 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -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) : []; diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index df69ad49..3b790a3c 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 */} diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index d0aaacee..735c5c79 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -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({ {locationName} )} + {getParticipantCount(ev) > 0 && ( +
+ + {getParticipantCount(ev)} +
+ )} {calendar && (
{calendar.name} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index ecaf09a1..74d9bc85 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -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")); } diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 9a8e9b89..4390f3cb 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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")); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 70bc298b..42c915f6 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -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")); } diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index cd8bcf03..3b68d9f4 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -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}
)} + {durationMinutes > 30 && getParticipantCount(event) > 0 && ( +
+ + {getParticipantCount(event)} +
+ )} ); } diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 132b786a..0b415d90 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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) => void; - onDelete?: (id: string) => void; + onSave: (data: Partial, 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; + } 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(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 ( +
+ + ); + } + return (
+
+ + + {isEdit && statusCounts && (existingParticipants.length > 0) && ( +

+ {t("participants.status_summary", { + accepted: statusCounts.accepted, + pending: statusCounts.tentative + statusCounts['needs-action'], + })} +

+ )} +
+
+ + {attendees.length > 0 && ( +
+ setSendInvitations(e.target.checked)} + className="rounded border-input" + /> + +
+ )}
{isEdit && onDelete ? ( showDeleteConfirm ? (
- - {t("form.delete_confirm")} - +
+ + {t("form.delete_confirm")} + + {hasParticipants && ( +

+ {t("participants.cancel_notification")} +

+ )} +
); } + +function StatusBadge({ status, isOrganizer, t }: { + status: CalendarParticipant['participationStatus']; + isOrganizer: boolean; + t: ReturnType; +}) { + if (isOrganizer) { + return {t("participants.organizer")}; + } + const colors: Record = { + 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 = { + accepted: "participants.accepted", + declined: "participants.declined", + tentative: "participants.tentative", + "needs-action": "participants.needs_action", + }; + return {t(labels[status] || labels["needs-action"])}; +} diff --git a/components/calendar/participant-input.tsx b/components/calendar/participant-input.tsx new file mode 100644 index 00000000..3b92474e --- /dev/null +++ b/components/calendar/participant-input.tsx @@ -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([]); + const [activeIndex, setActiveIndex] = useState(-1); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const listRef = useRef(null); + const debounceRef = useRef>(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 ( +
+
+ 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 && ( +
    + {suggestions.map((s, i) => ( +
  • { 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" + }`} + > +
    {s.name || s.email}
    + {s.name && ( +
    {s.email}
    + )} +
  • + ))} +
+ )} +
+ + {participants.length > 0 && ( +
+ {participants.map((p) => ( + + {p.name || p.email} + {!disabled && ( + + )} + + ))} +
+ )} +
+ ); +} diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx new file mode 100644 index 00000000..5fa57f5e --- /dev/null +++ b/components/email/calendar-invitation-banner.tsx @@ -0,0 +1,389 @@ +'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('loading'); + const [parsedEvent, setParsedEvent] = useState | null>(null); + const [rsvpStatus, setRsvpStatus] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [showCalendarPicker, setShowCalendarPicker] = useState(false); + const [selectedCalendarId, setSelectedCalendarId] = useState(''); + + 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 ( +
+ + + {t('loading')} +
+ ); + } + + if (state === 'error') { + return ( +
+ + {t('parse_error')} +
+ ); + } + + if (state === 'imported') { + return ( +
+ + {t('added')} +
+ ); + } + + if (state === 'rsvp-sent') { + return ( +
+ + {t('rsvp_sent')} +
+ ); + } + + return ( +
+ {/* Event info row */} +
+ {isCancellation ? ( + + ) : ( + + )} + +
+
+ + {isCancellation ? t('cancelled_title') : t('title')} + {summary?.title && `: ${summary.title}`} + +
+ +
+ {summary?.start && ( + + + {formatDateTime(summary.start)} + {summary.end && ` – ${formatDateTime(summary.end)}`} + + )} + {summary?.location && ( + + + {summary.location} + + )} + {summary?.organizer && ( + {t('organizer', { name: summary.organizer })} + )} + {summary && summary.attendeeCount > 0 && ( + + + {t('attendees', { count: summary.attendeeCount })} + + )} +
+
+
+ + {/* Action row */} + {!isCancellation && ( +
+ {supportsCalendar && myParticipant && ( + <> + + + + +
+ + )} + + {supportsCalendar && existingEvent && !myParticipant && ( + + + {t('already_in_calendar')} + + )} + + {supportsCalendar && !existingEvent && ( +
+ + + {showCalendarPicker && calendars.length > 1 && ( +
+
+ {t('select_calendar')} +
+ {calendars.map((cal) => ( + + ))} +
+ )} +
+ )} + + {!supportsCalendar && ( + {t('no_calendar')} + )} +
+ )} +
+ ); +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index aebef8c2..a0f2296f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -54,9 +54,11 @@ import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; -import { transformInlineStyles } from "@/lib/color-transform"; +import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; +import { CalendarInvitationBanner } from "./calendar-invitation-banner"; +import { findCalendarAttachment } from "@/lib/calendar-invitation"; interface EmailViewerProps { email: Email | null; @@ -198,7 +200,7 @@ export function EmailViewer({ const { isTablet } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities } = useAuthStore(); - const theme = useThemeStore((state) => state.theme); + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [showFullHeaders, setShowFullHeaders] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); @@ -425,24 +427,23 @@ export function EmailViewer({ if (shouldBlockExternal) { sanitizeConfig.FORBID_TAGS.push('link'); sanitizeConfig.FORBID_ATTR.push('background'); + } - // Hook to modify src attributes - DOMPurify.addHook('afterSanitizeAttributes', (node) => { - const htmlNode = node as HTMLElement; - // Block external images + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + const htmlNode = node as HTMLElement; + + if (shouldBlockExternal) { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { node.setAttribute('data-blocked-src', src); - // Use a subtle transparent placeholder node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); node.setAttribute('alt', ''); - htmlNode.style.display = 'none'; // Hide blocked images completely for cleaner look + htmlNode.style.display = 'none'; blockedExternalContent = true; } } - // Block external stylesheets and resources in style attributes if (htmlNode.style) { const style = htmlNode.style.cssText; if (style && style.includes('url(')) { @@ -452,18 +453,29 @@ export function EmailViewer({ blockedExternalContent = true; } } + } + } - // Transform inline color styles for dark mode readability - if (theme === 'dark') { - const originalStyles = htmlNode.style.cssText; - const transformedStyles = transformInlineStyles(originalStyles, 'dark'); - if (transformedStyles !== originalStyles) { - htmlNode.style.cssText = transformedStyles; - } + if (resolvedTheme === 'dark') { + if (htmlNode.style) { + const originalStyles = htmlNode.style.cssText; + const transformedStyles = transformInlineStyles(originalStyles, 'dark'); + if (transformedStyles !== originalStyles) { + htmlNode.style.cssText = transformedStyles; } } - }); - } + + const colorAttr = node.getAttribute('color'); + if (colorAttr) { + node.setAttribute('color', transformColorForDarkMode(colorAttr)); + } + + const bgcolorAttr = node.getAttribute('bgcolor'); + if (bgcolorAttr) { + node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr)); + } + } + }); // Sanitize HTML to prevent XSS let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); @@ -529,7 +541,7 @@ export function EmailViewer({ html: '

No content available

', isHtml: false }; - }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]); + }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]); // Detect List-Unsubscribe header for newsletter banners const listHeaders = useMemo(() => { @@ -541,6 +553,8 @@ export function EmailViewer({ listHeaders?.listUnsubscribe?.preferred && !dismissedUnsubBanners.has(email?.messageId || ''); + const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false; + // Show loading skeleton while email is being fetched if (isLoading && !email) { return ( @@ -1292,16 +1306,16 @@ export function EmailViewer({
- {/* Unified Notification Banner - External Content + Unsubscribe */} + {/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || - (shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && ( + (shouldShowUnsubBanner && listHeaders?.listUnsubscribe) || + hasCalendarInvitation) && (
-
+
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( -
- {/* Load images button - only in 'ask' mode */} +
{externalContentPolicy === 'ask' && ( )} - {/* Trust sender button - in both 'ask' and 'block' modes */} {email.from?.[0]?.email && (
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 92cca117..b90b2088 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; +import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; +import { useThemeStore } from "@/stores/theme-store"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn } from "@/lib/utils"; @@ -217,6 +219,7 @@ function EmailCard({ onMarkAsRead, }: EmailCardProps) { const t = useTranslations(); + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const sender = email.from?.[0]; const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -271,8 +274,10 @@ function EmailCard({ // Use shared sanitization config as base (more secure) const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; - if (!allowExternal) { - DOMPurify.addHook('afterSanitizeAttributes', (node) => { + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + const htmlNode = node as HTMLElement; + + if (!allowExternal) { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { @@ -290,8 +295,28 @@ function EmailCard({ blockedExternalContent = true; } } - }); - } + } + + if (resolvedTheme === 'dark') { + if (htmlNode.style) { + const originalStyles = htmlNode.style.cssText; + const transformedStyles = transformInlineStyles(originalStyles, 'dark'); + if (transformedStyles !== originalStyles) { + htmlNode.style.cssText = transformedStyles; + } + } + + const colorAttr = node.getAttribute('color'); + if (colorAttr) { + node.setAttribute('color', transformColorForDarkMode(colorAttr)); + } + + const bgcolorAttr = node.getAttribute('bgcolor'); + if (bgcolorAttr) { + node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr)); + } + } + }); const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); DOMPurify.removeHook('afterSanitizeAttributes'); @@ -324,7 +349,7 @@ function EmailCard({ } return { html: "", isHtml: false }; - }, [email, allowExternal]); + }, [email, allowExternal, resolvedTheme]); return (
= {}): Email { + return { + id: 'e1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 1024, + receivedAt: '2026-02-17T10:00:00Z', + hasAttachment: false, + ...overrides, + }; +} + +function makeParticipant(overrides: Partial = {}): CalendarParticipant { + return { + '@type': 'Participant', + name: 'Test', + email: 'test@example.com', + description: null, + sendTo: null, + kind: 'individual', + roles: { attendee: true }, + participationStatus: 'needs-action', + participationComment: null, + expectReply: false, + scheduleAgent: 'server', + scheduleForceSend: false, + scheduleId: null, + scheduleSequence: 0, + scheduleStatus: null, + scheduleUpdated: null, + invitedBy: null, + delegatedTo: null, + delegatedFrom: null, + memberOf: null, + locationId: null, + language: null, + links: null, + ...overrides, + }; +} + +describe('findCalendarAttachment', () => { + it('finds attachment by MIME type text/calendar', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'b1', size: 500, type: 'text/calendar', name: 'invite.ics' }, + ], + hasAttachment: true, + }); + const result = findCalendarAttachment(email); + expect(result).toBeTruthy(); + expect(result!.blobId).toBe('b1'); + }); + + it('finds attachment by application/ics type', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'b2', size: 500, type: 'application/ics', name: 'event.ics' }, + ], + hasAttachment: true, + }); + expect(findCalendarAttachment(email)?.blobId).toBe('b2'); + }); + + it('finds attachment by .ics file extension', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'b3', size: 500, type: 'application/octet-stream', name: 'meeting.ics' }, + ], + hasAttachment: true, + }); + expect(findCalendarAttachment(email)?.blobId).toBe('b3'); + }); + + it('finds attachment by .ical file extension', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'b4', size: 500, type: 'application/octet-stream', name: 'meeting.ical' }, + ], + hasAttachment: true, + }); + expect(findCalendarAttachment(email)?.blobId).toBe('b4'); + }); + + it('returns null when no calendar attachment exists', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'b5', size: 500, type: 'application/pdf', name: 'doc.pdf' }, + ], + hasAttachment: true, + }); + expect(findCalendarAttachment(email)).toBeNull(); + }); + + it('returns null when attachments is undefined', () => { + const email = makeEmail(); + expect(findCalendarAttachment(email)).toBeNull(); + }); + + it('detects text/calendar in textBody parts', () => { + const email = makeEmail({ + textBody: [ + { partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' }, + ], + }); + const result = findCalendarAttachment(email); + expect(result).toBeTruthy(); + expect(result!.blobId).toBe('tb1'); + }); + + it('prioritizes attachments over textBody', () => { + const email = makeEmail({ + attachments: [ + { partId: '1', blobId: 'att1', size: 500, type: 'text/calendar', name: 'invite.ics' }, + ], + textBody: [ + { partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' }, + ], + hasAttachment: true, + }); + expect(findCalendarAttachment(email)?.blobId).toBe('att1'); + }); +}); + +describe('getInvitationMethod', () => { + it('detects cancel when status is cancelled', () => { + expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel'); + }); + + it('detects request when participants have organizer role', () => { + const event: Partial = { + participants: { + org: makeParticipant({ roles: { owner: true }, name: 'Organizer' }), + att: makeParticipant({ roles: { attendee: true }, name: 'Attendee' }), + }, + }; + expect(getInvitationMethod(event)).toBe('request'); + }); + + it('detects request with chair role', () => { + const event: Partial = { + participants: { + org: makeParticipant({ roles: { chair: true }, name: 'Chair' }), + }, + }; + expect(getInvitationMethod(event)).toBe('request'); + }); + + it('returns unknown when no participants', () => { + expect(getInvitationMethod({})).toBe('unknown'); + }); + + it('returns unknown when participants have no organizer', () => { + const event: Partial = { + participants: { + att: makeParticipant({ roles: { attendee: true } }), + }, + }; + expect(getInvitationMethod(event)).toBe('unknown'); + }); +}); + +describe('formatEventSummary', () => { + it('extracts title from event', () => { + const summary = formatEventSummary({ title: 'Team Sync' }); + expect(summary.title).toBe('Team Sync'); + }); + + it('extracts location from event', () => { + const summary = formatEventSummary({ + locations: { loc1: { '@type': 'Location', name: 'Room A', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } }, + }); + expect(summary.location).toBe('Room A'); + }); + + it('extracts organizer info', () => { + const summary = formatEventSummary({ + participants: { + org: makeParticipant({ roles: { owner: true }, name: 'Alice', email: 'alice@example.com' }), + att: makeParticipant({ roles: { attendee: true }, name: 'Bob' }), + }, + }); + expect(summary.organizer).toBe('Alice'); + expect(summary.organizerEmail).toBe('alice@example.com'); + expect(summary.attendeeCount).toBe(1); + }); + + it('handles missing data gracefully', () => { + const summary = formatEventSummary({}); + expect(summary.title).toBe(''); + expect(summary.start).toBeNull(); + expect(summary.end).toBeNull(); + expect(summary.location).toBeNull(); + expect(summary.organizer).toBeNull(); + expect(summary.attendeeCount).toBe(0); + }); + + it('computes end from start + duration', () => { + const summary = formatEventSummary({ + start: '2026-02-17T10:00:00', + duration: 'PT1H30M', + }); + expect(summary.start).toBe('2026-02-17T10:00:00'); + expect(summary.end).toBeTruthy(); + const endDate = new Date(summary.end!); + expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1); + expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30); + }); + + it('uses utcStart and utcEnd when available', () => { + const summary = formatEventSummary({ + utcStart: '2026-02-17T15:00:00Z', + utcEnd: '2026-02-17T16:00:00Z', + start: '2026-02-17T10:00:00', + }); + expect(summary.start).toBe('2026-02-17T15:00:00Z'); + expect(summary.end).toBe('2026-02-17T16:00:00Z'); + }); +}); + +describe('findParticipantByEmail', () => { + it('finds participant by direct email match', () => { + const event: Partial = { + participants: { + p1: makeParticipant({ email: 'alice@example.com', name: 'Alice' }), + }, + }; + const result = findParticipantByEmail(event, 'alice@example.com'); + expect(result).toBeTruthy(); + expect(result!.id).toBe('p1'); + expect(result!.participant.name).toBe('Alice'); + }); + + it('matches case-insensitively', () => { + const event: Partial = { + participants: { + p1: makeParticipant({ email: 'Alice@Example.COM' }), + }, + }; + expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy(); + }); + + it('finds participant by sendTo mailto', () => { + const event: Partial = { + participants: { + p1: makeParticipant({ email: '', sendTo: { imip: 'mailto:bob@example.com' } }), + }, + }; + const result = findParticipantByEmail(event, 'bob@example.com'); + expect(result).toBeTruthy(); + expect(result!.id).toBe('p1'); + }); + + it('returns null when no match', () => { + const event: Partial = { + participants: { + p1: makeParticipant({ email: 'alice@example.com' }), + }, + }; + expect(findParticipantByEmail(event, 'unknown@example.com')).toBeNull(); + }); + + it('returns null with no participants', () => { + expect(findParticipantByEmail({}, 'test@example.com')).toBeNull(); + }); + + it('returns null with empty email', () => { + const event: Partial = { + participants: { + p1: makeParticipant({ email: 'alice@example.com' }), + }, + }; + expect(findParticipantByEmail(event, '')).toBeNull(); + }); +}); diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts new file mode 100644 index 00000000..0f80ce08 --- /dev/null +++ b/lib/__tests__/calendar-participants.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; +import { + isOrganizer, + getUserParticipantId, + getUserStatus, + getParticipantList, + getStatusCounts, + getParticipantCount, + buildParticipantMap, +} from '@/lib/calendar-participants'; + +function makeEvent(participants: Record> | null = null): CalendarEvent { + return { + '@type': 'Event', + id: 'ev1', + uid: 'uid-ev1', + calendarIds: { cal1: true }, + title: 'Test Event', + description: '', + descriptionContentType: 'text/plain', + start: '2026-03-01T10:00:00', + duration: 'PT1H', + timeZone: 'UTC', + showWithoutTime: false, + status: 'confirmed', + freeBusyStatus: 'busy', + privacy: 'public', + keywords: null, + categories: null, + color: null, + recurrenceId: null, + recurrenceIdTimeZone: null, + recurrenceRules: null, + recurrenceOverrides: null, + excludedRecurrenceRules: null, + useDefaultAlerts: false, + alerts: null, + locations: null, + virtualLocations: null, + links: null, + relatedTo: null, + utcStart: null, + utcEnd: null, + isDraft: false, + isOrigin: true, + sequence: 0, + created: '2026-03-01T09:00:00Z', + updated: '2026-03-01T09:00:00Z', + locale: null, + replyTo: null, + participants: participants as Record | null, + mayInviteSelf: false, + mayInviteOthers: false, + hideAttendees: false, + }; +} + +const orgParticipant: Partial = { + '@type': 'Participant', + name: 'Alice', + email: 'alice@example.com', + roles: { owner: true, attendee: true }, + participationStatus: 'accepted', + scheduleAgent: 'server', + sendTo: { imip: 'mailto:alice@example.com' }, + expectReply: false, + kind: 'individual', +}; + +const attendeeParticipant: Partial = { + '@type': 'Participant', + name: 'Bob', + email: 'bob@example.com', + roles: { attendee: true }, + participationStatus: 'needs-action', + scheduleAgent: 'server', + sendTo: { imip: 'mailto:bob@example.com' }, + expectReply: true, + kind: 'individual', +}; + +const acceptedAttendee: Partial = { + ...attendeeParticipant, + name: 'Carol', + email: 'carol@example.com', + participationStatus: 'accepted', +}; + +const declinedAttendee: Partial = { + ...attendeeParticipant, + name: 'Dave', + email: 'dave@example.com', + participationStatus: 'declined', +}; + +const tentativeAttendee: Partial = { + ...attendeeParticipant, + name: 'Eve', + email: 'eve@example.com', + participationStatus: 'tentative', +}; + +describe('isOrganizer', () => { + it('returns true when user email matches organizer', () => { + const event = makeEvent({ org: orgParticipant }); + expect(isOrganizer(event, ['alice@example.com'])).toBe(true); + }); + + it('returns true with case-insensitive match', () => { + const event = makeEvent({ org: orgParticipant }); + expect(isOrganizer(event, ['ALICE@EXAMPLE.COM'])).toBe(true); + }); + + it('returns false when user is not organizer', () => { + const event = makeEvent({ org: orgParticipant }); + expect(isOrganizer(event, ['bob@example.com'])).toBe(false); + }); + + it('returns false when no participants', () => { + const event = makeEvent(null); + expect(isOrganizer(event, ['alice@example.com'])).toBe(false); + }); + + it('returns true when user has multiple emails and one matches', () => { + const event = makeEvent({ org: orgParticipant }); + expect(isOrganizer(event, ['other@example.com', 'alice@example.com'])).toBe(true); + }); + + it('returns false when empty user emails', () => { + const event = makeEvent({ org: orgParticipant }); + expect(isOrganizer(event, [])).toBe(false); + }); +}); + +describe('getUserParticipantId', () => { + it('returns the participant ID for the user', () => { + const event = makeEvent({ + org: orgParticipant, + att1: attendeeParticipant, + }); + expect(getUserParticipantId(event, ['bob@example.com'])).toBe('att1'); + }); + + it('returns organizer ID when user is organizer', () => { + const event = makeEvent({ org: orgParticipant }); + expect(getUserParticipantId(event, ['alice@example.com'])).toBe('org'); + }); + + it('returns null when user not found', () => { + const event = makeEvent({ org: orgParticipant }); + expect(getUserParticipantId(event, ['unknown@example.com'])).toBeNull(); + }); + + it('returns null when no participants', () => { + const event = makeEvent(null); + expect(getUserParticipantId(event, ['alice@example.com'])).toBeNull(); + }); + + it('matches case-insensitively', () => { + const event = makeEvent({ att1: attendeeParticipant }); + expect(getUserParticipantId(event, ['BOB@EXAMPLE.COM'])).toBe('att1'); + }); +}); + +describe('getUserStatus', () => { + it('returns the participation status', () => { + const event = makeEvent({ att1: attendeeParticipant }); + expect(getUserStatus(event, ['bob@example.com'])).toBe('needs-action'); + }); + + it('returns accepted for organizer', () => { + const event = makeEvent({ org: orgParticipant }); + expect(getUserStatus(event, ['alice@example.com'])).toBe('accepted'); + }); + + it('returns null when user not found', () => { + const event = makeEvent({ org: orgParticipant }); + expect(getUserStatus(event, ['unknown@example.com'])).toBeNull(); + }); + + it('returns null when no participants', () => { + const event = makeEvent(null); + expect(getUserStatus(event, ['alice@example.com'])).toBeNull(); + }); +}); + +describe('getParticipantList', () => { + it('returns all participants as info objects', () => { + const event = makeEvent({ + org: orgParticipant, + att1: attendeeParticipant, + }); + const list = getParticipantList(event); + expect(list).toHaveLength(2); + expect(list.find(p => p.id === 'org')).toEqual({ + id: 'org', + name: 'Alice', + email: 'alice@example.com', + status: 'accepted', + isOrganizer: true, + }); + expect(list.find(p => p.id === 'att1')).toEqual({ + id: 'att1', + name: 'Bob', + email: 'bob@example.com', + status: 'needs-action', + isOrganizer: false, + }); + }); + + it('returns empty array when no participants', () => { + const event = makeEvent(null); + expect(getParticipantList(event)).toEqual([]); + }); + + it('defaults status to needs-action for missing status', () => { + const event = makeEvent({ + att1: { ...attendeeParticipant, participationStatus: undefined }, + }); + const list = getParticipantList(event); + expect(list[0].status).toBe('needs-action'); + }); +}); + +describe('getStatusCounts', () => { + it('counts statuses correctly', () => { + const event = makeEvent({ + org: orgParticipant, + att1: acceptedAttendee, + att2: declinedAttendee, + att3: tentativeAttendee, + att4: attendeeParticipant, + }); + const counts = getStatusCounts(event); + expect(counts.accepted).toBe(2); + expect(counts.declined).toBe(1); + expect(counts.tentative).toBe(1); + expect(counts['needs-action']).toBe(1); + }); + + it('returns all zeros when no participants', () => { + const event = makeEvent(null); + const counts = getStatusCounts(event); + expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 }); + }); +}); + +describe('getParticipantCount', () => { + it('returns correct count', () => { + const event = makeEvent({ + org: orgParticipant, + att1: attendeeParticipant, + }); + expect(getParticipantCount(event)).toBe(2); + }); + + it('returns 0 when no participants', () => { + const event = makeEvent(null); + expect(getParticipantCount(event)).toBe(0); + }); +}); + +describe('buildParticipantMap', () => { + it('creates organizer and attendees', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [ + { name: 'Bob', email: 'bob@example.com' }, + { name: 'Carol', email: 'carol@example.com' }, + ] + ); + + expect(Object.keys(map)).toHaveLength(3); + + const org = map['organizer']; + expect(org.name).toBe('Alice'); + expect(org.email).toBe('alice@example.com'); + expect(org.roles).toEqual({ owner: true, attendee: true }); + expect(org.participationStatus).toBe('accepted'); + expect(org.scheduleAgent).toBe('server'); + expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' }); + expect(org.expectReply).toBe(false); + + const att0 = map['attendee-0']; + expect(att0.name).toBe('Bob'); + expect(att0.email).toBe('bob@example.com'); + expect(att0.roles).toEqual({ attendee: true }); + expect(att0.participationStatus).toBe('needs-action'); + expect(att0.scheduleAgent).toBe('server'); + expect(att0.expectReply).toBe(true); + + const att1 = map['attendee-1']; + expect(att1.name).toBe('Carol'); + expect(att1.email).toBe('carol@example.com'); + }); + + it('creates only organizer when no attendees', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [] + ); + expect(Object.keys(map)).toHaveLength(1); + expect(map['organizer']).toBeDefined(); + }); + + it('sets @type to Participant for all entries', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: 'Bob', email: 'bob@example.com' }] + ); + Object.values(map).forEach(p => { + expect(p['@type']).toBe('Participant'); + }); + }); + + it('sets kind to individual for all entries', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: 'Bob', email: 'bob@example.com' }] + ); + Object.values(map).forEach(p => { + expect(p.kind).toBe('individual'); + }); + }); +}); diff --git a/lib/__tests__/color-transform.test.ts b/lib/__tests__/color-transform.test.ts index 135b958c..4835d0b5 100644 --- a/lib/__tests__/color-transform.test.ts +++ b/lib/__tests__/color-transform.test.ts @@ -4,6 +4,7 @@ import { getLuminance, isDarkColor, transformColorForDarkMode, + transformBgColorForDarkMode, transformInlineStyles, } from '../color-transform'; @@ -149,7 +150,7 @@ describe('isDarkColor', () => { }); describe('transformColorForDarkMode', () => { - it('should lighten very dark colors', () => { + it('should brighten very dark colors', () => { const original = '#111111'; const transformed = transformColorForDarkMode(original); const originalRgb = parseColor(original)!; @@ -160,11 +161,28 @@ describe('transformColorForDarkMode', () => { expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); }); - it('should transform #333333 to a lighter color', () => { + it('should transform #333333 to a bright color', () => { const transformed = transformColorForDarkMode('#333333'); const rgb = parseColor(transformed)!; const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.4); + expect(luminance).toBeGreaterThan(0.5); + }); + + it('should produce readable results for Google Calendar grays', () => { + const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043']; + for (const color of googleGrays) { + const transformed = transformColorForDarkMode(color); + const rgb = parseColor(transformed)!; + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + expect(luminance).toBeGreaterThan(0.55); + } + }); + + it('should preserve hue for colored text', () => { + const transformed = transformColorForDarkMode('#1a73e8'); + const rgb = parseColor(transformed)!; + expect(rgb.b).toBeGreaterThan(rgb.r); + expect(rgb.b).toBeGreaterThan(rgb.g); }); it('should preserve already light colors', () => { @@ -197,7 +215,7 @@ describe('transformColorForDarkMode', () => { expect(transformColorForDarkMode('inherit')).toBe('inherit'); }); - it('should lighten medium darkness colors', () => { + it('should brighten medium darkness colors', () => { const original = '#646463'; const transformed = transformColorForDarkMode(original); const originalRgb = parseColor(original)!; @@ -207,6 +225,67 @@ describe('transformColorForDarkMode', () => { expect(transformedRgb.g).toBeGreaterThan(originalRgb.g); expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); }); + + it('should ensure minimum contrast for all dark text colors', () => { + const darkTextColors = [ + '#000000', '#111111', '#222222', '#333333', '#444444', + '#555555', '#666666', '#777777', '#888888', + ]; + for (const color of darkTextColors) { + const transformed = transformColorForDarkMode(color); + const rgb = parseColor(transformed)!; + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + expect(luminance).toBeGreaterThan(0.4); + } + }); +}); + +describe('transformBgColorForDarkMode', () => { + it('should darken white backgrounds', () => { + const transformed = transformBgColorForDarkMode('#ffffff'); + const rgb = parseColor(transformed)!; + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + expect(luminance).toBeLessThan(0.1); + }); + + it('should darken light gray backgrounds', () => { + const transformed = transformBgColorForDarkMode('#f8f9fa'); + const rgb = parseColor(transformed)!; + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + expect(luminance).toBeLessThan(0.1); + }); + + it('should preserve already dark backgrounds', () => { + const darkBgs = ['#111111', '#1a1a2e', '#0f172a']; + for (const color of darkBgs) { + expect(transformBgColorForDarkMode(color)).toBe(color); + } + }); + + it('should preserve nearly transparent backgrounds', () => { + const original = 'rgba(255, 255, 255, 0.05)'; + expect(transformBgColorForDarkMode(original)).toBe(original); + }); + + it('should handle invalid colors gracefully', () => { + expect(transformBgColorForDarkMode('invalid')).toBe('invalid'); + expect(transformBgColorForDarkMode('inherit')).toBe('inherit'); + }); + + it('should handle rgba backgrounds', () => { + const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)'); + expect(transformed).toContain('rgba'); + expect(transformed).toContain('0.9'); + const rgb = parseColor(transformed)!; + expect(rgb.r).toBeLessThan(100); + }); + + it('should moderately darken medium backgrounds', () => { + const transformed = transformBgColorForDarkMode('#e0e0e0'); + const rgb = parseColor(transformed)!; + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); + expect(luminance).toBeLessThan(0.3); + }); }); describe('transformInlineStyles', () => { @@ -223,11 +302,23 @@ describe('transformInlineStyles', () => { expect(transformed).toContain('rgb('); }); - it('should transform background-color property', () => { - const original = 'background-color: #111111'; + it('should darken light background-color property', () => { + const original = 'background-color: #ffffff'; const transformed = transformInlineStyles(original, 'dark'); expect(transformed).not.toBe(original); expect(transformed).toContain('background-color:'); + const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + expect(colorMatch).not.toBeNull(); + if (colorMatch) { + const [, r] = colorMatch.map(Number); + expect(r).toBeLessThan(100); + } + }); + + it('should preserve dark background-color unchanged', () => { + const original = 'background-color: #111111'; + const transformed = transformInlineStyles(original, 'dark'); + expect(transformed).toBe(original); }); it('should preserve non-color properties', () => { @@ -238,7 +329,7 @@ describe('transformInlineStyles', () => { }); it('should handle multiple color properties', () => { - const original = 'color: #111111; background-color: #222222; font-weight: bold'; + const original = 'color: #111111; background-color: #ffffff; font-weight: bold'; const transformed = transformInlineStyles(original, 'dark'); expect(transformed).toContain('color:'); expect(transformed).toContain('background-color:'); @@ -256,7 +347,7 @@ describe('transformInlineStyles', () => { expect(transformInlineStyles('invalid', 'dark')).toBe('invalid'); }); - it('should transform the James Clear email colors', () => { + it('should brighten text colors for dark mode readability', () => { const original = 'color: #333333; font-family: Georgia; font-size: 16px'; const transformed = transformInlineStyles(original, 'dark'); @@ -268,14 +359,14 @@ describe('transformInlineStyles', () => { if (colorMatch) { const [, r, g, b] = colorMatch.map(Number); - expect(r).toBeGreaterThan(51); - expect(g).toBeGreaterThan(51); - expect(b).toBeGreaterThan(51); + expect(r).toBeGreaterThan(180); + expect(g).toBeGreaterThan(180); + expect(b).toBeGreaterThan(180); } }); - it('should handle background shorthand with color', () => { - const original = 'background: #333333'; + it('should darken background shorthand with color', () => { + const original = 'background: #ffffff'; const transformed = transformInlineStyles(original, 'dark'); expect(transformed).not.toBe(original); expect(transformed).toContain('background:'); @@ -293,4 +384,22 @@ describe('transformInlineStyles', () => { expect(transformed).not.toBe(original); expect(transformed).toContain('border-color:'); }); + + it('should produce good contrast for Google Calendar emails', () => { + const original = 'color: #5f6368; background-color: #f8f9fa'; + const transformed = transformInlineStyles(original, 'dark'); + + const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + + expect(textMatch).not.toBeNull(); + expect(bgMatch).not.toBeNull(); + + if (textMatch && bgMatch) { + const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]); + const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]); + const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05); + expect(contrast).toBeGreaterThan(4.5); + } + }); }); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts new file mode 100644 index 00000000..c3b556c5 --- /dev/null +++ b/lib/calendar-invitation.ts @@ -0,0 +1,146 @@ +import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; + +export function findCalendarAttachment(email: Email): Attachment | null { + if (email.attachments) { + for (const att of email.attachments) { + if ( + att.type === 'text/calendar' || + att.type === 'application/ics' || + att.name?.toLowerCase().endsWith('.ics') || + att.name?.toLowerCase().endsWith('.ical') + ) { + return att; + } + } + } + + if (email.textBody) { + for (const part of email.textBody) { + if (part.type === 'text/calendar' && part.blobId) { + return { + partId: part.partId, + blobId: part.blobId, + size: part.size, + name: part.name || 'invite.ics', + type: 'text/calendar', + }; + } + } + } + + return null; +} + +export function getInvitationMethod( + event: Partial +): 'request' | 'reply' | 'cancel' | 'unknown' { + if (event.status === 'cancelled') { + return 'cancel'; + } + + if (event.participants && Object.keys(event.participants).length > 0) { + const hasOrganizer = Object.values(event.participants).some( + (p: CalendarParticipant) => p.roles?.owner || p.roles?.chair + ); + if (hasOrganizer) { + return 'request'; + } + } + + return 'unknown'; +} + +export interface EventSummary { + title: string; + start: string | null; + end: string | null; + location: string | null; + organizer: string | null; + organizerEmail: string | null; + attendeeCount: number; +} + +export function formatEventSummary(event: Partial): EventSummary { + let location: string | null = null; + if (event.locations) { + const firstLocation = Object.values(event.locations)[0]; + if (firstLocation?.name) { + location = firstLocation.name; + } + } + + let organizer: string | null = null; + let organizerEmail: string | null = null; + let attendeeCount = 0; + + if (event.participants) { + for (const p of Object.values(event.participants)) { + if (p.roles?.owner || p.roles?.chair) { + organizer = p.name || p.email || null; + organizerEmail = p.email || null; + } + if (p.roles?.attendee) { + attendeeCount++; + } + } + } + + let end: string | null = null; + if (event.utcEnd) { + end = event.utcEnd; + } else if (event.start && event.duration) { + end = addDurationToDate(event.start, event.duration, event.timeZone); + } + + return { + title: event.title || '', + start: event.utcStart || event.start || null, + end, + location, + organizer, + organizerEmail, + attendeeCount, + }; +} + +function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null { + const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); + if (!match) return null; + + const days = parseInt(match[1] || '0'); + const hours = parseInt(match[2] || '0'); + const minutes = parseInt(match[3] || '0'); + const seconds = parseInt(match[4] || '0'); + + const date = new Date(start); + if (isNaN(date.getTime())) return null; + + date.setDate(date.getDate() + days); + date.setHours(date.getHours() + hours); + date.setMinutes(date.getMinutes() + minutes); + date.setSeconds(date.getSeconds() + seconds); + + return date.toISOString(); +} + +export function findParticipantByEmail( + event: Partial, + email: string +): { id: string; participant: CalendarParticipant } | null { + if (!event.participants || !email) return null; + + const lowerEmail = email.toLowerCase(); + for (const [id, p] of Object.entries(event.participants)) { + if (p.email?.toLowerCase() === lowerEmail) { + return { id, participant: p }; + } + if (p.sendTo) { + for (const addr of Object.values(p.sendTo)) { + if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) { + return { id, participant: p }; + } + } + } + } + return null; +} diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts new file mode 100644 index 00000000..a2f25364 --- /dev/null +++ b/lib/calendar-participants.ts @@ -0,0 +1,106 @@ +import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; + +export interface ParticipantInfo { + id: string; + name: string; + email: string; + status: CalendarParticipant['participationStatus']; + isOrganizer: boolean; +} + +export interface StatusCounts { + accepted: number; + declined: number; + tentative: number; + 'needs-action': number; +} + +export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean { + if (!event.participants) return false; + const lower = userEmails.map(e => e.toLowerCase()); + return Object.values(event.participants).some(p => + p.roles?.owner && lower.includes(p.email?.toLowerCase()) + ); +} + +export function getUserParticipantId(event: CalendarEvent, userEmails: string[]): string | null { + if (!event.participants) return null; + const lower = userEmails.map(e => e.toLowerCase()); + for (const [id, p] of Object.entries(event.participants)) { + if (lower.includes(p.email?.toLowerCase())) return id; + } + return null; +} + +export function getUserStatus( + event: CalendarEvent, + userEmails: string[] +): CalendarParticipant['participationStatus'] | null { + if (!event.participants) return null; + const lower = userEmails.map(e => e.toLowerCase()); + for (const p of Object.values(event.participants)) { + if (lower.includes(p.email?.toLowerCase())) return p.participationStatus; + } + return null; +} + +export function getParticipantList(event: CalendarEvent): ParticipantInfo[] { + if (!event.participants) return []; + return Object.entries(event.participants).map(([id, p]) => ({ + id, + name: p.name || '', + email: p.email || '', + status: p.participationStatus || 'needs-action', + isOrganizer: !!p.roles?.owner, + })); +} + +export function getStatusCounts(event: CalendarEvent): StatusCounts { + const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 }; + if (!event.participants) return counts; + for (const p of Object.values(event.participants)) { + const s = p.participationStatus || 'needs-action'; + if (s in counts) counts[s as keyof StatusCounts]++; + } + return counts; +} + +export function getParticipantCount(event: CalendarEvent): number { + if (!event.participants) return 0; + return Object.keys(event.participants).length; +} + +export function buildParticipantMap( + organizer: { name: string; email: string }, + attendees: { name: string; email: string }[] +): Record> { + const participants: Record> = {}; + + participants['organizer'] = { + '@type': 'Participant', + name: organizer.name, + email: organizer.email, + roles: { owner: true, attendee: true }, + participationStatus: 'accepted', + scheduleAgent: 'server', + sendTo: { imip: `mailto:${organizer.email}` }, + expectReply: false, + kind: 'individual', + }; + + attendees.forEach((a, i) => { + participants[`attendee-${i}`] = { + '@type': 'Participant', + name: a.name, + email: a.email, + roles: { attendee: true }, + participationStatus: 'needs-action', + scheduleAgent: 'server', + sendTo: { imip: `mailto:${a.email}` }, + expectReply: true, + kind: 'individual', + }; + }); + + return participants; +} diff --git a/lib/color-transform.ts b/lib/color-transform.ts index 418970f0..e6b30722 100644 --- a/lib/color-transform.ts +++ b/lib/color-transform.ts @@ -131,29 +131,37 @@ export function transformColorForDarkMode(colorString: string): string { const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - if (luminance < 0.4) { - const invR = 255 - rgb.r; - const invG = 255 - rgb.g; - const invB = 255 - rgb.b; + if (luminance >= 0.6) return colorString; - const boost = 1.3; - const r = Math.min(255, Math.round(invR * boost)); - const g = Math.min(255, Math.round(invG * boost)); - const b = Math.min(255, Math.round(invB * boost)); + const blendFactor = 0.85 - (luminance / 0.6) * 0.55; - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; + const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor)); + const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor)); + const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor)); + + return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; +} + +export function transformBgColorForDarkMode(colorString: string): string { + const rgb = parseColor(colorString); + if (!rgb) return colorString; + + if (rgb.a !== undefined && rgb.a < 0.1) { + return colorString; } - if (luminance >= 0.4 && luminance < 0.6) { - const factor = 1.5; - const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * factor * 0.4)); - const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * factor * 0.4)); - const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * factor * 0.4)); + const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; - } + if (luminance < 0.2) return colorString; - return colorString; + const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125); + const darkR = 30, darkG = 31, darkB = 38; + + const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor)); + const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor)); + const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor)); + + return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; } export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string { @@ -180,7 +188,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): if (property === 'background-color') { const hasImportant = value.includes('!important'); const colorValue = value.replace('!important', '').trim(); - const transformed = transformColorForDarkMode(colorValue); + const transformed = transformBgColorForDarkMode(colorValue); return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; } @@ -189,7 +197,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): if (colorMatch) { const hasImportant = value.includes('!important'); const originalColor = colorMatch[0]; - const transformed = transformColorForDarkMode(originalColor); + const transformed = transformBgColorForDarkMode(originalColor); const newValue = value.replace(originalColor, transformed); return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`; } diff --git a/locales/de/common.json b/locales/de/common.json index e65f817e..de901514 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -225,6 +225,23 @@ "success_mailto": "Abmeldeanfrage an Ihr E-Mail-Programm gesendet", "error": "Abmeldung nicht möglich", "dismiss": "Schließen" + }, + "calendar_invitation": { + "loading": "Veranstaltungsdetails werden geladen…", + "title": "Kalendereinladung", + "cancelled_title": "Veranstaltung abgesagt", + "organizer": "Organisiert von {name}", + "attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}", + "accept": "Annehmen", + "maybe": "Vielleicht", + "decline": "Ablehnen", + "add_to_calendar": "Zum Kalender hinzufügen", + "added": "Zum Kalender hinzugefügt", + "rsvp_sent": "Antwort gesendet", + "parse_error": "Einladung konnte nicht gelesen werden", + "no_calendar": "Kalender nicht verfügbar", + "select_calendar": "Kalender auswählen", + "already_in_calendar": "Bereits in deinem Kalender" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Zugesagt", "declined": "Abgelehnt", "tentative": "Vorläufig", - "needs_action": "Antwort ausstehend" + "needs_action": "Antwort ausstehend", + "remove": "Entfernen", + "email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen", + "send_invitations": "Einladungen an Teilnehmer senden", + "status_summary": "{accepted} zugesagt, {pending} ausstehend", + "cancel_notification": "Die Teilnehmer werden über die Absage benachrichtigt", + "you_organizer": "Sie sind der Organisator", + "you_attendee": "Sie sind ein Teilnehmer", + "no_participants": "Keine Teilnehmer", + "count": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}" }, "recurrence": { "title": "Wiederholung", @@ -1128,7 +1154,10 @@ "event_move_error": "Termin konnte nicht verschoben werden", "alert_title": "Bevorstehender Termin", "alert_now": "Beginnt jetzt", - "alert_in_minutes": "In {count} Min." + "alert_in_minutes": "In {count} Min.", + "invitation_sent": "Einladungen gesendet", + "rsvp_updated": "Antwort aktualisiert", + "rsvp_error": "Antwort konnte nicht aktualisiert werden" }, "status": { "loading_calendars": "Kalender werden geladen...", diff --git a/locales/en/common.json b/locales/en/common.json index 23930882..8a919e87 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -225,6 +225,23 @@ "success_mailto": "Unsubscribe request sent to your email client", "error": "Unable to unsubscribe", "dismiss": "Dismiss" + }, + "calendar_invitation": { + "loading": "Loading event details…", + "title": "Calendar Invitation", + "cancelled_title": "Event Cancelled", + "organizer": "Organized by {name}", + "attendees": "{count, plural, one {# attendee} other {# attendees}}", + "accept": "Accept", + "maybe": "Maybe", + "decline": "Decline", + "add_to_calendar": "Add to calendar", + "added": "Added to calendar", + "rsvp_sent": "Response sent", + "parse_error": "Could not read invitation", + "no_calendar": "Calendar not available", + "select_calendar": "Select calendar", + "already_in_calendar": "Already in your calendar" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Accepted", "declined": "Declined", "tentative": "Tentative", - "needs_action": "Needs action" + "needs_action": "Needs action", + "remove": "Remove", + "email_placeholder": "Add email address or search contacts", + "send_invitations": "Send invitations to participants", + "status_summary": "{accepted} accepted, {pending} pending", + "cancel_notification": "Participants will be notified of the cancellation", + "you_organizer": "You are the organizer", + "you_attendee": "You are an attendee", + "no_participants": "No participants", + "count": "{count, plural, one {# participant} other {# participants}}" }, "recurrence": { "title": "Recurrence", @@ -1128,7 +1154,10 @@ "event_move_error": "Failed to move event", "alert_title": "Upcoming event", "alert_now": "Starting now", - "alert_in_minutes": "In {count} min" + "alert_in_minutes": "In {count} min", + "invitation_sent": "Invitations sent", + "rsvp_updated": "Response updated", + "rsvp_error": "Failed to update response" }, "status": { "loading_calendars": "Loading calendars...", diff --git a/locales/es/common.json b/locales/es/common.json index a7b3ac79..b070d6fc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -225,6 +225,23 @@ "success_mailto": "Solicitud de cancelación enviada a su cliente de correo", "error": "No se pudo cancelar la suscripción", "dismiss": "Descartar" + }, + "calendar_invitation": { + "loading": "Cargando detalles del evento…", + "title": "Invitación de calendario", + "cancelled_title": "Evento cancelado", + "organizer": "Organizado por {name}", + "attendees": "{count, plural, one {# asistente} other {# asistentes}}", + "accept": "Aceptar", + "maybe": "Quizás", + "decline": "Rechazar", + "add_to_calendar": "Añadir al calendario", + "added": "Añadido al calendario", + "rsvp_sent": "Respuesta enviada", + "parse_error": "No se pudo leer la invitación", + "no_calendar": "Calendario no disponible", + "select_calendar": "Seleccionar calendario", + "already_in_calendar": "Ya está en tu calendario" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Aceptado", "declined": "Rechazado", "tentative": "Provisional", - "needs_action": "Pendiente de respuesta" + "needs_action": "Pendiente de respuesta", + "remove": "Eliminar", + "email_placeholder": "Añadir dirección de correo o buscar contactos", + "send_invitations": "Enviar invitaciones a los participantes", + "status_summary": "{accepted} aceptado(s), {pending} pendiente(s)", + "cancel_notification": "Se notificará a los participantes de la cancelación", + "you_organizer": "Eres el organizador", + "you_attendee": "Eres un participante", + "no_participants": "Sin participantes", + "count": "{count, plural, one {# participante} other {# participantes}}" }, "recurrence": { "title": "Recurrencia", @@ -1128,7 +1154,10 @@ "event_move_error": "Error al mover el evento", "alert_title": "Evento próximo", "alert_now": "Comienza ahora", - "alert_in_minutes": "En {count} min" + "alert_in_minutes": "En {count} min", + "invitation_sent": "Invitaciones enviadas", + "rsvp_updated": "Respuesta actualizada", + "rsvp_error": "Error al actualizar la respuesta" }, "status": { "loading_calendars": "Cargando calendarios...", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9b0f79f7..9f99b3fb 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -225,6 +225,23 @@ "success_mailto": "Demande de désabonnement envoyée à votre client mail", "error": "Impossible de se désabonner", "dismiss": "Ignorer" + }, + "calendar_invitation": { + "loading": "Chargement des détails…", + "title": "Invitation calendrier", + "cancelled_title": "Événement annulé", + "organizer": "Organisé par {name}", + "attendees": "{count, plural, one {# participant} other {# participants}}", + "accept": "Accepter", + "maybe": "Peut-être", + "decline": "Refuser", + "add_to_calendar": "Ajouter au calendrier", + "added": "Ajouté au calendrier", + "rsvp_sent": "Réponse envoyée", + "parse_error": "Impossible de lire l'invitation", + "no_calendar": "Calendrier non disponible", + "select_calendar": "Choisir un calendrier", + "already_in_calendar": "Déjà dans votre calendrier" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Accepté", "declined": "Refusé", "tentative": "Provisoire", - "needs_action": "En attente de réponse" + "needs_action": "En attente de réponse", + "remove": "Retirer", + "email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts", + "send_invitations": "Envoyer les invitations aux participants", + "status_summary": "{accepted} accepté(s), {pending} en attente", + "cancel_notification": "Les participants seront informés de l'annulation", + "you_organizer": "Vous êtes l'organisateur", + "you_attendee": "Vous êtes un participant", + "no_participants": "Aucun participant", + "count": "{count, plural, one {# participant} other {# participants}}" }, "recurrence": { "title": "Récurrence", @@ -1128,7 +1154,10 @@ "event_move_error": "Échec du déplacement de l'événement", "alert_title": "Événement à venir", "alert_now": "Commence maintenant", - "alert_in_minutes": "Dans {count} min" + "alert_in_minutes": "Dans {count} min", + "invitation_sent": "Invitations envoyées", + "rsvp_updated": "Réponse mise à jour", + "rsvp_error": "Échec de la mise à jour de la réponse" }, "status": { "loading_calendars": "Chargement des calendriers...", diff --git a/locales/it/common.json b/locales/it/common.json index bc21c3a3..030a193c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -225,6 +225,23 @@ "success_mailto": "Richiesta di annullamento iscrizione inviata al tuo client email", "error": "Impossibile annullare l'iscrizione", "dismiss": "Ignora" + }, + "calendar_invitation": { + "loading": "Caricamento dettagli evento…", + "title": "Invito calendario", + "cancelled_title": "Evento annullato", + "organizer": "Organizzato da {name}", + "attendees": "{count, plural, one {# partecipante} other {# partecipanti}}", + "accept": "Accetta", + "maybe": "Forse", + "decline": "Rifiuta", + "add_to_calendar": "Aggiungi al calendario", + "added": "Aggiunto al calendario", + "rsvp_sent": "Risposta inviata", + "parse_error": "Impossibile leggere l'invito", + "no_calendar": "Calendario non disponibile", + "select_calendar": "Seleziona calendario", + "already_in_calendar": "Già nel tuo calendario" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Accettato", "declined": "Rifiutato", "tentative": "Provvisorio", - "needs_action": "In attesa di risposta" + "needs_action": "In attesa di risposta", + "remove": "Rimuovi", + "email_placeholder": "Aggiungi indirizzo email o cerca contatti", + "send_invitations": "Invia inviti ai partecipanti", + "status_summary": "{accepted} accettato/i, {pending} in attesa", + "cancel_notification": "I partecipanti saranno avvisati della cancellazione", + "you_organizer": "Sei l'organizzatore", + "you_attendee": "Sei un partecipante", + "no_participants": "Nessun partecipante", + "count": "{count, plural, one {# partecipante} other {# partecipanti}}" }, "recurrence": { "title": "Ricorrenza", @@ -1128,7 +1154,10 @@ "event_move_error": "Spostamento dell'evento non riuscito", "alert_title": "Evento in arrivo", "alert_now": "Inizia ora", - "alert_in_minutes": "Tra {count} min" + "alert_in_minutes": "Tra {count} min", + "invitation_sent": "Inviti inviati", + "rsvp_updated": "Risposta aggiornata", + "rsvp_error": "Impossibile aggiornare la risposta" }, "status": { "loading_calendars": "Caricamento calendari...", diff --git a/locales/ja/common.json b/locales/ja/common.json index 82e28585..7cea7a3f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -225,6 +225,23 @@ "success_mailto": "購読解除リクエストをメールクライアントに送信しました", "error": "購読解除できませんでした", "dismiss": "閉じる" + }, + "calendar_invitation": { + "loading": "イベント詳細を読み込み中…", + "title": "カレンダー招待", + "cancelled_title": "イベントがキャンセルされました", + "organizer": "{name} が主催", + "attendees": "{count}名の参加者", + "accept": "承諾", + "maybe": "未定", + "decline": "辞退", + "add_to_calendar": "カレンダーに追加", + "added": "カレンダーに追加しました", + "rsvp_sent": "回答を送信しました", + "parse_error": "招待を読み込めませんでした", + "no_calendar": "カレンダーが利用できません", + "select_calendar": "カレンダーを選択", + "already_in_calendar": "カレンダーに登録済み" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "承諾", "declined": "辞退", "tentative": "仮承諾", - "needs_action": "未回答" + "needs_action": "未回答", + "remove": "削除", + "email_placeholder": "メールアドレスを追加または連絡先を検索", + "send_invitations": "参加者に招待を送信", + "status_summary": "{accepted}人承諾、{pending}人保留", + "cancel_notification": "キャンセルの通知が参加者に送信されます", + "you_organizer": "あなたは主催者です", + "you_attendee": "あなたは参加者です", + "no_participants": "参加者なし", + "count": "{count}人の参加者" }, "recurrence": { "title": "繰り返し", @@ -1128,7 +1154,10 @@ "event_move_error": "イベントの移動に失敗しました", "alert_title": "予定のイベント", "alert_now": "まもなく開始", - "alert_in_minutes": "{count}分後" + "alert_in_minutes": "{count}分後", + "invitation_sent": "招待を送信しました", + "rsvp_updated": "回答を更新しました", + "rsvp_error": "回答の更新に失敗しました" }, "status": { "loading_calendars": "カレンダーを読み込み中...", diff --git a/locales/nl/common.json b/locales/nl/common.json index c2e877c9..1cfba298 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -225,6 +225,23 @@ "success_mailto": "Uitschrijfverzoek verzonden naar je e-mailclient", "error": "Kan niet uitschrijven", "dismiss": "Sluiten" + }, + "calendar_invitation": { + "loading": "Evenementdetails laden…", + "title": "Agenda-uitnodiging", + "cancelled_title": "Evenement geannuleerd", + "organizer": "Georganiseerd door {name}", + "attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}", + "accept": "Accepteren", + "maybe": "Misschien", + "decline": "Weigeren", + "add_to_calendar": "Toevoegen aan agenda", + "added": "Toegevoegd aan agenda", + "rsvp_sent": "Reactie verzonden", + "parse_error": "Kan uitnodiging niet lezen", + "no_calendar": "Agenda niet beschikbaar", + "select_calendar": "Agenda selecteren", + "already_in_calendar": "Staat al in je agenda" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Geaccepteerd", "declined": "Geweigerd", "tentative": "Voorlopig", - "needs_action": "Reactie vereist" + "needs_action": "Reactie vereist", + "remove": "Verwijderen", + "email_placeholder": "E-mailadres toevoegen of contacten zoeken", + "send_invitations": "Uitnodigingen sturen naar deelnemers", + "status_summary": "{accepted} geaccepteerd, {pending} in afwachting", + "cancel_notification": "Deelnemers worden op de hoogte gebracht van de annulering", + "you_organizer": "Je bent de organisator", + "you_attendee": "Je bent een deelnemer", + "no_participants": "Geen deelnemers", + "count": "{count, plural, one {# deelnemer} other {# deelnemers}}" }, "recurrence": { "title": "Herhaling", @@ -1128,7 +1154,10 @@ "event_move_error": "Evenement verplaatsen mislukt", "alert_title": "Aankomend evenement", "alert_now": "Begint nu", - "alert_in_minutes": "Over {count} min" + "alert_in_minutes": "Over {count} min", + "invitation_sent": "Uitnodigingen verzonden", + "rsvp_updated": "Reactie bijgewerkt", + "rsvp_error": "Reactie kon niet worden bijgewerkt" }, "status": { "loading_calendars": "Agenda's laden...", diff --git a/locales/pt/common.json b/locales/pt/common.json index 50fe5e55..f0898fef 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -225,6 +225,23 @@ "success_mailto": "Solicitação de cancelamento enviada para seu cliente de e-mail", "error": "Não foi possível cancelar a inscrição", "dismiss": "Dispensar" + }, + "calendar_invitation": { + "loading": "Carregando detalhes do evento…", + "title": "Convite de calendário", + "cancelled_title": "Evento cancelado", + "organizer": "Organizado por {name}", + "attendees": "{count, plural, one {# participante} other {# participantes}}", + "accept": "Aceitar", + "maybe": "Talvez", + "decline": "Recusar", + "add_to_calendar": "Adicionar ao calendário", + "added": "Adicionado ao calendário", + "rsvp_sent": "Resposta enviada", + "parse_error": "Não foi possível ler o convite", + "no_calendar": "Calendário não disponível", + "select_calendar": "Selecionar calendário", + "already_in_calendar": "Já está no seu calendário" } }, "email_composer": { @@ -1066,7 +1083,16 @@ "accepted": "Aceito", "declined": "Recusado", "tentative": "Provisório", - "needs_action": "Aguardando resposta" + "needs_action": "Aguardando resposta", + "remove": "Remover", + "email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos", + "send_invitations": "Enviar convites aos participantes", + "status_summary": "{accepted} aceito(s), {pending} pendente(s)", + "cancel_notification": "Os participantes serão notificados do cancelamento", + "you_organizer": "Você é o organizador", + "you_attendee": "Você é um participante", + "no_participants": "Sem participantes", + "count": "{count, plural, one {# participante} other {# participantes}}" }, "recurrence": { "title": "Recorrência", @@ -1128,7 +1154,10 @@ "event_move_error": "Falha ao mover o evento", "alert_title": "Evento próximo", "alert_now": "Começa agora", - "alert_in_minutes": "Em {count} min" + "alert_in_minutes": "Em {count} min", + "invitation_sent": "Convites enviados", + "rsvp_updated": "Resposta atualizada", + "rsvp_error": "Falha ao atualizar resposta" }, "status": { "loading_calendars": "Carregando calendários...", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index b3998c68..6e21d548 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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; fetchEvents: (client: JMAPClient, start: string, end: string) => Promise; - createEvent: (client: JMAPClient, event: Partial) => Promise; - updateEvent: (client: JMAPClient, id: string, updates: Partial) => Promise; - deleteEvent: (client: JMAPClient, id: string) => Promise; + createEvent: (client: JMAPClient, event: Partial, sendSchedulingMessages?: boolean) => Promise; + updateEvent: (client: JMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise; + deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; + rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise; importEvents: (client: JMAPClient, events: Partial[], calendarId: string) => Promise; setSelectedDate: (date: Date) => void; setViewMode: (mode: CalendarViewMode) => void; @@ -59,12 +60,12 @@ export const useCalendarStore = create()( 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()( 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()( } }, - 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()( } }, - 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()( } }, + 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, + 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; try { + let cleanParticipants: Record | null = null; + if (src.participants) { + cleanParticipants = {}; + for (const [key, p] of Object.entries(src.participants)) { + const participant: Record = { + '@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 = { - ...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)[k]; + if (v === undefined || v === null) delete (data as Record)[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,