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`.
101 lines
6.8 KiB
TypeScript
101 lines
6.8 KiB
TypeScript
import { useState } from "react";
|
|
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
|
import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
|
|
import { Popover, type Anchor } from "@/ui/popover";
|
|
import { confirmDialog } from "@/ui/dialog";
|
|
import { toast } from "@/ui/toast";
|
|
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
|
import { describeRule } from "@/lib/recurrence";
|
|
import { useCompose } from "@/store/compose";
|
|
import { useSettings } from "@/store/settings";
|
|
import { categoryOf, eventColor } from "./CalendarContextMenu";
|
|
|
|
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
|
|
const cal = useCalendar();
|
|
const ev = inst.event;
|
|
const [busy, setBusy] = useState(false);
|
|
const categories = useSettings((s) => s.settings.eventCategories);
|
|
const color = eventColor(ev, inst.calendar?.color, categories);
|
|
const category = categoryOf(ev, categories);
|
|
const participants = Object.entries(ev.participants ?? {});
|
|
const myKeys = myParticipantKeys(ev, cal.identities);
|
|
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
|
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
|
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
|
const location = Object.values(ev.locations ?? {})[0];
|
|
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
|
const alerts = Object.values(ev.alerts ?? {});
|
|
const openCompose = useCompose((s) => s.open);
|
|
|
|
const del = async () => {
|
|
const recurring = isRecurring(ev);
|
|
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
|
if (!ok) return;
|
|
setBusy(true);
|
|
try {
|
|
await cal.destroyEvent(ev, participants.length > 1, "series");
|
|
toast.success("Event deleted");
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
|
setBusy(true);
|
|
try {
|
|
await cal.rsvp(ev, status);
|
|
toast.success("Response sent");
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
|
|
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
|
|
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
|
|
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
|
|
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
|
|
</div>
|
|
<h3>{ev.title || "(untitled)"}</h3>
|
|
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
|
|
{eventRule(ev) && <div className="ev-line"><Repeat size={15} /><span>{describeRule(eventRule(ev)!)}</span></div>}
|
|
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
|
|
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
|
|
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
|
|
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
|
|
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
|
|
<div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div>
|
|
{participants.length > 0 && (
|
|
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
|
|
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
|
|
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
|
|
{participants.map(([k, p]) => (
|
|
<div key={k} className="participant-row">
|
|
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
|
|
<span className="truncate">{p.name || participantEmail(p)}</span>
|
|
{p.roles?.owner && <span className="hint">organizer</span>}
|
|
{p.roles?.optional && <span className="hint">optional</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{myKeys.length > 0 && !isOrganizer && (
|
|
<div className="row" style={{ marginTop: 10, gap: 6 }}>
|
|
<span className="hint">Going?</span>
|
|
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
|
|
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
|
|
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
|
|
</div>
|
|
)}
|
|
</Popover>
|
|
);
|
|
}
|