Files
ihasmail-inbuxa/web/src/views/mail/InviteCard.tsx
T
jcoffey-dev 373822c2ae Resolve the base event id in the calendar store, not at the call sites
Closes #133.

`updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The
`baseEventId ?? id` that made them hit the series lived at four call
sites instead, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: its patch is `participants/{key}/participationStatus`,
which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed
at an instance it would quietly mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
2026-08-30 21:06:56 -07:00

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, 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, false, "series"); 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>
);
}