Merge remote-tracking branch 'origin/calendar-recurring-warning' into calendar-vocabulary-into-main
This commit is contained in:
@@ -81,7 +81,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
const duplicate = async () => {
|
||||
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
|
||||
try {
|
||||
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
toast.success("Event duplicated");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
@@ -184,8 +184,7 @@ function statusClass(i: EventInstance): string {
|
||||
const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]);
|
||||
let my: string | undefined;
|
||||
for (const p of Object.values(ev.participants ?? {})) {
|
||||
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
|
||||
if (addrs.some((a) => ids.includes(a))) my = p.participationStatus;
|
||||
if (participantAddresses(p).some((a) => ids.includes(a))) my = p.participationStatus;
|
||||
}
|
||||
if (ev.status === "cancelled") return "cancelled";
|
||||
if (my === "declined") return "declined";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, 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 } from "@/store/calendar";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
@@ -67,8 +67,8 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
const [color, setColor] = useState<string | null>(ev?.color ?? null);
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
|
||||
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
|
||||
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
|
||||
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev ? eventRule(ev) : undefined);
|
||||
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev ? eventRule(ev) : undefined));
|
||||
const [alerts, setAlerts] = useState<number[]>(() => {
|
||||
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;
|
||||
@@ -78,7 +78,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
|
||||
Object.entries(ev?.participants ?? {})
|
||||
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
|
||||
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" }))
|
||||
.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) }))
|
||||
.filter((a) => a.email),
|
||||
);
|
||||
const [sendInvites, setSendInvites] = useState(true);
|
||||
@@ -142,11 +142,11 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
|
||||
const participants: Record<string, JSCalendarParticipant> = {};
|
||||
if (attendees.length && myAddress) {
|
||||
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false };
|
||||
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) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase());
|
||||
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true };
|
||||
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<string, JSCalendarAlert> = {};
|
||||
@@ -161,10 +161,12 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
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,
|
||||
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : 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,
|
||||
recurrenceRules: rule ? [rule] : undefined,
|
||||
// Singular, and no array: Stalwart 0.16 rejects `recurrenceRules` outright (#30).
|
||||
recurrenceRule: rule ?? undefined,
|
||||
status,
|
||||
privacy,
|
||||
freeBusyStatus: freeBusy,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, type EventInstance } from "@/store/calendar";
|
||||
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";
|
||||
@@ -66,7 +66,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
</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>
|
||||
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</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>}
|
||||
@@ -75,12 +75,12 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
<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: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
|
||||
<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 || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span>
|
||||
<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>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 } from "@/store/calendar";
|
||||
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
|
||||
import { formatTimeRange } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
@@ -41,7 +41,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
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((p) => p.roles?.attendee);
|
||||
const attendees = Object.values(ev.participants ?? {}).filter(isAttendee);
|
||||
|
||||
const respond = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(status);
|
||||
@@ -90,11 +90,11 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
<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 || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</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={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
|
||||
{attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user