import { useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, 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 { CAP } from "@/jmap/client"; 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, formatWeekdayDate } from "@/lib/datetime"; import { WEEKDAY_KEYS, weekdayOptions, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; import { availabilityWindow } from "@/lib/availabilityWindow"; import { askEditScope, droppedMessage, runScoped } from "./scope"; import { plural, 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; attendees?: EmailAddress[] }; } 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(() => ev ? 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) : (init.seed?.attendees ?? []), ); /* * Off when the guest list was not typed but inherited -- from a message, so * far -- and on everywhere else, which is every event whose guests somebody * chose one at a time. * * A reminder made out of a bill carries the biller and everyone else the * mail went to. Left on, the primary button reads Send invites, and the * first press mails all of them an invitation to the reader's private note * to self. The switch is right there and says what it does, so inviting them * is one deliberate click; un-sending is not. */ const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length); const [busy, setBusy] = useState(false); /** By address: the busy periods, or `null` for "there is no free/busy to read". */ const [fb, setFb] = useState>({}); /** Days the panel has been stepped away from the event, for looking around it. */ const [fbOffset, setFbOffset] = useState(0); /** Where the pointer is over the grid, so the time a click would set is visible before it does. */ const [fbHover, setFbHover] = useState(null); 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, ""); /* * What the bars cover: whole days, from the day the event starts to the day * it ends. It used to be the start day and nothing else, which meant an event * spanning two days showed availability for one of them without saying so. */ const fbWindow = useMemo(() => availabilityWindow(start, end, { offsetDays: fbOffset }), [start, end, fbOffset]); /** Stalwart answers for your own account under its own id, which is the fallback when the directory does not list you. */ const selfPrincipalId = useSession((st) => st.accountFor(CAP.principals)); /* * Everyone the event concerns, you first. Scheduling around the other people * and not around yourself is how two things end up at the same time, and the * organiser's own calendar was the one row the panel never showed. */ const people = useMemo(() => { const seen = new Set(); const out: { email: string; name: string; self: boolean }[] = []; if (myPlainEmail) { seen.add(myPlainEmail.toLowerCase()); out.push({ email: myPlainEmail, name: translate("You"), self: true }); } for (const a of attendees) { if (seen.has(a.email.toLowerCase())) continue; seen.add(a.email.toLowerCase()); out.push({ email: a.email, name: a.name ?? a.email, self: false }); } return out; }, [myPlainEmail, attendees]); /* * Free/busy, for everyone we can read it for. * * Only people the directory knows have any: free/busy is answered per * principal, and somebody outside the server -- a customer, anyone at another * domain -- is not one. Those are recorded as `null` rather than left out, * because a row that is missing from a grid reads as a row with nothing in * it, which is to say "free", which is the one thing we do not know. */ useEffect(() => { if (!people.length || !contacts.principalsLoaded) { if (!contacts.principalsLoaded) void contacts.loadPrincipals(); return; } let cancelled = false; (async () => { const out: Record = {}; for (const person of people) { /* * Your own row is never unknown. If the directory does not list you * under the address the identity sends from -- an alias, a name that * differs from the login -- the account is still yours to read, and * Stalwart answers for it under the account's own id. */ const principalId = contacts.principals.find((x) => x.email?.toLowerCase() === person.email.toLowerCase())?.id ?? (person.self ? selfPrincipalId : undefined); if (!principalId) { out[person.email] = null; continue; } try { out[person.email] = await cal.availability(principalId, fbWindow.start, fbWindow.end); } catch { out[person.email] = null; } } if (!cancelled) setFb(out); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [people.map((p) => p.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded, selfPrincipalId]); 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 }; 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" && (
{weekdayOptions().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 && ( <> {people.length > 1 && (() => { /** Everything on a bar is placed as a fraction of the span it covers. */ const pct = (from: number, to: number) => ({ left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`, width: `${((to - from) / fbWindow.span) * 100}%`, }); /** Where along the window a pointer is, as a moment. */ const timeAt = (e: { clientX: number; currentTarget: Element }) => { const box = e.currentTarget.getBoundingClientRect(); const frac = Math.min(Math.max((e.clientX - box.left) / box.width, 0), 1); // Half-hourly: a bar is a few hundred pixels wide, and a // minute of it is not something anybody can aim at. const SNAP = 30 * 60_000; return new Date(Math.round((fbWindow.start.getTime() + frac * fbWindow.span) / SNAP) * SNAP); }; const known = people.filter((p) => fb[p.email]); // You are not a guest, so you are not counted as one -- and if // your own row cannot be read, that is not what this sentence // is about. const unknown = people.filter((p) => !p.self && fb[p.email] === null); const rangeLabel = fbWindow.days === 1 ? formatWeekdayDate(fbWindow.start) : `${formatNumericDate(fbWindow.start)} – ${formatNumericDate(new Date(fbWindow.end.getTime() - 1))}`; return (
{rangeLabel} {fbOffset !== 0 && } {/* The time a click would set, so placing an event is aimed rather than guessed. */} {fbHover ? (fbWindow.days === 1 ? formatClock(fbHover) : `${formatWeekday(fbHover, "short")} ${formatClock(fbHover)}`) : translate("Click to move the event")}
{fbWindow.ticks.filter((tk) => tk.major).map((tk) => ( {fbWindow.scale === "hours" ? formatClock(tk.time) : formatWeekday(tk.time, "short")} ))} {fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}
{people.map((person) => { const periods = fb[person.email]; const noData = periods === null; return (
{person.name}
setFbHover(timeAt(e))} onMouseLeave={() => setFbHover(null)} onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }} > {fbWindow.ticks.map((tk) => ( tk.at === 0 ? null : ))} {(periods ?? []).map((b, i) => { const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime()); const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime()); if (be <= bs) return null; return ; })} {fbHover && } {!allDay && }
); })} {fbWindow.daysHidden > 0 && (
{plural(fbWindow.daysHidden, { one: "The event runs {n} day longer than this shows.", other: "The event runs {n} days longer than this shows." })}
)} {/* Said once, under the grid, rather than repeated on every row that has nothing to show. */} {unknown.length > 0 && (
{known.length === 0 ? translate("Nobody here has free/busy on this server, so none of these rows can say whether anyone is free.") : plural(unknown.length, { one: "{n} guest is not on this server, so there is no free/busy to read for them.", other: "{n} guests are not on this server, so there is no free/busy to read for them.", })}
)}
); })()} )}