Merge remote-tracking branch 'origin/calendar-recurring-warning' into calendar-vocabulary-into-main
This commit is contained in:
@@ -615,6 +615,8 @@ export interface JSCalendarParticipant {
|
||||
email?: string;
|
||||
description?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
/** Where Stalwart 0.16 keeps the address, in place of `sendTo` / `email`. */
|
||||
calendarAddress?: string;
|
||||
kind?: "individual" | "group" | "location" | "resource";
|
||||
roles: Record<string, boolean>;
|
||||
locationId?: string;
|
||||
@@ -679,6 +681,8 @@ export interface JSCalendarEvent {
|
||||
recurrenceId?: LocalDate;
|
||||
recurrenceIdTimeZone?: string;
|
||||
recurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
/** Stalwart 0.16 stores a single rule under this name instead of the array above. */
|
||||
recurrenceRule?: JSCalendarRecurrenceRule;
|
||||
excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
|
||||
excluded?: boolean;
|
||||
@@ -686,6 +690,8 @@ export interface JSCalendarEvent {
|
||||
freeBusyStatus?: "free" | "busy";
|
||||
privacy?: "public" | "private" | "secret";
|
||||
replyTo?: Record<string, string>;
|
||||
/** Where Stalwart 0.16 keeps the organizer, in place of `replyTo`. */
|
||||
organizerCalendarAddress?: string;
|
||||
sentBy?: string;
|
||||
participants?: Record<string, JSCalendarParticipant>;
|
||||
requestStatus?: string;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { participantAddresses, participantEmail, isAttendee, eventRule, makeParticipant } from "@/store/calendar";
|
||||
import type { CalendarEvent, JSCalendarParticipant } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16.19 and RFC 8984 disagree about where a participant's address
|
||||
* lives. Sent the RFC's way, Stalwart keeps the event and drops the participant
|
||||
* map without a word — guests vanished and no invitation was ever sent (#26).
|
||||
* Shapes below are what a live 0.16.19 returned.
|
||||
*/
|
||||
const p = (o: Partial<JSCalendarParticipant>): JSCalendarParticipant => ({ roles: {}, ...o });
|
||||
const ev = (o: Partial<CalendarEvent>): CalendarEvent => ({ id: "e1", "@type": "Event", uid: "u1", calendarIds: { c1: true }, start: "2030-01-01T10:00:00", ...o } as CalendarEvent);
|
||||
|
||||
describe("participant addresses", () => {
|
||||
it("reads Stalwart's calendarAddress", () => {
|
||||
expect(participantEmail(p({ calendarAddress: "mailto:[email protected]" }))).toBe("[email protected]");
|
||||
});
|
||||
it("still reads the RFC 8984 spellings, for events written by other clients", () => {
|
||||
expect(participantEmail(p({ sendTo: { imip: "mailto:[email protected]" } }))).toBe("[email protected]");
|
||||
expect(participantEmail(p({ email: "[email protected]" }))).toBe("[email protected]");
|
||||
expect(participantAddresses(p({ calendarAddress: "mailto:[email protected]", email: "[email protected]" }))).toEqual(["mailto:[email protected]", "mailto:[email protected]"]);
|
||||
});
|
||||
it("has no address to offer when the participant carries none", () => {
|
||||
expect(participantEmail(p({ name: "Nameless" }))).toBe("");
|
||||
});
|
||||
it("counts a participant as attending under either role name", () => {
|
||||
expect(isAttendee(p({ roles: { attendee: true } }))).toBe(true);
|
||||
expect(isAttendee(p({ roles: { required: true } }))).toBe(true); // what Stalwart writes for REQ-PARTICIPANT
|
||||
expect(isAttendee(p({ roles: { optional: true } }))).toBe(true);
|
||||
expect(isAttendee(p({ roles: { owner: true } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeParticipant", () => {
|
||||
it("addresses a guest the way Stalwart stores them", () => {
|
||||
const guest = makeParticipant("[email protected]", "Guest", "attendee");
|
||||
expect(guest.calendarAddress).toBe("mailto:[email protected]");
|
||||
expect(guest.sendTo).toBeUndefined();
|
||||
expect(guest.roles).toEqual({ attendee: true, required: true });
|
||||
expect(guest.participationStatus).toBe("needs-action");
|
||||
expect(guest.expectReply).toBe(true);
|
||||
});
|
||||
it("marks the organizer as owner and keeps a status already given", () => {
|
||||
const me = makeParticipant("[email protected]", "John Coffey", "owner");
|
||||
expect(me.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(me.participationStatus).toBe("accepted");
|
||||
expect(me.expectReply).toBe(false);
|
||||
expect(makeParticipant("[email protected]", null, "attendee", "declined").participationStatus).toBe("declined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventRule", () => {
|
||||
it("reads Stalwart's singular rule and the RFC's array", () => {
|
||||
expect(eventRule(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly" } }))?.frequency).toBe("weekly");
|
||||
expect(eventRule(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))?.frequency).toBe("daily");
|
||||
expect(eventRule(ev({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,19 @@ describe("isRecurring", () => {
|
||||
it("does not call a one-off event a series just because it has a baseEventId", () => {
|
||||
expect(isRecurring(ev({ baseEventId: "ev1" }))).toBe(false);
|
||||
expect(isRecurring(ev({}))).toBe(false);
|
||||
// The shape a live 0.16.19 returns for a one-off: an instance id of its own,
|
||||
// and a base that is a different id. Neither makes it a series.
|
||||
expect(isRecurring(ev({ id: "eaaaaai", baseEventId: "i" }))).toBe(false);
|
||||
});
|
||||
it("still recognises an occurrence whose base is another event", () => {
|
||||
expect(isRecurring(ev({ id: "ev1_2", baseEventId: "ev1" }))).toBe(true);
|
||||
});
|
||||
it("recognises a series by its recurrence rules", () => {
|
||||
it("recognises a series by its rule, under either name", () => {
|
||||
expect(isRecurring(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly" }] }))).toBe(true);
|
||||
expect(isRecurring(ev({ baseEventId: "ev1", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))).toBe(true);
|
||||
expect(isRecurring(ev({ excludedRecurrenceRules: [{ "@type": "RecurrenceRule", frequency: "monthly" }] }))).toBe(true);
|
||||
// Stalwart 0.16 keeps a single rule under the singular name.
|
||||
expect(isRecurring(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 } }))).toBe(true);
|
||||
});
|
||||
it("recognises an occurrence, which arrives with no rule of its own", () => {
|
||||
// A live 0.16.19 expands a weekly series into instances like this: an id
|
||||
// per occurrence, a recurrenceId, and no rule attached.
|
||||
expect(isRecurring(ev({ id: "iaaaaas", recurrenceId: "2030-03-11T10:00:00" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+63
-11
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
|
||||
import { settings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
@@ -57,7 +57,7 @@ const EVENT_PROPS = [
|
||||
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
|
||||
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
|
||||
"recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo",
|
||||
"recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
|
||||
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
|
||||
];
|
||||
|
||||
@@ -300,15 +300,22 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
/**
|
||||
* Whether an event is part of a series.
|
||||
*
|
||||
* Not the same question as "does it have a baseEventId": `CalendarEvent/query`
|
||||
* runs with `expandRecurrences`, and Stalwart sets `baseEventId` on every event
|
||||
* it returns that way — a one-off event included, pointing at itself. Recurrence
|
||||
* rules are what make a series, so those are the question; a base that is some
|
||||
* *other* event means this is one occurrence of one, whether or not the expanded
|
||||
* instance carried the rules along with it.
|
||||
* Three things had to be checked against a live 0.16.19 to get this right, none
|
||||
* of which the mock reproduces:
|
||||
*
|
||||
* - `baseEventId` says nothing. `CalendarEvent/query` runs with
|
||||
* `expandRecurrences`, and a one-off comes back as id `eaaaaai` over base
|
||||
* `i` — an instance id of its own, and a base that is a different id.
|
||||
* - The rules say nothing on an instance. An occurrence of a weekly series
|
||||
* arrives with no rule attached at all; only the master carries one.
|
||||
* - Stalwart names that rule `recurrenceRule`, singular, not the RFC 8984
|
||||
* `recurrenceRules` array.
|
||||
*
|
||||
* What an occurrence does carry is a `recurrenceId`, and a one-off never has
|
||||
* one. Master or occurrence, that is what makes this a series.
|
||||
*/
|
||||
export function isRecurring(ev: CalendarEvent): boolean {
|
||||
return Boolean(ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || (ev.baseEventId && ev.baseEventId !== ev.id));
|
||||
return Boolean(ev.recurrenceRule || ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || ev.recurrenceId);
|
||||
}
|
||||
|
||||
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
|
||||
@@ -331,6 +338,52 @@ export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): E
|
||||
return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address a participant answers to, as lowercase `mailto:` URIs.
|
||||
*
|
||||
* Stalwart 0.16 keeps one address under `calendarAddress`; RFC 8984 spreads it
|
||||
* over `sendTo` and `email`. Reading has to accept all three — a mailbox may
|
||||
* hold events written by either, and by other clients besides.
|
||||
*/
|
||||
export function participantAddresses(p: JSCalendarParticipant): string[] {
|
||||
return [p.calendarAddress ?? "", ...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""]
|
||||
.filter(Boolean)
|
||||
.map((a) => a.toLowerCase());
|
||||
}
|
||||
|
||||
/** The address to show or write to, without the `mailto:`. */
|
||||
export function participantEmail(p: JSCalendarParticipant): string {
|
||||
return (participantAddresses(p)[0] ?? "").replace(/^mailto:/i, "");
|
||||
}
|
||||
|
||||
/** Whether this participant is attending, under any of the role names in use. */
|
||||
export function isAttendee(p: JSCalendarParticipant): boolean {
|
||||
return Boolean(p.roles?.attendee || p.roles?.required || p.roles?.optional || p.roles?.chair);
|
||||
}
|
||||
|
||||
/** The event's recurrence rule, under either spelling. */
|
||||
export function eventRule(ev: CalendarEvent): JSCalendarRecurrenceRule | undefined {
|
||||
return ev.recurrenceRule ?? ev.recurrenceRules?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a participant the way Stalwart 0.16 stores them: the address under
|
||||
* `calendarAddress`. Sent under RFC 8984's `sendTo`/`email` instead, the server
|
||||
* keeps the event and drops the whole participant map without saying so — which
|
||||
* is how invitations came to vanish (#26).
|
||||
*/
|
||||
export function makeParticipant(email: string, name: string | null | undefined, role: "owner" | "attendee", status?: string): JSCalendarParticipant {
|
||||
return {
|
||||
"@type": "Participant",
|
||||
name: name || undefined,
|
||||
calendarAddress: `mailto:${email}`,
|
||||
kind: "individual",
|
||||
roles: role === "owner" ? { owner: true, attendee: true } : { attendee: true, required: true },
|
||||
participationStatus: (status as JSCalendarParticipant["participationStatus"]) ?? (role === "owner" ? "accepted" : "needs-action"),
|
||||
expectReply: role !== "owner",
|
||||
};
|
||||
}
|
||||
|
||||
export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] {
|
||||
const mine = new Set<string>();
|
||||
for (const i of identities) {
|
||||
@@ -341,8 +394,7 @@ export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIden
|
||||
if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`);
|
||||
const keys: string[] = [];
|
||||
for (const [k, p] of Object.entries(ev.participants ?? {})) {
|
||||
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
|
||||
if (addrs.some((a) => mine.has(a))) keys.push(k);
|
||||
if (participantAddresses(p).some((a) => mine.has(a))) keys.push(k);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -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