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:
@@ -3,9 +3,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
||||
import { Calendar as CalendarIcon, MapPin } from "lucide-react";
|
||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarAgendaViewProps {
|
||||
@@ -179,6 +180,12 @@ export function CalendarAgendaView({
|
||||
<span className="truncate">{locationName}</span>
|
||||
</div>
|
||||
)}
|
||||
{getParticipantCount(ev) > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Users className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{getParticipantCount(ev)}</span>
|
||||
</div>
|
||||
)}
|
||||
{calendar && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{calendar.name}
|
||||
|
||||
@@ -180,7 +180,9 @@ export function CalendarDayView({
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_move_error"));
|
||||
}
|
||||
|
||||
@@ -123,7 +123,9 @@ export function CalendarMonthView({
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_move_error"));
|
||||
}
|
||||
|
||||
@@ -214,7 +214,9 @@ export function CalendarWeekView({
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_move_error"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Users } from "lucide-react";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
@@ -138,6 +140,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
||||
{timeString}
|
||||
</div>
|
||||
)}
|
||||
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
|
||||
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>{getParticipantCount(event)}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { X, Trash2, Check, HelpCircle, XCircle, Users } from "lucide-react";
|
||||
import { format, parseISO, addHours } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration } from "./event-card";
|
||||
import { ParticipantInput } from "./participant-input";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
|
||||
interface EventModalProps {
|
||||
event?: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
onSave: (data: Partial<CalendarEvent>) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||
onClose: () => void;
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date): string {
|
||||
@@ -50,11 +61,39 @@ export function EventModal({
|
||||
defaultDate,
|
||||
onSave,
|
||||
onDelete,
|
||||
onRsvp,
|
||||
onClose,
|
||||
currentUserEmails = [],
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!event;
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
if (!event) return true;
|
||||
if (!event.participants) return true;
|
||||
return isOrganizer(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
const isAttendeeMode = useMemo(() => {
|
||||
if (!event || !event.participants) return false;
|
||||
return !event.isOrigin && !userIsOrganizer;
|
||||
}, [event, userIsOrganizer]);
|
||||
|
||||
const userParticipantId = useMemo(() => {
|
||||
if (!event) return null;
|
||||
return getUserParticipantId(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
const userCurrentStatus = useMemo(() => {
|
||||
if (!event) return null;
|
||||
return getUserStatus(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
const existingParticipants = useMemo(() => {
|
||||
if (!event) return [];
|
||||
return getParticipantList(event);
|
||||
}, [event]);
|
||||
|
||||
const getInitialStart = (): Date => {
|
||||
if (event?.start) return parseISO(event.start);
|
||||
if (defaultDate) {
|
||||
@@ -114,6 +153,27 @@ export function EventModal({
|
||||
});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||
if (!event?.participants) return [];
|
||||
return existingParticipants
|
||||
.filter(p => !p.isOrganizer)
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
if (!event?.participants) return null;
|
||||
return getStatusCounts(event);
|
||||
}, [event]);
|
||||
|
||||
const handleAddAttendee = useCallback((p: { name: string; email: string }) => {
|
||||
setAttendees(prev => [...prev, p]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveAttendee = useCallback((email: string) => {
|
||||
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return;
|
||||
@@ -202,8 +262,26 @@ export function EventModal({
|
||||
};
|
||||
}
|
||||
|
||||
onSave(data);
|
||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, onSave]);
|
||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
||||
const organizerEmail = currentUserEmails[0];
|
||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||
data.participants = buildParticipantMap(
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
attendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
} else if (attendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||
onSave(data, shouldSendScheduling);
|
||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
onRsvp(event.id, userParticipantId, status);
|
||||
onClose();
|
||||
}, [event, userParticipantId, onRsvp, onClose]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -212,12 +290,12 @@ export function EventModal({
|
||||
if (e.key === "Escape") onClose();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
if (!isAttendeeMode) handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, handleSave]);
|
||||
}, [onClose, handleSave, isAttendeeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
@@ -243,6 +321,105 @@ export function EventModal({
|
||||
return () => modal.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||
|
||||
if (isAttendeeMode && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const durMin = parseDuration(event.duration);
|
||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||
const participants = getParticipantList(event);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
)}
|
||||
|
||||
{locationName && (
|
||||
<p className="text-sm text-muted-foreground">{locationName}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("participants.you_attendee")}
|
||||
</div>
|
||||
|
||||
{participants.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Users className="w-4 h-4" />
|
||||
{t("participants.title")}
|
||||
</div>
|
||||
<div className="space-y-1 pl-5">
|
||||
{participants.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">RSVP</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 mr-1" />
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||
@@ -290,6 +467,28 @@ export function EventModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4" />
|
||||
{t("participants.title")}
|
||||
</span>
|
||||
</label>
|
||||
<ParticipantInput
|
||||
participants={attendees}
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
accepted: statusCounts.accepted,
|
||||
pending: statusCounts.tentative + statusCounts['needs-action'],
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -393,19 +592,41 @@ export function EventModal({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sendInvitations"
|
||||
checked={sendInvitations}
|
||||
onChange={(e) => setSendInvitations(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="sendInvitations" className="text-sm">
|
||||
{t("participants.send_invitations")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
||||
{isEdit && onDelete ? (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
{hasParticipants && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("participants.cancel_notification")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id); onClose(); }}
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
@@ -442,3 +663,26 @@ export function EventModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, isOrganizer, t }: {
|
||||
status: CalendarParticipant['participationStatus'];
|
||||
isOrganizer: boolean;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
if (isOrganizer) {
|
||||
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
||||
}
|
||||
const colors: Record<string, string> = {
|
||||
accepted: "text-green-600 dark:text-green-400",
|
||||
declined: "text-red-600 dark:text-red-400",
|
||||
tentative: "text-amber-600 dark:text-amber-400",
|
||||
"needs-action": "text-muted-foreground",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
accepted: "participants.accepted",
|
||||
declined: "participants.declined",
|
||||
tentative: "participants.tentative",
|
||||
"needs-action": "participants.needs_action",
|
||||
};
|
||||
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
|
||||
}
|
||||
|
||||
@@ -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 { useAuthStore } from "@/stores/auth-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { transformInlineStyles } from "@/lib/color-transform";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||
|
||||
interface EmailViewerProps {
|
||||
email: Email | null;
|
||||
@@ -198,7 +200,7 @@ export function EmailViewer({
|
||||
const { isTablet } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities } = useAuthStore();
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
@@ -425,24 +427,23 @@ export function EmailViewer({
|
||||
if (shouldBlockExternal) {
|
||||
sanitizeConfig.FORBID_TAGS.push('link');
|
||||
sanitizeConfig.FORBID_ATTR.push('background');
|
||||
}
|
||||
|
||||
// Hook to modify src attributes
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const htmlNode = node as HTMLElement;
|
||||
// Block external images
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const htmlNode = node as HTMLElement;
|
||||
|
||||
if (shouldBlockExternal) {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||
node.setAttribute('data-blocked-src', src);
|
||||
// Use a subtle transparent placeholder
|
||||
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
||||
node.setAttribute('alt', '');
|
||||
htmlNode.style.display = 'none'; // Hide blocked images completely for cleaner look
|
||||
htmlNode.style.display = 'none';
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Block external stylesheets and resources in style attributes
|
||||
if (htmlNode.style) {
|
||||
const style = htmlNode.style.cssText;
|
||||
if (style && style.includes('url(')) {
|
||||
@@ -452,18 +453,29 @@ export function EmailViewer({
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform inline color styles for dark mode readability
|
||||
if (theme === 'dark') {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
if (transformedStyles !== originalStyles) {
|
||||
htmlNode.style.cssText = transformedStyles;
|
||||
}
|
||||
if (resolvedTheme === 'dark') {
|
||||
if (htmlNode.style) {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
if (transformedStyles !== originalStyles) {
|
||||
htmlNode.style.cssText = transformedStyles;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const colorAttr = node.getAttribute('color');
|
||||
if (colorAttr) {
|
||||
node.setAttribute('color', transformColorForDarkMode(colorAttr));
|
||||
}
|
||||
|
||||
const bgcolorAttr = node.getAttribute('bgcolor');
|
||||
if (bgcolorAttr) {
|
||||
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
@@ -529,7 +541,7 @@ export function EmailViewer({
|
||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||
isHtml: false
|
||||
};
|
||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
|
||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
|
||||
|
||||
// Detect List-Unsubscribe header for newsletter banners
|
||||
const listHeaders = useMemo(() => {
|
||||
@@ -541,6 +553,8 @@ export function EmailViewer({
|
||||
listHeaders?.listUnsubscribe?.preferred &&
|
||||
!dismissedUnsubBanners.has(email?.messageId || '');
|
||||
|
||||
const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false;
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
if (isLoading && !email) {
|
||||
return (
|
||||
@@ -1292,16 +1306,16 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unified Notification Banner - External Content + Unsubscribe */}
|
||||
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
|
||||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe) ||
|
||||
hasCalendarInvitation) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<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 */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Load images button - only in 'ask' mode */}
|
||||
<div className="flex items-center gap-3 flex-wrap md:justify-center">
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<button
|
||||
onClick={() => setAllowExternalContent(true)}
|
||||
@@ -1311,7 +1325,6 @@ export function EmailViewer({
|
||||
{t('load_external_content')}
|
||||
</button>
|
||||
)}
|
||||
{/* Trust sender button - in both 'ask' and 'block' modes */}
|
||||
{email.from?.[0]?.email && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1331,16 +1344,23 @@ export function EmailViewer({
|
||||
|
||||
{/* Unsubscribe Controls */}
|
||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||
<UnsubscribeBanner
|
||||
listUnsubscribe={listHeaders.listUnsubscribe}
|
||||
senderEmail={email?.from?.[0]?.email || ''}
|
||||
onDismiss={() => {
|
||||
const messageId = email?.messageId || '';
|
||||
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
||||
setDismissedUnsubBanners(newSet);
|
||||
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center md:justify-center">
|
||||
<UnsubscribeBanner
|
||||
listUnsubscribe={listHeaders.listUnsubscribe}
|
||||
senderEmail={email?.from?.[0]?.email || ''}
|
||||
onDismiss={() => {
|
||||
const messageId = email?.messageId || '';
|
||||
const newSet = new Set(dismissedUnsubBanners).add(messageId);
|
||||
setDismissedUnsubBanners(newSet);
|
||||
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar Invitation Banner */}
|
||||
{hasCalendarInvitation && (
|
||||
<CalendarInvitationBanner email={email} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -217,6 +219,7 @@ function EmailCard({
|
||||
onMarkAsRead,
|
||||
}: EmailCardProps) {
|
||||
const t = useTranslations();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -271,8 +274,10 @@ function EmailCard({
|
||||
// Use shared sanitization config as base (more secure)
|
||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||
|
||||
if (!allowExternal) {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const htmlNode = node as HTMLElement;
|
||||
|
||||
if (!allowExternal) {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||
@@ -290,8 +295,28 @@ function EmailCard({
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedTheme === 'dark') {
|
||||
if (htmlNode.style) {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
if (transformedStyles !== originalStyles) {
|
||||
htmlNode.style.cssText = transformedStyles;
|
||||
}
|
||||
}
|
||||
|
||||
const colorAttr = node.getAttribute('color');
|
||||
if (colorAttr) {
|
||||
node.setAttribute('color', transformColorForDarkMode(colorAttr));
|
||||
}
|
||||
|
||||
const bgcolorAttr = node.getAttribute('bgcolor');
|
||||
if (bgcolorAttr) {
|
||||
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||
@@ -324,7 +349,7 @@ function EmailCard({
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal]);
|
||||
}, [email, allowExternal, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
|
||||
Reference in New Issue
Block a user