Guests added to an event vanished on save and no invitation was ever sent. Not a guard in the editor, and nothing the server complained about: ihasmail addresses a participant the way RFC 8984 does, with sendTo and email, and Stalwart 0.16 keeps that address under calendarAddress. Handed the RFC's spelling it stores the event, drops the entire participant map, and reports success. Six shapes were tried against a live 0.16.19, down to sendTo and roles alone; all six were dropped, and patching a participant onto an existing event fails outright with "Patch operation failed". The same disagreement runs through two more properties. The organizer is organizerCalendarAddress, not replyTo. A recurrence is a single recurrenceRule, not a recurrenceRules array — and that one Stalwart refuses honestly, with invalidProperties, so no recurring event could be created at all and existing ones showed no repeat. So writes now use Stalwart's names and reads accept either, since a mailbox may hold events written by other clients. The mock now refuses what the real server refuses and drops what it drops: advertising the RFC spelling is exactly how this reached a live server unnoticed, the same way the capability-placement bug did. Verified against 0.16.19: participants, organizer and rule all survive a create, an update and a re-read, with the roles kept as sent. Fixes #26 Fixes #30
125 lines
6.4 KiB
TypeScript
125 lines
6.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react";
|
|
import { useLocation } from "wouter";
|
|
import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
|
|
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
|
|
import { formatTimeRange } from "@/lib/dates";
|
|
import { toast } from "@/ui/toast";
|
|
|
|
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
|
const cal = useCalendar();
|
|
const [, navigate] = useLocation();
|
|
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
|
const [existing, setExisting] = useState<CalendarEvent | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!cal.available || !part.blobId) return;
|
|
let cancelled = false;
|
|
cal
|
|
.parseIcs(part.blobId)
|
|
.then(async (evs) => {
|
|
if (cancelled) return;
|
|
setEvents(evs);
|
|
const first = evs[0];
|
|
if (first?.uid) setExisting(await cal.findByUid(first.uid));
|
|
})
|
|
.catch((err) => !cancelled && setError((err as Error).message));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [part.blobId, cal.available]);
|
|
|
|
if (!cal.available) return null;
|
|
if (error) return null;
|
|
const ev = events?.[0];
|
|
if (!ev) return null;
|
|
const method = (ev.method ?? "").toUpperCase();
|
|
const inst = toInstance({ ...ev, id: "tmp", calendarIds: {} } as CalendarEvent, cal.calendars);
|
|
const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner);
|
|
const location = Object.values(ev.locations ?? {})[0]?.name;
|
|
const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null;
|
|
const attendees = Object.values(ev.participants ?? {}).filter(isAttendee);
|
|
|
|
const respond = async (status: "accepted" | "tentative" | "declined") => {
|
|
setBusy(status);
|
|
try {
|
|
let target = existing;
|
|
if (!target) {
|
|
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
|
if (!calId) throw new Error("No calendar available");
|
|
const id = await cal.importEvent(ev, calId);
|
|
target = await cal.getEvent(id);
|
|
}
|
|
if (!target) throw new Error("Could not add the event to your calendar");
|
|
await cal.rsvp(target.id, status);
|
|
setExisting(await cal.getEvent(target.id));
|
|
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const addToCalendar = async () => {
|
|
setBusy("add");
|
|
try {
|
|
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
|
if (!calId) throw new Error("No calendar available");
|
|
const id = await cal.importEvent(ev, calId);
|
|
setExisting(await cal.getEvent(id));
|
|
toast.success("Added to your calendar");
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const title = method === "CANCEL" ? "Cancelled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
|
|
|
|
return (
|
|
<div className="invite-card">
|
|
<div className="row" style={{ alignItems: "flex-start" }}>
|
|
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
|
|
<div className="grow">
|
|
<div className="hint" style={{ marginBottom: 2 }}>{title}{method === "REPLY" && organizer ? "" : ""}</div>
|
|
<h4>{ev.title || "(untitled event)"}</h4>
|
|
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
|
|
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
|
|
{organizer && <div className="small muted">Organizer: {organizer.name || participantEmail(organizer)}</div>}
|
|
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
|
|
{method === "REPLY" && (
|
|
<div className="small" style={{ marginTop: 4 }}>
|
|
{attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{method !== "REPLY" && method !== "CANCEL" && (
|
|
<div className="rsvp">
|
|
{(method === "REQUEST" || attendees.length > 0) ? (
|
|
<>
|
|
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("accepted")}><Check size={14} /> {myStatus === "accepted" ? "Accepted" : "Yes"}</button>
|
|
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("tentative")}><HelpCircle size={14} /> {myStatus === "tentative" ? "Tentative" : "Maybe"}</button>
|
|
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("declined")}><X size={14} /> {myStatus === "declined" ? "Declined" : "No"}</button>
|
|
</>
|
|
) : (
|
|
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
|
|
)}
|
|
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
|
|
</div>
|
|
)}
|
|
{method === "CANCEL" && existing && (
|
|
<div className="rsvp">
|
|
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
|
</div>
|
|
)}
|
|
<span className="sr-only">{email.id}</span>
|
|
</div>
|
|
);
|
|
}
|