feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants, scheduling messages, and inline calendar invitation banner in email viewer with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
This commit is contained in:
@@ -61,6 +61,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
|||||||
- JMAP Calendar integration (RFC 8984) with capability detection
|
- JMAP Calendar integration (RFC 8984) with capability detection
|
||||||
- Month, week, day, and agenda views
|
- Month, week, day, and agenda views
|
||||||
- Event create, edit, and delete with recurrence rules and reminders
|
- 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
|
- Multi-day events spanning across days, column-based overlap layout
|
||||||
- Mini-calendar sidebar with calendar visibility toggles
|
- Mini-calendar sidebar with calendar visibility toggles
|
||||||
- Locale-aware date formatting (respects user's language)
|
- Locale-aware date formatting (respects user's language)
|
||||||
|
|||||||
+5
-1
@@ -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] Event notifications with client-side alert evaluation and toast display
|
||||||
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
||||||
- [x] Configurable notification settings (enable/disable, sound toggle)
|
- [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
|
### Email Filters
|
||||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
- [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 Sieve parser (14 tests)
|
||||||
- [x] Unit tests for calendar alerts (36 tests)
|
- [x] Unit tests for calendar alerts (36 tests)
|
||||||
- [x] Unit tests for calendar notification store (8 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] XSS attack vector testing
|
||||||
- [x] Playwright E2E framework setup
|
- [x] Playwright E2E framework setup
|
||||||
|
|
||||||
@@ -190,7 +195,6 @@ This document tracks the development status and planned features for JMAP Webmai
|
|||||||
## Planned Features
|
## Planned Features
|
||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
- [ ] Participant scheduling with iTIP invitations
|
|
||||||
- [ ] Free/busy queries (Principal/getAvailability)
|
- [ ] Free/busy queries (Principal/getAvailability)
|
||||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
||||||
- [ ] Email templates
|
- [ ] Email templates
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
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 { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal } from "@/components/calendar/event-modal";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-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() {
|
export default function CalendarPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,10 +33,16 @@ export default function CalendarPage() {
|
|||||||
const {
|
const {
|
||||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility,
|
setSelectedDate, setViewMode, toggleCalendarVisibility,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||||
|
const { identities } = useIdentityStore();
|
||||||
|
|
||||||
|
const currentUserEmails = useMemo(() =>
|
||||||
|
identities.map(id => id.email).filter(Boolean),
|
||||||
|
[identities]
|
||||||
|
);
|
||||||
|
|
||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
@@ -100,7 +107,7 @@ export default function CalendarPage() {
|
|||||||
if (client && calendars.length > 0) {
|
if (client && calendars.length > 0) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
}
|
}
|
||||||
}, [client, calendars.length, selectedCalendarIds, dateRange, fetchEvents]);
|
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||||
|
|
||||||
const navigatePrev = useCallback(() => {
|
const navigatePrev = useCallback(() => {
|
||||||
let next: Date;
|
let next: Date;
|
||||||
@@ -153,19 +160,23 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
if (editEvent) {
|
if (editEvent) {
|
||||||
await updateEvent(client, editEvent.id, data);
|
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
const created = await createEvent(client, data);
|
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(t("notifications.event_created"));
|
if (sendSchedulingMessages) {
|
||||||
|
toast.success(t("notifications.invitation_sent"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("notifications.event_created"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowEventModal(false);
|
setShowEventModal(false);
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
@@ -174,16 +185,26 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
}, [client, editEvent, createEvent, updateEvent, t]);
|
||||||
|
|
||||||
const handleDeleteEvent = useCallback(async (id: string) => {
|
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
await deleteEvent(client, id);
|
await deleteEvent(client, id, sendSchedulingMessages);
|
||||||
toast.success(t("notifications.event_deleted"));
|
toast.success(t("notifications.event_deleted"));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
}
|
}
|
||||||
}, [client, deleteEvent, t]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -330,7 +351,9 @@ export default function CalendarPage() {
|
|||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||||
|
currentUserEmails={currentUserEmails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ export default function ContactsPage() {
|
|||||||
addLocalContact,
|
addLocalContact,
|
||||||
updateLocalContact,
|
updateLocalContact,
|
||||||
deleteLocalContact,
|
deleteLocalContact,
|
||||||
getGroups,
|
|
||||||
getIndividuals,
|
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
createGroup,
|
createGroup,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
@@ -84,8 +82,8 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts]);
|
||||||
|
|
||||||
const groups = useMemo(() => getGroups(), [contacts]);
|
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||||
const individuals = useMemo(() => getIndividuals(), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||||
|
|||||||
@@ -862,8 +862,8 @@ export default function Home() {
|
|||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: flex grow
|
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
|
||||||
"md:flex-1 md:relative"
|
"md:flex-1 md:min-w-0 md:relative"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
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 { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface CalendarAgendaViewProps {
|
interface CalendarAgendaViewProps {
|
||||||
@@ -179,6 +180,12 @@ export function CalendarAgendaView({
|
|||||||
<span className="truncate">{locationName}</span>
|
<span className="truncate">{locationName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{getParticipantCount(ev) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||||
|
<Users className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>{getParticipantCount(ev)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{calendar && (
|
{calendar && (
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
{calendar.name}
|
{calendar.name}
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ export function CalendarDayView({
|
|||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
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 {
|
} catch {
|
||||||
toast.error(t("notifications.event_move_error"));
|
toast.error(t("notifications.event_move_error"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ export function CalendarMonthView({
|
|||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
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 {
|
} catch {
|
||||||
toast.error(t("notifications.event_move_error"));
|
toast.error(t("notifications.event_move_error"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,9 @@ export function CalendarWeekView({
|
|||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
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 {
|
} catch {
|
||||||
toast.error(t("notifications.event_move_error"));
|
toast.error(t("notifications.event_move_error"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
@@ -138,6 +140,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
|||||||
{timeString}
|
{timeString}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
|
||||||
|
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
<span>{getParticipantCount(event)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { 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 { parseDuration } from "./event-card";
|
||||||
|
import { ParticipantInput } from "./participant-input";
|
||||||
|
import {
|
||||||
|
isOrganizer,
|
||||||
|
getUserParticipantId,
|
||||||
|
getUserStatus,
|
||||||
|
getParticipantList,
|
||||||
|
getStatusCounts,
|
||||||
|
buildParticipantMap,
|
||||||
|
} from "@/lib/calendar-participants";
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
onSave: (data: Partial<CalendarEvent>) => void;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
currentUserEmails?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateInput(d: Date): string {
|
function formatDateInput(d: Date): string {
|
||||||
@@ -50,11 +61,39 @@ export function EventModal({
|
|||||||
defaultDate,
|
defaultDate,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
currentUserEmails = [],
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const isEdit = !!event;
|
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 => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return parseISO(event.start);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
@@ -114,6 +153,27 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
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 handleSave = useCallback(() => {
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
if (!trimmedTitle) return;
|
if (!trimmedTitle) return;
|
||||||
@@ -202,8 +262,26 @@ export function EventModal({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(data);
|
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, onSave]);
|
const organizerEmail = currentUserEmails[0];
|
||||||
|
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||||
|
data.participants = buildParticipantMap(
|
||||||
|
{ name: organizerName, email: organizerEmail },
|
||||||
|
attendees
|
||||||
|
) as Record<string, CalendarParticipant>;
|
||||||
|
} else if (attendees.length === 0 && event?.participants) {
|
||||||
|
data.participants = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||||
|
onSave(data, shouldSendScheduling);
|
||||||
|
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
|
||||||
|
|
||||||
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
|
onRsvp(event.id, userParticipantId, status);
|
||||||
|
onClose();
|
||||||
|
}, [event, userParticipantId, onRsvp, onClose]);
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -212,12 +290,12 @@ export function EventModal({
|
|||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSave();
|
if (!isAttendeeMode) handleSave();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose, handleSave]);
|
}, [onClose, handleSave, isAttendeeMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const modal = modalRef.current;
|
const modal = modalRef.current;
|
||||||
@@ -243,6 +321,105 @@ export function EventModal({
|
|||||||
return () => modal.removeEventListener("keydown", handler);
|
return () => modal.removeEventListener("keydown", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
|
if (isAttendeeMode && event) {
|
||||||
|
const startD = parseISO(event.start);
|
||||||
|
const durMin = parseDuration(event.duration);
|
||||||
|
const endD = new Date(startD.getTime() + durMin * 60000);
|
||||||
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||||
|
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 space-y-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||||
|
{!event.showWithoutTime && (
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationName && (
|
||||||
|
<p className="text-sm text-muted-foreground">{locationName}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("participants.you_attendee")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{t("participants.title")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-5">
|
||||||
|
{participants.map(p => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
|
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">RSVP</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
|
onClick={() => handleRsvp("accepted")}
|
||||||
|
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
{t("participants.accepted")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
|
onClick={() => handleRsvp("tentative")}
|
||||||
|
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4 mr-1" />
|
||||||
|
{t("participants.tentative")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "declined" ? "default" : "outline"}
|
||||||
|
onClick={() => handleRsvp("declined")}
|
||||||
|
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-1" />
|
||||||
|
{t("participants.declined")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||||
@@ -290,6 +467,28 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{t("participants.title")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<ParticipantInput
|
||||||
|
participants={attendees}
|
||||||
|
onAdd={handleAddAttendee}
|
||||||
|
onRemove={handleRemoveAttendee}
|
||||||
|
/>
|
||||||
|
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
{t("participants.status_summary", {
|
||||||
|
accepted: statusCounts.accepted,
|
||||||
|
pending: statusCounts.tentative + statusCounts['needs-action'],
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -393,19 +592,41 @@ export function EventModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{attendees.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sendInvitations"
|
||||||
|
checked={sendInvitations}
|
||||||
|
onChange={(e) => setSendInvitations(e.target.checked)}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
<label htmlFor="sendInvitations" className="text-sm">
|
||||||
|
{t("participants.send_invitations")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
||||||
{isEdit && onDelete ? (
|
{isEdit && onDelete ? (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<div>
|
||||||
{t("form.delete_confirm")}
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
</span>
|
{t("form.delete_confirm")}
|
||||||
|
</span>
|
||||||
|
{hasParticipants && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("participants.cancel_notification")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { onDelete(event!.id); onClose(); }}
|
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
>
|
>
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
@@ -442,3 +663,26 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status, isOrganizer, t }: {
|
||||||
|
status: CalendarParticipant['participationStatus'];
|
||||||
|
isOrganizer: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
if (isOrganizer) {
|
||||||
|
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
||||||
|
}
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
accepted: "text-green-600 dark:text-green-400",
|
||||||
|
declined: "text-red-600 dark:text-red-400",
|
||||||
|
tentative: "text-amber-600 dark:text-amber-400",
|
||||||
|
"needs-action": "text-muted-foreground",
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
accepted: "participants.accepted",
|
||||||
|
declined: "participants.declined",
|
||||||
|
tentative: "participants.tentative",
|
||||||
|
"needs-action": "participants.needs_action",
|
||||||
|
};
|
||||||
|
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParticipantInputProps {
|
||||||
|
participants: Participant[];
|
||||||
|
onAdd: (participant: Participant) => void;
|
||||||
|
onRemove: (email: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
||||||
|
const t = useTranslations("calendar.participants");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
const updateSuggestions = useCallback((q: string) => {
|
||||||
|
if (q.length < 2) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = useContactStore.getState().getAutocomplete(q);
|
||||||
|
const existing = new Set(participants.map(p => p.email.toLowerCase()));
|
||||||
|
const filtered = results.filter(r => !existing.has(r.email.toLowerCase()));
|
||||||
|
setSuggestions(filtered.slice(0, 8));
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => updateSuggestions(value), 200);
|
||||||
|
}, [updateSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addParticipant = useCallback((p: Participant) => {
|
||||||
|
if (!p.email || !EMAIL_REGEX.test(p.email)) return;
|
||||||
|
if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return;
|
||||||
|
onAdd(p);
|
||||||
|
setQuery("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [participants, onAdd]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown" && showSuggestions) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp" && showSuggestions) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(i => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||||
|
addParticipant(suggestions[activeIndex]);
|
||||||
|
} else if (query.trim() && EMAIL_REGEX.test(query.trim())) {
|
||||||
|
addParticipant({ name: "", email: query.trim() });
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={t("email_placeholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={showSuggestions}
|
||||||
|
aria-controls="participant-suggestions"
|
||||||
|
aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSuggestions && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
id="participant-suggestions"
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={s.email}
|
||||||
|
id={`suggestion-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
||||||
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||||
|
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{s.name || s.email}</div>
|
||||||
|
{s.name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{participants.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.email}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||||
|
>
|
||||||
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(p.email)}
|
||||||
|
className="flex-shrink-0 p-0.5 rounded-full hover:bg-muted-foreground/20 transition-colors min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||||
|
aria-label={`${t("remove")} ${p.name || p.email}`}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<BannerState>('loading');
|
||||||
|
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||||
|
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
|
|
||||||
|
const attachment = findCalendarAttachment(email);
|
||||||
|
|
||||||
|
const parseEvent = useCallback(async () => {
|
||||||
|
if (!client || !attachment) return;
|
||||||
|
setState('loading');
|
||||||
|
try {
|
||||||
|
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
|
||||||
|
if (events.length > 0) {
|
||||||
|
const parsed = events[0];
|
||||||
|
setParsedEvent(parsed);
|
||||||
|
if (parsed.uid && supportsCalendar) {
|
||||||
|
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||||
|
if (!storeHasIt) {
|
||||||
|
try {
|
||||||
|
const serverEvents = await client.queryCalendarEvents({});
|
||||||
|
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
|
||||||
|
if (matching.length > 0) {
|
||||||
|
useCalendarStore.setState((s) => {
|
||||||
|
const existingIds = new Set(s.events.map((e) => e.id));
|
||||||
|
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||||
|
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore lookup failure */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState('parsed');
|
||||||
|
} else {
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
}, [client, attachment, supportsCalendar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachment) {
|
||||||
|
parseEvent();
|
||||||
|
}
|
||||||
|
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (calendars.length > 0 && !selectedCalendarId) {
|
||||||
|
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
|
||||||
|
setSelectedCalendarId(defaultCal.id);
|
||||||
|
}
|
||||||
|
}, [calendars, selectedCalendarId]);
|
||||||
|
|
||||||
|
if (!attachment) return null;
|
||||||
|
|
||||||
|
const method = parsedEvent ? getInvitationMethod(parsedEvent) : 'unknown';
|
||||||
|
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||||
|
const isCancellation = method === 'cancel';
|
||||||
|
|
||||||
|
const existingEvent = parsedEvent?.uid
|
||||||
|
? storeEvents.find((e) => e.uid === parsedEvent.uid)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const myParticipantParsed = parsedEvent && currentUserEmail
|
||||||
|
? findParticipantByEmail(parsedEvent, currentUserEmail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const myParticipantServer = existingEvent && currentUserEmail
|
||||||
|
? findParticipantByEmail(existingEvent, currentUserEmail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const myParticipant = myParticipantServer || myParticipantParsed;
|
||||||
|
|
||||||
|
const currentRsvp = rsvpStatus
|
||||||
|
|| myParticipantServer?.participant.participationStatus
|
||||||
|
|| myParticipantParsed?.participant.participationStatus
|
||||||
|
|| null;
|
||||||
|
|
||||||
|
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
|
||||||
|
if (!client || !parsedEvent || isProcessing) return;
|
||||||
|
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existingEvent && myParticipant) {
|
||||||
|
await rsvpEvent(client, existingEvent.id, myParticipant.id, status);
|
||||||
|
setRsvpStatus(status);
|
||||||
|
setState('rsvp-sent');
|
||||||
|
} else if (calId) {
|
||||||
|
const imported = await importEvents(client, [parsedEvent], calId);
|
||||||
|
if (imported > 0) {
|
||||||
|
const newEvent = useCalendarStore.getState().events.find(
|
||||||
|
(e) => e.uid === parsedEvent.uid
|
||||||
|
);
|
||||||
|
const participant = myParticipant
|
||||||
|
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
||||||
|
if (newEvent && participant) {
|
||||||
|
await rsvpEvent(client, newEvent.id, participant.id, status);
|
||||||
|
}
|
||||||
|
setRsvpStatus(status);
|
||||||
|
setState('rsvp-sent');
|
||||||
|
} else {
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState('error');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (calendarId?: string) => {
|
||||||
|
const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
|
||||||
|
if (!client || !parsedEvent || !calId || isProcessing) {
|
||||||
|
if (!calId) setState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const count = await importEvents(client, [parsedEvent], calId);
|
||||||
|
if (count > 0) {
|
||||||
|
setState('imported');
|
||||||
|
} else {
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState('error');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
return format.dateTime(date, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('loading')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'imported') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('added')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'rsvp-sent') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('rsvp_sent')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Event info row */}
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
{isCancellation ? (
|
||||||
|
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Calendar className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{isCancellation ? t('cancelled_title') : t('title')}
|
||||||
|
{summary?.title && `: ${summary.title}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||||||
|
{summary?.start && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatDateTime(summary.start)}
|
||||||
|
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary?.location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{summary.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary?.organizer && (
|
||||||
|
<span>{t('organizer', { name: summary.organizer })}</span>
|
||||||
|
)}
|
||||||
|
{summary && summary.attendeeCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{t('attendees', { count: summary.attendeeCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
{!isCancellation && (
|
||||||
|
<div className="flex items-center gap-2 ml-6 flex-wrap">
|
||||||
|
{supportsCalendar && myParticipant && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRsvp('accepted')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
aria-pressed={currentRsvp === 'accepted'}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||||
|
currentRsvp === 'accepted'
|
||||||
|
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
{t('accept')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRsvp('tentative')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
aria-pressed={currentRsvp === 'tentative'}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||||
|
currentRsvp === 'tentative'
|
||||||
|
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('maybe')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRsvp('declined')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
aria-pressed={currentRsvp === 'declined'}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||||
|
currentRsvp === 'declined'
|
||||||
|
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
{t('decline')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supportsCalendar && existingEvent && !myParticipant && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
|
||||||
|
{t('already_in_calendar')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supportsCalendar && !existingEvent && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (calendars.length <= 1) {
|
||||||
|
handleImport();
|
||||||
|
} else {
|
||||||
|
setShowCalendarPicker(!showCalendarPicker);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CalendarCheck className="w-3.5 h-3.5" />
|
||||||
|
{t('add_to_calendar')}
|
||||||
|
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCalendarPicker && calendars.length > 1 && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-md shadow-lg border border-border z-10 py-1">
|
||||||
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{t('select_calendar')}
|
||||||
|
</div>
|
||||||
|
{calendars.map((cal) => (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => {
|
||||||
|
setShowCalendarPicker(false);
|
||||||
|
handleImport(cal.id);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: sanitizeColor(cal.color) }}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-foreground">{cal.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!supportsCalendar && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,9 +54,11 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useThemeStore } from "@/stores/theme-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 { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
|
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -198,7 +200,7 @@ export function EmailViewer({
|
|||||||
const { isTablet } = useDeviceDetection();
|
const { isTablet } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
@@ -425,24 +427,23 @@ export function EmailViewer({
|
|||||||
if (shouldBlockExternal) {
|
if (shouldBlockExternal) {
|
||||||
sanitizeConfig.FORBID_TAGS.push('link');
|
sanitizeConfig.FORBID_TAGS.push('link');
|
||||||
sanitizeConfig.FORBID_ATTR.push('background');
|
sanitizeConfig.FORBID_ATTR.push('background');
|
||||||
|
}
|
||||||
|
|
||||||
// Hook to modify src attributes
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
const htmlNode = node as HTMLElement;
|
||||||
const htmlNode = node as HTMLElement;
|
|
||||||
// Block external images
|
if (shouldBlockExternal) {
|
||||||
if (node.tagName === 'IMG') {
|
if (node.tagName === 'IMG') {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||||
node.setAttribute('data-blocked-src', src);
|
node.setAttribute('data-blocked-src', src);
|
||||||
// Use a subtle transparent placeholder
|
|
||||||
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
||||||
node.setAttribute('alt', '');
|
node.setAttribute('alt', '');
|
||||||
htmlNode.style.display = 'none'; // Hide blocked images completely for cleaner look
|
htmlNode.style.display = 'none';
|
||||||
blockedExternalContent = true;
|
blockedExternalContent = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block external stylesheets and resources in style attributes
|
|
||||||
if (htmlNode.style) {
|
if (htmlNode.style) {
|
||||||
const style = htmlNode.style.cssText;
|
const style = htmlNode.style.cssText;
|
||||||
if (style && style.includes('url(')) {
|
if (style && style.includes('url(')) {
|
||||||
@@ -452,18 +453,29 @@ export function EmailViewer({
|
|||||||
blockedExternalContent = true;
|
blockedExternalContent = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Transform inline color styles for dark mode readability
|
if (resolvedTheme === 'dark') {
|
||||||
if (theme === 'dark') {
|
if (htmlNode.style) {
|
||||||
const originalStyles = htmlNode.style.cssText;
|
const originalStyles = htmlNode.style.cssText;
|
||||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||||
if (transformedStyles !== originalStyles) {
|
if (transformedStyles !== originalStyles) {
|
||||||
htmlNode.style.cssText = transformedStyles;
|
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
|
// Sanitize HTML to prevent XSS
|
||||||
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||||
@@ -529,7 +541,7 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
|
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
|
||||||
|
|
||||||
// Detect List-Unsubscribe header for newsletter banners
|
// Detect List-Unsubscribe header for newsletter banners
|
||||||
const listHeaders = useMemo(() => {
|
const listHeaders = useMemo(() => {
|
||||||
@@ -541,6 +553,8 @@ export function EmailViewer({
|
|||||||
listHeaders?.listUnsubscribe?.preferred &&
|
listHeaders?.listUnsubscribe?.preferred &&
|
||||||
!dismissedUnsubBanners.has(email?.messageId || '');
|
!dismissedUnsubBanners.has(email?.messageId || '');
|
||||||
|
|
||||||
|
const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false;
|
||||||
|
|
||||||
// Show loading skeleton while email is being fetched
|
// Show loading skeleton while email is being fetched
|
||||||
if (isLoading && !email) {
|
if (isLoading && !email) {
|
||||||
return (
|
return (
|
||||||
@@ -1292,16 +1306,16 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unified Notification Banner - External Content + Unsubscribe */}
|
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
|
||||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
|
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe) ||
|
||||||
|
hasCalendarInvitation) && (
|
||||||
<div className="border-b border-border bg-muted/30 isolate">
|
<div className="border-b border-border bg-muted/30 isolate">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
|
<div className="flex flex-col gap-3 isolate">
|
||||||
{/* External Content Controls */}
|
{/* External Content Controls */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap md:justify-center">
|
||||||
{/* Load images button - only in 'ask' mode */}
|
|
||||||
{externalContentPolicy === 'ask' && (
|
{externalContentPolicy === 'ask' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAllowExternalContent(true)}
|
onClick={() => setAllowExternalContent(true)}
|
||||||
@@ -1311,7 +1325,6 @@ export function EmailViewer({
|
|||||||
{t('load_external_content')}
|
{t('load_external_content')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Trust sender button - in both 'ask' and 'block' modes */}
|
|
||||||
{email.from?.[0]?.email && (
|
{email.from?.[0]?.email && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1331,16 +1344,23 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Unsubscribe Controls */}
|
{/* Unsubscribe Controls */}
|
||||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||||
<UnsubscribeBanner
|
<div className="flex items-center md:justify-center">
|
||||||
listUnsubscribe={listHeaders.listUnsubscribe}
|
<UnsubscribeBanner
|
||||||
senderEmail={email?.from?.[0]?.email || ''}
|
listUnsubscribe={listHeaders.listUnsubscribe}
|
||||||
onDismiss={() => {
|
senderEmail={email?.from?.[0]?.email || ''}
|
||||||
const messageId = email?.messageId || '';
|
onDismiss={() => {
|
||||||
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
const messageId = email?.messageId || '';
|
||||||
setDismissedUnsubBanners(newSet);
|
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
||||||
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
setDismissedUnsubBanners(newSet);
|
||||||
}}
|
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Calendar Invitation Banner */}
|
||||||
|
{hasCalendarInvitation && (
|
||||||
|
<CalendarInvitationBanner email={email} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
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 { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||||
@@ -217,6 +219,7 @@ function EmailCard({
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
}: EmailCardProps) {
|
}: EmailCardProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
@@ -271,8 +274,10 @@ function EmailCard({
|
|||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
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') {
|
if (node.tagName === 'IMG') {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||||
@@ -290,8 +295,28 @@ function EmailCard({
|
|||||||
blockedExternalContent = true;
|
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);
|
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||||
@@ -324,7 +349,7 @@ function EmailCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { html: "", isHtml: false };
|
return { html: "", isHtml: false };
|
||||||
}, [email, allowExternal]);
|
}, [email, allowExternal, resolvedTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
findCalendarAttachment,
|
||||||
|
getInvitationMethod,
|
||||||
|
formatEventSummary,
|
||||||
|
findParticipantByEmail,
|
||||||
|
} from '../calendar-invitation';
|
||||||
|
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
function makeEmail(overrides: Partial<Email> = {}): 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> = {}): 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<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
participants: {
|
||||||
|
p1: makeParticipant({ email: 'Alice@Example.COM' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds participant by sendTo mailto', () => {
|
||||||
|
const event: Partial<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
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<CalendarEvent> = {
|
||||||
|
participants: {
|
||||||
|
p1: makeParticipant({ email: 'alice@example.com' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(findParticipantByEmail(event, '')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Partial<CalendarParticipant>> | 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<string, CalendarParticipant> | null,
|
||||||
|
mayInviteSelf: false,
|
||||||
|
mayInviteOthers: false,
|
||||||
|
hideAttendees: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgParticipant: Partial<CalendarParticipant> = {
|
||||||
|
'@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<CalendarParticipant> = {
|
||||||
|
'@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<CalendarParticipant> = {
|
||||||
|
...attendeeParticipant,
|
||||||
|
name: 'Carol',
|
||||||
|
email: 'carol@example.com',
|
||||||
|
participationStatus: 'accepted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const declinedAttendee: Partial<CalendarParticipant> = {
|
||||||
|
...attendeeParticipant,
|
||||||
|
name: 'Dave',
|
||||||
|
email: 'dave@example.com',
|
||||||
|
participationStatus: 'declined',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tentativeAttendee: Partial<CalendarParticipant> = {
|
||||||
|
...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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getLuminance,
|
getLuminance,
|
||||||
isDarkColor,
|
isDarkColor,
|
||||||
transformColorForDarkMode,
|
transformColorForDarkMode,
|
||||||
|
transformBgColorForDarkMode,
|
||||||
transformInlineStyles,
|
transformInlineStyles,
|
||||||
} from '../color-transform';
|
} from '../color-transform';
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ describe('isDarkColor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('transformColorForDarkMode', () => {
|
describe('transformColorForDarkMode', () => {
|
||||||
it('should lighten very dark colors', () => {
|
it('should brighten very dark colors', () => {
|
||||||
const original = '#111111';
|
const original = '#111111';
|
||||||
const transformed = transformColorForDarkMode(original);
|
const transformed = transformColorForDarkMode(original);
|
||||||
const originalRgb = parseColor(original)!;
|
const originalRgb = parseColor(original)!;
|
||||||
@@ -160,11 +161,28 @@ describe('transformColorForDarkMode', () => {
|
|||||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
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 transformed = transformColorForDarkMode('#333333');
|
||||||
const rgb = parseColor(transformed)!;
|
const rgb = parseColor(transformed)!;
|
||||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
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', () => {
|
it('should preserve already light colors', () => {
|
||||||
@@ -197,7 +215,7 @@ describe('transformColorForDarkMode', () => {
|
|||||||
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should lighten medium darkness colors', () => {
|
it('should brighten medium darkness colors', () => {
|
||||||
const original = '#646463';
|
const original = '#646463';
|
||||||
const transformed = transformColorForDarkMode(original);
|
const transformed = transformColorForDarkMode(original);
|
||||||
const originalRgb = parseColor(original)!;
|
const originalRgb = parseColor(original)!;
|
||||||
@@ -207,6 +225,67 @@ describe('transformColorForDarkMode', () => {
|
|||||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
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', () => {
|
describe('transformInlineStyles', () => {
|
||||||
@@ -223,11 +302,23 @@ describe('transformInlineStyles', () => {
|
|||||||
expect(transformed).toContain('rgb(');
|
expect(transformed).toContain('rgb(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transform background-color property', () => {
|
it('should darken light background-color property', () => {
|
||||||
const original = 'background-color: #111111';
|
const original = 'background-color: #ffffff';
|
||||||
const transformed = transformInlineStyles(original, 'dark');
|
const transformed = transformInlineStyles(original, 'dark');
|
||||||
expect(transformed).not.toBe(original);
|
expect(transformed).not.toBe(original);
|
||||||
expect(transformed).toContain('background-color:');
|
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', () => {
|
it('should preserve non-color properties', () => {
|
||||||
@@ -238,7 +329,7 @@ describe('transformInlineStyles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple color properties', () => {
|
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');
|
const transformed = transformInlineStyles(original, 'dark');
|
||||||
expect(transformed).toContain('color:');
|
expect(transformed).toContain('color:');
|
||||||
expect(transformed).toContain('background-color:');
|
expect(transformed).toContain('background-color:');
|
||||||
@@ -256,7 +347,7 @@ describe('transformInlineStyles', () => {
|
|||||||
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
|
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 original = 'color: #333333; font-family: Georgia; font-size: 16px';
|
||||||
const transformed = transformInlineStyles(original, 'dark');
|
const transformed = transformInlineStyles(original, 'dark');
|
||||||
|
|
||||||
@@ -268,14 +359,14 @@ describe('transformInlineStyles', () => {
|
|||||||
|
|
||||||
if (colorMatch) {
|
if (colorMatch) {
|
||||||
const [, r, g, b] = colorMatch.map(Number);
|
const [, r, g, b] = colorMatch.map(Number);
|
||||||
expect(r).toBeGreaterThan(51);
|
expect(r).toBeGreaterThan(180);
|
||||||
expect(g).toBeGreaterThan(51);
|
expect(g).toBeGreaterThan(180);
|
||||||
expect(b).toBeGreaterThan(51);
|
expect(b).toBeGreaterThan(180);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle background shorthand with color', () => {
|
it('should darken background shorthand with color', () => {
|
||||||
const original = 'background: #333333';
|
const original = 'background: #ffffff';
|
||||||
const transformed = transformInlineStyles(original, 'dark');
|
const transformed = transformInlineStyles(original, 'dark');
|
||||||
expect(transformed).not.toBe(original);
|
expect(transformed).not.toBe(original);
|
||||||
expect(transformed).toContain('background:');
|
expect(transformed).toContain('background:');
|
||||||
@@ -293,4 +384,22 @@ describe('transformInlineStyles', () => {
|
|||||||
expect(transformed).not.toBe(original);
|
expect(transformed).not.toBe(original);
|
||||||
expect(transformed).toContain('border-color:');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<CalendarEvent>
|
||||||
|
): '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<CalendarEvent>): 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<CalendarEvent>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, Partial<CalendarParticipant>> {
|
||||||
|
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+27
-19
@@ -131,29 +131,37 @@ export function transformColorForDarkMode(colorString: string): string {
|
|||||||
|
|
||||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||||
|
|
||||||
if (luminance < 0.4) {
|
if (luminance >= 0.6) return colorString;
|
||||||
const invR = 255 - rgb.r;
|
|
||||||
const invG = 255 - rgb.g;
|
|
||||||
const invB = 255 - rgb.b;
|
|
||||||
|
|
||||||
const boost = 1.3;
|
const blendFactor = 0.85 - (luminance / 0.6) * 0.55;
|
||||||
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));
|
|
||||||
|
|
||||||
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 luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||||
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));
|
|
||||||
|
|
||||||
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 {
|
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') {
|
if (property === 'background-color') {
|
||||||
const hasImportant = value.includes('!important');
|
const hasImportant = value.includes('!important');
|
||||||
const colorValue = value.replace('!important', '').trim();
|
const colorValue = value.replace('!important', '').trim();
|
||||||
const transformed = transformColorForDarkMode(colorValue);
|
const transformed = transformBgColorForDarkMode(colorValue);
|
||||||
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +197,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
|
|||||||
if (colorMatch) {
|
if (colorMatch) {
|
||||||
const hasImportant = value.includes('!important');
|
const hasImportant = value.includes('!important');
|
||||||
const originalColor = colorMatch[0];
|
const originalColor = colorMatch[0];
|
||||||
const transformed = transformColorForDarkMode(originalColor);
|
const transformed = transformBgColorForDarkMode(originalColor);
|
||||||
const newValue = value.replace(originalColor, transformed);
|
const newValue = value.replace(originalColor, transformed);
|
||||||
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
|
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Abmeldeanfrage an Ihr E-Mail-Programm gesendet",
|
"success_mailto": "Abmeldeanfrage an Ihr E-Mail-Programm gesendet",
|
||||||
"error": "Abmeldung nicht möglich",
|
"error": "Abmeldung nicht möglich",
|
||||||
"dismiss": "Schließen"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Zugesagt",
|
"accepted": "Zugesagt",
|
||||||
"declined": "Abgelehnt",
|
"declined": "Abgelehnt",
|
||||||
"tentative": "Vorläufig",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Wiederholung",
|
"title": "Wiederholung",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Termin konnte nicht verschoben werden",
|
"event_move_error": "Termin konnte nicht verschoben werden",
|
||||||
"alert_title": "Bevorstehender Termin",
|
"alert_title": "Bevorstehender Termin",
|
||||||
"alert_now": "Beginnt jetzt",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Kalender werden geladen...",
|
"loading_calendars": "Kalender werden geladen...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Unsubscribe request sent to your email client",
|
"success_mailto": "Unsubscribe request sent to your email client",
|
||||||
"error": "Unable to unsubscribe",
|
"error": "Unable to unsubscribe",
|
||||||
"dismiss": "Dismiss"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Accepted",
|
"accepted": "Accepted",
|
||||||
"declined": "Declined",
|
"declined": "Declined",
|
||||||
"tentative": "Tentative",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Recurrence",
|
"title": "Recurrence",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Failed to move event",
|
"event_move_error": "Failed to move event",
|
||||||
"alert_title": "Upcoming event",
|
"alert_title": "Upcoming event",
|
||||||
"alert_now": "Starting now",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Loading calendars...",
|
"loading_calendars": "Loading calendars...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Solicitud de cancelación enviada a su cliente de correo",
|
"success_mailto": "Solicitud de cancelación enviada a su cliente de correo",
|
||||||
"error": "No se pudo cancelar la suscripción",
|
"error": "No se pudo cancelar la suscripción",
|
||||||
"dismiss": "Descartar"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Aceptado",
|
"accepted": "Aceptado",
|
||||||
"declined": "Rechazado",
|
"declined": "Rechazado",
|
||||||
"tentative": "Provisional",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Recurrencia",
|
"title": "Recurrencia",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Error al mover el evento",
|
"event_move_error": "Error al mover el evento",
|
||||||
"alert_title": "Evento próximo",
|
"alert_title": "Evento próximo",
|
||||||
"alert_now": "Comienza ahora",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Cargando calendarios...",
|
"loading_calendars": "Cargando calendarios...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Demande de désabonnement envoyée à votre client mail",
|
"success_mailto": "Demande de désabonnement envoyée à votre client mail",
|
||||||
"error": "Impossible de se désabonner",
|
"error": "Impossible de se désabonner",
|
||||||
"dismiss": "Ignorer"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Accepté",
|
"accepted": "Accepté",
|
||||||
"declined": "Refusé",
|
"declined": "Refusé",
|
||||||
"tentative": "Provisoire",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Récurrence",
|
"title": "Récurrence",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Échec du déplacement de l'événement",
|
"event_move_error": "Échec du déplacement de l'événement",
|
||||||
"alert_title": "Événement à venir",
|
"alert_title": "Événement à venir",
|
||||||
"alert_now": "Commence maintenant",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Chargement des calendriers...",
|
"loading_calendars": "Chargement des calendriers...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Richiesta di annullamento iscrizione inviata al tuo client email",
|
"success_mailto": "Richiesta di annullamento iscrizione inviata al tuo client email",
|
||||||
"error": "Impossibile annullare l'iscrizione",
|
"error": "Impossibile annullare l'iscrizione",
|
||||||
"dismiss": "Ignora"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Accettato",
|
"accepted": "Accettato",
|
||||||
"declined": "Rifiutato",
|
"declined": "Rifiutato",
|
||||||
"tentative": "Provvisorio",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Ricorrenza",
|
"title": "Ricorrenza",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Spostamento dell'evento non riuscito",
|
"event_move_error": "Spostamento dell'evento non riuscito",
|
||||||
"alert_title": "Evento in arrivo",
|
"alert_title": "Evento in arrivo",
|
||||||
"alert_now": "Inizia ora",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Caricamento calendari...",
|
"loading_calendars": "Caricamento calendari...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "購読解除リクエストをメールクライアントに送信しました",
|
"success_mailto": "購読解除リクエストをメールクライアントに送信しました",
|
||||||
"error": "購読解除できませんでした",
|
"error": "購読解除できませんでした",
|
||||||
"dismiss": "閉じる"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "承諾",
|
"accepted": "承諾",
|
||||||
"declined": "辞退",
|
"declined": "辞退",
|
||||||
"tentative": "仮承諾",
|
"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": {
|
"recurrence": {
|
||||||
"title": "繰り返し",
|
"title": "繰り返し",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "イベントの移動に失敗しました",
|
"event_move_error": "イベントの移動に失敗しました",
|
||||||
"alert_title": "予定のイベント",
|
"alert_title": "予定のイベント",
|
||||||
"alert_now": "まもなく開始",
|
"alert_now": "まもなく開始",
|
||||||
"alert_in_minutes": "{count}分後"
|
"alert_in_minutes": "{count}分後",
|
||||||
|
"invitation_sent": "招待を送信しました",
|
||||||
|
"rsvp_updated": "回答を更新しました",
|
||||||
|
"rsvp_error": "回答の更新に失敗しました"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "カレンダーを読み込み中...",
|
"loading_calendars": "カレンダーを読み込み中...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Uitschrijfverzoek verzonden naar je e-mailclient",
|
"success_mailto": "Uitschrijfverzoek verzonden naar je e-mailclient",
|
||||||
"error": "Kan niet uitschrijven",
|
"error": "Kan niet uitschrijven",
|
||||||
"dismiss": "Sluiten"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Geaccepteerd",
|
"accepted": "Geaccepteerd",
|
||||||
"declined": "Geweigerd",
|
"declined": "Geweigerd",
|
||||||
"tentative": "Voorlopig",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Herhaling",
|
"title": "Herhaling",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Evenement verplaatsen mislukt",
|
"event_move_error": "Evenement verplaatsen mislukt",
|
||||||
"alert_title": "Aankomend evenement",
|
"alert_title": "Aankomend evenement",
|
||||||
"alert_now": "Begint nu",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Agenda's laden...",
|
"loading_calendars": "Agenda's laden...",
|
||||||
|
|||||||
+31
-2
@@ -225,6 +225,23 @@
|
|||||||
"success_mailto": "Solicitação de cancelamento enviada para seu cliente de e-mail",
|
"success_mailto": "Solicitação de cancelamento enviada para seu cliente de e-mail",
|
||||||
"error": "Não foi possível cancelar a inscrição",
|
"error": "Não foi possível cancelar a inscrição",
|
||||||
"dismiss": "Dispensar"
|
"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": {
|
"email_composer": {
|
||||||
@@ -1066,7 +1083,16 @@
|
|||||||
"accepted": "Aceito",
|
"accepted": "Aceito",
|
||||||
"declined": "Recusado",
|
"declined": "Recusado",
|
||||||
"tentative": "Provisório",
|
"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": {
|
"recurrence": {
|
||||||
"title": "Recorrência",
|
"title": "Recorrência",
|
||||||
@@ -1128,7 +1154,10 @@
|
|||||||
"event_move_error": "Falha ao mover o evento",
|
"event_move_error": "Falha ao mover o evento",
|
||||||
"alert_title": "Evento próximo",
|
"alert_title": "Evento próximo",
|
||||||
"alert_now": "Começa agora",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Carregando calendários...",
|
"loading_calendars": "Carregando calendários...",
|
||||||
|
|||||||
+130
-16
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
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';
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||||
@@ -22,9 +22,10 @@ interface CalendarStore {
|
|||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
fetchCalendars: (client: JMAPClient) => Promise<void>;
|
fetchCalendars: (client: JMAPClient) => Promise<void>;
|
||||||
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
|
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
|
||||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
|
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
|
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
|
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>;
|
||||||
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||||
setSelectedDate: (date: Date) => void;
|
setSelectedDate: (date: Date) => void;
|
||||||
setViewMode: (mode: CalendarViewMode) => void;
|
setViewMode: (mode: CalendarViewMode) => void;
|
||||||
@@ -59,12 +60,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
const calendars = await client.getCalendars();
|
const calendars = await client.getCalendars();
|
||||||
const { selectedCalendarIds } = get();
|
const { selectedCalendarIds } = get();
|
||||||
|
const validIds = calendars.map(c => c.id);
|
||||||
|
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
|
||||||
set({
|
set({
|
||||||
calendars,
|
calendars,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
selectedCalendarIds: selectedCalendarIds.length === 0
|
selectedCalendarIds: stillValid.length > 0 ? stillValid : validIds,
|
||||||
? calendars.map(c => c.id)
|
|
||||||
: selectedCalendarIds,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to fetch calendars:', error);
|
debug.error('Failed to fetch calendars:', error);
|
||||||
@@ -75,11 +76,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
fetchEvents: async (client, start, end) => {
|
fetchEvents: async (client, start, end) => {
|
||||||
set({ isLoadingEvents: true, error: null });
|
set({ isLoadingEvents: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { selectedCalendarIds } = get();
|
|
||||||
const events = await client.queryCalendarEvents({
|
const events = await client.queryCalendarEvents({
|
||||||
after: start,
|
after: start,
|
||||||
before: end,
|
before: end,
|
||||||
inCalendars: selectedCalendarIds.length > 0 ? selectedCalendarIds : undefined,
|
|
||||||
});
|
});
|
||||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,10 +87,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createEvent: async (client, event) => {
|
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const created = await client.createCalendarEvent(event);
|
const created = await client.createCalendarEvent(event, sendSchedulingMessages);
|
||||||
set((state) => ({ events: [...state.events, created] }));
|
set((state) => ({ events: [...state.events, created] }));
|
||||||
return created;
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,10 +100,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateEvent: async (client, id, updates) => {
|
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.updateCalendarEvent(id, updates);
|
await client.updateCalendarEvent(id, updates, sendSchedulingMessages);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||||
}));
|
}));
|
||||||
@@ -115,28 +114,143 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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<CalendarEvent>,
|
||||||
|
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) => {
|
importEvents: async (client, events, calendarId) => {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
const src = event as Partial<CalendarEvent>;
|
||||||
try {
|
try {
|
||||||
|
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
|
||||||
|
if (src.participants) {
|
||||||
|
cleanParticipants = {};
|
||||||
|
for (const [key, p] of Object.entries(src.participants)) {
|
||||||
|
const participant: Record<string, unknown> = {
|
||||||
|
'@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<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
...event,
|
|
||||||
calendarIds: { [calendarId]: true },
|
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<string, unknown>)[k];
|
||||||
|
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
||||||
|
});
|
||||||
const created = await client.createCalendarEvent(data);
|
const created = await client.createCalendarEvent(data);
|
||||||
set((state) => ({ events: [...state.events, created] }));
|
set((state) => ({ events: [...state.events, created] }));
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} 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);
|
debug.error('Failed to import event:', event.title, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imported;
|
return imported;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteEvent: async (client, id) => {
|
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.deleteCalendarEvent(id);
|
await client.deleteCalendarEvent(id, sendSchedulingMessages);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => e.id !== id),
|
events: state.events.filter(e => e.id !== id),
|
||||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||||
|
|||||||
Reference in New Issue
Block a user