import { useEffect, useMemo, useRef, useState } from "react"; import { Plus, Trash2, Users } from "lucide-react"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { useSession } from "@/store/session"; import { useContacts } from "@/store/contacts"; import { Dialog } from "@/ui/dialog"; import { ColorSwatches, Switch } from "@/ui/misc"; import { toast } from "@/ui/toast"; import { RecipientInput } from "../compose/RecipientInput"; import { DateField, DateTimeField } from "@/ui/datefield"; import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates"; import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; import { askEditScope, droppedMessage, runScoped } from "./scope"; import { t as translate } from "@/lib/i18n"; export interface EditorInit { event?: CalendarEvent; start: Date; end: Date; allDay: boolean; /** * Values a new event opens with, from wherever it was begun -- a message, * so far. Not an event: this is still a form the reader has to finish, so * `editing` stays false and the dialog says New event / Create. */ seed?: { title?: string; description?: string }; } const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080]; /** * Fields this form always sends that a single occurrence will not take. * * `useDefaultAlerts` and `calendarIds` are refused with `invalidProperties`; * the rest are dropped from the patch while the response still reports * success. Both halves are reasons not to send them — the second more so, * because nothing would say it had happened. */ const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]); export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) { const cal = useCalendar(); const settings = useSettings((s) => s.settings); const session = useSession((s) => s.session); const [base, setBase] = useState(init.event && !init.event.baseEventId ? init.event : undefined); const [scope, setScope] = useState(init.event?.baseEventId ? undefined : "series"); const editing = Boolean(init.event); /* * Which event this form is even about has to be settled before it opens. * * A form populated from the master shows the series' start date, so editing * Wednesday's standup would offer to move Monday's — right for the series and * wrong for one date. So the scope is asked first, and the occurrence itself * is what the form loads when the answer is "this occurrence". */ /* * Asked once per event, and deliberately not tied to the effect's lifetime. * * Two things make the obvious version wrong. A dialog is queued in a store * the moment it is requested, so it outlives the effect that asked for it: a * re-run queues a second prompt the first answer cannot retract, and the * reader is asked the same question twice. And gating the *answer* on a * cleanup flag is worse — React's StrictMode runs mount, cleanup, mount, so * the flag is already set by the time anyone clicks and the editor never * opens at all. The ref is what makes this once; the answer is applied * whenever it arrives. */ const asked = useRef(null); useEffect(() => { const ev = init.event; if (!ev) { setBase(null); setScope("series"); return; } if (!ev.baseEventId) { setBase(ev); setScope("series"); return; } if (asked.current === ev.id) return; asked.current = ev.id; void (async () => { const chosen = isRecurring(ev) && isOccurrence(ev) ? await askEditScope(ev) : "series"; if (!chosen) { onClose(); return; } setScope(chosen); if (chosen === "occurrence") setBase(ev); else void cal.getEvent(ev.baseEventId!).then(setBase); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [init.event?.id]); if (base === undefined || scope === undefined) return null; return ; } function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; scope: EventScope; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) { /** This form is editing one date rather than the series behind it. */ const oneDate = scope === "occurrence"; const cal = useCalendar(); const contacts = useContacts(); const ev = base; const calendars = Object.values(cal.calendars).filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn); const initialCal = ev ? Object.keys(ev.calendarIds)[0] : (calendars.find((c) => c.isDefault)?.id ?? calendars[0]?.id); const evTz = ev?.timeZone ?? settingsTz; const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start; const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end; const [title, setTitle] = useState(ev?.title ?? init.seed?.title ?? ""); const [calendarId, setCalendarId] = useState(initialCal ?? ""); const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay); const [start, setStart] = useState(baseStart); const [end, setEnd] = useState(baseEnd); const [tz, setTz] = useState(evTz); const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? ""); const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? ""); const [description, setDescription] = useState(ev?.description ?? init.seed?.description ?? ""); const [status, setStatus] = useState>(ev?.status ?? "confirmed"); const [privacy, setPrivacy] = useState>(ev?.privacy ?? "public"); const [freeBusy, setFreeBusy] = useState>(ev?.freeBusyStatus ?? "busy"); const [color, setColor] = useState(ev?.color ?? null); const categories = useSettings((s) => s.settings.eventCategories); const [category, setCategory] = useState(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? ""); const [rule, setRule] = useState(ev ? eventRule(ev) : undefined); const [preset, setPreset] = useState(presetFor(ev ? eventRule(ev) : undefined)); const [alerts, setAlerts] = useState(() => { const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0); if (ev) return a; return defaultAlert >= 0 ? [defaultAlert] : []; }); const myKeys = ev ? myParticipantKeys(ev, cal.identities) : []; const [attendees, setAttendees] = useState(() => Object.entries(ev?.participants ?? {}) .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })) .filter((a) => a.email), ); const [sendInvites, setSendInvites] = useState(true); const [busy, setBusy] = useState(false); const [fb, setFb] = useState>({}); const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length))); const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0]; const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : ""); const myPlainEmail = myAddress.replace(/^mailto:/i, ""); // Free/busy lookup for attendees that are directory principals useEffect(() => { if (!attendees.length || !contacts.principalsLoaded) { if (!contacts.principalsLoaded) void contacts.loadPrincipals(); return; } const dayStart = new Date(start); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(dayStart.getTime() + DAY_MS); let cancelled = false; (async () => { const out: Record = {}; for (const a of attendees) { const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase()); if (!p) continue; try { out[a.email] = await cal.availability(p.id, dayStart, dayEnd); } catch { /* ignore */ } } if (!cancelled) setFb(out); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [attendees.map((a) => a.email).join(","), start.getTime(), contacts.principalsLoaded]); const onStartChange = (d: Date) => { if (Number.isNaN(d.getTime())) return; const dur = end.getTime() - start.getTime(); setStart(d); setEnd(new Date(d.getTime() + Math.max(dur, allDay ? DAY_MS : 15 * 60_000))); }; const save = async () => { if (!calendarId) { toast.error(translate("Choose a calendar")); return; } if (end <= start) { toast.error(translate("End must be after start")); return; } setBusy(true); try { const s = allDay ? new Date(start.getFullYear(), start.getMonth(), start.getDate()) : start; let e = allDay ? new Date(end.getFullYear(), end.getMonth(), end.getDate()) : end; if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS); const participants: Record = {}; if (attendees.length && myAddress) { participants.me = makeParticipant(myPlainEmail, identity?.name, "owner"); for (const a of attendees) { // preserve existing status if the attendee was already there const existing = Object.values(ev?.participants ?? {}).find((p) => participantEmail(p).toLowerCase() === a.email.toLowerCase()); participants[newKey("p")] = makeParticipant(a.email, a.name, "attendee", existing?.participationStatus); } } const alertObj: Record = {}; for (const m of alerts) alertObj[newKey("a")] = { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: formatDuration(-m * 60), relativeTo: "start" }, action: "display" }; const obj: Record = { title: title.trim() || "(untitled)", description: description.trim() || undefined, showWithoutTime: allDay, start: allDay ? `${toLocalDateOnly(s)}T00:00:00` : dateToZonedLocal(s, tz), timeZone: allDay ? null : tz, duration: formatDuration(Math.round((e.getTime() - s.getTime()) / 1000)), locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined, virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined, participants: Object.keys(participants).length ? participants : undefined, // Stalwart 0.16 names the organizer here; RFC 8984's replyTo is ignored. organizerCalendarAddress: Object.keys(participants).length && myAddress ? myAddress : undefined, alerts: Object.keys(alertObj).length ? alertObj : undefined, useDefaultAlerts: false, // Singular, and no array: Stalwart 0.16 rejects `recurrenceRules` outright (#30). recurrenceRule: rule ?? undefined, status, privacy, freeBusyStatus: freeBusy, color: color ?? (category ? categories.find((c) => c.name === category)?.color : undefined), categories: category ? { [category]: true } : undefined, }; const invites = sendInvites && attendees.length > 0; if (ev) { /* * A single occurrence takes less than the series does. Four of the * fields this form always sends are among them — `useDefaultAlerts` * and `calendarIds` are refused outright, `recurrenceRule`, * `privacy` and `organizerCalendarAddress` are dropped in silence — * so they are left out here rather than sent and believed. The store * still checks; this is what stops it having to complain. */ const source = oneDate ? Object.fromEntries(Object.entries(obj).filter(([k]) => !OCCURRENCE_OMIT.has(k))) : obj; const patch: Record = {}; for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v; if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true }; const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s)); if (!dropped) { setBusy(false); return; } toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated")); } else { const clean: Record = {}; for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v; await cal.createEvent(clean as Partial, calendarId, invites); toast.success(invites ? "Event created and invitations sent" : "Event created"); } onClose(); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const }; const dayWindow = useMemo(() => { const ds = new Date(start); ds.setHours(0, 0, 0, 0); return { ds, de: new Date(ds.getTime() + DAY_MS) }; }, [start]); return ( }>
{ev && isRecurring(ev) && (
{oneDate ? `Editing ${formatNumericDate(start)} only — the rest of the series is unchanged. Repeat, calendar and privacy belong to the series and are not shown.` : "This is a recurring event — changes apply to the whole series."}
)}
setTitle(e.target.value)} />
{allDay ? ( <> v && onStartChange(new Date(`${v}T00:00:00`))} /> {translate("to")} v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} /> ) : ( <> v && onStartChange(fromInputDateTime(v))} /> {translate("to")} v && setEnd(fromInputDateTime(v))} /> )}
{!allDay && ( )} {!oneDate && ( )}
{!oneDate && preset === "custom" && (
{translate("Repeat every")} setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
{customRule.frequency === "weekly" && (
{WEEKDAYS.map((w) => { const on = customRule.byDay?.some((d) => d.day === w.key); return ; })}
)}
{translate("Ends")} {customRule.until && v && setRule({ ...customRule, until: `${v}T23:59:59` })} />} {customRule.count && setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
{describeRule(customRule)}
)}
setLocation(e.target.value)} placeholder={translate("Add location")} />
setVurl(e.target.value)} placeholder={translate("https://meet.example.com/…")} />
{attendees.length > 0 && ( <> {Object.keys(fb).length > 0 && (
{translate("Availability on {date}", { date: formatNumericDate(start) })}
{attendees.filter((a) => fb[a.email]).map((a) => (
{a.name ?? a.email}
{fb[a.email]!.map((b, i) => { const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime()); const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime()); if (be <= bs) return null; return ; })} {!allDay && }
))}
)} )}